@skyhook-io/k8s-ui 1.14.9 → 1.14.11

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.
Files changed (80) hide show
  1. package/package.json +3 -2
  2. package/src/components/applications/ApplicationDetail.tsx +24 -11
  3. package/src/components/audit/AuditAlerts.tsx +1 -3
  4. package/src/components/audit/AuditCoveragePresentation.test.tsx +48 -2
  5. package/src/components/checks/ChecksView.test.tsx +55 -0
  6. package/src/components/checks/ChecksView.tsx +38 -27
  7. package/src/components/dock/NodeTerminalTab.test.tsx +89 -0
  8. package/src/components/dock/NodeTerminalTab.tsx +51 -47
  9. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +17 -9
  10. package/src/components/gitops/detail-helpers.test.ts +32 -0
  11. package/src/components/gitops/detail-helpers.ts +21 -0
  12. package/src/components/gitops/health-provenance.test.ts +4 -3
  13. package/src/components/gitops/index.ts +2 -1
  14. package/src/components/gitops/insights/ArgoResourceDiff.tsx +131 -54
  15. package/src/components/issues/IssuesView.tsx +6 -0
  16. package/src/components/issues/ResourceIssuesSection.test.tsx +36 -0
  17. package/src/components/issues/diagnostic.ts +4 -0
  18. package/src/components/logs/LogCore.tsx +3 -3
  19. package/src/components/logs/WorkloadLogsViewer.tsx +41 -11
  20. package/src/components/logs/useLogBuffer.ts +1 -0
  21. package/src/components/logs/useLogStream.ts +1 -2
  22. package/src/components/resources/KueueAdmissionSection.test.tsx +95 -0
  23. package/src/components/resources/KueueAdmissionSection.tsx +87 -0
  24. package/src/components/resources/ResourcesView.default-sort.test.ts +26 -0
  25. package/src/components/resources/ResourcesView.tsx +91 -16
  26. package/src/components/resources/curated-column-ownership.test.ts +45 -0
  27. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +4 -3
  28. package/src/components/resources/renderers/ContainerEnvironmentSection.tsx +10 -3
  29. package/src/components/resources/renderers/JobSetRenderer.test.tsx +280 -0
  30. package/src/components/resources/renderers/JobSetRenderer.tsx +322 -0
  31. package/src/components/resources/renderers/KueueProvisioningRenderers.test.tsx +67 -0
  32. package/src/components/resources/renderers/KueueProvisioningRenderers.tsx +91 -0
  33. package/src/components/resources/renderers/KueueQueueNavigation.test.tsx +69 -0
  34. package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +123 -0
  35. package/src/components/resources/renderers/KueueQueueRenderers.tsx +299 -0
  36. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +121 -0
  37. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +230 -0
  38. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +25 -0
  39. package/src/components/resources/renderers/RayClusterRenderer.tsx +93 -0
  40. package/src/components/resources/renderers/RayServiceRenderer.test.tsx +62 -0
  41. package/src/components/resources/renderers/RayServiceRenderer.tsx +114 -0
  42. package/src/components/resources/renderers/index.ts +11 -0
  43. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +103 -2
  44. package/src/components/resources/resource-utils-jobset-lws.ts +43 -9
  45. package/src/components/resources/resource-utils-kueue.ts +145 -50
  46. package/src/components/resources/resource-utils-ray.test.ts +58 -1
  47. package/src/components/resources/resource-utils-ray.ts +56 -90
  48. package/src/components/shared/ResourceRendererDispatch.test.tsx +117 -2
  49. package/src/components/shared/ResourceRendererDispatch.tsx +47 -10
  50. package/src/components/timeline/TimelineList.tsx +5 -0
  51. package/src/components/timeline/TimelineSwimlanes.test.tsx +15 -0
  52. package/src/components/timeline/TimelineSwimlanes.tsx +43 -23
  53. package/src/components/topology/K8sResourceNode.tsx +3 -3
  54. package/src/components/ui/CodeViewer.tsx +4 -0
  55. package/src/components/ui/Toast.tsx +9 -3
  56. package/src/components/ui/drawer-components.tsx +3 -1
  57. package/src/components/workload/PodList.tsx +154 -0
  58. package/src/components/workload/WorkloadView.tsx +29 -159
  59. package/src/components/workload/index.ts +11 -1
  60. package/src/components/workload/workload-logs-availability.test.ts +20 -0
  61. package/src/components/workload/workload-pod-presentation.test.ts +27 -0
  62. package/src/types/core.ts +24 -0
  63. package/src/types/gitops-tree.ts +5 -2
  64. package/src/types/scheduling.ts +71 -0
  65. package/src/utils/api-resources.test.ts +20 -1
  66. package/src/utils/api-resources.ts +26 -0
  67. package/src/utils/application-topology.test.ts +25 -0
  68. package/src/utils/application-topology.ts +2 -1
  69. package/src/utils/applications.test.ts +82 -0
  70. package/src/utils/applications.ts +62 -24
  71. package/src/utils/gitops-route.test.ts +20 -1
  72. package/src/utils/gitops-route.ts +15 -7
  73. package/src/utils/log-format.ts +5 -2
  74. package/src/utils/log-stream-error.test.ts +13 -0
  75. package/src/utils/navigation.test.ts +2 -0
  76. package/src/utils/navigation.ts +1 -1
  77. package/src/utils/resource-hierarchy.test.ts +402 -15
  78. package/src/utils/resource-hierarchy.ts +263 -178
  79. package/src/utils/topology-neighborhood.test.ts +72 -1
  80. package/src/utils/topology-neighborhood.ts +40 -18
