@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.7.4",
3
+ "version": "1.7.6",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -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
- onRowClick: (row: GitOpsRow) => void
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
- <tr
1057
- key={row.id}
1058
- onClick={() => onOpen(row)}
1059
- className={clsx(
1060
- 'cursor-pointer border-b border-theme-border bg-theme-base hover:bg-theme-hover',
1061
- row.terminating && 'opacity-70',
1062
- )}
1063
- >
1064
- <TableCell>
1065
- <div className="flex min-w-0 items-center gap-2">
1066
- <span className={`h-8 w-1 shrink-0 rounded-full ${statusStripe(row)}`} />
1067
- {row.terminating && (
1068
- <Tooltip content="Pending deletion finalizers still running">
1069
- <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">
1070
- <Trash2 className="h-3 w-3" />
1071
- Terminating
1072
- </span>
1073
- </Tooltip>
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
- </div>
1087
- </TableCell>
1088
- <TableCell>{row.project || '-'}</TableCell>
1089
- <TableCell>
1090
- {row.terminating
1091
- ? <span className="text-[11px] text-theme-text-tertiary">—</span>
1092
- : <SyncStatusBadge sync={row.sync as any} suspended={row.suspended} />}
1093
- </TableCell>
1094
- <TableCell>
1095
- {row.terminating
1096
- ? <span className="text-[11px] text-theme-text-tertiary">—</span>
1097
- : <HealthStatusBadge health={row.health as any} />}
1098
- </TableCell>
1099
- <TableCell>
1100
- <div className="truncate text-theme-text-primary">{row.repository || row.chart || '-'}</div>
1101
- <div className="truncate text-xs text-theme-text-tertiary">{[row.targetRevision, row.path || row.chart].filter(Boolean).join(' · ') || '-'}</div>
1102
- </TableCell>
1103
- <TableCell>
1104
- <DestinationCell row={row} onDestinationClick={onDestinationClick} />
1105
- <div className="truncate text-xs text-theme-text-tertiary">{row.destinationNamespace || row.namespace || '-'}</div>
1106
- </TableCell>
1107
- <TableCell>
1108
- {row.terminating
1109
- ? <span className="text-orange-400/80">Pending {formatRelativeAge(row.terminationStartedAt ?? '') || 'now'}</span>
1110
- : formatRelativeAge(row.lastSync || row.createdAt)}
1111
- </TableCell>
1112
- </tr>
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
- return (
1149
- <button
1150
- type="button"
1151
- onClick={() => onOpen(row)}
1152
- className={clsx(
1153
- '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',
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={handleClick}
1307
- className={
1308
- '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 ' +
1309
- (highConfidence
1310
- ? 'border border-sky-500/50 bg-sky-500/15 text-sky-700'
1311
- : 'border border-sky-500/25 bg-sky-500/5 text-sky-600')
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
- {highConfidence ? '✓ ' : ''}{short}
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 cursor-pointer transition-colors',
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 resource={resource} kind={kind} group={group} column={col.key} majorityNodeMinorVersion={majorityNodeMinorVersion} extraColumn={extraColumnsByKey?.get(col.key)} />
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
- <span className={clsx('text-sm font-medium truncate block', isTerminating ? 'text-theme-text-tertiary line-through' : 'text-theme-text-primary')}>
4426
- {meta.name}
4427
- </span>
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
- errorStatus(error) === 503 && error.message.includes('RBAC cache')
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 above. Surfaces that treat
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 isRBACForbidden(error) || isRBACCacheUnavailable(error)
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 (isRBACForbidden(error)) {
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 failed; the section shows a tactful note instead of silence. */
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
- <div className="text-sm text-theme-text-tertiary">
167
- Could not resolve referenced role
168
- {roleName ? ` "${roleName}"` : ''} — it may not exist (orphan binding)
169
- or be unavailable.
170
- </div>
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 { PaneLoader } from '../ui/PaneLoader'
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
- {resourceLoading ? (
484
- <PaneLoader className="h-32" />
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
- {resourceLoading ? (
715
- <PaneLoader className="h-32" />
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
+ }
@@ -1,4 +1,5 @@
1
1
  export * from './core'
2
+ export * from './fetch-error'
2
3
  export * from './gitops'
3
4
  export * from './gitops-tree'
4
5
  export * from './gitops-insights'