@skyhook-io/radar-app 1.14.5 → 1.14.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.
Files changed (83) hide show
  1. package/README.md +2 -9
  2. package/package.json +6 -6
  3. package/src/App.tsx +91 -328
  4. package/src/RadarApp.tsx +3 -8
  5. package/src/api/client.jobset.test.ts +18 -0
  6. package/src/api/client.resource-identity.test.ts +44 -0
  7. package/src/api/client.rightsizing.test.ts +7 -3
  8. package/src/api/client.ts +233 -78
  9. package/src/api/preferences.test.ts +54 -0
  10. package/src/api/preferences.ts +38 -0
  11. package/src/components/CloudFunnelButton.test.tsx +155 -0
  12. package/src/components/CloudFunnelButton.tsx +14 -8
  13. package/src/components/ContextSwitcher.tsx +13 -132
  14. package/src/components/applications/ApplicationsView.identity.test.ts +39 -0
  15. package/src/components/applications/ApplicationsView.tsx +5 -3
  16. package/src/components/audit/AuditSettingsDialog.tsx +15 -11
  17. package/src/components/diagnose/AgentControls.tsx +26 -3
  18. package/src/components/diagnose/DiagnoseContext.tsx +1 -0
  19. package/src/components/diagnose/agentCatalog.ts +6 -0
  20. package/src/components/diagnose/launch.test.ts +23 -0
  21. package/src/components/diagnose/launch.ts +4 -0
  22. package/src/components/diagnose/parts.test.tsx +18 -0
  23. package/src/components/dock/NodeTerminalTab.test.tsx +34 -0
  24. package/src/components/dock/NodeTerminalTab.tsx +5 -3
  25. package/src/components/dock/WorkloadLogsTab.test.tsx +66 -0
  26. package/src/components/dock/WorkloadLogsTab.tsx +2 -1
  27. package/src/components/execution/BatchExecutionView.render.test.tsx +2 -1
  28. package/src/components/execution/BatchExecutionView.test.ts +52 -1
  29. package/src/components/execution/BatchExecutionView.tsx +324 -65
  30. package/src/components/execution/JobSetAdmission.test.tsx +42 -0
  31. package/src/components/execution/JobSetAdmission.tsx +17 -0
  32. package/src/components/execution/JobSetMemberComparison.test.tsx +30 -0
  33. package/src/components/execution/JobSetMemberComparison.tsx +88 -0
  34. package/src/components/execution/batch-run-actions.test.ts +1 -0
  35. package/src/components/execution/batch-timeline.test.ts +3 -0
  36. package/src/components/execution/execution-definition.test.ts +15 -1
  37. package/src/components/execution/execution-definition.ts +4 -0
  38. package/src/components/execution/member-collection.render.test.tsx +142 -0
  39. package/src/components/gitops/GitOpsView.tsx +33 -2
  40. package/src/components/gitops/destination-toast.test.ts +33 -0
  41. package/src/components/gitops/destination-toast.ts +46 -0
  42. package/src/components/gitops/useDestinationCluster.ts +30 -0
  43. package/src/components/helm/TrackChartSourceDialog.tsx +20 -11
  44. package/src/components/home/MCPSetupDialog.tsx +10 -0
  45. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
  46. package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
  47. package/src/components/nav/PrimaryNavRail.tsx +2 -5
  48. package/src/components/resource/PVCUsageBar.render.test.tsx +168 -0
  49. package/src/components/resource/PVCUsageBar.tsx +62 -16
  50. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -1
  51. package/src/components/resources/ResourcesView.tsx +3 -3
  52. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +8 -0
  53. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +39 -0
  54. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +37 -0
  55. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +18 -0
  56. package/src/components/resources/renderers/RayClusterRenderer.tsx +17 -0
  57. package/src/components/resources/renderers/RayServiceRenderer.test.tsx +69 -0
  58. package/src/components/resources/renderers/RayServiceRenderer.tsx +38 -0
  59. package/src/components/rightsizing/RightsizingScanView.tsx +34 -22
  60. package/src/components/rightsizing/copy.test.ts +7 -0
  61. package/src/components/rightsizing/model.test.ts +2 -1
  62. package/src/components/rightsizing/notices.test.tsx +4 -3
  63. package/src/components/settings/OperatorManagedNotice.tsx +17 -0
  64. package/src/components/settings/SettingsDialog.tsx +157 -42
  65. package/src/components/settings/settings-state.test.ts +18 -0
  66. package/src/components/settings/settings-state.ts +18 -0
  67. package/src/components/timeline/TimelineView.tsx +9 -3
  68. package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
  69. package/src/components/ui/command-items.ts +0 -3
  70. package/src/components/useContextSwitchFlow.tsx +147 -0
  71. package/src/components/workload/WorkloadView.test.ts +17 -1
  72. package/src/components/workload/WorkloadView.tsx +36 -18
  73. package/src/context/ContextSwitchContext.tsx +18 -2
  74. package/src/context/NavCustomization.tsx +4 -58
  75. package/src/context/ThemeContext.tsx +3 -11
  76. package/src/hooks/useClusterLoadState.ts +2 -2
  77. package/src/hooks/useFavorites.ts +3 -5
  78. package/src/utils/auditBadges.ts +1 -1
  79. package/src/utils/navigation.test.ts +18 -1
  80. package/src/utils/navigation.ts +12 -5
  81. package/src/utils/topology-selection.test.ts +56 -0
  82. package/src/utils/topology-selection.ts +9 -9
  83. package/src/components/ui/CommandPalette.tsx +0 -261