@@ -0,0 +1,95 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { KueueAdmissionSection } from './KueueAdmissionSection'
4
+ import type { KueueAdmissionResponse, SchedulingObservation } from '../../types/scheduling'
5
+
6
+ const ref = { kind: 'Workload', group: 'kueue.x-k8s.io', namespace: 'ml', name: 'training-abc' }
7
+ function response(observation: Partial<SchedulingObservation> = {}): KueueAdmissionResponse {
8
+ return { uid: 'root-uid', installed: true, total: 1, truncated: false, workloads: [{
9
+ apiVersion: 'kueue.x-k8s.io/v1beta2', name: ref.name, namespace: 'ml', uid: 'uid', generation: 2, createdAt: null, deleting: false, ref, projection: 'available', linksLimited: false,
10
+ scheduling: { observations: [{ source: 'kueue', domain: 'admission', subject: ref, subjectGeneration: 2, decision: 'unsatisfied', kueue: { phase: 'pending' }, ...observation }] },
11
+ }] }
12
+ }
13
+ function render(data?: KueueAdmissionResponse, props: Partial<React.ComponentProps<typeof KueueAdmissionSection>> = {}) {
14
+ return renderToStaticMarkup(<KueueAdmissionSection data={data} loading={false} hinted={false} externalExecution={false} onNavigate={() => {}} {...props} />)
15
+ }
16
+
17
+ describe('Kueue admission investigation', () => {
18
+ it('hides confirmed empty unhinted roots but preserves unavailable and in-flight evidence', () => {
19
+ const empty = { uid: 'root-uid', installed: true, total: 0, truncated: false, workloads: [] }
20
+ expect(render(empty)).toBe('')
21
+ expect(render(empty, { hinted: true })).toContain('No controller-owned Kueue Workload observed')
22
+ expect(render(undefined, { loading: true })).toContain('Looking for controller-owned Workloads')
23
+ expect(render(empty, { error: 'Forbidden', onRetry: () => {} })).toContain('Admission evidence unavailable')
24
+ expect(render(empty, { error: 'Cache not ready' })).not.toContain('No controller-owned')
25
+ expect(render(empty, { error: 'Workload list denied', forbidden: true, onRetry: () => {} })).not.toContain('Retry admission lookup')
26
+ expect(render({ ...empty, installed: false }, { hinted: true })).toContain('not served by this cluster')
27
+ })
28
+
29
+ it('does not claim that local absence proves remote execution failed', () => {
30
+ expect(render({ uid: 'root-uid', installed: true, total: 0, truncated: false, workloads: [] }, { hinted: true, externalExecution: true })).toContain('local absence does not establish remote admission or execution state')
31
+ })
32
+
33
+ it('shows reported queue reasons and stale generation without inferring causality', () => {
34
+ const html = render(response({ primaryCondition: { type: 'QuotaReserved', status: 'False', reason: 'Inadmissible', message: 'ClusterQueue training is inactive', observedGeneration: 1 }, queues: [{ name: 'ready', roles: ['submission'] }] }))
35
+ expect(html).toContain('Inadmissible')
36
+ expect(html).toContain('ClusterQueue training is inactive')
37
+ expect(html).toContain('stale evidence')
38
+ expect(html).toContain('submission queue: ready')
39
+ expect(html).not.toContain('suspended because')
40
+ })
41
+
42
+ it.each([
43
+ ['pending', 'Pending admission'], ['quota_reserved', 'Quota reserved'], ['admitted', 'Admitted'], ['finished', 'Finished'],
44
+ ] as const)('preserves native phase %s', (phase, label) => {
45
+ const html = render(response({ decision: 'satisfied', kueue: { phase } }))
46
+ expect(html).toContain(label)
47
+ expect(html).toContain('does not prove that Jobs or Pods are running')
48
+ })
49
+
50
+ it('keeps disruptions, inactive state, check retries and requeues distinct', () => {
51
+ const html = render(response({ decision: 'held', kueue: { phase: 'pending', active: false, requeueState: { count: 0, requeueAt: '2026-09-22T00:00:00Z' } }, disruptions: [{ type: 'Evicted', status: 'True', reason: 'Preempted' }], gates: [{ kind: 'preemption_gate', name: 'gate', decision: 'unsatisfied', retryCount: 0, requeueAfterSeconds: 0 }] }))
52
+ expect(html).toContain('Workload inactive')
53
+ expect(html).toContain('Preempted')
54
+ expect(html).toContain('alone does not establish an admission blocker')
55
+ expect(html).toContain('Retries: 0')
56
+ expect(html).toContain('Requeues: 0')
57
+ })
58
+
59
+ it('does not turn unsupported or unreadable Workloads into an absent association', () => {
60
+ const data = response()
61
+ data.workloads[0].projection = 'unsupported'
62
+ data.workloads[0].apiVersion = 'kueue.x-k8s.io/v1beta1'
63
+ expect(render(data)).toContain('Scheduling projection is unavailable for kueue.x-k8s.io/v1beta1')
64
+ data.workloads[0].projection = 'forbidden'
65
+ data.workloads[0].ref = undefined
66
+ const html = render(data)
67
+ expect(html).toContain('permission to get this Workload is required')
68
+ expect(html).not.toContain('<button')
69
+ })
70
+
71
+ it('does not present an unrecognized finish reason as a successful outcome', () => {
72
+ const html = render(response({ decision: 'unknown', primaryCondition: { type: 'Finished', status: 'True', reason: 'CustomFinish' }, kueue: { phase: 'finished' } }))
73
+ expect(html).toContain('CustomFinish')
74
+ expect(html).toContain('lucide-minus')
75
+ expect(html).not.toContain('lucide-check')
76
+ })
77
+
78
+ it('shows a deactivation condition once when it is also the primary evidence', () => {
79
+ const condition = { type: 'DeactivationTarget', status: 'True', reason: 'AdmissionCheck' }
80
+ const html = render(response({ decision: 'held', primaryCondition: condition, disruptions: [condition], kueue: { phase: 'pending', active: false } }))
81
+ expect(html).toContain('Primary condition: DeactivationTarget')
82
+ expect(html.match(/AdmissionCheck/g)).toHaveLength(1)
83
+ })
84
+
85
+ it('keeps bounded records separate and never names the newest as current', () => {
86
+ const data = response()
87
+ data.total = 9
88
+ data.truncated = true
89
+ data.workloads.push({ ...data.workloads[0], uid: 'second', name: 'older', deleting: true })
90
+ const html = render(data)
91
+ expect(html).toContain('2 of 9 Workloads shown')
92
+ expect(html).toContain('order does not identify a current attempt')
93
+ expect(html).toContain('Deleting')
94
+ })
95
+ })
@@ -0,0 +1,87 @@
1
+ import { isKueueConditionStale } from './resource-utils-kueue'
2
+ import { Badge } from '../ui/Badge'
3
+ import { AlertBanner, ConditionsSection, ResourceLink } from '../ui/drawer-components'
4
+ import { kindToPluralWithGroup } from '../../utils/navigation'
5
+ import type { KueueAdmissionResponse, SchedulingObservation, SchedulingRef } from '../../types/scheduling'
6
+
7
+ interface KueueAdmissionSectionProps {
8
+ data?: KueueAdmissionResponse
9
+ loading: boolean
10
+ error?: string
11
+ forbidden?: boolean
12
+ hinted: boolean
13
+ externalExecution: boolean
14
+ onRetry?: () => void
15
+ onNavigate?: (ref: { kind: string; namespace: string; name: string; group?: string }) => void
16
+ }
17
+
18
+ const phases = { pending: 'Pending admission', quota_reserved: 'Quota reserved', admitted: 'Admitted', finished: 'Finished' }
19
+
20
+ export function KueueAdmissionSection({ data, loading, error, forbidden, hinted, externalExecution, onRetry, onNavigate }: KueueAdmissionSectionProps) {
21
+ if (!loading && !error && data && data.workloads.length === 0 && !hinted) return null
22
+ const link = (name: string, ref?: SchedulingRef) => ref
23
+ ? <ResourceLink {...ref} kind={kindToPluralWithGroup(ref.kind, ref.group ?? '')} label={name} onNavigate={onNavigate} />
24
+ : name
25
+ return (
26
+ <section className="rounded-lg border border-theme-border bg-theme-surface p-4" aria-label="Kueue admission">
27
+ <h3 className="text-sm font-semibold text-theme-text-primary">Kueue admission</h3>
28
+ <p className="mt-1 text-xs text-theme-text-secondary">Admission and execution are separate observations. An admitted Workload does not prove that Jobs or Pods are running.</p>
29
+ {loading ? <p className="mt-3 text-sm text-theme-text-secondary">Looking for controller-owned Workloads…</p>
30
+ : error ? <div className="mt-3 [&>div]:mb-0"><AlertBanner variant={forbidden ? 'info' : 'warning'} title={forbidden ? 'Admission lookup requires permission' : 'Admission evidence unavailable'} message={error}>{!forbidden && onRetry && <button type="button" className="mt-2 text-accent-text hover:underline" onClick={onRetry}>Retry admission lookup</button>}</AlertBanner></div>
31
+ : data && !data.installed ? <p className="mt-3 text-sm text-theme-text-secondary">Kueue Workloads are not served by this cluster.</p>
32
+ : data && data.workloads.length === 0 ? <p className="mt-3 text-sm text-theme-text-secondary">No controller-owned Kueue Workload observed in this namespace.{externalExecution ? ' This JobSet uses an external controller; local absence does not establish remote admission or execution state.' : ' Queue metadata alone does not establish an admission decision.'}</p>
33
+ : data && <div className="mt-3 space-y-3">
34
+ {data.total > 1 && <p className="text-xs text-theme-text-secondary">{data.truncated ? `${data.workloads.length} of ${data.total}` : data.total} Workloads shown, newest first. Each is a separate controller-owned record; order does not identify a current attempt.</p>}
35
+ {data.workloads.map((workload) => <article key={workload.uid} className="card-inner space-y-2 text-sm">
36
+ <div className="flex flex-wrap items-center gap-2"><span className="font-medium">{link(workload.name, workload.ref)}</span>{workload.deleting && <Badge severity="alert">Deleting</Badge>}<span className="text-xs text-theme-text-tertiary">Generation {workload.generation}</span></div>
37
+ {workload.projection === 'unsupported' ? <p className="text-theme-text-secondary">Associated Workload found. Scheduling projection is unavailable for {workload.apiVersion}; inspect the resource for native evidence.</p>
38
+ : workload.projection === 'forbidden' ? <p className="text-theme-text-secondary">Associated Workload found, but permission to get this Workload is required for its scheduling detail.</p>
39
+ : workload.scheduling?.observations?.map((observation, index) => <AdmissionObservation key={index} observation={observation} link={link} />)}
40
+ {workload.linksLimited && <p className="text-xs text-theme-text-tertiary">Some resource links are unavailable. Names already reported by the Workload remain visible.</p>}
41
+ </article>)}
42
+ </div>}
43
+ </section>
44
+ )
45
+ }
46
+
47
+ function AdmissionObservation({ observation, link }: { observation: SchedulingObservation; link: (name: string, ref?: SchedulingRef) => React.ReactNode }) {
48
+ const kueue = observation.kueue
49
+ const condition = observation.primaryCondition
50
+ const stale = isKueueConditionStale(condition, observation.subjectGeneration)
51
+ const conditions = [condition, ...(observation.disruptions ?? []), kueue?.podsReady, kueue?.waitingForReplacementPods]
52
+ .filter((entry) => entry != null)
53
+ .filter((entry, index, entries) => entries.findIndex((candidate) => candidate.type === entry.type) === index)
54
+ return <div className="space-y-2">
55
+ <div className="flex flex-wrap items-center gap-2">
56
+ <Badge severity={stale || observation.decision === 'unknown' ? 'neutral' : observation.decision === 'satisfied' ? 'success' : 'warning'}>{kueue ? phases[kueue.phase] : 'Admission'}</Badge>
57
+ <span className="text-theme-text-secondary">Decision: {observation.decision}</span>
58
+ {kueue?.outcome && <span>Outcome: {kueue.outcome}</span>}
59
+ {kueue?.active === false && <span>Workload inactive</span>}
60
+ </div>
61
+ {stale && <p className="text-sm text-theme-text-secondary">The primary condition describes generation {condition!.observedGeneration}; this Workload is now generation {observation.subjectGeneration}. Treat that condition as stale evidence.</p>}
62
+ {condition && <p className="text-xs text-theme-text-secondary">Selected admission evidence · Primary condition: {condition.type}</p>}
63
+ {!condition && <p className="text-theme-text-secondary">No primary admission condition reported.</p>}
64
+ <ConditionsSection
65
+ conditions={conditions}
66
+ defaultExpanded={true}
67
+ getConditionTone={(entry) => {
68
+ if (entry.status !== 'True' && entry.status !== 'False') return 'unknown'
69
+ if (isKueueConditionStale(entry, observation.subjectGeneration)) return 'unknown'
70
+ if (entry.type === 'Finished') return entry.status === 'False' ? 'unknown' : kueue?.outcome === 'failed' ? 'fail' : kueue?.outcome === 'succeeded' ? 'ok' : 'unknown'
71
+ if (entry.type === condition?.type) return observation.decision === 'satisfied' ? 'ok' : observation.decision === 'unknown' ? 'unknown' : 'warning'
72
+ if (entry.type === 'WaitingForReplacementPods' || observation.disruptions?.some((item) => item.type === entry.type)) return entry.status === 'True' ? 'warning' : 'ok'
73
+ return entry.status === 'True' ? 'ok' : 'warning'
74
+ }}
75
+ />
76
+ {!!observation.queues?.length && <div className="flex flex-wrap gap-x-5 gap-y-1 text-xs">{observation.queues.map((queue, index) => <span key={index}>{queue.roles.join(' + ')} queue: {link(queue.name, queue.ref)}</span>)}</div>}
77
+ {observation.gates?.map((gate, index) => <div key={index} className="border-l-2 border-theme-border pl-3 text-xs text-theme-text-secondary">
78
+ <p>{gate.kind === 'preemption_gate' ? 'Preemption gate' : 'Admission check'}: {link(gate.name, gate.ref)} · {gate.nativeState || gate.decision}</p>
79
+ {gate.message && <p className="whitespace-pre-wrap break-words">{gate.message}</p>}
80
+ {gate.kind === 'preemption_gate' && <p>This gate governs preemption; it alone does not establish an admission blocker.</p>}
81
+ {gate.retryCount != null && <p>Retries: {gate.retryCount}</p>}
82
+ {gate.requeueAfterSeconds != null && <p>Requeue delay: {gate.requeueAfterSeconds}s</p>}
83
+ </div>)}
84
+ {kueue?.requeueState && <p className="text-xs text-theme-text-secondary">Requeues: {kueue.requeueState.count ?? 'Not reported'}{kueue.requeueState.requeueAt && ` · Eligible again: ${kueue.requeueState.requeueAt}`}</p>}
85
+ {kueue?.concurrentAdmission && <p className="text-xs">Parent Workload: {link(kueue.concurrentAdmission.parentName, kueue.concurrentAdmission.parentRef)}</p>}
86
+ </div>
87
+ }
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { sanitizeSavedSort } from './ResourcesView'
3
+
4
+ // The per-kind column record crosses the localStorage boundary, so a saved sort
5
+ // can be anything a hand edit or an older build left there.
6
+ describe('sanitizeSavedSort', () => {
7
+ it('accepts a column with a known direction', () => {
8
+ expect(sanitizeSavedSort({ column: 'age', direction: 'desc' })).toEqual({ column: 'age', direction: 'desc' })
9
+ expect(sanitizeSavedSort({ column: 'label:app', direction: 'asc' })).toEqual({ column: 'label:app', direction: 'asc' })
10
+ })
11
+
12
+ it('reads a missing or malformed record as no saved sort', () => {
13
+ expect(sanitizeSavedSort(undefined)).toBeNull()
14
+ expect(sanitizeSavedSort(null)).toBeNull()
15
+ expect(sanitizeSavedSort('age')).toBeNull()
16
+ expect(sanitizeSavedSort({})).toBeNull()
17
+ expect(sanitizeSavedSort({ column: '', direction: 'asc' })).toBeNull()
18
+ expect(sanitizeSavedSort({ column: 'age' })).toBeNull()
19
+ expect(sanitizeSavedSort({ column: 'age', direction: 'sideways' })).toBeNull()
20
+ expect(sanitizeSavedSort({ column: 42, direction: 'asc' })).toBeNull()
21
+ })
22
+
23
+ it('drops fields it does not know rather than carrying them into state', () => {
24
+ expect(sanitizeSavedSort({ column: 'name', direction: 'asc', extra: true })).toEqual({ column: 'name', direction: 'asc' })
25
+ })
26
+ })
@@ -980,7 +980,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
980
980
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
981
981
  { key: 'status', label: 'Status', width: 'w-28' },
