@skyhook-io/k8s-ui 1.7.5 → 1.7.7

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.
@@ -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 {
@@ -228,6 +229,13 @@ const TAILWIND_WIDTH_TO_PX: Record<string, number> = {
228
229
  'w-48': 192, 'w-56': 224, 'w-64': 256,
229
230
  }
230
231
 
232
+ const COMPARE_COLUMN_WIDTH = 36
233
+ const COMPARE_COLUMN_STYLE: React.CSSProperties = {
234
+ width: COMPARE_COLUMN_WIDTH,
235
+ minWidth: COMPARE_COLUMN_WIDTH,
236
+ maxWidth: COMPARE_COLUMN_WIDTH,
237
+ }
238
+
231
239
  function getColumnMinWidth(col: Column): number {
232
240
  if (col.minWidth) return col.minWidth
233
241
  if (!col.width) return 200 // Name column (no width class) gets wider minimum
@@ -1681,11 +1689,6 @@ interface ResourcesViewData {
1681
1689
 
1682
1690
  export const ResourcesViewDataContext = React.createContext<ResourcesViewData>({})
1683
1691
 
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
1692
  export interface ResourceQueryResult {
1690
1693
  data?: any[]
1691
1694
  isLoading: boolean
@@ -1756,6 +1759,15 @@ interface ResourcesViewProps {
1756
1759
  * should also wire onResourceClick to handle the deep-link-on-load
1757
1760
  * case. */
1758
1761
  onRowSelect?: (resource: any) => void
1762
+ /**
1763
+ * When provided, the name cell renders as a real `<a href>` instead of
1764
+ * relying on per-cell click handlers for navigation. Restores ⌘-click /
1765
+ * middle-click / "Copy link" / hover URL preview / screen-reader link
1766
+ * semantics. Hosts using full-page navigation should prefer this over
1767
+ * `onRowSelect`; the anchor will own navigation and the rest of the row
1768
+ * remains clickable for selection (drawer open).
1769
+ */
1770
+ rowHrefFor?: (resource: any) => string
1759
1771
  /**
1760
1772
  * Overrides the default compare-mode submit (which navigates to
1761
1773
  * `/compare?kind=...&a=...&b=...`). Hosts use this to route to a
@@ -1927,6 +1939,7 @@ export function ResourcesView({
1927
1939
  defaultKind = DEFAULT_KIND_INFO,
1928
1940
  extraLeadingColumns,
1929
1941
  onRowSelect,
1942
+ rowHrefFor,
1930
1943
  onCompareSubmit,
1931
1944
  resolveRowCluster,
1932
1945
  onClearNamespaces,
@@ -3361,6 +3374,18 @@ export function ResourcesView({
3361
3374
  return allColumns.filter(c => visibleColumns.has(c.key))
3362
3375
  }, [allColumns, visibleColumns])
3363
3376
 
3377
+ // Fixed-width columns can consume the table's flexible space and collapse
3378
+ // the required name column. Keep a real table minimum and let the container
3379
+ // scroll horizontally when the viewport is too narrow.
3380
+ const tableMinWidth = useMemo(() => {
3381
+ const compareColumnWidth = compareMode ? COMPARE_COLUMN_WIDTH : 0
3382
+ const baseMinWidth = columns.reduce((sum, col) => sum + (columnWidths[col.key] || getColumnMinWidth(col)), compareColumnWidth)
3383
+ const flexibleNameColumn = columns.find(col => col.key === 'name' && !columnWidths[col.key])
3384
+
3385
+ if (!hasResizedColumns || !flexibleNameColumn) return baseMinWidth
3386
+ return baseMinWidth + getColumnMinWidth(flexibleNameColumn)
3387
+ }, [columns, columnWidths, compareMode, hasResizedColumns])
3388
+
3364
3389
  // Stable virtuoso components — memoized to avoid remounting the table on every render
3365
3390
  const virtuosoComponents = useMemo(() => ({
3366
3391
  Table: React.forwardRef<HTMLTableElement, React.TableHTMLAttributes<HTMLTableElement>>(function VirtuosoTable(props, ref) {
@@ -3369,7 +3394,7 @@ export function ResourcesView({
3369
3394
  {...props}
3370
3395
  ref={ref}
3371
3396
  className="w-full"
3372
- style={{ ...props.style, tableLayout: 'fixed' }}
3397
+ style={{ ...props.style, tableLayout: 'fixed', minWidth: tableMinWidth }}
3373
3398
  >
3374
3399
  <colgroup>
3375
3400
  {/*
@@ -3379,7 +3404,7 @@ export function ResourcesView({
3379
3404
  the missing entry by stealing width from a sized neighbour
3380
3405
  — typically blowing this narrow column out to ~200px.
3381
3406
  */}
3382
- {compareMode && <col style={{ width: 36 }} />}
3407
+ {compareMode && <col style={{ width: COMPARE_COLUMN_WIDTH }} />}
3383
3408
  {columns.map(col => (
3384
3409
  <col
3385
3410
  key={col.key}
@@ -3397,7 +3422,7 @@ export function ResourcesView({
3397
3422
  )
3398
3423
  }),
3399
3424
  TableRow: VirtuosoTableRow,
3400
- }), [columns, columnWidths, hasResizedColumns, compareMode])
3425
+ }), [columns, columnWidths, hasResizedColumns, compareMode, tableMinWidth])
3401
3426
 
