@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.
- package/package.json +3 -2
- package/src/components/applications/ApplicationDetail.tsx +24 -11
- package/src/components/audit/AuditAlerts.tsx +1 -3
- package/src/components/audit/AuditCoveragePresentation.test.tsx +48 -2
- package/src/components/checks/ChecksView.test.tsx +55 -0
- package/src/components/checks/ChecksView.tsx +38 -27
- package/src/components/dock/NodeTerminalTab.test.tsx +89 -0
- package/src/components/dock/NodeTerminalTab.tsx +51 -47
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +17 -9
- package/src/components/gitops/detail-helpers.test.ts +32 -0
- package/src/components/gitops/detail-helpers.ts +21 -0
- package/src/components/gitops/health-provenance.test.ts +4 -3
- package/src/components/gitops/index.ts +2 -1
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +131 -54
- package/src/components/issues/IssuesView.tsx +6 -0
- package/src/components/issues/ResourceIssuesSection.test.tsx +36 -0
- package/src/components/issues/diagnostic.ts +4 -0
- package/src/components/logs/LogCore.tsx +3 -3
- package/src/components/logs/WorkloadLogsViewer.tsx +41 -11
- package/src/components/logs/useLogBuffer.ts +1 -0
- package/src/components/logs/useLogStream.ts +1 -2
- package/src/components/resources/KueueAdmissionSection.test.tsx +95 -0
- package/src/components/resources/KueueAdmissionSection.tsx +87 -0
- package/src/components/resources/ResourcesView.default-sort.test.ts +26 -0
- package/src/components/resources/ResourcesView.tsx +91 -16
- package/src/components/resources/curated-column-ownership.test.ts +45 -0
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +4 -3
- package/src/components/resources/renderers/ContainerEnvironmentSection.tsx +10 -3
- package/src/components/resources/renderers/JobSetRenderer.test.tsx +280 -0
- package/src/components/resources/renderers/JobSetRenderer.tsx +322 -0
- package/src/components/resources/renderers/KueueProvisioningRenderers.test.tsx +67 -0
- package/src/components/resources/renderers/KueueProvisioningRenderers.tsx +91 -0
- package/src/components/resources/renderers/KueueQueueNavigation.test.tsx +69 -0
- package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +123 -0
- package/src/components/resources/renderers/KueueQueueRenderers.tsx +299 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +121 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +230 -0
- package/src/components/resources/renderers/RayClusterRenderer.test.tsx +25 -0
- package/src/components/resources/renderers/RayClusterRenderer.tsx +93 -0
- package/src/components/resources/renderers/RayServiceRenderer.test.tsx +62 -0
- package/src/components/resources/renderers/RayServiceRenderer.tsx +114 -0
- package/src/components/resources/renderers/index.ts +11 -0
- package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +103 -2
- package/src/components/resources/resource-utils-jobset-lws.ts +43 -9
- package/src/components/resources/resource-utils-kueue.ts +145 -50
- package/src/components/resources/resource-utils-ray.test.ts +58 -1
- package/src/components/resources/resource-utils-ray.ts +56 -90
- package/src/components/shared/ResourceRendererDispatch.test.tsx +117 -2
- package/src/components/shared/ResourceRendererDispatch.tsx +47 -10
- package/src/components/timeline/TimelineList.tsx +5 -0
- package/src/components/timeline/TimelineSwimlanes.test.tsx +15 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +43 -23
- package/src/components/topology/K8sResourceNode.tsx +3 -3
- package/src/components/ui/CodeViewer.tsx +4 -0
- package/src/components/ui/Toast.tsx +9 -3
- package/src/components/ui/drawer-components.tsx +3 -1
- package/src/components/workload/PodList.tsx +154 -0
- package/src/components/workload/WorkloadView.tsx +29 -159
- package/src/components/workload/index.ts +11 -1
- package/src/components/workload/workload-logs-availability.test.ts +20 -0
- package/src/components/workload/workload-pod-presentation.test.ts +27 -0
- package/src/types/core.ts +24 -0
- package/src/types/gitops-tree.ts +5 -2
- package/src/types/scheduling.ts +71 -0
- package/src/utils/api-resources.test.ts +20 -1
- package/src/utils/api-resources.ts +26 -0
- package/src/utils/application-topology.test.ts +25 -0
- package/src/utils/application-topology.ts +2 -1
- package/src/utils/applications.test.ts +82 -0
- package/src/utils/applications.ts +62 -24
- package/src/utils/gitops-route.test.ts +20 -1
- package/src/utils/gitops-route.ts +15 -7
- package/src/utils/log-format.ts +5 -2
- package/src/utils/log-stream-error.test.ts +13 -0
- package/src/utils/navigation.test.ts +2 -0
- package/src/utils/navigation.ts +1 -1
- package/src/utils/resource-hierarchy.test.ts +402 -15
- package/src/utils/resource-hierarchy.ts +263 -178
- package/src/utils/topology-neighborhood.test.ts +72 -1
- 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: '
|
|
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
|
|
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
|
-
|
|
5116
|
+
newColumn = column
|
|
5117
|
+
newDirection = 'desc'
|
|
5048
5118
|
} else if (sortDirection === 'desc') {
|
|
5049
|
-
|
|
5050
|
-
|
|
5119
|
+
newColumn = null
|
|
5120
|
+
newDirection = null
|
|
5051
5121
|
} else {
|
|
5052
|
-
|
|
5122
|
+
newColumn = column
|
|
5123
|
+
newDirection = 'asc'
|
|
5053
5124
|
}
|
|
5054
5125
|
} else {
|
|
5055
|
-
|
|
5056
|
-
|
|
5126
|
+
newColumn = column
|
|
5127
|
+
newDirection = 'asc'
|
|
5057
5128
|
}
|
|
5058
|
-
|
|
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 (
|
|
5296
|
-
const extra = extraColumnsByKey.get(
|
|
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,
|
|
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,
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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'
|