@skyhook-io/k8s-ui 1.7.4 → 1.7.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/gitops/GitOpsTableView.tsx +164 -103
- package/src/components/resources/ResourcesView.tsx +50 -13
- package/src/components/resources/renderers/RBACErrorSection.tsx +6 -9
- package/src/components/resources/renderers/RoleBindingRenderer.test.tsx +76 -0
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +23 -7
- package/src/components/ui/FetchResult.test.tsx +93 -0
- package/src/components/ui/FetchResult.tsx +141 -0
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/WorkloadView.tsx +13 -18
- package/src/types/fetch-error.test.ts +46 -0
- package/src/types/fetch-error.ts +20 -0
- package/src/types/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType, type ReactNode } from 'react'
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
3
|
import {
|
|
4
4
|
AlertTriangle,
|
|
@@ -141,14 +141,27 @@ export interface GitOpsTableViewProps {
|
|
|
141
141
|
counts: Record<string, number>
|
|
142
142
|
// Caller refresh — typically invalidates its useQuery + refetches.
|
|
143
143
|
onRefresh?: () => void
|
|
144
|
-
// Row click — caller routes to its own detail page.
|
|
145
|
-
|
|
144
|
+
// Row click — caller routes to its own detail page. When the host also
|
|
145
|
+
// passes `rowHrefFor`, the callback receives the MouseEvent so it can
|
|
146
|
+
// `preventDefault()` for SPA-local nav (e.g. react-router) or skip the
|
|
147
|
+
// preventDefault to let the anchor's default full-page navigation run
|
|
148
|
+
// (required for cross-router-boundary links).
|
|
149
|
+
onRowClick: (row: GitOpsRow, event?: ReactMouseEvent) => void
|
|
150
|
+
/** When provided, the Application-name cell renders as a real `<a href>`
|
|
151
|
+
* and the `<tr>` drops its row-level click handler. Restores ⌘-click /
|
|
152
|
+
* middle-click / "Copy link" / hover URL preview / screen-reader link
|
|
153
|
+
* semantics. `onRowClick` still fires on unmodified clicks (event arg
|
|
154
|
+
* supplied) for analytics or to take over navigation. */
|
|
155
|
+
rowHrefFor?: (row: GitOpsRow) => string
|
|
146
156
|
|
|
147
157
|
// Called when the user clicks the destination cluster chip in the
|
|
148
158
|
// Destination cell. Fleet-only; OSS leaves undefined. Caller routes to
|
|
149
159
|
// the destination cluster's workloads view (filtered by the Argo
|
|
150
160
|
// instance label) — the chip itself stops row-click propagation.
|
|
151
161
|
onDestinationClick?: (row: GitOpsRow, destination: FleetDestinationStamp) => void
|
|
162
|
+
/** Anchor equivalent of `onDestinationClick`. Same rationale as
|
|
163
|
+
* `rowHrefFor` — real `<a href>` for the destination chip. */
|
|
164
|
+
destinationHrefFor?: (row: GitOpsRow, destination: FleetDestinationStamp) => string
|
|
152
165
|
// Cross-cluster surfaces (Hub-only); OSS leaves these undefined.
|
|
153
166
|
crossClusterCount?: number
|
|
154
167
|
destinationFilter?: DestinationFilter
|
|
@@ -188,7 +201,9 @@ export function GitOpsTableView({
|
|
|
188
201
|
counts,
|
|
189
202
|
onRefresh,
|
|
190
203
|
onRowClick,
|
|
204
|
+
rowHrefFor,
|
|
191
205
|
onDestinationClick,
|
|
206
|
+
destinationHrefFor,
|
|
192
207
|
crossClusterCount,
|
|
193
208
|
destinationFilter,
|
|
194
209
|
onDestinationFilterChange,
|
|
@@ -227,22 +242,6 @@ export function GitOpsTableView({
|
|
|
227
242
|
const hasGlobalNamespaceFilter = !!onClearNamespaces && (globalNamespaces?.length ?? 0) > 0
|
|
228
243
|
const hasAnyFilter = hasLocalFilters || hasGlobalNamespaceFilter
|
|
229
244
|
|
|
230
|
-
const clearLocalFilters = () => {
|
|
231
|
-
setSearch('')
|
|
232
|
-
setSyncFilters(new Set())
|
|
233
|
-
setHealthFilters(new Set())
|
|
234
|
-
setProjectFilters(new Set())
|
|
235
|
-
setNamespaceFilters(new Set())
|
|
236
|
-
setLabelFilters(new Set())
|
|
237
|
-
setAutomationFilter('all')
|
|
238
|
-
setLifecycleFilter('all')
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
const clearAllFilters = () => {
|
|
242
|
-
clearLocalFilters()
|
|
243
|
-
onClearNamespaces?.()
|
|
244
|
-
}
|
|
245
|
-
|
|
246
245
|
// Optional '/' keyboard shortcut to focus search. Avoided as a default to
|
|
247
246
|
// not collide with other surfaces' keyboard maps; OSS opts in via prop.
|
|
248
247
|
useEffect(() => {
|
|
@@ -343,8 +342,9 @@ export function GitOpsTableView({
|
|
|
343
342
|
setAutomationFilter('all')
|
|
344
343
|
setLifecycleFilter('all')
|
|
345
344
|
setReconcilingOnly(false)
|
|
345
|
+
onClearNamespaces?.()
|
|
346
346
|
onDestinationFilterChange?.('all')
|
|
347
|
-
}, [onDestinationFilterChange])
|
|
347
|
+
}, [onClearNamespaces, onDestinationFilterChange])
|
|
348
348
|
|
|
349
349
|
const noOtherFiltersActive = useCallback(
|
|
350
350
|
(
|
|
@@ -642,9 +642,9 @@ export function GitOpsTableView({
|
|
|
642
642
|
)}
|
|
643
643
|
</div>
|
|
644
644
|
) : viewMode === 'tiles' ? (
|
|
645
|
-
<GitOpsTiles rows={filteredRows} onOpen={onRowClick} />
|
|
645
|
+
<GitOpsTiles rows={filteredRows} onOpen={onRowClick} hrefFor={rowHrefFor} />
|
|
646
646
|
) : (
|
|
647
|
-
<GitOpsTable rows={filteredRows} onOpen={onRowClick} onDestinationClick={onDestinationClick} />
|
|
647
|
+
<GitOpsTable rows={filteredRows} onOpen={onRowClick} hrefFor={rowHrefFor} onDestinationClick={onDestinationClick} destinationHrefFor={destinationHrefFor} />
|
|
648
648
|
)}
|
|
649
649
|
</div>
|
|
650
650
|
</div>
|
|
@@ -1032,11 +1032,15 @@ function StatusDistribution({ rows }: { rows: GitOpsRow[] }) {
|
|
|
1032
1032
|
function GitOpsTable({
|
|
1033
1033
|
rows,
|
|
1034
1034
|
onOpen,
|
|
1035
|
+
hrefFor,
|
|
1035
1036
|
onDestinationClick,
|
|
1037
|
+
destinationHrefFor,
|
|
1036
1038
|
}: {
|
|
1037
1039
|
rows: GitOpsRow[]
|
|
1038
|
-
onOpen: (row: GitOpsRow) => void
|
|
1040
|
+
onOpen: (row: GitOpsRow, event?: ReactMouseEvent) => void
|
|
1041
|
+
hrefFor?: (row: GitOpsRow) => string
|
|
1039
1042
|
onDestinationClick?: (row: GitOpsRow, destination: FleetDestinationStamp) => void
|
|
1043
|
+
destinationHrefFor?: (row: GitOpsRow, destination: FleetDestinationStamp) => string
|
|
1040
1044
|
}) {
|
|
1041
1045
|
return (
|
|
1042
1046
|
<table className="w-full min-w-[1040px] table-fixed border-separate border-spacing-0 text-sm">
|
|
@@ -1052,65 +1056,82 @@ function GitOpsTable({
|
|
|
1052
1056
|
</tr>
|
|
1053
1057
|
</thead>
|
|
1054
1058
|
<tbody>
|
|
1055
|
-
{rows.map((row) =>
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
<div className="min-w-0">
|
|
1076
|
-
<div className="truncate font-medium text-theme-text-primary">{row.name}</div>
|
|
1077
|
-
<div className="truncate text-xs text-theme-text-tertiary">
|
|
1078
|
-
{row.tool === 'argo' ? 'ArgoCD' : 'FluxCD'} {row.kind}
|
|
1079
|
-
{row._cluster && (
|
|
1080
|
-
<span title={row._cluster.name !== shortClusterName(row._cluster.name) ? row._cluster.name : undefined}>
|
|
1081
|
-
{' · '}{shortClusterName(row._cluster.name)}
|
|
1059
|
+
{rows.map((row) => {
|
|
1060
|
+
const href = hrefFor?.(row)
|
|
1061
|
+
return (
|
|
1062
|
+
<tr
|
|
1063
|
+
key={row.id}
|
|
1064
|
+
onClick={href ? undefined : () => onOpen(row)}
|
|
1065
|
+
className={clsx(
|
|
1066
|
+
'border-b border-theme-border bg-theme-base hover:bg-theme-hover',
|
|
1067
|
+
!href && 'cursor-pointer',
|
|
1068
|
+
row.terminating && 'opacity-70',
|
|
1069
|
+
)}
|
|
1070
|
+
>
|
|
1071
|
+
<TableCell>
|
|
1072
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
1073
|
+
<span className={`h-8 w-1 shrink-0 rounded-full ${statusStripe(row)}`} />
|
|
1074
|
+
{row.terminating && (
|
|
1075
|
+
<Tooltip content="Pending deletion — finalizers still running">
|
|
1076
|
+
<span className="inline-flex shrink-0 items-center gap-1 rounded border border-orange-500/40 bg-orange-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-orange-400">
|
|
1077
|
+
<Trash2 className="h-3 w-3" />
|
|
1078
|
+
Terminating
|
|
1082
1079
|
</span>
|
|
1080
|
+
</Tooltip>
|
|
1081
|
+
)}
|
|
1082
|
+
<div className="min-w-0">
|
|
1083
|
+
{href ? (
|
|
1084
|
+
<a
|
|
1085
|
+
href={href}
|
|
1086
|
+
onClick={(e) => {
|
|
1087
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return
|
|
1088
|
+
onOpen(row, e)
|
|
1089
|
+
}}
|
|
1090
|
+
className="block truncate font-medium text-theme-text-primary hover:underline focus-visible:underline focus-visible:outline-none rounded-sm"
|
|
1091
|
+
>
|
|
1092
|
+
{row.name}
|
|
1093
|
+
</a>
|
|
1094
|
+
) : (
|
|
1095
|
+
<div className="truncate font-medium text-theme-text-primary">{row.name}</div>
|
|
1083
1096
|
)}
|
|
1097
|
+
<div className="truncate text-xs text-theme-text-tertiary">
|
|
1098
|
+
{row.tool === 'argo' ? 'ArgoCD' : 'FluxCD'} {row.kind}
|
|
1099
|
+
{row._cluster && (
|
|
1100
|
+
<span title={row._cluster.name !== shortClusterName(row._cluster.name) ? row._cluster.name : undefined}>
|
|
1101
|
+
{' · '}{shortClusterName(row._cluster.name)}
|
|
1102
|
+
</span>
|
|
1103
|
+
)}
|
|
1104
|
+
</div>
|
|
1084
1105
|
</div>
|
|
1085
1106
|
</div>
|
|
1086
|
-
</
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
</
|
|
1112
|
-
|
|
1113
|
-
)
|
|
1107
|
+
</TableCell>
|
|
1108
|
+
<TableCell>{row.project || '-'}</TableCell>
|
|
1109
|
+
<TableCell>
|
|
1110
|
+
{row.terminating
|
|
1111
|
+
? <span className="text-[11px] text-theme-text-tertiary">—</span>
|
|
1112
|
+
: <SyncStatusBadge sync={row.sync as any} suspended={row.suspended} />}
|
|
1113
|
+
</TableCell>
|
|
1114
|
+
<TableCell>
|
|
1115
|
+
{row.terminating
|
|
1116
|
+
? <span className="text-[11px] text-theme-text-tertiary">—</span>
|
|
1117
|
+
: <HealthStatusBadge health={row.health as any} />}
|
|
1118
|
+
</TableCell>
|
|
1119
|
+
<TableCell>
|
|
1120
|
+
<div className="truncate text-theme-text-primary">{row.repository || row.chart || '-'}</div>
|
|
1121
|
+
<div className="truncate text-xs text-theme-text-tertiary">{[row.targetRevision, row.path || row.chart].filter(Boolean).join(' · ') || '-'}</div>
|
|
1122
|
+
</TableCell>
|
|
1123
|
+
<TableCell>
|
|
1124
|
+
<DestinationCell row={row} onDestinationClick={onDestinationClick} destinationHrefFor={destinationHrefFor} />
|
|
1125
|
+
<div className="truncate text-xs text-theme-text-tertiary">{row.destinationNamespace || row.namespace || '-'}</div>
|
|
1126
|
+
</TableCell>
|
|
1127
|
+
<TableCell>
|
|
1128
|
+
{row.terminating
|
|
1129
|
+
? <span className="text-orange-400/80">Pending {formatRelativeAge(row.terminationStartedAt ?? '') || 'now'}</span>
|
|
1130
|
+
: formatRelativeAge(row.lastSync || row.createdAt)}
|
|
1131
|
+
</TableCell>
|
|
1132
|
+
</tr>
|
|
1133
|
+
)
|
|
1134
|
+
})}
|
|
1114
1135
|
</tbody>
|
|
1115
1136
|
</table>
|
|
1116
1137
|
)
|
|
@@ -1119,14 +1140,16 @@ function GitOpsTable({
|
|
|
1119
1140
|
function GitOpsTiles({
|
|
1120
1141
|
rows,
|
|
1121
1142
|
onOpen,
|
|
1143
|
+
hrefFor,
|
|
1122
1144
|
}: {
|
|
1123
1145
|
rows: GitOpsRow[]
|
|
1124
|
-
onOpen: (row: GitOpsRow) => void
|
|
1146
|
+
onOpen: (row: GitOpsRow, event?: ReactMouseEvent) => void
|
|
1147
|
+
hrefFor?: (row: GitOpsRow) => string
|
|
1125
1148
|
}) {
|
|
1126
1149
|
return (
|
|
1127
1150
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-3 p-4">
|
|
1128
1151
|
{rows.map((row) => (
|
|
1129
|
-
<GitOpsTile key={row.id} row={row} onOpen={onOpen} />
|
|
1152
|
+
<GitOpsTile key={row.id} row={row} onOpen={onOpen} href={hrefFor?.(row)} />
|
|
1130
1153
|
))}
|
|
1131
1154
|
</div>
|
|
1132
1155
|
)
|
|
@@ -1135,9 +1158,11 @@ function GitOpsTiles({
|
|
|
1135
1158
|
function GitOpsTile({
|
|
1136
1159
|
row,
|
|
1137
1160
|
onOpen,
|
|
1161
|
+
href,
|
|
1138
1162
|
}: {
|
|
1139
1163
|
row: GitOpsRow
|
|
1140
|
-
onOpen: (row: GitOpsRow) => void
|
|
1164
|
+
onOpen: (row: GitOpsRow, event?: ReactMouseEvent) => void
|
|
1165
|
+
href?: string
|
|
1141
1166
|
}) {
|
|
1142
1167
|
const source = compactRepoSource(row.repository || row.chart, row.path || row.chart)
|
|
1143
1168
|
const revision = row.targetRevision || ''
|
|
@@ -1145,15 +1170,12 @@ function GitOpsTile({
|
|
|
1145
1170
|
const recencyClass = recencyTone(lastSyncRaw)
|
|
1146
1171
|
const dest = row.destination ? compactClusterURL(row.destination) : ''
|
|
1147
1172
|
const ns = row.destinationNamespace || row.namespace
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
row.terminating && 'opacity-80',
|
|
1155
|
-
)}
|
|
1156
|
-
>
|
|
1173
|
+
const tileClass = clsx(
|
|
1174
|
+
'group relative flex min-w-0 flex-col overflow-hidden rounded-md border border-theme-border bg-theme-surface text-left shadow-theme-sm transition-all hover:border-theme-text-tertiary/40 hover:shadow-theme-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-theme-text-primary/20',
|
|
1175
|
+
row.terminating && 'opacity-80',
|
|
1176
|
+
)
|
|
1177
|
+
const body = (
|
|
1178
|
+
<>
|
|
1157
1179
|
<div className={clsx('h-1 w-full', statusStripe(row))} />
|
|
1158
1180
|
<div className="flex flex-1 flex-col gap-3 px-4 pb-4 pt-3">
|
|
1159
1181
|
<div className="line-clamp-2 break-all text-[15px] font-semibold leading-tight text-theme-text-primary">
|
|
@@ -1199,6 +1221,25 @@ function GitOpsTile({
|
|
|
1199
1221
|
</div>
|
|
1200
1222
|
)}
|
|
1201
1223
|
</div>
|
|
1224
|
+
</>
|
|
1225
|
+
)
|
|
1226
|
+
if (href) {
|
|
1227
|
+
return (
|
|
1228
|
+
<a
|
|
1229
|
+
href={href}
|
|
1230
|
+
onClick={(e) => {
|
|
1231
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return
|
|
1232
|
+
onOpen(row, e)
|
|
1233
|
+
}}
|
|
1234
|
+
className={tileClass}
|
|
1235
|
+
>
|
|
1236
|
+
{body}
|
|
1237
|
+
</a>
|
|
1238
|
+
)
|
|
1239
|
+
}
|
|
1240
|
+
return (
|
|
1241
|
+
<button type="button" onClick={() => onOpen(row)} className={tileClass}>
|
|
1242
|
+
{body}
|
|
1202
1243
|
</button>
|
|
1203
1244
|
)
|
|
1204
1245
|
}
|
|
@@ -1275,9 +1316,11 @@ function TableHead({ children, className = '' }: { children: ReactNode; classNam
|
|
|
1275
1316
|
function DestinationCell({
|
|
1276
1317
|
row,
|
|
1277
1318
|
onDestinationClick,
|
|
1319
|
+
destinationHrefFor,
|
|
1278
1320
|
}: {
|
|
1279
1321
|
row: GitOpsRow
|
|
1280
1322
|
onDestinationClick?: (row: GitOpsRow, destination: FleetDestinationStamp) => void
|
|
1323
|
+
destinationHrefFor?: (row: GitOpsRow, destination: FleetDestinationStamp) => string
|
|
1281
1324
|
}) {
|
|
1282
1325
|
const dest = row._destination
|
|
1283
1326
|
// Non-fleet (OSS) path — show the raw destination string.
|
|
@@ -1288,10 +1331,6 @@ function DestinationCell({
|
|
|
1288
1331
|
return <span className="block truncate text-theme-text-tertiary">same cluster</span>
|
|
1289
1332
|
}
|
|
1290
1333
|
if ((dest.match === 'exact' || dest.match === 'inferred') && dest.cluster_id && dest.cluster_name) {
|
|
1291
|
-
const handleClick = (e: React.MouseEvent) => {
|
|
1292
|
-
e.stopPropagation()
|
|
1293
|
-
onDestinationClick?.(row, dest)
|
|
1294
|
-
}
|
|
1295
1334
|
const short = shortClusterName(dest.cluster_name)
|
|
1296
1335
|
// High-confidence (URL match): solid sky chip with a small ✓ marker.
|
|
1297
1336
|
// Medium-confidence (name match): same chip styling but no marker, and
|
|
@@ -1300,19 +1339,41 @@ function DestinationCell({
|
|
|
1300
1339
|
// the human-readable reason from the hub.
|
|
1301
1340
|
const highConfidence = dest.confidence === 'high'
|
|
1302
1341
|
const tooltipReason = dest.reason ? ` (${dest.reason})` : ''
|
|
1342
|
+
const chipClass =
|
|
1343
|
+
'block max-w-full truncate rounded px-1.5 py-0.5 text-xs font-medium hover:bg-sky-500/20 dark:text-sky-300 ' +
|
|
1344
|
+
(highConfidence
|
|
1345
|
+
? 'border border-sky-500/50 bg-sky-500/15 text-sky-700'
|
|
1346
|
+
: 'border border-sky-500/25 bg-sky-500/5 text-sky-600')
|
|
1347
|
+
const title = `Open workloads in ${dest.cluster_name}${tooltipReason}`
|
|
1348
|
+
const chipBody = `${highConfidence ? '✓ ' : ''}${short}`
|
|
1349
|
+
const destHref = destinationHrefFor?.(row, dest)
|
|
1350
|
+
if (destHref) {
|
|
1351
|
+
return (
|
|
1352
|
+
<a
|
|
1353
|
+
href={destHref}
|
|
1354
|
+
// The chip sits inside the row's `<td>`; when a host wires
|
|
1355
|
+
// `destinationHrefFor` without `rowHrefFor`, the `<tr>` retains
|
|
1356
|
+
// its own onClick. Stop the bubble so a click on the chip
|
|
1357
|
+
// doesn't also trigger row navigation.
|
|
1358
|
+
onClick={(e) => e.stopPropagation()}
|
|
1359
|
+
className={chipClass + ' focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/40'}
|
|
1360
|
+
title={title}
|
|
1361
|
+
>
|
|
1362
|
+
{chipBody}
|
|
1363
|
+
</a>
|
|
1364
|
+
)
|
|
1365
|
+
}
|
|
1303
1366
|
return (
|
|
1304
1367
|
<button
|
|
1305
1368
|
type="button"
|
|
1306
|
-
onClick={
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
}
|
|
1313
|
-
title={`Open workloads in ${dest.cluster_name}${tooltipReason}`}
|
|
1369
|
+
onClick={(e) => {
|
|
1370
|
+
e.stopPropagation()
|
|
1371
|
+
onDestinationClick?.(row, dest)
|
|
1372
|
+
}}
|
|
1373
|
+
className={chipClass}
|
|
1374
|
+
title={title}
|
|
1314
1375
|
>
|
|
1315
|
-
{
|
|
1376
|
+
{chipBody}
|
|
1316
1377
|
</button>
|
|
1317
1378
|
)
|
|
1318
1379
|
}
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
import { clsx } from 'clsx'
|
|
29
29
|
import { ResourceBar } from '../ui/ResourceBar'
|
|
30
30
|
import type { SelectedResource, APIResource } from '../../types'
|
|
31
|
+
import { isForbiddenError } from '../../types/fetch-error'
|
|
31
32
|
import type { NavigateToResource } from '../../utils/navigation'
|
|
32
33
|
import { categorizeResources, CORE_RESOURCES } from '../../utils/api-resources'
|
|
33
34
|
import {
|
|
@@ -1681,11 +1682,6 @@ interface ResourcesViewData {
|
|
|
1681
1682
|
|
|
1682
1683
|
export const ResourcesViewDataContext = React.createContext<ResourcesViewData>({})
|
|
1683
1684
|
|
|
1684
|
-
// Inline helper replacing ApiError/isForbiddenError from the removed api/client import
|
|
1685
|
-
function isForbiddenError(error: any): boolean {
|
|
1686
|
-
return error?.status === 403
|
|
1687
|
-
}
|
|
1688
|
-
|
|
1689
1685
|
export interface ResourceQueryResult {
|
|
1690
1686
|
data?: any[]
|
|
1691
1687
|
isLoading: boolean
|
|
@@ -1756,6 +1752,15 @@ interface ResourcesViewProps {
|
|
|
1756
1752
|
* should also wire onResourceClick to handle the deep-link-on-load
|
|
1757
1753
|
* case. */
|
|
1758
1754
|
onRowSelect?: (resource: any) => void
|
|
1755
|
+
/**
|
|
1756
|
+
* When provided, the name cell renders as a real `<a href>` instead of
|
|
1757
|
+
* relying on per-cell click handlers for navigation. Restores ⌘-click /
|
|
1758
|
+
* middle-click / "Copy link" / hover URL preview / screen-reader link
|
|
1759
|
+
* semantics. Hosts using full-page navigation should prefer this over
|
|
1760
|
+
* `onRowSelect`; the anchor will own navigation and the rest of the row
|
|
1761
|
+
* remains clickable for selection (drawer open).
|
|
1762
|
+
*/
|
|
1763
|
+
rowHrefFor?: (resource: any) => string
|
|
1759
1764
|
/**
|
|
1760
1765
|
* Overrides the default compare-mode submit (which navigates to
|
|
1761
1766
|
* `/compare?kind=...&a=...&b=...`). Hosts use this to route to a
|
|
@@ -1927,6 +1932,7 @@ export function ResourcesView({
|
|
|
1927
1932
|
defaultKind = DEFAULT_KIND_INFO,
|
|
1928
1933
|
extraLeadingColumns,
|
|
1929
1934
|
onRowSelect,
|
|
1935
|
+
rowHrefFor,
|
|
1930
1936
|
onCompareSubmit,
|
|
1931
1937
|
resolveRowCluster,
|
|
1932
1938
|
onClearNamespaces,
|
|
@@ -4256,6 +4262,7 @@ export function ResourcesView({
|
|
|
4256
4262
|
onMouseEnter={() => setHighlightedIndex(-1)}
|
|
4257
4263
|
compareMode={compareMode}
|
|
4258
4264
|
comparePickIndex={pickIdx}
|
|
4265
|
+
rowHref={rowHrefFor?.(resource)}
|
|
4259
4266
|
/>
|
|
4260
4267
|
)
|
|
4261
4268
|
}}
|
|
@@ -4301,6 +4308,10 @@ interface ResourceRowCellsProps {
|
|
|
4301
4308
|
compareMode?: boolean
|
|
4302
4309
|
/** -1 when not picked; 0 = pick A; 1 = pick B. */
|
|
4303
4310
|
comparePickIndex?: number
|
|
4311
|
+
/** When provided, the name cell renders as `<a href>` and the other
|
|
4312
|
+
* data cells drop their click handlers. The compare-mode chip column
|
|
4313
|
+
* is unaffected (still toggles picks). */
|
|
4314
|
+
rowHref?: string
|
|
4304
4315
|
}
|
|
4305
4316
|
|
|
4306
4317
|
function rowHighlightClass(
|
|
@@ -4320,9 +4331,13 @@ function rowHighlightClass(
|
|
|
4320
4331
|
return 'group-hover/row:bg-theme-surface/50'
|
|
4321
4332
|
}
|
|
4322
4333
|
|
|
4323
|
-
function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, hasSpacerColumn, isSelected, isHighlighted, majorityNodeMinorVersion, onClick, onMouseEnter, compareMode, comparePickIndex = -1 }: ResourceRowCellsProps) {
|
|
4334
|
+
function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, hasSpacerColumn, isSelected, isHighlighted, majorityNodeMinorVersion, onClick, onMouseEnter, compareMode, comparePickIndex = -1, rowHref }: ResourceRowCellsProps) {
|
|
4324
4335
|
const rowHighlight = rowHighlightClass(compareMode, comparePickIndex, isSelected, isHighlighted)
|
|
4325
4336
|
const pickedSide = comparePickIndex === 0 ? 'a' : comparePickIndex === 1 ? 'b' : null
|
|
4337
|
+
// When the host supplies an anchor, drop per-cell onClick for the data
|
|
4338
|
+
// columns: the anchor is the only navigation surface. The compare-mode
|
|
4339
|
+
// chip column keeps its onClick so pick toggling still works.
|
|
4340
|
+
const cellsAreClickable = !rowHref
|
|
4326
4341
|
return (
|
|
4327
4342
|
<>
|
|
4328
4343
|
{compareMode && (
|
|
@@ -4353,15 +4368,24 @@ function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, h
|
|
|
4353
4368
|
{columns.map((col) => (
|
|
4354
4369
|
<td
|
|
4355
4370
|
key={col.key}
|
|
4356
|
-
onClick={onClick}
|
|
4371
|
+
onClick={cellsAreClickable ? onClick : undefined}
|
|
4357
4372
|
onMouseEnter={onMouseEnter}
|
|
4358
4373
|
className={clsx(
|
|
4359
|
-
'px-4 py-3 border-b-subtle
|
|
4374
|
+
'px-4 py-3 border-b-subtle transition-colors',
|
|
4375
|
+
cellsAreClickable && 'cursor-pointer',
|
|
4360
4376
|
col.key !== 'status' && 'overflow-hidden truncate',
|
|
4361
4377
|
rowHighlight,
|
|
4362
4378
|
)}
|
|
4363
4379
|
>
|
|
4364
|
-
<CellContent
|
|
4380
|
+
<CellContent
|
|
4381
|
+
resource={resource}
|
|
4382
|
+
kind={kind}
|
|
4383
|
+
group={group}
|
|
4384
|
+
column={col.key}
|
|
4385
|
+
majorityNodeMinorVersion={majorityNodeMinorVersion}
|
|
4386
|
+
extraColumn={extraColumnsByKey?.get(col.key)}
|
|
4387
|
+
nameHref={col.key === 'name' ? rowHref : undefined}
|
|
4388
|
+
/>
|
|
4365
4389
|
</td>
|
|
4366
4390
|
))}
|
|
4367
4391
|
{hasSpacerColumn && <td className="border-b-subtle p-0" />}
|
|
@@ -4405,9 +4429,12 @@ interface CellContentProps {
|
|
|
4405
4429
|
* column key. Render via the extra's render() and short-circuit
|
|
4406
4430
|
* the built-in cell logic. */
|
|
4407
4431
|
extraColumn?: ExtraColumn
|
|
4432
|
+
/** When set on the name column, the resource name renders as `<a href>`
|
|
4433
|
+
* so ⌘-click / copy-link / hover-URL all work. */
|
|
4434
|
+
nameHref?: string
|
|
4408
4435
|
}
|
|
4409
4436
|
|
|
4410
|
-
function CellContent({ resource, kind, column, group, majorityNodeMinorVersion, extraColumn }: CellContentProps) {
|
|
4437
|
+
function CellContent({ resource, kind, column, group, majorityNodeMinorVersion, extraColumn, nameHref }: CellContentProps) {
|
|
4411
4438
|
// Parent-injected extra columns short-circuit the built-in switch.
|
|
4412
4439
|
// Used by hosts that inject leading columns (e.g. a multi-cluster Cluster column).
|
|
4413
4440
|
if (extraColumn) {
|
|
@@ -4419,12 +4446,22 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
|
|
|
4419
4446
|
// Common columns
|
|
4420
4447
|
if (column === 'name') {
|
|
4421
4448
|
const isTerminating = !!meta.deletionTimestamp
|
|
4449
|
+
const nameClass = clsx('text-sm font-medium truncate block', isTerminating ? 'text-theme-text-tertiary line-through' : 'text-theme-text-primary')
|
|
4422
4450
|
return (
|
|
4423
4451
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
4424
4452
|
<Tooltip content={meta.name}>
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
|
|
4453
|
+
{nameHref ? (
|
|
4454
|
+
<a
|
|
4455
|
+
href={nameHref}
|
|
4456
|
+
className={clsx(nameClass, 'hover:underline focus-visible:underline focus-visible:outline-none rounded-sm')}
|
|
4457
|
+
>
|
|
4458
|
+
{meta.name}
|
|
4459
|
+
</a>
|
|
4460
|
+
) : (
|
|
4461
|
+
<span className={nameClass}>
|
|
4462
|
+
{meta.name}
|
|
4463
|
+
</span>
|
|
4464
|
+
)}
|
|
4428
4465
|
</Tooltip>
|
|
4429
4466
|
<CopyNameButton name={meta.name} />
|
|
4430
4467
|
{isTerminating && (
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Shield } from 'lucide-react'
|
|
2
2
|
import type { ComponentType } from 'react'
|
|
3
3
|
import { Section } from '../../ui/drawer-components'
|
|
4
|
+
import { isFetchError, isForbiddenError } from '../../../types/fetch-error'
|
|
4
5
|
|
|
5
6
|
interface RBACErrorSectionProps {
|
|
6
7
|
title: string
|
|
@@ -13,24 +14,20 @@ interface RBACErrorSectionProps {
|
|
|
13
14
|
errorPrefix?: string
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
const errorStatus = (error: Error): number | undefined => (error as { status?: number }).status
|
|
17
|
-
|
|
18
17
|
// 503 because Radar's SA can't read RBAC, so the informers never synced — a
|
|
19
18
|
// cluster-static config state (same on every resource), not a failure. The message
|
|
20
19
|
// check distinguishes it from a generic connectivity 503 ("Not connected to
|
|
21
20
|
// cluster"), which is a real fault and must stay loud (red).
|
|
22
21
|
const isRBACCacheUnavailable = (error: Error): boolean =>
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
// 403 because the requesting user lacks list permission on bindings.
|
|
26
|
-
const isRBACForbidden = (error: Error): boolean => errorStatus(error) === 403
|
|
22
|
+
isFetchError(error) && error.status === 503 && error.message.includes('RBAC cache')
|
|
27
23
|
|
|
28
|
-
// True for the two expected, non-actionable RBAC states
|
|
24
|
+
// True for the two expected, non-actionable RBAC states. isForbiddenError (403)
|
|
25
|
+
// means the requesting user lacks list permission on bindings. Surfaces that treat
|
|
29
26
|
// the RBAC section as a bonus (Pod/Workload Permissions) hide it entirely for these.
|
|
30
27
|
// Genuine faults — connectivity 503, 500, network errors — are deliberately NOT
|
|
31
28
|
// included, so they still surface rather than being silently dropped.
|
|
32
29
|
export function isRBACUnavailable(error: Error): boolean {
|
|
33
|
-
return
|
|
30
|
+
return isForbiddenError(error) || isRBACCacheUnavailable(error)
|
|
34
31
|
}
|
|
35
32
|
|
|
36
33
|
// RBACErrorSection renders each expected state as a calm note (distinct copy per
|
|
@@ -53,7 +50,7 @@ export function RBACErrorSection({
|
|
|
53
50
|
</Section>
|
|
54
51
|
)
|
|
55
52
|
}
|
|
56
|
-
if (
|
|
53
|
+
if (isForbiddenError(error)) {
|
|
57
54
|
return (
|
|
58
55
|
<Section title={title} icon={icon}>
|
|
59
56
|
<div className="text-sm text-theme-text-tertiary">
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { RoleBindingRenderer } from './RoleBindingRenderer'
|
|
4
|
+
|
|
5
|
+
function shaped(message: string, status: number) {
|
|
6
|
+
return Object.assign(new Error(message), { status })
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const binding = {
|
|
10
|
+
metadata: { name: 'rb-1', namespace: 'dev' },
|
|
11
|
+
roleRef: { kind: 'ClusterRole', name: 'view', apiGroup: 'rbac.authorization.k8s.io' },
|
|
12
|
+
subjects: [
|
|
13
|
+
{ kind: 'ServiceAccount', name: 'alice', namespace: 'dev' },
|
|
14
|
+
],
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('RoleBindingRenderer rules section', () => {
|
|
18
|
+
it('renders rule rows when rules are present', () => {
|
|
19
|
+
const html = renderToString(
|
|
20
|
+
<RoleBindingRenderer
|
|
21
|
+
data={binding}
|
|
22
|
+
roleRules={[{ verbs: ['get', 'list'], resources: ['pods'], apiGroups: [''] }]}
|
|
23
|
+
/>,
|
|
24
|
+
)
|
|
25
|
+
expect(html).toContain('Rules Granted')
|
|
26
|
+
expect(html).toContain('pods')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('renders "no rules" when rules array is empty (loaded but role has none)', () => {
|
|
30
|
+
const html = renderToString(<RoleBindingRenderer data={binding} roleRules={[]} />)
|
|
31
|
+
expect(html).toContain('no rules')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('renders the orphan-or-unavailable fallback when rules is null and no error', () => {
|
|
35
|
+
const html = renderToString(<RoleBindingRenderer data={binding} roleRules={null} />)
|
|
36
|
+
expect(html).toContain('Could not resolve referenced role')
|
|
37
|
+
expect(html).toContain('orphan binding')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('renders the Access denied message when rules is null and the fetch was 403', () => {
|
|
41
|
+
const html = renderToString(
|
|
42
|
+
<RoleBindingRenderer
|
|
43
|
+
data={binding}
|
|
44
|
+
roleRules={null}
|
|
45
|
+
roleRulesError={shaped('forbidden', 403)}
|
|
46
|
+
/>,
|
|
47
|
+
)
|
|
48
|
+
expect(html).toContain('Access denied reading referenced role')
|
|
49
|
+
expect(html).toContain('view')
|
|
50
|
+
expect(html).not.toContain('orphan binding')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('falls back to the orphan-or-unavailable message for non-403 errors (404, 500, network)', () => {
|
|
54
|
+
const html = renderToString(
|
|
55
|
+
<RoleBindingRenderer
|
|
56
|
+
data={binding}
|
|
57
|
+
roleRules={null}
|
|
58
|
+
roleRulesError={shaped('not found', 404)}
|
|
59
|
+
/>,
|
|
60
|
+
)
|
|
61
|
+
expect(html).toContain('Could not resolve referenced role')
|
|
62
|
+
expect(html).not.toContain('Access denied')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('keeps showing cached rules when a refetch fails with 403 (stale data preserved)', () => {
|
|
66
|
+
const html = renderToString(
|
|
67
|
+
<RoleBindingRenderer
|
|
68
|
+
data={binding}
|
|
69
|
+
roleRules={[{ verbs: ['get'], resources: ['secrets'], apiGroups: [''] }]}
|
|
70
|
+
roleRulesError={shaped('forbidden', 403)}
|
|
71
|
+
/>,
|
|
72
|
+
)
|
|
73
|
+
expect(html).toContain('secrets')
|
|
74
|
+
expect(html).not.toContain('Access denied')
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -2,6 +2,7 @@ import { Shield, Users, Eye } from 'lucide-react'
|
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
3
|
import { Section, PropertyList, Property, ResourceLink, AlertBanner } from '../../ui/drawer-components'
|
|
4
4
|
import type { ResourceRef, RBACPolicyRule } from '../../../types'
|
|
5
|
+
import { isForbiddenError } from '../../../types/fetch-error'
|
|
5
6
|
import {
|
|
6
7
|
rbacVerbBadgeClass,
|
|
7
8
|
rbacResourceBadgeClass,
|
|
@@ -15,9 +16,14 @@ interface RoleBindingRendererProps {
|
|
|
15
16
|
onNavigate?: (ref: ResourceRef) => void
|
|
16
17
|
/** Rules from the referenced Role/ClusterRole. Undefined means the host
|
|
17
18
|
* hasn't wired the fetch (inline rules preview is omitted). Null means
|
|
18
|
-
* the fetch
|
|
19
|
+
* the fetch finished without a resource (orphan binding); the section
|
|
20
|
+
* says so. */
|
|
19
21
|
roleRules?: RBACPolicyRule[] | null
|
|
20
22
|
roleRulesLoading?: boolean
|
|
23
|
+
/** Error from the role/clusterrole fetch. When present and shaped like
|
|
24
|
+
* a FetchErrorShape (status + message), the rules section distinguishes
|
|
25
|
+
* a 403 ("Access denied") from the orphan-or-unavailable fallback. */
|
|
26
|
+
roleRulesError?: unknown
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
// Wide groups whose membership effectively widens a binding beyond a named
|
|
@@ -57,7 +63,7 @@ function getSubjectKindBadgeClass(kind: string): string {
|
|
|
57
63
|
}
|
|
58
64
|
}
|
|
59
65
|
|
|
60
|
-
export function RoleBindingRenderer({ data, onNavigate, roleRules, roleRulesLoading }: RoleBindingRendererProps) {
|
|
66
|
+
export function RoleBindingRenderer({ data, onNavigate, roleRules, roleRulesLoading, roleRulesError }: RoleBindingRendererProps) {
|
|
61
67
|
const roleRef = data.roleRef || {}
|
|
62
68
|
const subjects: any[] = data.subjects || []
|
|
63
69
|
const isClusterRoleBinding = data.kind === 'ClusterRoleBinding'
|
|
@@ -117,6 +123,7 @@ export function RoleBindingRenderer({ data, onNavigate, roleRules, roleRulesLoad
|
|
|
117
123
|
<RulesPreviewSection
|
|
118
124
|
rules={roleRules}
|
|
119
125
|
loading={!!roleRulesLoading}
|
|
126
|
+
error={roleRulesError}
|
|
120
127
|
roleName={roleRef.name}
|
|
121
128
|
/>
|
|
122
129
|
)}
|
|
@@ -152,10 +159,12 @@ export function RoleBindingRenderer({ data, onNavigate, roleRules, roleRulesLoad
|
|
|
152
159
|
function RulesPreviewSection({
|
|
153
160
|
rules,
|
|
154
161
|
loading,
|
|
162
|
+
error,
|
|
155
163
|
roleName,
|
|
156
164
|
}: {
|
|
157
165
|
rules: RBACPolicyRule[] | null
|
|
158
166
|
loading: boolean
|
|
167
|
+
error?: unknown
|
|
159
168
|
roleName?: string
|
|
160
169
|
}) {
|
|
161
170
|
return (
|
|
@@ -163,11 +172,18 @@ function RulesPreviewSection({
|
|
|
163
172
|
{loading ? (
|
|
164
173
|
<div className="text-sm text-theme-text-tertiary">Loading rules…</div>
|
|
165
174
|
) : !rules ? (
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
175
|
+
isForbiddenError(error) ? (
|
|
176
|
+
<div className="text-sm text-theme-text-tertiary">
|
|
177
|
+
Access denied reading referenced role
|
|
178
|
+
{roleName ? ` "${roleName}"` : ''}.
|
|
179
|
+
</div>
|
|
180
|
+
) : (
|
|
181
|
+
<div className="text-sm text-theme-text-tertiary">
|
|
182
|
+
Could not resolve referenced role
|
|
183
|
+
{roleName ? ` "${roleName}"` : ''} — it may not exist (orphan binding)
|
|
184
|
+
or be unavailable.
|
|
185
|
+
</div>
|
|
186
|
+
)
|
|
171
187
|
) : rules.length === 0 ? (
|
|
172
188
|
<div className="text-sm text-theme-text-tertiary">
|
|
173
189
|
The referenced role has no rules.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { FetchResult } from './FetchResult'
|
|
4
|
+
|
|
5
|
+
function shaped(message: string, status: number) {
|
|
6
|
+
return Object.assign(new Error(message), { status })
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('FetchResult', () => {
|
|
10
|
+
it('renders the loader when loading, ignoring error', () => {
|
|
11
|
+
const html = renderToString(<FetchResult loading={true} error={shaped('forbidden', 403)} />)
|
|
12
|
+
expect(html).toContain('Loading')
|
|
13
|
+
expect(html).not.toContain('Access denied')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('renders notFoundMessage when neither loading nor error (disabled-query fallback)', () => {
|
|
17
|
+
const html = renderToString(<FetchResult loading={false} notFoundMessage="Pod not found" />)
|
|
18
|
+
expect(html).toContain('Pod not found')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('default notFoundMessage is "Resource not found"', () => {
|
|
22
|
+
const html = renderToString(<FetchResult loading={false} />)
|
|
23
|
+
expect(html).toContain('Resource not found')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('shows "Access denied" headline and the server message on 403', () => {
|
|
27
|
+
const html = renderToString(
|
|
28
|
+
<FetchResult
|
|
29
|
+
loading={false}
|
|
30
|
+
error={shaped('no access to clusterroles (cluster-scoped resource requires explicit RBAC)', 403)}
|
|
31
|
+
/>,
|
|
32
|
+
)
|
|
33
|
+
expect(html).toContain('Access denied')
|
|
34
|
+
expect(html).toContain('no access to clusterroles')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('uses notFoundMessage and renders the server message on 404', () => {
|
|
38
|
+
const html = renderToString(
|
|
39
|
+
<FetchResult
|
|
40
|
+
loading={false}
|
|
41
|
+
error={shaped('pods web-1 not found', 404)}
|
|
42
|
+
notFoundMessage="Pod not found"
|
|
43
|
+
/>,
|
|
44
|
+
)
|
|
45
|
+
expect(html).toContain('Pod not found')
|
|
46
|
+
expect(html).toContain('pods web-1 not found')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('renders "Cluster unavailable" on 503', () => {
|
|
50
|
+
const html = renderToString(
|
|
51
|
+
<FetchResult loading={false} error={shaped('Resource cache not available', 503)} />,
|
|
52
|
+
)
|
|
53
|
+
expect(html).toContain('Cluster unavailable')
|
|
54
|
+
expect(html).toContain('Resource cache not available')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('renders "Sign-in required" on 401', () => {
|
|
58
|
+
const html = renderToString(
|
|
59
|
+
<FetchResult loading={false} error={shaped('Unauthorized', 401)} />,
|
|
60
|
+
)
|
|
61
|
+
expect(html).toContain('Sign-in required')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("renders a generic 'Couldn't load' for other 5xx", () => {
|
|
65
|
+
const html = renderToString(
|
|
66
|
+
<FetchResult loading={false} error={shaped('internal server error', 500)} />,
|
|
67
|
+
)
|
|
68
|
+
expect(html).toContain('Couldn')
|
|
69
|
+
expect(html).toContain('internal server error')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('handles a network failure (Error without .status) via the generic branch', () => {
|
|
73
|
+
const html = renderToString(
|
|
74
|
+
<FetchResult loading={false} error={new Error('Failed to fetch')} />,
|
|
75
|
+
)
|
|
76
|
+
expect(html).toContain('Couldn')
|
|
77
|
+
expect(html).toContain('Failed to fetch')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('handles an AbortError-style throw without a status field', () => {
|
|
81
|
+
const aborted = new Error('The user aborted a request.')
|
|
82
|
+
aborted.name = 'AbortError'
|
|
83
|
+
const html = renderToString(<FetchResult loading={false} error={aborted} />)
|
|
84
|
+
expect(html).toContain('Couldn')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('renders the Copy-error button when an error message is present', () => {
|
|
88
|
+
const html = renderToString(
|
|
89
|
+
<FetchResult loading={false} error={shaped('forbidden', 403)} />,
|
|
90
|
+
)
|
|
91
|
+
expect(html).toContain('Copy error')
|
|
92
|
+
})
|
|
93
|
+
})
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { ShieldOff, AlertTriangle, ServerCrash, LogIn, Copy, Check, type LucideIcon } from 'lucide-react'
|
|
3
|
+
import { clsx } from 'clsx'
|
|
4
|
+
import { PaneLoader } from './PaneLoader'
|
|
5
|
+
import { isFetchError } from '../../types/fetch-error'
|
|
6
|
+
|
|
7
|
+
// FetchResult collapses (loading, error, no-data) into one rendered outcome:
|
|
8
|
+
// loader while loading, a typed error surface when the fetch threw, or the
|
|
9
|
+
// notFoundMessage when neither — matching the prior plain-text "Resource not
|
|
10
|
+
// found" so a disabled React Query (loading=false, data=undefined, error=null
|
|
11
|
+
// under v5) still renders a sane fallback rather than going blank.
|
|
12
|
+
//
|
|
13
|
+
// Decision tree:
|
|
14
|
+
// loading → <PaneLoader/>
|
|
15
|
+
// error (403) → "Access denied" + error.message
|
|
16
|
+
// error (404) → notFoundMessage + error.message
|
|
17
|
+
// error (503) → "Cluster unavailable" + error.message
|
|
18
|
+
// error (401) → "Sign-in required" (apiFetch redirects; fallback)
|
|
19
|
+
// error (other / no shape) → "Couldn't load this view" + error.message
|
|
20
|
+
// no loading, no error → notFoundMessage (headline only, no detail)
|
|
21
|
+
//
|
|
22
|
+
// Separate from EmptyState (which conveys "no data here" with
|
|
23
|
+
// healthy/filtered/neutral tones) because HTTP-level fetch failures need
|
|
24
|
+
// distinct visual and informational semantics — error vs absence.
|
|
25
|
+
//
|
|
26
|
+
// The error contract is duck-typed via FetchErrorShape so this stays in
|
|
27
|
+
// @skyhook-io/k8s-ui without importing ApiError from either web/ (OSS)
|
|
28
|
+
// or radar-hub-web.
|
|
29
|
+
|
|
30
|
+
interface FetchResultProps {
|
|
31
|
+
loading: boolean
|
|
32
|
+
error?: unknown
|
|
33
|
+
/** Body line for 404 (resource fetched, server says missing). Default "Resource not found". */
|
|
34
|
+
notFoundMessage?: string
|
|
35
|
+
/** Pin to parent height. Existing call sites use "h-32" or "h-full". */
|
|
36
|
+
className?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function FetchResult({
|
|
40
|
+
loading,
|
|
41
|
+
error,
|
|
42
|
+
notFoundMessage = 'Resource not found',
|
|
43
|
+
className = 'h-32',
|
|
44
|
+
}: FetchResultProps) {
|
|
45
|
+
if (loading) {
|
|
46
|
+
return <PaneLoader className={className} />
|
|
47
|
+
}
|
|
48
|
+
if (error === undefined || error === null) {
|
|
49
|
+
// No loading + no error = the query is disabled or returned no data.
|
|
50
|
+
// Render the headline-only "not found" state so callers gated on `!data`
|
|
51
|
+
// don't end up with a blank body when React Query v5 leaves isLoading=false.
|
|
52
|
+
return (
|
|
53
|
+
<div className={clsx('flex items-center justify-center text-theme-text-tertiary', className)}>
|
|
54
|
+
{notFoundMessage}
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
return <ErrorSurface error={error} notFoundMessage={notFoundMessage} className={className} />
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface ErrorSurfaceProps {
|
|
62
|
+
error: unknown
|
|
63
|
+
notFoundMessage: string
|
|
64
|
+
className: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function ErrorSurface({ error, notFoundMessage, className }: ErrorSurfaceProps) {
|
|
68
|
+
const classified = classify(error, notFoundMessage)
|
|
69
|
+
const Icon = classified.icon
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div
|
|
73
|
+
role="status"
|
|
74
|
+
className={clsx('flex flex-col items-center justify-center gap-2 px-6 text-center', className)}
|
|
75
|
+
>
|
|
76
|
+
<Icon className="h-5 w-5 text-theme-text-tertiary" aria-hidden />
|
|
77
|
+
<div className="text-sm font-medium text-theme-text-secondary">{classified.headline}</div>
|
|
78
|
+
{classified.detail && (
|
|
79
|
+
<div className="flex items-center gap-2 max-w-md">
|
|
80
|
+
<span className="text-xs text-theme-text-tertiary break-words">{classified.detail}</span>
|
|
81
|
+
<CopyErrorButton text={classified.detail} />
|
|
82
|
+
</div>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface Classified {
|
|
89
|
+
headline: string
|
|
90
|
+
detail: string | null
|
|
91
|
+
icon: LucideIcon
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function classify(error: unknown, notFoundMessage: string): Classified {
|
|
95
|
+
if (isFetchError(error)) {
|
|
96
|
+
switch (error.status) {
|
|
97
|
+
case 403:
|
|
98
|
+
return { headline: 'Access denied', detail: error.message, icon: ShieldOff }
|
|
99
|
+
case 404:
|
|
100
|
+
return { headline: notFoundMessage, detail: error.message, icon: AlertTriangle }
|
|
101
|
+
case 401:
|
|
102
|
+
return { headline: 'Sign-in required', detail: error.message, icon: LogIn }
|
|
103
|
+
case 503:
|
|
104
|
+
return { headline: 'Cluster unavailable', detail: error.message, icon: ServerCrash }
|
|
105
|
+
default:
|
|
106
|
+
return { headline: "Couldn't load this view", detail: error.message, icon: AlertTriangle }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Network failures (no .status), DOMException for AbortError, anything thrown without our shape.
|
|
110
|
+
return { headline: "Couldn't load this view", detail: errorMessageOf(error), icon: AlertTriangle }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function errorMessageOf(error: unknown): string | null {
|
|
114
|
+
if (error instanceof Error && error.message) return error.message
|
|
115
|
+
if (typeof error === 'string') return error
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function CopyErrorButton({ text }: { text: string }) {
|
|
120
|
+
const [copied, setCopied] = useState(false)
|
|
121
|
+
const onCopy = () => {
|
|
122
|
+
navigator.clipboard.writeText(text).then(
|
|
123
|
+
() => {
|
|
124
|
+
setCopied(true)
|
|
125
|
+
window.setTimeout(() => setCopied(false), 1500)
|
|
126
|
+
},
|
|
127
|
+
() => { /* best-effort */ },
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
return (
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
onClick={onCopy}
|
|
134
|
+
className="flex-shrink-0 p-1 rounded text-theme-text-tertiary hover:text-theme-text-secondary hover:bg-theme-hover"
|
|
135
|
+
title={copied ? 'Copied' : 'Copy error'}
|
|
136
|
+
aria-label={copied ? 'Copied' : 'Copy error'}
|
|
137
|
+
>
|
|
138
|
+
{copied ? <Check className="h-3 w-3" aria-hidden /> : <Copy className="h-3 w-3" aria-hidden />}
|
|
139
|
+
</button>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
@@ -5,6 +5,7 @@ export { MiddleEllipsis } from './MiddleEllipsis'
|
|
|
5
5
|
export type { MiddleEllipsisProps } from './MiddleEllipsis'
|
|
6
6
|
export { EmptyState } from './EmptyState'
|
|
7
7
|
export type { EmptyStateTone, EmptyStateVariant } from './EmptyState'
|
|
8
|
+
export { FetchResult } from './FetchResult'
|
|
8
9
|
export { FilterPill } from './FilterPill'
|
|
9
10
|
export type { FilterPillTone } from './FilterPill'
|
|
10
11
|
export { StatusDot, mapHealthToTone } from './status-tone'
|
|
@@ -2,7 +2,7 @@ import { useState, useMemo, useEffect, useRef, useCallback, type ReactNode } fro
|
|
|
2
2
|
import { flushSync } from 'react-dom'
|
|
3
3
|
import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
|
|
4
4
|
import { startViewTransitionSafe } from '../../utils/view-transition'
|
|
5
|
-
import {
|
|
5
|
+
import { FetchResult } from '../ui/FetchResult'
|
|
6
6
|
import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
|
|
7
7
|
import { clsx } from 'clsx'
|
|
8
8
|
import {
|
|
@@ -80,6 +80,9 @@ interface WorkloadViewProps {
|
|
|
80
80
|
certificateInfo?: any
|
|
81
81
|
/** Whether the resource is loading */
|
|
82
82
|
isLoading?: boolean
|
|
83
|
+
/** Fetch error for the resource (preserves status + message so the
|
|
84
|
+
* drawer body can distinguish 403/404/503 from "no data"). */
|
|
85
|
+
resourceError?: unknown
|
|
83
86
|
/** Function to refetch the resource data */
|
|
84
87
|
refetch?: () => void
|
|
85
88
|
|
|
@@ -187,6 +190,7 @@ export function WorkloadView({
|
|
|
187
190
|
relationships,
|
|
188
191
|
certificateInfo,
|
|
189
192
|
isLoading: resourceLoading = false,
|
|
193
|
+
resourceError,
|
|
190
194
|
refetch: refetchProp,
|
|
191
195
|
// Timeline
|
|
192
196
|
allEvents,
|
|
@@ -480,10 +484,8 @@ export function WorkloadView({
|
|
|
480
484
|
|
|
481
485
|
{/* Content — viewTransitionName scopes View Transitions API cross-fade to this element */}
|
|
482
486
|
<div className="flex-1 overflow-y-auto" style={{ viewTransitionName: 'drawer-content' }}>
|
|
483
|
-
{
|
|
484
|
-
<
|
|
485
|
-
) : !resource ? (
|
|
486
|
-
<div className="flex items-center justify-center h-32 text-theme-text-tertiary">Resource not found</div>
|
|
487
|
+
{!resource ? (
|
|
488
|
+
<FetchResult loading={resourceLoading} error={resourceError} className="h-32" />
|
|
487
489
|
) : showYaml ? (
|
|
488
490
|
<EditableYamlView
|
|
489
491
|
resource={selectedResource}
|
|
@@ -660,6 +662,7 @@ export function WorkloadView({
|
|
|
660
662
|
selectedResource={selectedResource}
|
|
661
663
|
relationships={relationships}
|
|
662
664
|
isLoading={resourceLoading}
|
|
665
|
+
error={resourceError}
|
|
663
666
|
onNavigate={onNavigateToResource}
|
|
664
667
|
onCopy={copyToClipboard}
|
|
665
668
|
copied={copied}
|
|
@@ -711,10 +714,8 @@ export function WorkloadView({
|
|
|
711
714
|
)}
|
|
712
715
|
{activeTab === 'yaml' && (
|
|
713
716
|
<div className="h-full overflow-auto">
|
|
714
|
-
{
|
|
715
|
-
<
|
|
716
|
-
) : !resource ? (
|
|
717
|
-
<div className="flex items-center justify-center h-32 text-theme-text-tertiary">Resource not found</div>
|
|
717
|
+
{!resource ? (
|
|
718
|
+
<FetchResult loading={resourceLoading} error={resourceError} className="h-32" />
|
|
718
719
|
) : (
|
|
719
720
|
<EditableYamlView
|
|
720
721
|
resource={selectedResource}
|
|
@@ -1165,6 +1166,7 @@ function InfoTab({
|
|
|
1165
1166
|
selectedResource,
|
|
1166
1167
|
relationships,
|
|
1167
1168
|
isLoading,
|
|
1169
|
+
error,
|
|
1168
1170
|
onNavigate,
|
|
1169
1171
|
onCopy,
|
|
1170
1172
|
copied,
|
|
@@ -1185,6 +1187,7 @@ function InfoTab({
|
|
|
1185
1187
|
selectedResource: SelectedResource
|
|
1186
1188
|
relationships?: Relationships
|
|
1187
1189
|
isLoading: boolean
|
|
1190
|
+
error?: unknown
|
|
1188
1191
|
onNavigate?: NavigateToResource
|
|
1189
1192
|
onCopy: (text: string, key: string) => void
|
|
1190
1193
|
copied: string | null
|
|
@@ -1201,16 +1204,8 @@ function InfoTab({
|
|
|
1201
1204
|
updatesError?: Error | null
|
|
1202
1205
|
extraContent?: ReactNode
|
|
1203
1206
|
}) {
|
|
1204
|
-
if (isLoading) {
|
|
1205
|
-
return <PaneLoader className="h-full" />
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
1207
|
if (!resource) {
|
|
1209
|
-
return
|
|
1210
|
-
<div className="flex items-center justify-center h-full text-theme-text-tertiary">
|
|
1211
|
-
Resource not found
|
|
1212
|
-
</div>
|
|
1213
|
-
)
|
|
1208
|
+
return <FetchResult loading={isLoading} error={error} className="h-full" />
|
|
1214
1209
|
}
|
|
1215
1210
|
|
|
1216
1211
|
return (
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { isFetchError, isForbiddenError } from './fetch-error'
|
|
3
|
+
|
|
4
|
+
function shaped(message: string, status: number) {
|
|
5
|
+
return Object.assign(new Error(message), { status })
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
describe('isFetchError', () => {
|
|
9
|
+
it('accepts an Error decorated with a numeric status', () => {
|
|
10
|
+
expect(isFetchError(shaped('forbidden', 403))).toBe(true)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('accepts a plain object with status and message', () => {
|
|
14
|
+
expect(isFetchError({ status: 500, message: 'boom' })).toBe(true)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('rejects a network failure without a status field', () => {
|
|
18
|
+
expect(isFetchError(new Error('Failed to fetch'))).toBe(false)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('rejects abort/cancel DOMException-style throws (no status)', () => {
|
|
22
|
+
const aborted = new Error('The user aborted a request.')
|
|
23
|
+
aborted.name = 'AbortError'
|
|
24
|
+
expect(isFetchError(aborted)).toBe(false)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('rejects undefined, null, primitives', () => {
|
|
28
|
+
expect(isFetchError(undefined)).toBe(false)
|
|
29
|
+
expect(isFetchError(null)).toBe(false)
|
|
30
|
+
expect(isFetchError('forbidden')).toBe(false)
|
|
31
|
+
expect(isFetchError(403)).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('rejects an object with a non-numeric status', () => {
|
|
35
|
+
expect(isFetchError({ status: '403', message: 'forbidden' })).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('isForbiddenError', () => {
|
|
40
|
+
it('is true only for 403 on a fetch-error shape', () => {
|
|
41
|
+
expect(isForbiddenError(shaped('nope', 403))).toBe(true)
|
|
42
|
+
expect(isForbiddenError(shaped('nope', 404))).toBe(false)
|
|
43
|
+
expect(isForbiddenError(new Error('Failed to fetch'))).toBe(false)
|
|
44
|
+
expect(isForbiddenError(null)).toBe(false)
|
|
45
|
+
})
|
|
46
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// FetchErrorShape is the duck-typed contract every fetch error in the
|
|
2
|
+
// app already satisfies — both web/'s ApiError and radar-hub-web's
|
|
3
|
+
// ApiError expose .status and .message. Living in @skyhook-io/k8s-ui
|
|
4
|
+
// without importing either lets presentational components (FetchResult,
|
|
5
|
+
// ResourcesView's forbidden-kind sidebar) classify errors uniformly
|
|
6
|
+
// across the OSS binary and Radar Hub.
|
|
7
|
+
export interface FetchErrorShape {
|
|
8
|
+
status: number
|
|
9
|
+
message: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isFetchError(error: unknown): error is FetchErrorShape {
|
|
13
|
+
if (typeof error !== 'object' || error === null) return false
|
|
14
|
+
const e = error as Record<string, unknown>
|
|
15
|
+
return typeof e.status === 'number' && typeof e.message === 'string'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isForbiddenError(error: unknown): boolean {
|
|
19
|
+
return isFetchError(error) && error.status === 403
|
|
20
|
+
}
|