982
982
  { key: 'rayVersion', label: 'Ray Version', width: 'w-28' },
983
- { key: 'workers', label: 'Workers', width: 'w-24', tooltip: 'Available/desired worker replicas' },
983
+ { key: 'workers', label: 'Workers', width: 'w-24', tooltip: 'Ready/desired worker Pods' },
984
984
  { key: 'headService', label: 'Head Service', width: 'w-48' },
985
985
  { key: 'age', label: 'Age', width: 'w-24' },
986
986
  ],
@@ -3071,6 +3071,17 @@ export function getCellFilterKind(kind: string, group?: string): string {
3071
3071
  return `__generic_${normalized}`
3072
3072
  }
3073
3073
 
3074
+ // A sort read back from localStorage. Anything but a non-empty column and a
3075
+ // known direction reads as "no saved sort" rather than crashing the comparator
3076
+ // or driving the order with a direction it does not understand.
3077
+ export function sanitizeSavedSort(raw: unknown): { column: string; direction: 'asc' | 'desc' } | null {
3078
+ if (!raw || typeof raw !== 'object') return null
3079
+ const { column, direction } = raw as { column?: unknown; direction?: unknown }
3080
+ if (typeof column !== 'string' || !column) return null
3081
+ if (direction !== 'asc' && direction !== 'desc') return null
3082
+ return { column, direction }
3083
+ }
3084
+
3074
3085
  // Get the default visible columns for a kind
