@skyhook-io/radar-app 1.14.4 → 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.
- package/README.md +2 -9
- package/package.json +6 -6
- package/src/App.tsx +91 -328
- package/src/RadarApp.tsx +3 -8
- package/src/api/client.jobset.test.ts +18 -0
- package/src/api/client.resource-identity.test.ts +44 -0
- package/src/api/client.rightsizing.test.ts +7 -3
- package/src/api/client.ts +233 -78
- package/src/api/preferences.test.ts +54 -0
- package/src/api/preferences.ts +38 -0
- package/src/components/CloudFunnelButton.test.tsx +155 -0
- package/src/components/CloudFunnelButton.tsx +14 -8
- package/src/components/ContextSwitcher.tsx +13 -132
- package/src/components/applications/ApplicationsView.identity.test.ts +39 -0
- package/src/components/applications/ApplicationsView.tsx +5 -3
- package/src/components/audit/AuditSettingsDialog.tsx +15 -11
- package/src/components/diagnose/AIMarkdown.tsx +8 -3
- package/src/components/diagnose/AgentControls.tsx +26 -3
- package/src/components/diagnose/AssessmentCard.tsx +249 -237
- package/src/components/diagnose/DiagnoseContext.tsx +1 -0
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +47 -0
- package/src/components/diagnose/InvestigationEvidencePane.tsx +15 -2
- package/src/components/diagnose/InvestigationEvidenceSections.tsx +1 -1
- package/src/components/diagnose/InvestigationView.tsx +4 -1
- package/src/components/diagnose/agentCatalog.ts +6 -0
- package/src/components/diagnose/investigationCase.test.tsx +56 -0
- package/src/components/diagnose/investigationEvidence/adapters/logs.test.ts +60 -0
- package/src/components/diagnose/investigationEvidence/adapters/logs.ts +7 -3
- package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +1 -1
- package/src/components/diagnose/investigationEvidence/observations.ts +8 -5
- package/src/components/diagnose/investigationEvidence/types.ts +1 -1
- package/src/components/diagnose/launch.test.ts +23 -0
- package/src/components/diagnose/launch.ts +4 -0
- package/src/components/diagnose/parts.test.tsx +159 -2
- package/src/components/dock/NodeTerminalTab.test.tsx +34 -0
- package/src/components/dock/NodeTerminalTab.tsx +5 -3
- package/src/components/dock/WorkloadLogsTab.test.tsx +66 -0
- package/src/components/dock/WorkloadLogsTab.tsx +2 -1
- package/src/components/execution/BatchExecutionView.render.test.tsx +2 -1
- package/src/components/execution/BatchExecutionView.test.ts +52 -1
- package/src/components/execution/BatchExecutionView.tsx +324 -65
- package/src/components/execution/JobSetAdmission.test.tsx +42 -0
- package/src/components/execution/JobSetAdmission.tsx +17 -0
- package/src/components/execution/JobSetMemberComparison.test.tsx +30 -0
- package/src/components/execution/JobSetMemberComparison.tsx +88 -0
- package/src/components/execution/batch-run-actions.test.ts +1 -0
- package/src/components/execution/batch-timeline.test.ts +3 -0
- package/src/components/execution/execution-definition.test.ts +15 -1
- package/src/components/execution/execution-definition.ts +4 -0
- package/src/components/execution/member-collection.render.test.tsx +142 -0
- package/src/components/gitops/GitOpsView.tsx +33 -2
- package/src/components/gitops/destination-toast.test.ts +33 -0
- package/src/components/gitops/destination-toast.ts +46 -0
- package/src/components/gitops/useDestinationCluster.ts +30 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +20 -11
- package/src/components/home/MCPSetupDialog.tsx +10 -0
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
- package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
- package/src/components/nav/PrimaryNavRail.tsx +2 -5
- package/src/components/resource/PVCUsageBar.render.test.tsx +168 -0
- package/src/components/resource/PVCUsageBar.tsx +62 -16
- package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -1
- package/src/components/resources/ResourcesView.tsx +3 -3
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +8 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +39 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +37 -0
- package/src/components/resources/renderers/RayClusterRenderer.test.tsx +18 -0
- package/src/components/resources/renderers/RayClusterRenderer.tsx +17 -0
- package/src/components/resources/renderers/RayServiceRenderer.test.tsx +69 -0
- package/src/components/resources/renderers/RayServiceRenderer.tsx +38 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +207 -83
- package/src/components/rightsizing/copy.test.ts +7 -0
- package/src/components/rightsizing/empty-state.test.tsx +67 -0
- package/src/components/rightsizing/model.test.ts +120 -2
- package/src/components/rightsizing/model.ts +36 -13
- package/src/components/rightsizing/notices.test.tsx +176 -0
- package/src/components/settings/OperatorManagedNotice.tsx +17 -0
- package/src/components/settings/SettingsDialog.tsx +157 -42
- package/src/components/settings/settings-state.test.ts +18 -0
- package/src/components/settings/settings-state.ts +18 -0
- package/src/components/timeline/TimelineView.tsx +9 -3
- package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
- package/src/components/ui/Markdown.tsx +33 -6
- package/src/components/ui/command-items.ts +0 -3
- package/src/components/useContextSwitchFlow.tsx +147 -0
- package/src/components/workload/WorkloadView.test.ts +17 -1
- package/src/components/workload/WorkloadView.tsx +36 -18
- package/src/context/ContextSwitchContext.tsx +18 -2
- package/src/context/NavCustomization.tsx +4 -58
- package/src/context/ThemeContext.tsx +3 -11
- package/src/hooks/useClusterLoadState.ts +2 -2
- package/src/hooks/useFavorites.ts +3 -5
- package/src/utils/auditBadges.ts +1 -1
- package/src/utils/navigation.test.ts +18 -1
- package/src/utils/navigation.ts +12 -5
- package/src/utils/topology-selection.test.ts +56 -0
- package/src/utils/topology-selection.ts +9 -9
- 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
|
+
}
|