@@ -0,0 +1,42 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import type { KueueAdmissionResponse } from '@skyhook-io/k8s-ui/types/scheduling'
4
+ import { JobSetAdmission } from './JobSetAdmission'
5
+
6
+ const state = vi.hoisted(() => ({ data: undefined as KueueAdmissionResponse | undefined, query: vi.fn() }))
7
+ vi.mock('../../api/client', () => ({
8
+ ApiError: class extends Error {},
9
+ useKueueAdmission: (...args: unknown[]) => {
10
+ state.query(...args)
11
+ return { data: state.data, isLoading: false, refetch: vi.fn() }
12
+ },
13
+ }))
14
+
15
+ beforeEach(() => {
16
+ state.query.mockClear()
17
+ state.data = { uid: 'old-uid', installed: true, total: 1, truncated: false, workloads: [{
18
+ apiVersion: 'kueue.x-k8s.io/v1beta2', namespace: 'ml', name: 'old-workload', uid: 'workload-uid', generation: 1,
19
+ createdAt: null, deleting: false, projection: 'unsupported', linksLimited: false,
20
+ }] }
21
+ })
22
+
23
+ function render(uid: string) {
24
+ return renderToStaticMarkup(<JobSetAdmission resource={{ metadata: { uid } }} namespace="ml" name="training" />)
25
+ }
26
+
27
+ describe('JobSet admission identity', () => {
28
+ it('rejects old evidence after recreation under the same name', () => {
29
+ expect(render('old-uid')).toContain('old-workload')
30
+ const html = render('new-uid')
31
+ expect(state.query).toHaveBeenLastCalledWith('ml', 'training', 'new-uid')
32
+ expect(html).not.toContain('old-workload')
33
+ expect(html).toContain('different JobSet instance')
34
+ })
35
+
36
+ it('accepts absence only for the displayed root instance', () => {
37
+ state.data = { uid: 'old-uid', installed: false, total: 0, truncated: false, workloads: [] }
38
+ expect(render('new-uid')).toContain('different JobSet instance')
39
+ state.data.uid = 'new-uid'
40
+ expect(render('new-uid')).toBe('')
41
+ })
42
+ })
@@ -0,0 +1,17 @@
1
+ import { KueueAdmissionSection } from '@skyhook-io/k8s-ui/components/resources/KueueAdmissionSection'
2
+ import { ApiError, useKueueAdmission } from '../../api/client'
3
+
4
+ export function JobSetAdmission({ resource, namespace, name, onNavigate }: {
5
+ resource: any
6
+ namespace: string
7
+ name: string
8
+ onNavigate?: (ref: { kind: string; namespace: string; name: string; group?: string }) => void
9
+ }) {
10
+ const query = useKueueAdmission(namespace, name, resource.metadata?.uid)
11
+ const data = query.data?.uid === resource.metadata?.uid ? query.data : undefined
12
+ const error = query.error?.message ?? (query.data && !data ? 'Admission evidence belongs to a different JobSet instance; waiting for a fresh observation.' : undefined)
13
+ const labels = resource.metadata?.labels
14
+ const externalExecution = Boolean(resource.spec?.managedBy && resource.spec.managedBy !== 'jobset.sigs.k8s.io/jobset-controller')
15
+ const hinted = Boolean(labels?.['kueue.x-k8s.io/queue-name'] || labels?.['kueue.x-k8s.io/prebuilt-workload-name'] || externalExecution)
16
+ return <KueueAdmissionSection data={data} loading={query.isLoading} error={error} forbidden={query.error instanceof ApiError && query.error.status === 403} hinted={hinted} externalExecution={externalExecution} onRetry={() => { void query.refetch() }} onNavigate={onNavigate} />
17
+ }
@@ -0,0 +1,30 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { JobSetMemberComparison } from './JobSetMemberComparison'
4
+ import type { JobSetUsage } from '../../api/client'
5
+
6
+ const empty: JobSetUsage = { runningPods: 0, reportingPods: 0, stalePods: 0, cpu: null, memory: null, cpuRequest: 0, memoryRequest: 0 }
7
+ function render(usage: JobSetUsage) {
8
+ return renderToStaticMarkup(<JobSetMemberComparison runs={[{ group: 'batch', kind: 'jobs', namespace: 'ns', name: 'leader', phase: 'Running', active: true, jobset: { replicatedJob: 'leader', jobIndex: '0' } }]} total={250} filteredTotal={1} truncated={false} loading={false} error={null} roles={['leader']} role="leader" onRoleChange={() => {}} search="" onSearchChange={() => {}} state="all" onStateChange={() => {}} selected="jobs/ns/leader" resourcesLoading={false} resourceError={null} durationFor={() => '2m'} resources={{ uid: 'root', source: 'metrics.k8s.io', total: usage, members: { leader: usage } }} />)
9
+ }
10
+
11
+ describe('JobSet resource comparison', () => {
12
+ it('keeps absent usage distinct from measured zero and completed Pods', () => {
13
+ const absent = render({ ...empty, runningPods: 2 })
14
+ expect(absent).toContain('0/2 running Pods reporting')
15
+ expect(absent).not.toContain('0m')
16
+ const zero = render({ ...empty, runningPods: 1, reportingPods: 1, cpu: 0, memory: 0 })
17
+ expect(zero).toContain('1/1 running Pods reporting')
18
+ expect(zero).not.toContain('Unavailable')
19
+ expect(render(empty)).toContain('No running Pods')
20
+ })
21
+ it('shows partial coverage, stale samples, independent counts and declared extended requests', () => {
22
+ const html = render({ ...empty, runningPods: 2, reportingPods: 1, stalePods: 1, cpu: 25000000, memory: 33554432, cpuRequest: 100000000, memoryRequest: 67108864, extendedRequests: { 'nvidia.com/gpu': '2' } })
23
+ expect(html).toContain('1/2 running Pods reporting')
24
+ expect(html).toContain('1 stale')
25
+ expect(html).toContain('250 retained')
26
+ expect(html).toContain('1 matching')
27
+ expect(html).toContain('usage not collected')
28
+ expect(html).toContain('outside these filters')
29
+ })
30
+ })
@@ -0,0 +1,88 @@
1
+ import { Badge, EmptyState, FetchResult } from '@skyhook-io/k8s-ui'
2
+ import { formatCPUNanocores, formatMemoryBytes } from '@skyhook-io/k8s-ui/utils/format'
3
+ import type { JobSetResources, JobSetUsage, WorkloadRun } from '../../api/client'
4
+
5
+ interface Props {
6
+ runs: WorkloadRun[]
7
+ total: number
8
+ filteredTotal: number
9
+ truncated: boolean
10
+ loading: boolean
11
+ error: unknown
12
+ roles: string[]
13
+ role: string
14
+ onRoleChange: (value: string) => void
15
+ search: string
16
+ onSearchChange: (value: string) => void
17
+ state: 'all' | 'active' | 'failed'
18
+ onStateChange: (value: 'all' | 'active' | 'failed') => void
19
+ selected: string
20
+ onSelect?: (key: string) => void
21
+ resources?: JobSetResources
22
+ resourcesLoading: boolean
23
+ resourceError: unknown
24
+ durationFor: (run: WorkloadRun) => string
25
+ }
26
+
27
+ function UsageValue({ usage, resource }: { usage?: JobSetUsage; resource: 'cpu' | 'memory' }) {
28
+ const value = usage?.[resource]
29
+ if (value === undefined || value === null) return <span className="text-theme-text-tertiary">—</span>
30
+ const format = resource === 'cpu' ? formatCPUNanocores : formatMemoryBytes
31
+ const request = resource === 'cpu' ? usage!.cpuRequest : usage!.memoryRequest
32
+ return <><span className="tabular-nums text-theme-text-primary">{format(value)}</span><span className="block text-[10px] text-theme-text-tertiary">{request > 0 ? `${format(request)} requested` : 'No request reported'}</span></>
33
+ }
34
+
35
+ function Coverage({ usage }: { usage?: JobSetUsage }) {
36
+ if (!usage) return <span className="text-theme-text-tertiary">Unavailable</span>
37
+ return <span className="text-theme-text-secondary">{usage.runningPods === 0 ? 'No running Pods' : `${usage.reportingPods}/${usage.runningPods} running Pods reporting`}{usage.stalePods > 0 && ` · ${usage.stalePods} stale`}</span>
38
+ }
39
+
40
+ export function JobSetMemberComparison(props: Props) {
41
+ const { resources, runs, role, roles } = props
42
+ const ordered = [...runs].sort((a, b) => (a.jobset?.replicatedJob ?? '').localeCompare(b.jobset?.replicatedJob ?? ''))
43
+ const inputClass = 'rounded-md border border-theme-border bg-theme-elevated px-2 py-1.5 text-xs text-theme-text-primary'
44
+ return <section className="rounded-lg border border-theme-border bg-theme-surface" aria-label="Member Jobs">
45
+ <div className="space-y-3 border-b border-theme-border p-3">
46
+ <div className="flex flex-wrap items-center justify-between gap-2">
47
+ <h3 className="text-sm font-semibold text-theme-text-primary">Member Jobs <span className="font-normal text-theme-text-tertiary">{props.loading ? '· Loading Jobs…' : runs.length === props.total ? `· ${props.total} Jobs` : `· ${runs.length} shown · ${props.filteredTotal} matching · ${props.total} retained`}</span></h3>
48
+ <span className="text-xs text-theme-text-tertiary">Controller-owned Jobs currently in Kubernetes</span>
49
+ </div>
50
+ <div className="flex flex-wrap items-center gap-2">
51
+ <select aria-label="Filter member role" value={role} onChange={e => props.onRoleChange(e.target.value)} className={inputClass}>
52
+ <option value="">All roles</option>{[...new Set([...roles, ...runs.map(run => run.jobset?.replicatedJob).filter((name): name is string => Boolean(name)), ...(role ? [role] : [])])].map(name => <option key={name} value={name}>{name}</option>)}
53
+ </select>
54
+ <select aria-label="Filter member state" value={props.state} onChange={e => props.onStateChange(e.target.value as Props['state'])} className={inputClass}><option value="all">All states</option><option value="active">Active</option><option value="failed">Failed</option></select>
55
+ <input aria-label="Search all member Jobs" value={props.search} onChange={e => props.onSearchChange(e.target.value)} placeholder="Search all member Jobs" className={`${inputClass} min-w-48 flex-1`} />
56
+ {(role || props.state !== 'all' || props.search) && <button type="button" className="text-xs text-accent-text hover:underline" onClick={() => { props.onRoleChange(''); props.onStateChange('all'); props.onSearchChange('') }}>Clear filters</button>}
57
+ </div>
58
+ {resources && <div className="flex flex-wrap items-start gap-x-5 gap-y-2 text-xs">
59
+ <span className="font-medium text-theme-text-secondary">Whole JobSet<br/><span className="font-normal text-theme-text-tertiary">Current resource usage</span></span>
60
+ <span>CPU <UsageValue usage={resources.total} resource="cpu" /></span>
61
+ <span>Memory <UsageValue usage={resources.total} resource="memory" /></span>
62
+ <span><Coverage usage={resources.total}/>{resources.total.observedAt && <span className="block text-[10px] text-theme-text-tertiary">Oldest sample: {new Date(resources.total.observedAt).toLocaleTimeString()}</span>}</span>
63
+ {resources.total.extendedRequests && Object.entries(resources.total.extendedRequests).map(([name, count]) => <span key={name} className="text-theme-text-secondary">{name}: {count} requested<span className="block text-[10px] text-theme-text-tertiary">Nonterminal Pods · usage not collected</span></span>)}
64
+ </div>}
65
+ {Boolean(resources?.unavailable || props.resourceError) && <p className="text-xs text-theme-text-secondary">{resources?.unavailable || (props.resourceError instanceof Error ? props.resourceError.message : 'Resource usage unavailable.')}</p>}
66
+ {!resources && !props.resourceError && props.resourcesLoading && <p className="text-xs text-theme-text-tertiary">Loading resource usage…</p>}
67
+ </div>
68
+ {props.loading ? <FetchResult loading className="min-h-24" /> : props.error ? <EmptyState variant="card" tone="neutral" headline="Member Jobs unavailable" body={props.error instanceof Error ? props.error.message : 'Unable to read child Jobs.'} /> : runs.length === 0 ? <EmptyState variant="card" tone="neutral" headline={props.total ? 'No Jobs match these filters' : 'No child Jobs currently retained'} body={props.total ? 'Change the role, state, or name filter.' : 'Jobs may not exist yet, may be managed externally, or may already have been cleaned up.'} /> : <div className="max-h-72 overflow-auto">
69
+ <table className="w-full text-left text-xs">
70
+ <thead className="sticky top-0 bg-theme-surface text-theme-text-tertiary"><tr>{['Role', 'Job', 'State', 'Duration', 'CPU usage', 'Memory usage', 'Metric coverage'].map(label => <th key={label} className="whitespace-nowrap border-b border-theme-border px-3 py-2 font-medium">{label}</th>)}</tr></thead>
71
+ <tbody>{ordered.map(run => {
72
+ const key = `${run.kind}/${run.namespace}/${run.name}`
73
+ const usage = resources?.members[run.name]
74
+ return <tr key={key} className={`border-b border-theme-border-subtle last:border-0 ${props.selected === key ? 'selection' : 'hover:bg-theme-hover'}`}>
75
+ <td className="px-3 py-2 text-theme-text-secondary">{run.jobset?.replicatedJob ?? 'Not reported'}{run.jobset?.jobIndex !== undefined && ` #${run.jobset.jobIndex}`}</td>
76
+ <th scope="row" className="max-w-xs px-3 py-2 font-medium"><button type="button" aria-pressed={props.selected === key} className="block max-w-full truncate text-accent-text hover:underline" onClick={() => props.onSelect?.(key)}>{run.name}</button></th>
77
+ <td className="px-3 py-2"><Badge severity={run.phase === 'Failed' || run.phase === 'Error' ? 'error' : run.phase === 'Succeeded' ? 'success' : run.active ? 'info' : 'neutral'}>{run.phase}{run.deleting ? ' · deleting' : ''}</Badge></td>
78
+ <td className="whitespace-nowrap px-3 py-2 text-theme-text-secondary">{props.durationFor(run) || '—'}</td>
79
+ <td className="whitespace-nowrap px-3 py-2"><UsageValue usage={usage} resource="cpu" /></td>
80
+ <td className="whitespace-nowrap px-3 py-2"><UsageValue usage={usage} resource="memory" /></td>
81
+ <td className="px-3 py-2"><Coverage usage={usage}/>{usage?.extendedRequests && Object.entries(usage.extendedRequests).map(([name, count]) => <span key={name} className="block text-[10px] text-theme-text-tertiary">{name}: {count} requested · usage not collected</span>)}</td>
82
+ </tr>
83
+ })}</tbody>
84
+ </table>
85
+ </div>}
86
+ <p className="border-t border-theme-border px-3 py-2 text-[10px] text-theme-text-tertiary">Usage and requests cover reporting running Pods only; missing and stale samples are excluded. Whole-JobSet totals include members outside these filters.{props.truncated && ' Only the first 200 matching Jobs are shown. Refine the search to find another member.'}</p>
87
+ </section>
88
+ }
@@ -5,6 +5,7 @@ import { batchRunNextStep } from './batch-run-actions'
5
5
  function run(phase: string, counts: Partial<Pick<WorkloadRun, 'podFailed' | 'podSucceeded' | 'podRunning' | 'podPending'>> = {}): WorkloadRun {
6
6
  return {
7
7
  kind: 'jobs',
8
+ group: 'batch',
8
9
  namespace: 'jobs',
9
10
  name: 'example',
10
11
  phase,
@@ -7,6 +7,7 @@ describe('workloadRunTimelineEvents', () => {
7
7
  const runs: WorkloadRun[] = [
8
8
  {
9
9
  kind: 'jobs',
10
+ group: 'batch',
10
11
  namespace: 'dev',
11
12
  name: 'nightly-1',
12
13
  phase: 'Succeeded',
@@ -17,6 +18,7 @@ describe('workloadRunTimelineEvents', () => {
17
18
  },
18
19
  {
19
20
  kind: 'jobs',
21
+ group: 'batch',
20
22
  namespace: 'dev',
21
23
  name: 'nightly-2',
22
24
  phase: 'Failed',
@@ -43,6 +45,7 @@ describe('workloadRunTimelineEvents', () => {
43
45
  it('uses Workflow resource identity for Argo runs', () => {
44
46
  const events = workloadRunTimelineEvents([{
45
47
  kind: 'workflows',
48
+ group: 'argoproj.io',
46
49
  namespace: 'dev',
47
50
  name: 'migration-abc',
48
51
  phase: 'Error',
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { executionDefinitionFingerprint, executionDefinitionSummary } from './execution-definition'
2
+ import { executionDefinitionDiffers, executionDefinitionFingerprint, executionDefinitionSummary } from './execution-definition'
3
3
 
4
4
  describe('executionDefinitionSummary', () => {
5
5
  it('explains a Kubernetes Job pod template and effective defaults', () => {
@@ -139,6 +139,20 @@ describe('executionDefinitionSummary', () => {
139
139
  expect(executionDefinitionFingerprint(stored)).toBe(executionDefinitionFingerprint(current))
140
140
  })
141
141
 
142
+ it('only reports drift when both definitions are comparable', () => {
143
+ const captured = executionDefinitionSummary('Job', {
144
+ spec: { template: { spec: { containers: [{ name: 'worker', image: 'example/worker:v1' }] } } },
145
+ })
146
+ const changed = executionDefinitionSummary('Job', {
147
+ spec: { template: { spec: { containers: [{ name: 'worker', image: 'example/worker:v2' }] } } },
148
+ })
149
+
150
+ expect(executionDefinitionDiffers(null, captured)).toBe(false)
151
+ expect(executionDefinitionDiffers(captured, null)).toBe(false)
152
+ expect(executionDefinitionDiffers(captured, captured)).toBe(false)
153
+ expect(executionDefinitionDiffers(captured, changed)).toBe(true)
154
+ })
155
+
142
156
  it('reads a ScaledJob embedded Job target', () => {
143
157
  const summary = executionDefinitionSummary('ScaledJob', {
144
158
  spec: {
@@ -56,6 +56,10 @@ export function executionDefinitionFingerprint(summary: ExecutionDefinitionSumma
56
56
  return JSON.stringify(summary)
57
57
  }
58
58
 
59
+ export function executionDefinitionDiffers(current: ExecutionDefinitionSummary | null, captured: ExecutionDefinitionSummary | null): boolean {
60
+ return current != null && captured != null && executionDefinitionFingerprint(current) !== executionDefinitionFingerprint(captured)
61
+ }
62
+
59
63
  function kubernetesJobSummary(jobSpec: any): ExecutionDefinitionSummary {
60
64
  const podSpec = jobSpec.template?.spec ?? {}
61
65
  const containers = Array.isArray(podSpec.containers) ? podSpec.containers : []
@@ -0,0 +1,142 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import type { WorkloadRun, WorkloadRunsResponse } from '../../api/client'
4
+ import { BatchExecutionFullscreen } from './BatchExecutionView'
5
+ import { ScheduledWorkloadLogsViewer, workloadRunLogsKey } from '../logs/ScheduledWorkloadLogsViewer'
6
+
7
+ const state = vi.hoisted(() => ({
8
+ response: { collection: 'members', runs: [], total: 0, truncated: false } as WorkloadRunsResponse | undefined,
9
+ isLoading: false,
10
+ error: undefined as Error | undefined,
11
+ useResource: vi.fn().mockReturnValue({ data: { spec: {} } }),
12
+ }))
13
+
14
+ vi.mock('../../api/client', () => ({
15
+ useResource: (...args: unknown[]) => state.useResource(...args),
16
+ useWorkloadPods: () => ({ data: { pods: [], total: 0, truncated: false } }),
17
+ useJobSetResources: () => ({ data: undefined, isLoading: false }),
18
+ useWorkloadRuns: () => ({ data: state.response, isLoading: state.isLoading, error: state.error }),
19
+ }))
20
+ vi.mock('../logs/WorkloadLogsViewer', () => ({
21
+ WorkloadLogsViewer: ({ kind, namespace, name }: { kind: string; namespace: string; name: string }) => <div data-log-target={`${kind}/${namespace}/${name}`}>Logs for {name}</div>,
22
+ }))
23
+ vi.mock('./JobSetAdmission', () => ({ JobSetAdmission: () => null }))
24
+
25
+ const member: WorkloadRun = {
26
+ group: 'batch', kind: 'jobs', namespace: 'training', name: 'distributed-workers-0',
27
+ phase: 'Running', active: true,
28
+ jobset: { replicatedJob: 'workers', jobIndex: '0', replicatedJobReplicas: '2', restartAttempt: '0', jobRestartAttempt: '0' },
29
+ }
30
+
31
+ function overview(selectedRunKey?: string, apiVersion = 'jobset.x-k8s.io/v1alpha2') {
32
+ return renderToStaticMarkup(<BatchExecutionFullscreen kind="JobSet" apiKind="jobsets" namespace="training" name="distributed" resource={{ apiVersion, kind: 'JobSet', spec: { replicatedJobs: [{ name: 'workers', replicas: 2 }] } }} selectedRunKey={selectedRunKey} />)
33
+ }
34
+ function logs(selectedRunKey?: string) {
35
+ return renderToStaticMarkup(<ScheduledWorkloadLogsViewer kind="JobSet" namespace="training" name="distributed" selectedRunKey={selectedRunKey} />)
36
+ }
37
+
38
+ beforeEach(() => {
39
+ state.response = { collection: 'members', runs: [member], total: 1, truncated: false }
40
+ state.isLoading = false
41
+ state.error = undefined
42
+ state.useResource.mockReset()
43
+ state.useResource.mockReturnValue({ data: { spec: {} } })
44
+ })
45
+
46
+ describe('member collection consumers', () => {
47
+ it('keeps the root and declared roles visible during the first member load', () => {
48
+ state.response = undefined
49
+ state.isLoading = true
50
+ const html = overview()
51
+ expect(html).toContain('JobSet overview')
52
+ expect(html).toContain('Role progress')
53
+ expect(html).toContain('Loading Jobs')
54
+ expect(html).not.toContain('No child Jobs currently retained')
55
+ expect(html).not.toContain('Inspect')
56
+ })
57
+
58
+ it('keeps the root visible when member reads fail, without claiming absence', () => {
59
+ state.response = undefined
60
+ state.error = new Error('Forbidden: Jobs are not readable')
61
+ const html = overview()
62
+ expect(html).toContain('JobSet overview')
63
+ expect(html).toContain('Member Jobs unavailable')
64
+ expect(html).toContain('Forbidden: Jobs are not readable')
65
+ expect(html).not.toContain('No child Jobs currently retained')
66
+ expect(overview(undefined, 'other.example/v1')).not.toContain('JobSet overview')
67
+ })
68
+
69
+ it('renders nested role, zero index and selected member in Overview and Logs', () => {
70
+ const html = overview('jobs/training/distributed-workers-0')
71
+ expect(html).toContain('Member Jobs')
72
+ expect(html).toContain('workers #0')
73
+ expect(html).toContain('JobSet restart attempt')
74
+ expect(html).not.toContain('Run history')
75
+ expect(state.useResource).toHaveBeenCalledWith('jobs', 'training', member.name, 'batch', expect.objectContaining({ enabled: true }))
76
+ const logHTML = logs('jobs/training/distributed-workers-0')
77
+ expect(logHTML).toContain('workers #0')
78
+ expect(logHTML).toContain(`Logs for ${member.name}`)
79
+ expect(logHTML).toContain(`data-log-target="jobs/training/${member.name}"`)
80
+ expect(logHTML).not.toContain('data-log-target="jobsets/')
81
+ })
82
+
83
+ it('does not silently select a different member outside a truncated window', () => {
84
+ state.response = { collection: 'members', runs: [member], total: 201, truncated: true }
85
+ const missing = 'jobs/training/omitted-worker'
86
+ expect(overview(missing)).toContain('Selected Job is currently unavailable')
87
+ const html = logs(missing)
88
+ expect(html).toContain('Select a shown member Job')
89
+ expect(html).not.toContain('Logs for')
90
+ })
91
+
92
+ it.each([{ remaining: [] }, { remaining: [{ ...member, name: 'other-worker' }] }])('preserves an explicit member through a recreation gap with $remaining', ({ remaining }) => {
93
+ const selected = 'jobs/training/distributed-workers-0'
94
+ state.response = { collection: 'members', runs: remaining, total: remaining.length, truncated: false }
95
+ expect(overview(selected)).toContain('Selected Job is currently unavailable')
96
+ const html = logs(selected)
97
+ expect(html).toContain('Your selection is preserved if it reappears')
98
+ expect(html).not.toContain('truncated window')
99
+ expect(html).not.toContain('Logs for')
100
+ if (remaining.length === 0) expect(html).not.toContain('aria-label="Select a shown member Job"')
101
+ state.response = { collection: 'members', runs: [member], total: 1, truncated: false }
102
+ expect(logs(selected)).toContain(`Logs for ${member.name}`)
103
+ expect(overview(selected)).not.toContain('Selected Job is currently unavailable')
104
+ })
105
+
106
+ it('opens the exact selected member outside the list in Overview and Logs', () => {
107
+ const selected = { ...member, name: 'off-window' }
108
+ state.response = { collection: 'members', runs: [member], selected, total: 250, filteredTotal: 250, truncated: true }
109
+ const key = 'jobs/training/off-window'
110
+ expect(overview(key)).toContain('Selected Job is outside the current filter or shown window')
111
+ expect(logs(key)).toContain('Logs for off-window')
112
+ expect(logs(key)).not.toContain('Selected Job is currently unavailable')
113
+ })
114
+
115
+ it('waits for declared roles before enabling a role log scope', () => {
116
+ state.useResource.mockReturnValue({ data: undefined })
117
+ expect(logs()).toContain('<option value="role" disabled="">Role</option>')
118
+ state.useResource.mockReturnValue({ data: { spec: { replicatedJobs: [{ name: 'prepare' }, { name: 'workers' }] } } })
119
+ expect(logs()).toContain('<option value="role">Role</option>')
120
+ })
121
+
122
+ it('uses collection semantics even when there are no members', () => {
123
+ state.response = { collection: 'members', runs: [], total: 0, truncated: false }
124
+ expect(overview()).toContain('Member Jobs')
125
+ expect(overview()).toContain('No child Jobs currently retained')
126
+ expect(logs()).toContain('No child Jobs found')
127
+ })
128
+
129
+ it('reads response semantics instead of guessing membership from the parent kind', () => {
130
+ state.response = { collection: 'runs', runs: [{ ...member, jobset: undefined }], total: 1, truncated: false }
131
+ expect(overview()).toContain('Run history')
132
+ expect(overview()).not.toContain('Member Jobs')
133
+ expect(logs()).not.toContain('workers #0')
134
+ })
135
+
136
+ it('restarts logs for either native retry scope at the same Job address', () => {
137
+ expect(workloadRunLogsKey({ ...member })).toBe(workloadRunLogsKey(member))
138
+ expect(workloadRunLogsKey({ ...member, jobset: { ...member.jobset, restartAttempt: '1' } })).not.toBe(workloadRunLogsKey(member))
139
+ expect(workloadRunLogsKey({ ...member, jobset: { ...member.jobset, jobRestartAttempt: '1' } })).not.toBe(workloadRunLogsKey(member))
140
+ expect(workloadRunLogsKey({ ...member, jobset: {} })).not.toBe(workloadRunLogsKey(member))
141
+ })
142
+ })
@@ -19,6 +19,7 @@ import {
19
19
  formatGitOpsSourceUrl,
20
20
  getGitOpsResourceStatus,
21
21
  getGitOpsTool,
22
+ isDestinationRef,
22
23
  isArgoOperationInProgress,
23
24
  isArgoSuspendedByRadar,
24
25
  gitOpsInsightChangeKey,
@@ -40,11 +41,15 @@ import {
40
41
  type GitOpsRow,
41
42
  type GitOpsRowAction,
42
43
  type GitOpsTreeFilters,
44
+ type GitOpsTreeNode,
43
45
  type GitOpsTreeRef,
44
46
  type GitOpsTreePreset,
45
47
  type SelectedResource,
46
48
  } from '@skyhook-io/k8s-ui'
47
49
  import { useToast } from '../ui/Toast'
50
+ import { useContextSwitchFlow } from '../useContextSwitchFlow'
51
+ import { useDestinationCluster } from './useDestinationCluster'
52
+ import { destinationToast } from './destination-toast'
48
53
 
49
54
  import {
50
55
  fetchJSON,
@@ -352,7 +357,7 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
352
357
  function GitOpsDetailView({ namespaces, onOpenResource, onOpenSettings }: GitOpsViewProps) {
353
358
  const location = useLocation()
354
359
  const navigate = useNavigate()
355
- const { showError, showSuccess } = useToast()
360
+ const { showError, showSuccess, showToast } = useToast()
356
361
  const parts = location.pathname.split('/').filter(Boolean)
357
362
  const kind = parts[2] || 'applications'
358
363
  const namespace = parts[3] === '_' ? '' : decodePathPart(parts[3] || '')
@@ -380,6 +385,7 @@ function GitOpsDetailView({ namespaces, onOpenResource, onOpenSettings }: GitOps
380
385
  const resourceQ = useResource<any>(kind, namespace, name, group)
381
386
  const treeQ = useGitOpsTree(kind, namespace, name, group, namespaces)
382
387
  const insightsQ = useGitOpsInsights(kind, namespace, name, group, namespaces)
388
+ const switchFlow = useContextSwitchFlow()
383
389
  const status = resourceQ.data ? getGitOpsResourceStatus(kind, resourceQ.data) : null
384
390
  const tool = getGitOpsTool(kind, group)
385
391
  // Argo "auto-sync ON" is determined by spec.syncPolicy.automated being set,
@@ -463,8 +469,32 @@ function GitOpsDetailView({ namespaces, onOpenResource, onOpenSettings }: GitOps
463
469
  roles: graphRoles,
464
470
  }), [graphHealth, graphKinds, graphNamespaces, graphRoles, graphSync])
465
471
  const graphFacets = useMemo(() => buildTreeFacets(tree), [tree])
472
+ // The tree and insights load separately; either one seeing a remote
473
+ // destination is enough to keep its resources from opening here.
474
+ const remoteDestination = !!(tree?.remoteDestination || insightsQ.data?.summary?.remoteDestination)
475
+ const destination = useDestinationCluster(remoteDestination, kind, namespace, name)
466
476
 
467
- function openResourceFromTree(ref: GitOpsTreeRef | GitOpsInsightRef) {
477
+ function openResourceFromTree(ref: GitOpsTreeRef | GitOpsInsightRef, node?: GitOpsTreeNode) {
478
+ // Locality comes from the node, not the name: a remote app can deploy a
479
+ // same-named copy of itself to its destination.
480
+ if (node?.role !== 'root' && isDestinationRef(tree, remoteDestination, ref)) {
481
+ const toast = destinationToast(`${ref.kind} ${ref.namespace ? `${ref.namespace}/` : ''}${ref.name}`, destination)
482
+ const target = destination.context
483
+ showToast(toast.message, {
484
+ type: 'info',
485
+ detail: toast.detail,
486
+ action: toast.actionLabel && target ? {
487
+ label: toast.actionLabel,
488
+ onClick: () => switchFlow.requestSwitch(target, {
489
+ kind: kindToPluralWithGroup(ref.kind, ref.group ?? ''),
490
+ namespace: ref.namespace || '',
491
+ name: ref.name,
492
+ group: ref.group,
493
+ }),
494
+ } : undefined,
495
+ })
496
+ return
497
+ }
468
498
  if (isGitOpsDetailRef(ref) && isValidKubernetesName(ref.name)) {
469
499
  const detailKind = kindToPluralWithGroup(ref.kind, ref.group ?? '')
470
500
  // The tree's root node is this page's own subject — clicking it must not
@@ -822,6 +852,7 @@ function GitOpsDetailView({ namespaces, onOpenResource, onOpenSettings }: GitOps
822
852
  }}
823
853
  >
824
854
  {/* Modals — portaled to body, only render the ones for the current tool. */}
855
+ {switchFlow.confirmDialog}
825
856
  {isArgoApp && (
826
857
  <>
827
858
  <SyncOptionsDialog
@@ -0,0 +1,33 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { destinationToast } from './destination-toast'
3
+
4
+ const ctx = {
5
+ name: 'arn:aws:eks:eu-central-1:420859418125:cluster/eks-prod-cluster',
6
+ cluster: 'arn:aws:eks:eu-central-1:420859418125:cluster/eks-prod-cluster',
7
+ user: 'u',
8
+ namespace: '',
9
+ isCurrent: false,
10
+ }
11
+
12
+ describe('destinationToast', () => {
13
+ test('offers to open it in the matching context', () => {
14
+ const t = destinationToast('ConfigMap ingress-nginx/controller', { context: ctx, server: 'https://65b5.gr7.eu-central-1.eks.amazonaws.com', canSwitch: true })
15
+ expect(t.message).toBe('ConfigMap ingress-nginx/controller is on eks-prod-cluster')
16
+ expect(t.actionLabel).toBe('Open in eks-prod-cluster')
17
+ })
18
+ test('names the cluster without an action when switching is off', () => {
19
+ const t = destinationToast('ConfigMap a/b', { context: ctx, server: 'https://65b5.gr7.eu-central-1.eks.amazonaws.com', canSwitch: false })
20
+ expect(t.message).toContain('eks-prod-cluster')
21
+ expect(t.actionLabel).toBeUndefined()
22
+ })
23
+ test('says which server is missing from the kubeconfig', () => {
24
+ const t = destinationToast('ConfigMap a/b', { server: 'https://34.73.119.131', canSwitch: true })
25
+ expect(t.detail).toContain('34.73.119.131')
26
+ expect(t.actionLabel).toBeUndefined()
27
+ })
28
+ test('unknown destination', () => {
29
+ const t = destinationToast('ConfigMap a/b', { canSwitch: true })
30
+ expect(t.message).toBe('ConfigMap a/b is on another cluster')
31
+ expect(t.actionLabel).toBeUndefined()
32
+ })
33
+ })
@@ -0,0 +1,46 @@
1
+ import type { DestinationCluster } from './useDestinationCluster'
2
+ import { parseContextForSwitcher } from '../../utils/context-name'
3
+
4
+ export interface DestinationToast {
5
+ message: string
6
+ detail: string
7
+ // Set when the resource can be opened by switching to a kubeconfig context.
8
+ actionLabel?: string
9
+ }
10
+
11
+ // What to tell someone who clicked a resource that lives on a remote GitOps
12
+ // object's destination cluster, and how they can open it.
13
+ export function destinationToast(resource: string, dest: DestinationCluster): DestinationToast {
14
+ if (dest.context) {
15
+ const cluster = parseContextForSwitcher(dest.context).clusterName
16
+ if (!dest.canSwitch) {
17
+ return {
18
+ message: `${resource} is on ${cluster}`,
19
+ detail: 'Switching clusters is turned off in this Radar.',
20
+ }
21
+ }
22
+ return {
23
+ message: `${resource} is on ${cluster}`,
24
+ detail: 'Opening it switches Radar to that cluster.',
25
+ actionLabel: `Open in ${cluster}`,
26
+ }
27
+ }
28
+ if (dest.server) {
29
+ return {
30
+ message: `${resource} is on another cluster`,
31
+ detail: `No kubeconfig context points at ${hostOf(dest.server)}. Add one to open it from Radar.`,
32
+ }
33
+ }
34
+ return {
35
+ message: `${resource} is on another cluster`,
36
+ detail: "Radar couldn't tell which cluster, so it can't open it from here.",
37
+ }
38
+ }
39
+
40
+ function hostOf(server: string): string {
41
+ try {
42
+ return new URL(server).host
43
+ } catch {
44
+ return server
45
+ }
46
+ }
@@ -0,0 +1,30 @@
1
+ import { useCapabilities, useContexts, useGitOpsDestination } from '../../api/client'
2
+ import type { ContextInfo } from '../../types'
3
+
4
+ export interface DestinationCluster {
5
+ // The destination's API server host, when Radar can tell.
6
+ server?: string
7
+ // A kubeconfig context for that cluster other than the current one.
8
+ context?: ContextInfo
9
+ // Whether this Radar may switch contexts at all.
10
+ canSwitch: boolean
11
+ }
12
+
13
+ // Resolves where a remote GitOps object deploys and which kubeconfig context
14
+ // reaches it. The server does the matching; this only asks for remote objects.
15
+ export function useDestinationCluster(
16
+ remote: boolean | undefined,
17
+ kind: string,
18
+ namespace: string,
19
+ name: string,
20
+ ): DestinationCluster {
21
+ const destination = useGitOpsDestination(kind, namespace, name, !!remote)
22
+ const contexts = useContexts()
23
+ const capabilities = useCapabilities()
24
+ const contextName = destination.data?.contexts[0]
25
+ return {
26
+ server: destination.data?.server || undefined,
27
+ context: contextName ? contexts.data?.find((c) => c.name === contextName) : undefined,
28
+ canSwitch: capabilities.data?.configManagement !== 'operator',
29
+ }
30
+ }