3402
3427
  // Calculate filter options with counts based on current resources (before filtering)
3403
3428
  const filterOptions = useMemo(() => {
@@ -3943,7 +3968,7 @@ export function ResourcesView({
3943
3968
 
3944
3969
  {/* Table */}
3945
3970
  <div
3946
- className="flex-1 overflow-y-auto overflow-x-hidden relative"
3971
+ className="flex-1 overflow-auto relative"
3947
3972
  ref={tableContainerRef}
3948
3973
  onClick={(e) => {
3949
3974
  if (e.target === e.currentTarget && selectedResource) {
@@ -4056,7 +4081,7 @@ export function ResourcesView({
4056
4081
  // Inline px width — under `table-layout:fixed`,
4057
4082
  // `w-9` is a hint the browser absorbs into leftover
4058
4083
  // row width on an icon-only column.
4059
- style={{ width: 36, minWidth: 36, maxWidth: 36 }}
4084
+ style={COMPARE_COLUMN_STYLE}
4060
4085
  className="px-2 py-3 text-xs font-medium uppercase tracking-wide bg-theme-base border-b border-r-subtle border-theme-border text-center text-skyhook-400"
4061
4086
  title="Compare mode"
4062
4087
  >
@@ -4256,6 +4281,7 @@ export function ResourcesView({
4256
4281
  onMouseEnter={() => setHighlightedIndex(-1)}
4257
4282
  compareMode={compareMode}
4258
4283
  comparePickIndex={pickIdx}
4284
+ rowHref={rowHrefFor?.(resource)}
4259
4285
  />
4260
4286
  )
4261
4287
  }}
@@ -4301,6 +4327,10 @@ interface ResourceRowCellsProps {
4301
4327
  compareMode?: boolean
4302
4328
  /** -1 when not picked; 0 = pick A; 1 = pick B. */
4303
4329
  comparePickIndex?: number
4330
+ /** When provided, the name cell renders as `<a href>` and the other
4331
+ * data cells drop their click handlers. The compare-mode chip column
4332
+ * is unaffected (still toggles picks). */
4333
+ rowHref?: string
4304
4334
  }
4305
4335
 
4306
4336
  function rowHighlightClass(
@@ -4320,16 +4350,20 @@ function rowHighlightClass(
4320
4350
  return 'group-hover/row:bg-theme-surface/50'
4321
4351
  }
4322
4352
 
4323
- function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, hasSpacerColumn, isSelected, isHighlighted, majorityNodeMinorVersion, onClick, onMouseEnter, compareMode, comparePickIndex = -1 }: ResourceRowCellsProps) {
4353
+ function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, hasSpacerColumn, isSelected, isHighlighted, majorityNodeMinorVersion, onClick, onMouseEnter, compareMode, comparePickIndex = -1, rowHref }: ResourceRowCellsProps) {
4324
4354
  const rowHighlight = rowHighlightClass(compareMode, comparePickIndex, isSelected, isHighlighted)
4325
4355
  const pickedSide = comparePickIndex === 0 ? 'a' : comparePickIndex === 1 ? 'b' : null
4356
+ // When the host supplies an anchor, drop per-cell onClick for the data
4357
+ // columns: the anchor is the only navigation surface. The compare-mode
4358
+ // chip column keeps its onClick so pick toggling still works.
4359
+ const cellsAreClickable = !rowHref
4326
4360
  return (
4327
4361
  <>
4328
4362
  {compareMode && (
4329
4363
  <td
4330
4364
  onClick={onClick}
4331
4365
  onMouseEnter={onMouseEnter}
4332
- style={{ width: 36, minWidth: 36, maxWidth: 36 }}
4366
+ style={COMPARE_COLUMN_STYLE}
4333
4367
  className={clsx('px-2 py-3 border-b-subtle cursor-pointer text-center align-middle transition-colors', rowHighlight)}
4334
4368
  >
4335
4369
  {pickedSide ? (
@@ -4353,15 +4387,24 @@ function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, h
4353
4387
  {columns.map((col) => (
4354
4388
  <td
4355
4389
  key={col.key}
4356
- onClick={onClick}
4390
+ onClick={cellsAreClickable ? onClick : undefined}
4357
4391
  onMouseEnter={onMouseEnter}
4358
4392
  className={clsx(
4359
- 'px-4 py-3 border-b-subtle cursor-pointer transition-colors',
4393
+ 'px-4 py-3 border-b-subtle transition-colors',
4394
+ cellsAreClickable && 'cursor-pointer',
4360
4395
  col.key !== 'status' && 'overflow-hidden truncate',
4361
4396
  rowHighlight,
4362
4397
  )}
4363
4398
  >
4364
- <CellContent resource={resource} kind={kind} group={group} column={col.key} majorityNodeMinorVersion={majorityNodeMinorVersion} extraColumn={extraColumnsByKey?.get(col.key)} />
4399
+ <CellContent
4400
+ resource={resource}
4401
+ kind={kind}
4402
+ group={group}
4403
+ column={col.key}
4404
+ majorityNodeMinorVersion={majorityNodeMinorVersion}
4405
+ extraColumn={extraColumnsByKey?.get(col.key)}
4406
+ nameHref={col.key === 'name' ? rowHref : undefined}
4407
+ />
4365
4408
  </td>
4366
4409
  ))}
4367
4410
  {hasSpacerColumn && <td className="border-b-subtle p-0" />}
@@ -4405,9 +4448,12 @@ interface CellContentProps {
4405
4448
  * column key. Render via the extra's render() and short-circuit
4406
4449
  * the built-in cell logic. */
4407
4450
  extraColumn?: ExtraColumn
4451
+ /** When set on the name column, the resource name renders as `<a href>`
4452
+ * so ⌘-click / copy-link / hover-URL all work. */
4453
+ nameHref?: string
4408
4454
  }
4409
4455
 
4410
- function CellContent({ resource, kind, column, group, majorityNodeMinorVersion, extraColumn }: CellContentProps) {
4456
+ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion, extraColumn, nameHref }: CellContentProps) {
4411
4457
  // Parent-injected extra columns short-circuit the built-in switch.
4412
4458
  // Used by hosts that inject leading columns (e.g. a multi-cluster Cluster column).
4413
4459
  if (extraColumn) {
@@ -4419,12 +4465,22 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
4419
4465
  // Common columns
4420
4466
  if (column === 'name') {
4421
4467
  const isTerminating = !!meta.deletionTimestamp
4468
+ const nameClass = clsx('text-sm font-medium truncate block', isTerminating ? 'text-theme-text-tertiary line-through' : 'text-theme-text-primary')
4422
4469
  return (
4423
4470
  <div className="flex items-center gap-1.5 min-w-0">
4424
4471
  <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>
4472
+ {nameHref ? (
4473
+ <a
4474
+ href={nameHref}
4475
+ className={clsx(nameClass, 'hover:underline focus-visible:underline focus-visible:outline-none rounded-sm')}
4476
+ >
4477
+ {meta.name}
4478
+ </a>
4479
+ ) : (
4480
+ <span className={nameClass}>
4481
+ {meta.name}
4482
+ </span>
4483
+ )}
4428
4484
  </Tooltip>
4429
4485
  <CopyNameButton name={meta.name} />
4430
4486
  {isTerminating && (
@@ -1,4 +1,4 @@
1
- import { GitBranch, FolderTree, Settings, Target, XCircle, History, ListChecks } from 'lucide-react'
1
+ import { GitBranch, FolderTree, Settings, Target, XCircle, History, ListChecks, ExternalLink } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Section, PropertyList, Property, ConditionsSection, ProblemAlerts } from '../../ui/drawer-components'
4
4
  import { formatAge } from '../resource-utils'
@@ -10,6 +10,9 @@ import {
10
10
  type ArgoResource,
11
11
  } from '../../../types/gitops'
12
12
  import { BADGE_INACTIVE } from '../../../utils/badge-colors'
13
+ import { buildRepoBrowseUrl, buildPathBrowseUrl } from '../../../utils/git-provider-urls'
14
+
15
+ const REPO_LINK_CLASS = 'text-blue-400 hover:text-blue-300 hover:underline break-all'
13
16
 
14
17
  interface ArgoApplicationRendererProps {
15
18
  data: any
@@ -19,10 +22,51 @@ interface ArgoApplicationRendererProps {
19
22
 
20
23
  function SourceProperties({ source }: { source: any }) {
21
24
  if (!source) return null
25
+ // Helm chart sources point repoURL at a chart registry, not a browseable git repo.
26
+ const isHelmSource = !!source.chart
27
+ const repoHref = isHelmSource ? null : buildRepoBrowseUrl(source.repoURL)
28
+ const pathHref = isHelmSource ? null : buildPathBrowseUrl(source.repoURL, source.path, source.targetRevision)
22
29
  return (
23
30
  <>
24
- <Property label="Repository" value={source.repoURL} />
25
- {source.path && <Property label="Path" value={source.path} />}
31
+ <Property
32
+ label="Repository"
33
+ value={
34
+ repoHref ? (
35
+ <a
36
+ href={repoHref}
37
+ target="_blank"
38
+ rel="noopener noreferrer"
39
+ title={source.repoURL}
40
+ className={`${REPO_LINK_CLASS} inline-flex items-center gap-1`}
41
+ >
42
+ {source.repoURL}
43
+ <ExternalLink className="w-3 h-3 shrink-0" />
44
+ </a>
45
+ ) : (
46
+ source.repoURL
47
+ )
48
+ }
49
+ />
50
+ {source.path && (
51
+ <Property
52
+ label="Path"
53
+ value={
54
+ pathHref ? (
55
+ <a
56
+ href={pathHref}
57
+ target="_blank"
58
+ rel="noopener noreferrer"
59
+ title={source.path}
60
+ className={REPO_LINK_CLASS}
61
+ >
62
+ {source.path}
63
+ </a>
64
+ ) : (
65
+ source.path
66
+ )
67
+ }
68
+ />
69
+ )}
26
70
  {source.targetRevision && (
27
71
  <Property
28
72
  label="Target Revision"
@@ -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.
@@ -57,6 +57,24 @@ export function getArgoApplicationStatus(app: any): StatusBadge {
57
57
  return { text: health || sync || 'Unknown', color: healthColors.unknown, level: 'unknown' }
58
58
  }
59
59
 
60
+ // Radar suspends an Argo Application by clearing spec.syncPolicy.automated and
61
+ // recording the prior prune/selfHeal flags in annotations so Resume can restore
62
+ // them. The *presence* of any of these annotations marks the app as suspended —
63
+ // independent of health.status, which stays whatever the app's resources report
64
+ // (often Missing/OutOfSync, never literally "Suspended"). Both the current
65
+ // radarhq.io keys and the legacy skyhook.io keys (still on apps suspended by
66
+ // older builds) count. Shared so the fleet table and the detail page can't
67
+ // disagree on whether an app is suspended.
68
+ export function isArgoSuspendedByRadar(app: any): boolean {
69
+ const a = app?.metadata?.annotations
70
+ return Boolean(
71
+ a?.['radarhq.io/suspended-prune'] ||
72
+ a?.['radarhq.io/suspended-selfheal'] ||
73
+ a?.['skyhook.io/suspended-prune'] ||
74
+ a?.['skyhook.io/suspended-selfheal'],
75
+ )
76
+ }
77
+
60
78
  // ============================================================================
61
79
  // ARGOCD TABLE CELL UTILITIES
62
80
  // ============================================================================
@@ -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
+ })