3075
3086
  function getDefaultVisibleColumns(columns: Column[]): Set<string> {
3076
3087
  return new Set(columns.filter(c => c.defaultVisible !== false).map(c => c.key))
@@ -3272,6 +3283,26 @@ function saveColumnSettings(kind: string, group: string | undefined, settings: C
3272
3283
  } catch { /* ignore */ }
3273
3284
  }
3274
3285
 
3286
+ const SORT_SETTINGS_PREFIX = 'radar-sort-'
3287
+
3288
+ // Kept apart from the column record on purpose: a column record's existence
3289
+ // is read as "the user chose columns" (GPU auto-show stands down, the
3290
+ // stale-defaults migration is skipped), and a sort alone is not that choice.
3291
+ function loadSavedSort(kind: string, group?: string): { column: string; direction: 'asc' | 'desc' } | null {
3292
+ try {
3293
+ const raw = localStorage.getItem(SORT_SETTINGS_PREFIX + columnSettingsKey(kind, group))
3294
+ return raw ? sanitizeSavedSort(JSON.parse(raw)) : null
3295
+ } catch { return null }
3296
+ }
3297
+
3298
+ function saveSavedSort(kind: string, group: string | undefined, sort: { column: string; direction: 'asc' | 'desc' } | null) {
3299
+ try {
3300
+ const key = SORT_SETTINGS_PREFIX + columnSettingsKey(kind, group)
3301
+ if (sort) localStorage.setItem(key, JSON.stringify(sort))
3302
+ else localStorage.removeItem(key)
3303
+ } catch { /* ignore */ }
3304
+ }
3305
+
3275
3306
  function clearColumnSettings(kind: string, group?: string) {
3276
3307
  try {
3277
3308
  const key = COLUMN_SETTINGS_PREFIX + columnSettingsKey(kind, group)
@@ -3859,6 +3890,30 @@ export function ResourcesView({
3859
3890
  return m
3860
3891
  }, [extraLeadingColumns, builtCustomColumns, builtPrinterColumns])
3861
3892
 
3893
+ // Sortable columns outside KNOWN_COLUMNS, as a value-stable key list. Keyed
3894
+ // on the joined keys rather than the map itself: a host that rebuilds
3895
+ // extraLeadingColumns on every render would otherwise re-run the sort-apply
3896
+ // effect every render and wipe whatever the user just sorted by.
3897
+ // Keys this table renders with a sort control, joined rather than kept as an
3898
+ // array: a host that rebuilds extraLeadingColumns every render would give an
3899
+ // identity-keyed dependency a new value each time, and the apply effect below
3900
+ // would wipe whatever the user just sorted by on every render.
3901
+ const sortableColumnKeys = useMemo(
3902
+ () => allColumns.filter(c => isColumnSortable(c, extraColumnsByKey)).map(c => c.key).sort().join('\n'),
3903
+ [allColumns, extraColumnsByKey],
3904
+ )
3905
+
3906
+ const canSortBy = useCallback(
3907
+ (column: string) => (sortableColumnKeys ? sortableColumnKeys.split('\n') : []).includes(column),
3908
+ [sortableColumnKeys],
3909
+ )
3910
+ // A restored sort can name a column this table does not render right now: a
3911
+ // custom column that was removed, a CRD whose printer columns changed, or one
3912
+ // that simply has not loaded yet. Applying it would order rows by an absent
3913
+ // value with no header arrow to undo it from, so it applies only while the
3914
+ // column is actually sortable here, and springs back the moment it is.
3915
+ const effectiveSortColumn = sortColumn && canSortBy(sortColumn) ? sortColumn : null
3916
+
3862
3917
  // Guards the save effect from persisting on the initial load of each kind
3863
3918
  // (set false by the load effect, flipped true on its first skipped save).
3864
3919
  const isColumnSettingsLoaded = useRef(false)
@@ -3894,6 +3949,12 @@ export function ResourcesView({
3894
3949
  // (non-array, blank path, bad source) must not crash the later .map or add dead columns.
3895
3950
  const savedCustom = sanitizeCustomColumnDefs(saved?.custom)
3896
3951
  setCustomColumns(savedCustom)
3952
+ // Restored as saved. Whether this table can sort by it right now is decided
3953
+ // at render time (effectiveSortColumn), because the rendered column set is
3954
+ // not settled here: printer columns and custom columns land later.
3955
+ const savedSort = loadSavedSort(selectedKind.name, selectedKind.group)
3956
+ setSortColumn(savedSort?.column ?? null)
3957
+ setSortDirection(savedSort?.direction ?? null)
3897
3958
  // Reset on a kind change only. This effect also re-runs when the printer
3898
3959
  // columns arrive, and that is the moment the offered set shrinks — clearing
3899
3960
  // here would drop the evidence for the column they replaced just before the
@@ -4083,6 +4144,9 @@ export function ResourcesView({
4083
4144
  const customKeys = new Set(builtCustomColumns.map(c => c.key))
4084
4145
  setVisibleColumns(getDefaultVisibleColumns(allColumns.filter(c => !customKeys.has(c.key))))
4085
4146
  setColumnWidths({})
4147
+ setSortColumn(null)
4148
+ setSortDirection(null)
4149
+ saveSavedSort(selectedKind.name, selectedKind.group, null)
4086
4150
  isColumnSettingsLoaded.current = false
4087
4151
  // Back to data-driven defaults: re-arm GPU auto-show for this kind.
4088
4152
  hadSavedColumnSettings.current = false
@@ -5016,7 +5080,7 @@ export function ResourcesView({
5016
5080
  isForbiddenError(selectedQueryError) ||
5017
5081
  (!selectedHasRows && !selectedQueryError && forbiddenKinds.has(selectedKindCountKey))
5018
5082
 
5019
- // Reset sort and filters when kind changes (but not when syncing from URL navigation)
5083
+ // Reset filters when kind changes (but not when syncing from URL navigation)
5020
5084
  // Track previous kind to skip on mount (where the effect fires but kind hasn't actually changed)
5021
5085
  // Keyed on group as well as plural: two CRDs can share a plural across API
5022
5086
  // groups, and their printer columns are unrelated. Resetting on the plural
@@ -5029,8 +5093,6 @@ export function ResourcesView({
5029
5093
  return
5030
5094
  }
5031
5095
  prevKindRef.current = selectedKindIdentity
5032
- setSortColumn(null)
5033
- setSortDirection(null)
5034
5096
  setOpenColumnFilter(null)
5035
5097
  if (!isSyncingFromURL.current) {
5036
5098
  setColumnFilters({})
@@ -5041,21 +5103,34 @@ export function ResourcesView({
5041
5103
 
5042
5104
  // Toggle sort for a column
5043
5105
  const handleSort = useCallback((column: string) => {
5106
+ // The N/A/S shortcuts fire on every kind, including ones with no such
5107
+ // column. Sorting is a no-op there, but it would still be saved for this
5108
+ // kind and come back as an invisible sort next visit.
5109
+ if (!canSortBy(column)) return
5110
+ let newColumn: string | null
5111
+ let newDirection: SortDirection
5112
+
5044
5113
  if (sortColumn === column) {
5045
5114
  // Cycle: asc -> desc -> null
5046
5115
  if (sortDirection === 'asc') {
5047
- setSortDirection('desc')
5116
+ newColumn = column
5117
+ newDirection = 'desc'
5048
5118
  } else if (sortDirection === 'desc') {
5049
- setSortColumn(null)
5050
- setSortDirection(null)
5119
+ newColumn = null
5120
+ newDirection = null
5051
5121
  } else {
5052
- setSortDirection('asc')
5122
+ newColumn = column
5123
+ newDirection = 'asc'
5053
5124
  }
5054
5125
  } else {
5055
- setSortColumn(column)
5056
- setSortDirection('asc')
5126
+ newColumn = column
5127
+ newDirection = 'asc'
5057
5128
  }
5058
- }, [sortColumn, sortDirection])
5129
+
5130
+ setSortColumn(newColumn)
5131
+ setSortDirection(newDirection)
5132
+ saveSavedSort(selectedKind.name, selectedKind.group, newColumn && newDirection ? { column: newColumn, direction: newDirection } : null)
5133
+ }, [sortColumn, sortDirection, canSortBy, selectedKind.name, selectedKind.group])
5059
5134
 
5060
5135
  // Get sortable value from a resource for a given column
5061
5136
  const getSortValue = useCallback((resource: any, column: string, kind?: string): string | number => {
@@ -5292,15 +5367,15 @@ export function ResourcesView({
5292
5367
 
5293
5368
  // Apply custom sorting if set. Extra columns override
5294
5369
  // the built-in getSortValue for their key.
5295
- if (sortColumn && sortDirection) {
5296
- const extra = extraColumnsByKey.get(sortColumn)
5370
+ if (effectiveSortColumn && sortDirection) {
5371
+ const extra = extraColumnsByKey.get(effectiveSortColumn)
5297
5372
  // Normalized, matching the filter call sites: a group-qualified kind
5298
5373
  // (istiogateways, cnpgclusters) otherwise arrives here as its bare plural
5299
5374
  // and misses its own sort readers. Hoisted out of the comparator.
5300
5375
  // The ownership-aware wrapper also keeps a foreign CRD that reuses a
5301
5376
  // curated plural on the generic reader used by its cells and filters.
5302
5377
  const sortKind = getCellFilterKind(selectedKind.name, selectedKind.group)
5303
- const keyOf = extra?.getSortValue ?? ((r: any) => getSortValue(r, sortColumn, sortKind))
5378
+ const keyOf = extra?.getSortValue ?? ((r: any) => getSortValue(r, effectiveSortColumn, sortKind))
5304
5379
  // Derived once per row, not once per comparison: a sort visits O(n log n)
5305
5380
  // pairs, and the status key now runs a per-kind status derivation.
5306
5381
  const decorated = result.map((r: any) => ({ r, k: keyOf(r) }))
@@ -5386,7 +5461,7 @@ export function ResourcesView({
5386
5461
  }
5387
5462
 
5388
5463
  return result
5389
- }, [resources, deferredSearchTerm, regexMode, searchRegex, columnFilters, columnFilterExcludes, problemFilters, showInactiveReplicaSets, labelSelector, ownerKind, ownerName, selectedKind.name, sortColumn, sortDirection, getSortValue, extraColumnsByKey, podMatchesProblemFilter])
5464
+ }, [resources, deferredSearchTerm, regexMode, searchRegex, columnFilters, columnFilterExcludes, problemFilters, showInactiveReplicaSets, labelSelector, ownerKind, ownerName, selectedKind.name, effectiveSortColumn, sortDirection, getSortValue, extraColumnsByKey, podMatchesProblemFilter])
5390
5465
 
5391
5466
  // For nodes table: compute the majority minor version so outliers can be highlighted
5392
5467
  const majorityNodeMinorVersion = useMemo(() => {
@@ -6439,7 +6514,7 @@ export function ResourcesView({
6439
6514
  {columns.map((col, colIdx) => {
6440
6515
  // Built-in sortable keys, plus any extra/custom column that carries its own getSortValue.
6441
6516
  const isSortable = isColumnSortable(col, extraColumnsByKey)
6442
- const isSorted = sortColumn === col.key
6517
+ const isSorted = effectiveSortColumn === col.key
6443
6518
  const isLastCol = colIdx === columns.length - 1
6444
6519
  const filterCol = filterableColumnMap.get(col.key)
6445
6520
  const activeFilterValues = columnFilters[col.key] || []
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { readFileSync } from 'fs'
3
3
  import { join } from 'path'
4
+ import { parse } from 'yaml'
4
5
  import { hasCuratedColumns } from './ResourcesView'
5
6
 
6
7
  // Parsed from the source rather than exported: this asserts a property of the
@@ -28,6 +29,50 @@ function objectKeys(declName: string): string[] {
28
29
  const CURATED = objectKeys('const KNOWN_COLUMNS')
29
30
  const OWNED = new Set(objectKeys('const CURATED_COLUMN_GROUPS'))
30
31
 
32
+ describe('Radar Cloud integration read baseline coverage', () => {
33
+ const root = join(__dirname, '../../../../..')
34
+ const policy = parse(readFileSync(join(root, 'deploy/helm/radar/files/integration-read-baseline.yaml'), 'utf8')) as {
35
+ entries: { group: string; resources: string[]; scope: string; decision: string }[]
36
+ }
37
+
38
+ it('requires an explicit permission decision for every curated API identity', () => {
39
+ const aliases = new Map<string, Set<string>>()
40
+ for (const [, resource, body] of objectBody('const GROUP_QUALIFIED_COLUMN_KEYS').matchAll(/^ {2}([a-z0-9_]+):\s*\{([^}]+)\}/gm)) {
41
+ for (const [, group, key] of body.matchAll(/'([^']+)':\s*'([^']+)'/g)) {
42
+ const identity = `${group}/${key}`
43
+ const plurals = aliases.get(identity) ?? new Set<string>()
44
+ plurals.add(resource)
45
+ aliases.set(identity, plurals)
46
+ }
47
+ }
48
+ const missing: string[] = []
49
+ let checked = 0
50
+ for (const [, key, groups] of objectBody('const CURATED_COLUMN_GROUPS').matchAll(/^ {2}([a-z0-9_]+):\s*\[([^\]]+)\]/gm)) {
51
+ checked++
52
+ for (const [, group] of groups.matchAll(/'([^']+)'/g)) {
53
+ const candidates = aliases.get(`${group}/${key}`) ?? new Set([key])
54
+ if (!policy.entries.some(e => e.group === group && e.resources.some(r => candidates.has(r)))) {
55
+ missing.push(`${group}/${key}`)
56
+ }
57
+ }
58
+ }
59
+ expect(missing).toEqual([])
60
+ expect(checked).toBe(OWNED.size)
61
+ expect(policy.entries.length).toBeGreaterThan(0)
62
+ })
63
+
64
+ it('matches the exact plural and scope of pinned GPU ecosystem CRDs', () => {
65
+ const rows = readFileSync(join(root, 'scripts/gpu-ecosystem-demo/resources.tsv'), 'utf8')
66
+ .split('\n').filter(line => line && !line.startsWith('#'))
67
+ for (const row of rows) {
68
+ const [, group, resource, , scope] = row.split('\t')
69
+ const entry = policy.entries.find(e => e.group === group && e.resources.includes(resource))
70
+ expect(entry, `${group}/${resource}`).toBeDefined()
71
+ expect(entry?.scope, `${group}/${resource}`).toBe(scope)
72
+ }
73
+ })
74
+ })
75
+
31
76
  // Curated sets keyed on a kind the API server reserves. A CRD cannot be created
32
77
  // in a core group, so these can never be claimed by a foreign resource.
33
78
  const CORE_KEYS = new Set([
@@ -9,9 +9,10 @@ interface Props {
9
9
  data: any
10
10
  onNavigate?: (ref: { kind: string; namespace: string; name: string; group?: string }) => void
11
11
  apiBase?: string
12
+ canConnect?: boolean
12
13
  }
13
14
 
14
- export function CAPIClusterRenderer({ data, onNavigate, apiBase = '' }: Props) {
15
+ export function CAPIClusterRenderer({ data, onNavigate, apiBase = '', canConnect = true }: Props) {
15
16
  const status = data.status || {}
16
17
  const spec = data.spec || {}
17
18
  const conditions = status.v1beta2?.conditions || status.conditions || []
@@ -125,7 +126,7 @@ export function CAPIClusterRenderer({ data, onNavigate, apiBase = '' }: Props) {
125
126
 
126
127
  {/* Kubeconfig Actions */}
127
128
  <div className="px-3 py-2 flex items-center gap-2">
128
- <button
129
+ {canConnect && <button
129
130
  onClick={handleConnectToCluster}
130
131
  disabled={connectState === 'loading' || connectState === 'success'}
131
132
  className="btn-brand flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md"
@@ -135,7 +136,7 @@ export function CAPIClusterRenderer({ data, onNavigate, apiBase = '' }: Props) {
135
136
  {connectState === 'error' && <AlertCircle className="w-3.5 h-3.5" />}
136
137
  {connectState === 'idle' && <Globe className="w-3.5 h-3.5" />}
137
138
  {connectState === 'loading' ? 'Connecting...' : connectState === 'success' ? 'Connected — reloading...' : 'Connect to Cluster'}
138
- </button>
139
+ </button>}
139
140
  <button
140
141
  onClick={handleDownloadKubeconfig}
141
142
  disabled={downloadState === 'loading'}
@@ -20,10 +20,12 @@ import {
20
20
  import { clsx } from 'clsx'
21
21
  import type {
22
22
  PodEnvironmentResponse,
23
+ PodEnvironmentEvidence,
23
24
  PodEnvironmentRevealResponse,
24
25
  PodEnvironmentRow,
25
26
  PodEnvironmentSource,
26
27
  } from '../../../types'
28
+ import { formatLogTimestamp } from '../../../utils/log-format'
27
29
  import { Badge, type BadgeSeverity } from '../../ui/Badge'
28
30
  import { Section, type CopyHandler } from '../../ui/drawer-components'
29
31
  import { Tooltip } from '../../ui/Tooltip'
@@ -501,7 +503,7 @@ function CompactStatusCell({ row }: { row: PodEnvironmentRow }) {
501
503
  : row.evidence.kind === 'removed'
502
504
  ? 'Removed after start'
503
505
  : 'Changed after start'
504
- return <CompactStatusBadge label={label} explanation={row.evidence.message} severity="warning" icon={<History className="h-3.5 w-3.5" aria-hidden />} />
506
+ return <CompactStatusBadge label={label} explanation={changeEvidenceExplanation(row.evidence)} severity="warning" icon={<History className="h-3.5 w-3.5" aria-hidden />} />
505
507
  }
506
508
  if (row.state === 'denied') return <CompactStatusBadge label="Access needed" explanation={row.message} severity="info" icon={<LockKeyhole className="h-3.5 w-3.5" aria-hidden />} />
507
509
  if (row.runtimeDependent) return <CompactStatusBadge label="At startup" explanation={row.message} severity="neutral" icon={<Minus className="h-3.5 w-3.5" aria-hidden />} />
@@ -545,7 +547,7 @@ function StatusCell({ row }: { row: PodEnvironmentRow }) {
545
547
  : row.evidence.kind === 'removed'
546
548
  ? 'Removed after start'
547
549
  : 'Changed after start'
548
- return <StatusBadge explanation={row.evidence.message}><Badge severity="warning" size="sm">{label}</Badge></StatusBadge>
550
+ return <StatusBadge explanation={changeEvidenceExplanation(row.evidence)}><Badge severity="warning" size="sm">{label}</Badge></StatusBadge>
549
551
  }
550
552
  if (row.state === 'denied') return <StatusBadge explanation={row.message}><Badge severity="info" size="sm">Access needed</Badge></StatusBadge>
551
553
  if (row.runtimeDependent) return <StatusBadge explanation={row.message}><Badge severity="neutral" size="sm">At startup</Badge></StatusBadge>
@@ -556,10 +558,15 @@ function StatusCell({ row }: { row: PodEnvironmentRow }) {
556
558
 
557
559
  function missingStatus(row: PodEnvironmentRow) {
558
560
  const label = row.missingImpact === 'restartBlocked' ? 'Restart blocked' : 'Prevents start'
559
- const explanation = [row.message, row.evidence?.message].filter(Boolean).join(' ')
561
+ const explanation = [row.message, changeEvidenceExplanation(row.evidence)].filter(Boolean).join(' ')
560
562
  return { label, explanation }
561
563
  }
562
564
 
565
+ function changeEvidenceExplanation(evidence?: PodEnvironmentEvidence) {
566
+ if (!evidence) return undefined
567
+ return [evidence.message, `Change observed at ${formatLogTimestamp(evidence.changedAt, 'iso-utc')}.`].filter(Boolean).join(' ')
568
+ }
569
+
563
570
  function hasEnvironmentStatus(row: PodEnvironmentRow) {
564
571
  return Boolean(row.evidence)
565
572
  || row.state === 'denied'