@skyhook-io/k8s-ui 1.14.12 → 1.16.0
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 -3
- package/src/components/audit/AuditFindingsTable.tsx +1 -1
- package/src/components/checks/ChecksView.tsx +8 -8
- package/src/components/compare/CompareResourcePicker.tsx +4 -3
- package/src/components/gitops/GitOpsDetailLayout.tsx +3 -1
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +12 -2
- package/src/components/gitops/RollbackDialog.tsx +4 -3
- package/src/components/gitops/SyncOptionsDialog.tsx +4 -3
- package/src/components/gitops/health-provenance.test.ts +1 -1
- package/src/components/gitops/index.ts +1 -0
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +1 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +4 -1
- package/src/components/issues/IssuesView.tsx +2 -2
- package/src/components/logs/LogCore.tsx +131 -97
- package/src/components/logs/StructuredLogLine.tsx +18 -28
- package/src/components/logs/log-palette.ts +4 -0
- package/src/components/logs/useLogBuffer.ts +8 -39
- package/src/components/logs/useLogSearch.ts +52 -24
- package/src/components/namespace-switcher/NamespacePicker.test.tsx +140 -0
- package/src/components/namespace-switcher/NamespacePicker.tsx +26 -20
- package/src/components/resources/KueueAdmissionSection.test.tsx +183 -6
- package/src/components/resources/KueueAdmissionSection.tsx +140 -51
- package/src/components/resources/ResourcesView.tsx +4 -0
- package/src/components/resources/printer-columns.test.ts +1 -1
- package/src/components/resources/renderers/JobRenderer.tsx +5 -1
- package/src/components/resources/renderers/JobSetRenderer.tsx +4 -1
- package/src/components/resources/renderers/KueueQueueNavigation.test.tsx +2 -2
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +2 -15
- package/src/components/resources/renderers/PodRenderer.test.tsx +15 -0
- package/src/components/resources/renderers/PodRenderer.tsx +10 -2
- package/src/components/resources/renderers/PolicySection.test.tsx +42 -0
- package/src/components/resources/renderers/PolicySection.tsx +10 -1
- package/src/components/resources/renderers/RayClusterRenderer.tsx +7 -7
- package/src/components/resources/renderers/RayJobRenderer.test.tsx +71 -0
- package/src/components/resources/renderers/RayJobRenderer.tsx +134 -0
- package/src/components/resources/renderers/ReflectorSection.test.tsx +4 -2
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +7 -3
- package/src/components/resources/renderers/index.ts +2 -0
- package/src/components/resources/resource-utils-kueue.ts +14 -0
- package/src/components/resources/resource-utils-ray.ts +14 -4
- package/src/components/shared/CreateResourceDialog.tsx +6 -2
- package/src/components/shared/DrainPlanDialog.test.tsx +10 -0
- package/src/components/shared/DrainPlanDialog.tsx +14 -3
- package/src/components/shared/ResourceActionsBar.tsx +18 -11
- package/src/components/shared/ResourceRendererDispatch.test.tsx +12 -5
- package/src/components/shared/ResourceRendererDispatch.tsx +15 -6
- package/src/components/shared/SetImageDialog.tsx +4 -2
- package/src/components/shared/revision-role-badges.test.ts +1 -1
- package/src/components/trace/reachGraphModel.test.ts +1 -0
- package/src/components/trace/reachInspector.test.ts +4 -9
- package/src/components/ui/ConfirmDialog.tsx +5 -3
- package/src/components/ui/DialogPortal.test.tsx +279 -0
- package/src/components/ui/DialogPortal.tsx +147 -9
- package/src/components/ui/FilterPill.tsx +1 -1
- package/src/components/ui/RadarUpgradeNote.click.test.tsx +24 -0
- package/src/components/ui/RadarUpgradeNote.test.tsx +66 -0
- package/src/components/ui/RadarUpgradeNote.tsx +129 -0
- package/src/components/ui/ResourceBar.tsx +12 -6
- package/src/components/ui/SelectMenu.tsx +62 -19
- package/src/components/ui/YamlReview.tsx +4 -1
- package/src/components/ui/drawer-components.test.tsx +14 -1
- package/src/components/ui/drawer-components.tsx +50 -40
- package/src/components/ui/index.ts +8 -0
- package/src/components/workload/WorkloadView.tsx +109 -3
- package/src/types/core.ts +24 -4
- package/src/types/fetch-error.ts +33 -0
- package/src/utils/log-format.ts +1 -1
- package/src/utils/log-level.test.ts +269 -0
- package/src/utils/log-level.ts +301 -0
- package/src/utils/resource-hierarchy.test.ts +69 -0
- package/src/utils/resource-hierarchy.ts +87 -52
|
@@ -1,87 +1,176 @@
|
|
|
1
|
-
import { isKueueConditionStale } from './resource-utils-kueue'
|
|
1
|
+
import { admissionCheckSeverity, isKueueConditionStale } from './resource-utils-kueue'
|
|
2
|
+
import { Disclosure } from '../ui/Disclosure'
|
|
3
|
+
import { formatRelativeAgeTime } from '../../utils/format'
|
|
2
4
|
import { Badge } from '../ui/Badge'
|
|
3
|
-
import { AlertBanner,
|
|
5
|
+
import { AlertBanner, ResourceLink, Section } from '../ui/drawer-components'
|
|
4
6
|
import { kindToPluralWithGroup } from '../../utils/navigation'
|
|
5
|
-
import type { KueueAdmissionResponse, SchedulingObservation, SchedulingRef } from '../../types/scheduling'
|
|
7
|
+
import type { KueueAdmissionResponse, SchedulingCondition, SchedulingObservation, SchedulingRef } from '../../types/scheduling'
|
|
6
8
|
|
|
7
9
|
interface KueueAdmissionSectionProps {
|
|
10
|
+
presentation?: 'card' | 'drawer'
|
|
8
11
|
data?: KueueAdmissionResponse
|
|
9
12
|
loading: boolean
|
|
10
13
|
error?: string
|
|
11
14
|
forbidden?: boolean
|
|
12
15
|
hinted: boolean
|
|
13
16
|
externalExecution: boolean
|
|
17
|
+
hasOwner?: boolean
|
|
14
18
|
onRetry?: () => void
|
|
15
19
|
onNavigate?: (ref: { kind: string; namespace: string; name: string; group?: string }) => void
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
const phases = { pending: 'Pending admission', quota_reserved: 'Quota reserved', admitted: 'Admitted', finished: 'Finished' }
|
|
19
23
|
|
|
20
|
-
export function KueueAdmissionSection({ data, loading, error, forbidden, hinted, externalExecution, onRetry, onNavigate }: KueueAdmissionSectionProps) {
|
|
24
|
+
export function KueueAdmissionSection({ presentation = 'card', data, loading, error, forbidden, hinted, externalExecution, hasOwner, onRetry, onNavigate }: KueueAdmissionSectionProps) {
|
|
21
25
|
if (!loading && !error && data && data.workloads.length === 0 && !hinted) return null
|
|
22
26
|
const link = (name: string, ref?: SchedulingRef) => ref
|
|
23
27
|
? <ResourceLink {...ref} kind={kindToPluralWithGroup(ref.kind, ref.group ?? '')} label={name} onNavigate={onNavigate} />
|
|
24
28
|
: name
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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">
|
|
29
|
+
const spacing = presentation === 'drawer' ? '' : 'mt-3'
|
|
30
|
+
const content = <>
|
|
31
|
+
{loading ? <p className={`${spacing} text-sm text-theme-text-secondary`}>Looking for controller-owned Workloads…</p>
|
|
32
|
+
: error ? <div className={`${spacing} [&>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>
|
|
33
|
+
: data && !data.installed ? <p className={`${spacing} text-sm text-theme-text-secondary`}>Kueue Workloads are not served by this cluster.</p>
|
|
34
|
+
: data && data.workloads.length === 0 ? <p className={`${spacing} text-sm text-theme-text-secondary`}>No controller-owned Kueue Workload observed in this namespace.{hasOwner ? ' Admission may be tracked on the owning resource; use the existing owner link to inspect it.' : externalExecution ? ' This workload uses an external controller; local absence does not establish remote admission or execution state.' : ' Queue metadata alone does not establish an admission decision.'}</p>
|
|
35
|
+
: data && <div className={`${spacing} space-y-3`}>
|
|
34
36
|
{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="
|
|
36
|
-
|
|
37
|
+
{data.workloads.map((workload) => <article key={workload.uid} className="space-y-2 text-sm border-t border-theme-border pt-3 first:border-0 first:pt-0">
|
|
38
|
+
{data.total > 1 && workload.createdAt && <p className="text-xs text-theme-text-tertiary">Created <time dateTime={workload.createdAt} title={workload.createdAt}>{formatRelativeAgeTime(workload.createdAt)}</time></p>}
|
|
39
|
+
{workload.projection !== 'available' && <div className="flex flex-wrap items-center gap-2">{link(workload.name, workload.ref)}{workload.deleting && <Badge severity="alert">Deleting</Badge>}</div>}
|
|
37
40
|
{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
41
|
: 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} />)}
|
|
42
|
+
: workload.scheduling?.observations?.map((observation, index) => <AdmissionObservation key={index} presentation={presentation} observation={observation} identity={link(workload.name, workload.ref)} sourceLink={workload.ref && onNavigate ? link("View Workload details", workload.ref) : undefined} generation={workload.generation} deleting={workload.deleting} link={link} />)}
|
|
40
43
|
{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
44
|
</article>)}
|
|
42
45
|
</div>}
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
</>
|
|
47
|
+
return presentation === 'drawer'
|
|
48
|
+
? <Section title="Kueue admission">{content}</Section>
|
|
49
|
+
: <section className="space-y-3" aria-label="Kueue admission"><h3 className="text-sm font-semibold text-theme-text-primary">Kueue admission</h3>{content}</section>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type Link = (name: string, ref?: SchedulingRef) => React.ReactNode
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
const evidenceCard = 'min-w-0 overflow-hidden rounded-lg border border-theme-border bg-theme-surface'
|
|
56
|
+
const cardHeader = 'border-b border-theme-border bg-theme-elevated px-3 py-2.5'
|
|
57
|
+
const groupHeading = 'text-xs font-medium uppercase tracking-wider text-theme-text-secondary'
|
|
58
|
+
type Gate = NonNullable<SchedulingObservation['gates']>[number]
|
|
59
|
+
|
|
60
|
+
function conditionLabel(condition: SchedulingCondition): string {
|
|
61
|
+
const { type, status } = condition
|
|
62
|
+
if (type === 'PodsReady') return `Pods ready: ${status === 'True' ? 'yes' : status === 'False' ? 'no' : status}`
|
|
63
|
+
if (type === 'WaitingForReplacementPods') return `Replacement Pods: ${status === 'True' ? 'waiting' : status === 'False' ? 'not waiting' : status}`
|
|
64
|
+
if (status === 'True') {
|
|
65
|
+
if (type === 'Evicted') return 'Evicted'
|
|
66
|
+
if (type === 'Preempted') return 'Preempted'
|
|
67
|
+
if (type === 'DeactivationTarget') return 'Deactivation requested'
|
|
68
|
+
}
|
|
69
|
+
return `${type}=${status}`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function ConditionEvidence({ condition, generation, showAge }: { condition: SchedulingCondition; generation?: number; showAge?: boolean }) {
|
|
73
|
+
return <div className="space-y-1 py-3 first:pt-0 last:pb-0">
|
|
74
|
+
<p className="max-w-3xl whitespace-pre-wrap break-words text-theme-text-secondary">
|
|
75
|
+
<span className="block font-medium text-theme-text-primary">{conditionLabel(condition)}{showAge && condition.lastTransitionTime && <span className="font-normal text-xs text-theme-text-tertiary"> · Status since {formatRelativeAgeTime(condition.lastTransitionTime)}</span>}</span>
|
|
76
|
+
{condition.message || condition.reason || `${condition.type}=${condition.status}`}
|
|
77
|
+
</p>
|
|
78
|
+
{isKueueConditionStale(condition, generation) && <p className="text-xs text-warning-text">Stale evidence: condition generation {condition.observedGeneration}; Workload generation {generation}.</p>}
|
|
79
|
+
</div>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function GateEvidence({ gate, link }: { gate: Gate; link: Link }) {
|
|
83
|
+
const retryMetadata = [gate.retryCount != null && gate.retryCount > 0 ? `Retries: ${gate.retryCount}` : null, gate.requeueAfterSeconds != null && gate.requeueAfterSeconds > 0 ? `Requeue delay: ${gate.requeueAfterSeconds}s` : null].filter(Boolean)
|
|
84
|
+
return <div className="space-y-2 py-3">
|
|
85
|
+
<div className="grid grid-cols-[minmax(0,1fr)_6rem] items-start gap-2">
|
|
86
|
+
<span className="min-w-0 break-words font-medium [&_button]:text-left">{link(gate.name, gate.ref)}</span>
|
|
87
|
+
<Badge className="justify-self-end" severity={gate.kind === 'preemption_gate' ? 'neutral' : admissionCheckSeverity(gate.nativeState)}>{gate.nativeState || 'Unknown'}</Badge>
|
|
88
|
+
</div>
|
|
89
|
+
{gate.lastTransitionTime && <p className="text-xs text-theme-text-tertiary">Status since <time dateTime={gate.lastTransitionTime} title={gate.lastTransitionTime}>{formatRelativeAgeTime(gate.lastTransitionTime)}</time></p>}
|
|
90
|
+
{gate.message && <p className="max-w-3xl whitespace-pre-wrap break-words">{gate.message}</p>}
|
|
91
|
+
{retryMetadata.length > 0 && <p className="text-xs text-theme-text-secondary">{retryMetadata.join(' · ')}</p>}
|
|
92
|
+
</div>
|
|
45
93
|
}
|
|
46
94
|
|
|
47
|
-
function AdmissionObservation({ observation, link }: { observation: SchedulingObservation;
|
|
95
|
+
function AdmissionObservation({ observation, identity, sourceLink, generation, deleting, presentation, link }: { observation: SchedulingObservation; identity: React.ReactNode; sourceLink?: React.ReactNode; generation: number; deleting: boolean; presentation: 'card' | 'drawer'; link: Link }) {
|
|
48
96
|
const kueue = observation.kueue
|
|
49
97
|
const condition = observation.primaryCondition
|
|
50
98
|
const stale = isKueueConditionStale(condition, observation.subjectGeneration)
|
|
51
|
-
const
|
|
99
|
+
const secondary = [...(observation.disruptions ?? []), kueue?.podsReady, kueue?.waitingForReplacementPods]
|
|
52
100
|
.filter((entry) => entry != null)
|
|
101
|
+
.filter((entry) => entry.type !== condition?.type)
|
|
53
102
|
.filter((entry, index, entries) => entries.findIndex((candidate) => candidate.type === entry.type) === index)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
103
|
+
const nominal = (entry: SchedulingCondition) => !isKueueConditionStale(entry, observation.subjectGeneration) && ((entry.type === 'PodsReady' && entry.status === 'True') || (entry.type === 'WaitingForReplacementPods' && entry.status === 'False'))
|
|
104
|
+
const supporting = secondary.filter(nominal)
|
|
105
|
+
const visible = secondary.filter((entry) => !nominal(entry))
|
|
106
|
+
const isDisruption = (entry: SchedulingCondition) => observation.disruptions?.some((item) => item.type === entry.type)
|
|
107
|
+
const disruptions = visible.filter(isDisruption)
|
|
108
|
+
const readiness = visible.filter((entry) => !isDisruption(entry))
|
|
109
|
+
const checks = (observation.gates ?? []).filter((gate) => gate.kind !== 'preemption_gate')
|
|
110
|
+
const pendingChecks = checks.filter((gate) => gate.nativeState !== 'Ready')
|
|
111
|
+
const readyChecks = checks.filter((gate) => gate.nativeState === 'Ready')
|
|
112
|
+
const preemption = (observation.gates ?? []).filter((gate) => gate.kind === 'preemption_gate')
|
|
113
|
+
const split = presentation === 'card' && (visible.length > 0 || preemption.length > 0)
|
|
114
|
+
const severity = stale || observation.decision === 'unknown' ? 'neutral'
|
|
115
|
+
: kueue?.phase === 'finished' ? kueue.outcome === 'failed' ? 'error' : kueue.outcome === 'succeeded' ? 'success' : 'neutral'
|
|
116
|
+
: observation.decision === 'satisfied' ? 'success' : 'warning'
|
|
117
|
+
const qualifier = observation.decision === 'held' && kueue?.active !== false ? 'Admission held' : observation.decision === 'unknown' ? 'Admission unknown' : null
|
|
118
|
+
return <div className="@container/admission space-y-4">
|
|
119
|
+
<div className={split ? 'grid min-w-0 items-start gap-4 @min-[1024px]/admission:grid-cols-[minmax(0,1.7fr)_minmax(320px,0.9fr)]' : 'space-y-4'}>
|
|
120
|
+
<div className="min-w-0 space-y-4">
|
|
121
|
+
<section aria-label="Admission" className={evidenceCard}>
|
|
122
|
+
<h4 className={`${groupHeading} ${cardHeader}`}>Admission</h4>
|
|
123
|
+
<div className="space-y-2 p-3">
|
|
124
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
125
|
+
<span className="font-medium break-all">{identity}</span>
|
|
126
|
+
<Badge severity={severity}>{kueue ? phases[kueue.phase] : 'Admission'}</Badge>
|
|
127
|
+
{qualifier && <span className="text-xs text-theme-text-secondary">{qualifier}</span>}
|
|
128
|
+
{kueue?.active === false && <Badge severity="neutral">Workload inactive</Badge>}
|
|
129
|
+
{deleting && <Badge severity="alert">Deleting</Badge>}
|
|
130
|
+
{kueue?.outcome && <span>Outcome: {kueue.outcome}</span>}
|
|
131
|
+
</div>
|
|
132
|
+
{stale && <p className="text-xs text-warning-text">Stale evidence: condition generation {condition!.observedGeneration}; Workload generation {observation.subjectGeneration}.</p>}
|
|
133
|
+
{condition ? <p className="max-w-3xl whitespace-pre-wrap break-words">{condition.message || <><code>{condition.reason}</code>{condition.reason && ' · '}{condition.type}={condition.status}</>}</p> : <p className="text-theme-text-secondary">No primary admission condition reported.</p>}
|
|
134
|
+
{condition?.lastTransitionTime && <p className="text-xs text-theme-text-tertiary">{condition.type}={condition.status} since {formatRelativeAgeTime(condition.lastTransitionTime)}</p>}
|
|
135
|
+
<div className="empty:hidden flex flex-wrap gap-x-5 gap-y-2 border-t border-theme-border pt-2 text-xs text-theme-text-secondary">
|
|
136
|
+
{sourceLink && <span>{sourceLink}</span>}
|
|
137
|
+
{(!!observation.queues?.length || kueue?.concurrentAdmission) && <div className="flex flex-wrap gap-x-5 gap-y-1 text-xs text-theme-text-secondary">
|
|
138
|
+
{observation.queues?.map((queue, index) => <span key={index}>{queue.roles.join(' + ').replace(/^./, (letter) => letter.toUpperCase())} queue: {link(queue.name, queue.ref)}</span>)}
|
|
139
|
+
{kueue?.concurrentAdmission && <span>Parent Workload: {link(kueue.concurrentAdmission.parentName, kueue.concurrentAdmission.parentRef)}</span>}
|
|
140
|
+
</div>}
|
|
141
|
+
{kueue?.requeueState && ((kueue.requeueState.count ?? 0) > 0 || kueue.requeueState.requeueAt) && <p className="text-xs text-theme-text-secondary">{kueue.requeueState.count != null && `Requeues: ${kueue.requeueState.count}`}{kueue.requeueState.requeueAt && `${kueue.requeueState.count != null ? ' · ' : ''}Eligible again: ${kueue.requeueState.requeueAt}`}</p>}
|
|
142
|
+
{(kueue?.phase === 'admitted' || kueue?.phase === 'quota_reserved') && <p className="text-xs text-theme-text-tertiary">Admission status; execution is shown separately.</p>}
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
<div className="border-t border-theme-border p-3">
|
|
146
|
+
<Disclosure summary={<span>Technical details{supporting.length > 0 && ` · ${supporting.length} additional condition${supporting.length === 1 ? '' : 's'}`}{readyChecks.length > 0 && ` · ${readyChecks.length} ready check${readyChecks.length === 1 ? '' : 's'}`}</span>} summaryClassName="text-xs text-theme-text-secondary">
|
|
147
|
+
<div className="space-y-2 pt-2 text-xs text-theme-text-secondary">
|
|
148
|
+
<p>Decision: {observation.decision} · Generation {generation}</p>
|
|
149
|
+
{[condition, ...secondary].filter((entry) => entry != null).map((entry) => <div key={entry.type}>
|
|
150
|
+
<p>{entry.type}={entry.status}{entry.reason && ` · Reason: ${entry.reason}`}{entry.observedGeneration != null && ` · Observed generation ${entry.observedGeneration}`}{entry.lastTransitionTime && ` · Status since ${formatRelativeAgeTime(entry.lastTransitionTime)}`}</p>
|
|
151
|
+
{supporting.includes(entry) && entry.message && <p className="whitespace-pre-wrap break-words">{entry.message}</p>}
|
|
152
|
+
</div>)}
|
|
153
|
+
{(observation.gates ?? []).filter((gate) => gate.retryCount === 0 || gate.requeueAfterSeconds === 0).map((gate, index) => <p key={index}>{gate.kind === 'preemption_gate' ? 'Preemption gate' : 'Admission check'} {gate.name}{gate.retryCount === 0 && ' · Retries: 0'}{gate.requeueAfterSeconds === 0 && ' · Requeue delay: 0s'}</p>)}
|
|
154
|
+
{kueue?.requeueState?.count === 0 && !kueue.requeueState.requeueAt && <p>Requeues: 0</p>}
|
|
155
|
+
{readyChecks.map((gate, index) => <GateEvidence key={index} gate={gate} link={link} />)}
|
|
156
|
+
</div>
|
|
157
|
+
</Disclosure>
|
|
158
|
+
</div>
|
|
159
|
+
</section>
|
|
160
|
+
{pendingChecks.length > 0 && <section aria-label="Admission checks" className={evidenceCard}>
|
|
161
|
+
<h4 className={`${groupHeading} ${cardHeader}`}>Admission checks · {pendingChecks.length} not ready</h4>
|
|
162
|
+
<div className="divide-y divide-theme-border [&>div]:p-3">{pendingChecks.map((gate, index) => <GateEvidence key={index} gate={gate} link={link} />)}</div>
|
|
163
|
+
</section>}
|
|
60
164
|
</div>
|
|
61
|
-
{
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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>}
|
|
165
|
+
{(visible.length > 0 || preemption.length > 0) && <section aria-label="Supporting evidence" className={`${evidenceCard} self-start`}>
|
|
166
|
+
<h4 className={`${groupHeading} ${cardHeader}`}>Supporting evidence</h4>
|
|
167
|
+
<div className="divide-y divide-theme-border px-3">
|
|
168
|
+
{disruptions.length > 0 && <div className="space-y-3 py-3"><h5 className={groupHeading}>Reported disruptions · {disruptions.length}</h5><div className="divide-y divide-theme-border">{disruptions.map((entry) => <ConditionEvidence key={entry.type} condition={entry} generation={observation.subjectGeneration} showAge />)}</div></div>}
|
|
169
|
+
{readiness.length > 0 && <div className="space-y-3 py-3"><h5 className={groupHeading}>Pod readiness · {readiness.length}</h5><div className="divide-y divide-theme-border">{readiness.map((entry) => <ConditionEvidence key={entry.type} condition={entry} generation={observation.subjectGeneration} />)}</div></div>}
|
|
170
|
+
{preemption.length > 0 && <div className="space-y-2 py-3"><h5 className={groupHeading}>Preemption gates · {preemption.length}</h5><p className="text-xs text-theme-text-tertiary">Governs preemption; it alone does not establish an admission blocker.</p><div className="divide-y divide-theme-border">{preemption.map((gate, index) => <GateEvidence key={index} gate={gate} link={link} />)}</div></div>}
|
|
171
|
+
</div>
|
|
172
|
+
</section>}
|
|
173
|
+
</div>
|
|
174
|
+
|
|
86
175
|
</div>
|
|
87
176
|
}
|
|
@@ -6243,6 +6243,7 @@ export function ResourcesView({
|
|
|
6243
6243
|
{onCreateResource && (
|
|
6244
6244
|
<Tooltip content={`Create ${selectedKind.kind || 'resource'}`}>
|
|
6245
6245
|
<button
|
|
6246
|
+
aria-label={`Create ${selectedKind.kind || 'resource'}`}
|
|
6246
6247
|
onClick={() => onCreateResource(selectedKind)}
|
|
6247
6248
|
className="p-2 hover:bg-theme-elevated rounded-lg text-theme-text-secondary hover:text-theme-text-primary transition-colors"
|
|
6248
6249
|
>
|
|
@@ -6846,6 +6847,7 @@ export function ResourcesView({
|
|
|
6846
6847
|
<div className="flex items-center justify-center gap-3">
|
|
6847
6848
|
<button
|
|
6848
6849
|
type="button"
|
|
6850
|
+
aria-label="Decrease replicas"
|
|
6849
6851
|
onClick={() => setBulkScaleReplicas(Math.max(0, bulkScaleReplicas - 1))}
|
|
6850
6852
|
disabled={bulkScaleReplicas <= 0}
|
|
6851
6853
|
className="p-2 rounded-lg bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
@@ -6857,12 +6859,14 @@ export function ResourcesView({
|
|
|
6857
6859
|
min="0"
|
|
6858
6860
|
max="10000"
|
|
6859
6861
|
value={bulkScaleReplicas}
|
|
6862
|
+
aria-label="Replicas"
|
|
6860
6863
|
onChange={(e) => setBulkScaleReplicas(Math.min(10000, Math.max(0, Number.parseInt(e.target.value, 10) || 0)))}
|
|
6861
6864
|
className="w-24 text-center text-2xl font-semibold bg-theme-elevated border border-theme-border rounded-lg py-2 text-theme-text-primary focus:outline-none focus:border-skyhook-500"
|
|
6862
6865
|
autoFocus
|
|
6863
6866
|
/>
|
|
6864
6867
|
<button
|
|
6865
6868
|
type="button"
|
|
6869
|
+
aria-label="Increase replicas"
|
|
6866
6870
|
onClick={() => setBulkScaleReplicas(Math.min(10000, bulkScaleReplicas + 1))}
|
|
6867
6871
|
disabled={bulkScaleReplicas >= 10000}
|
|
6868
6872
|
className="p-2 rounded-lg bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
@@ -113,7 +113,7 @@ describe('formatPrinterCell', () => {
|
|
|
113
113
|
describe('printerCellSortValue', () => {
|
|
114
114
|
// Sorting must use the value, not its rendered text, or 10 sorts before 9.
|
|
115
115
|
it('sorts numbers numerically', () => {
|
|
116
|
-
expect([10, 9, 100].map(printerCellSortValue).sort((a, b) => (a as number) - (b as number)))
|
|
116
|
+
expect([10, 9, 100].map(v => printerCellSortValue(v)).sort((a, b) => (a as number) - (b as number)))
|
|
117
117
|
.toEqual([9, 10, 100])
|
|
118
118
|
})
|
|
119
119
|
it('sorts booleans by truth and falls back to empty for anything else', () => {
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
1
2
|
import { Clock } from 'lucide-react'
|
|
2
3
|
import { Section, PropertyList, Property, ConditionsSection, AlertBanner } from '../../ui/drawer-components'
|
|
3
4
|
import { formatDuration } from '../resource-utils'
|
|
4
5
|
|
|
5
6
|
interface JobRendererProps {
|
|
6
7
|
data: any
|
|
8
|
+
admissionContent?: ReactNode
|
|
7
9
|
}
|
|
8
10
|
|
|
9
11
|
// Extract problems from Job status and conditions
|
|
@@ -42,7 +44,7 @@ function getJobProblems(data: any): string[] {
|
|
|
42
44
|
return problems
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
export function JobRenderer({ data }: JobRendererProps) {
|
|
47
|
+
export function JobRenderer({ data, admissionContent }: JobRendererProps) {
|
|
46
48
|
const status = data.status || {}
|
|
47
49
|
const spec = data.spec || {}
|
|
48
50
|
const conditions = status.conditions || []
|
|
@@ -82,6 +84,8 @@ export function JobRenderer({ data }: JobRendererProps) {
|
|
|
82
84
|
<AlertBanner variant="success" title="Job Completed Successfully" />
|
|
83
85
|
)}
|
|
84
86
|
|
|
87
|
+
{admissionContent}
|
|
88
|
+
|
|
85
89
|
<Section title="Status" icon={Clock}>
|
|
86
90
|
<PropertyList>
|
|
87
91
|
<Property label="Succeeded" value={status.succeeded || 0} />
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
1
2
|
import { Activity, Boxes, Network as NetworkIcon, ShieldCheck } from 'lucide-react'
|
|
2
3
|
import {
|
|
3
4
|
ConditionsSection,
|
|
@@ -13,6 +14,7 @@ import { getJobSetStatus } from '../resource-utils-jobset-lws'
|
|
|
13
14
|
|
|
14
15
|
interface JobSetRendererProps {
|
|
15
16
|
data: any
|
|
17
|
+
admissionContent?: ReactNode
|
|
16
18
|
mode?: 'detail' | 'overview' | 'configuration'
|
|
17
19
|
shownMemberCounts?: ReadonlyMap<string, number>
|
|
18
20
|
onSelectRole?: (role: string) => void
|
|
@@ -50,7 +52,7 @@ function sumCountArray(value: unknown): number | undefined {
|
|
|
50
52
|
return value.reduce((total: number, count: unknown) => total + (typeof count === 'number' ? count : 0), 0)
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
export function JobSetRenderer({ data, mode = 'detail', shownMemberCounts, onSelectRole }: JobSetRendererProps) {
|
|
55
|
+
export function JobSetRenderer({ data, admissionContent, mode = 'detail', shownMemberCounts, onSelectRole }: JobSetRendererProps) {
|
|
54
56
|
const spec = data?.spec || {}
|
|
55
57
|
const status = data?.status || {}
|
|
56
58
|
const conditions: any[] = Array.isArray(status.conditions) ? status.conditions : []
|
|
@@ -85,6 +87,7 @@ export function JobSetRenderer({ data, mode = 'detail', shownMemberCounts, onSel
|
|
|
85
87
|
return (
|
|
86
88
|
<>
|
|
87
89
|
{showSummary && <ProblemAlerts problems={problems} />}
|
|
90
|
+
{showSummary && admissionContent}
|
|
88
91
|
|
|
89
92
|
{showSummary && externalController && (
|
|
90
93
|
<div className="mb-3 rounded border border-theme-border bg-theme-elevated p-3 text-sm text-theme-text-secondary">
|
|
@@ -5,7 +5,7 @@ import { expect, it, vi } from 'vitest'
|
|
|
5
5
|
import { ClusterQueueRenderer, LocalQueueRenderer } from './KueueQueueRenderers'
|
|
6
6
|
|
|
7
7
|
it('navigates from a namespaced LocalQueue to exact cluster-scoped Kueue dependencies', async () => {
|
|
8
|
-
globalThis
|
|
8
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
9
9
|
const container = document.createElement('div')
|
|
10
10
|
const root = createRoot(container)
|
|
11
11
|
const onNavigate = vi.fn()
|
|
@@ -64,6 +64,6 @@ it('navigates from a namespaced LocalQueue to exact cluster-scoped Kueue depende
|
|
|
64
64
|
})
|
|
65
65
|
} finally {
|
|
66
66
|
await act(async () => root.unmount())
|
|
67
|
-
globalThis
|
|
67
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: false })
|
|
68
68
|
}
|
|
69
69
|
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ReactNode } from 'react'
|
|
2
2
|
import { Cpu, Gauge, ListChecks } from 'lucide-react'
|
|
3
3
|
import { formatRelativeAgeTime } from '../../../utils/format'
|
|
4
|
-
import { Badge
|
|
4
|
+
import { Badge } from '../../ui/Badge'
|
|
5
5
|
import {
|
|
6
6
|
AlertBanner,
|
|
7
7
|
ConditionsSection,
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
type ConditionTone,
|
|
13
13
|
} from '../../ui/drawer-components'
|
|
14
14
|
import { formatResources } from '../resource-utils'
|
|
15
|
-
import { getKueueWorkloadStatus, getKueueWorkloadPriority, getKueueWorkloadStatusCondition, isKueueWorkloadFailureReason, isKueueConditionStale } from '../resource-utils-kueue'
|
|
15
|
+
import { admissionCheckSeverity, getKueueWorkloadStatus, getKueueWorkloadPriority, getKueueWorkloadStatusCondition, isKueueWorkloadFailureReason, isKueueConditionStale } from '../resource-utils-kueue'
|
|
16
16
|
|
|
17
17
|
const KUEUE_GROUP = 'kueue.x-k8s.io'
|
|
18
18
|
|
|
@@ -22,19 +22,6 @@ interface KueueWorkloadRendererProps {
|
|
|
22
22
|
onNavigate?: (ref: { kind: string; namespace: string; name: string; group?: string }) => void
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
function admissionCheckSeverity(state: string): BadgeSeverity {
|
|
26
|
-
switch (state) {
|
|
27
|
-
case 'Ready':
|
|
28
|
-
return 'success'
|
|
29
|
-
case 'Rejected':
|
|
30
|
-
return 'error'
|
|
31
|
-
case 'Retry':
|
|
32
|
-
return 'warning'
|
|
33
|
-
default:
|
|
34
|
-
return 'neutral'
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
25
|
function resourceList(resources: any): string | null {
|
|
39
26
|
if (!resources || typeof resources !== 'object' || Object.keys(resources).length === 0) return null
|
|
40
27
|
return formatResources(Object.fromEntries(Object.entries(resources).map(([name, quantity]) => [name, String(quantity)])))
|
|
@@ -435,3 +435,18 @@ describe('PodRenderer metrics', () => {
|
|
|
435
435
|
expect(html).toContain('CPU')
|
|
436
436
|
})
|
|
437
437
|
})
|
|
438
|
+
|
|
439
|
+
describe('PodRenderer on a Radar without variable sources', () => {
|
|
440
|
+
it('falls back to the spec view with an upgrade note, not an error', () => {
|
|
441
|
+
const unsupported = Object.assign(new Error('unsupported'), {
|
|
442
|
+
radarUpgrade: { feature: 'Values from ConfigMaps and Secrets', minimumVersion: 'v1.9.0', currentVersion: 'v1.7.2' },
|
|
443
|
+
})
|
|
444
|
+
const html = renderToString(
|
|
445
|
+
<PodRenderer data={pod} onCopy={() => undefined} copied={null} environmentError={unsupported} />,
|
|
446
|
+
)
|
|
447
|
+
expect(html).toContain('Values from ConfigMaps and Secrets')
|
|
448
|
+
expect(html).toContain('need Radar v1.9 or newer on this cluster. It runs v1.7.2.')
|
|
449
|
+
expect(html).toContain('ConfigMap')
|
|
450
|
+
expect(html).not.toContain('could not be loaded')
|
|
451
|
+
})
|
|
452
|
+
})
|
|
@@ -23,6 +23,8 @@ import type {
|
|
|
23
23
|
RBACPolicyRule,
|
|
24
24
|
} from '../../../types'
|
|
25
25
|
import { Tooltip } from '../../ui/Tooltip'
|
|
26
|
+
import { RadarUpgradeNote } from '../../ui/RadarUpgradeNote'
|
|
27
|
+
import { getRadarUpgradeRequirement, type RadarUpgradeRequirement } from '../../../types/fetch-error'
|
|
26
28
|
import { MetricsChart } from '../../ui/MetricsChart'
|
|
27
29
|
import { MetricsUnavailableNotice } from './MetricsUnavailableNotice'
|
|
28
30
|
import { ContainerEnvironmentSection } from './ContainerEnvironmentSection'
|
|
@@ -191,10 +193,12 @@ function EnvVarsSection({
|
|
|
191
193
|
initContainers,
|
|
192
194
|
containers,
|
|
193
195
|
resolvedEnvFrom,
|
|
196
|
+
upgrade,
|
|
194
197
|
}: {
|
|
195
198
|
initContainers: any[]
|
|
196
199
|
containers: any[]
|
|
197
200
|
resolvedEnvFrom?: ResolvedEnvFrom
|
|
201
|
+
upgrade?: RadarUpgradeRequirement | null
|
|
198
202
|
}) {
|
|
199
203
|
const allContainers = [...initContainers, ...containers]
|
|
200
204
|
const containersWithEnv = allContainers.filter((c: any) => c.env?.length > 0 || c.envFrom?.length > 0)
|
|
@@ -205,6 +209,7 @@ function EnvVarsSection({
|
|
|
205
209
|
return (
|
|
206
210
|
<Section title={`Environment Variables · ${subtitle}`} icon={List} defaultExpanded={false}>
|
|
207
211
|
<div className="space-y-4">
|
|
212
|
+
{upgrade && <RadarUpgradeNote requirement={upgrade} />}
|
|
208
213
|
{allContainers.map((container: any) => {
|
|
209
214
|
const envVars: any[] = container.env || []
|
|
210
215
|
const envFrom: any[] = container.envFrom || []
|
|
@@ -312,6 +317,8 @@ export function PodRenderer({
|
|
|
312
317
|
const hasResolvedEnvironmentRows = environment?.containers.some(
|
|
313
318
|
container => container.rows.length > 0 || container.truncated,
|
|
314
319
|
) ?? false
|
|
320
|
+
// An older Radar can't resolve variable sources; fall back to the spec view.
|
|
321
|
+
const environmentUpgrade = getRadarUpgradeRequirement(environmentError)
|
|
315
322
|
|
|
316
323
|
const namespace = data.metadata?.namespace
|
|
317
324
|
const podName = data.metadata?.name
|
|
@@ -792,7 +799,7 @@ export function PodRenderer({
|
|
|
792
799
|
<div className="text-xs text-theme-text-tertiary">Loading variable sources…</div>
|
|
793
800
|
</Section>
|
|
794
801
|
)}
|
|
795
|
-
{hasEnvironmentDeclarations && environmentError && !environment && (
|
|
802
|
+
{hasEnvironmentDeclarations && environmentError && !environment && !environmentUpgrade && (
|
|
796
803
|
<Section title="Environment Variables" icon={List} defaultExpanded={false}>
|
|
797
804
|
<div className="space-y-2 text-xs">
|
|
798
805
|
<p className="text-theme-text-secondary">Variable sources could not be loaded.</p>
|
|
@@ -800,11 +807,12 @@ export function PodRenderer({
|
|
|
800
807
|
</div>
|
|
801
808
|
</Section>
|
|
802
809
|
)}
|
|
803
|
-
{(!environment || !hasResolvedEnvironmentRows) && !environmentLoading && !environmentError && hasEnvironmentDeclarations && (
|
|
810
|
+
{(!environment || !hasResolvedEnvironmentRows) && !environmentLoading && (!environmentError || environmentUpgrade) && hasEnvironmentDeclarations && (
|
|
804
811
|
<EnvVarsSection
|
|
805
812
|
initContainers={initContainers}
|
|
806
813
|
containers={containers}
|
|
807
814
|
resolvedEnvFrom={resolvedEnvFrom}
|
|
815
|
+
upgrade={environmentUpgrade}
|
|
808
816
|
/>
|
|
809
817
|
)}
|
|
810
818
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import { renderToString } from 'react-dom/server'
|
|
3
3
|
import { PolicySection } from './PolicySection'
|
|
4
|
+
import { RadarUpgradeContext } from '../../ui/RadarUpgradeNote'
|
|
4
5
|
import type { PolicyResourceResponse } from '../../../types/policy'
|
|
5
6
|
|
|
6
7
|
const base: PolicyResourceResponse = {
|
|
@@ -157,3 +158,44 @@ describe('when results were withheld from this caller', () => {
|
|
|
157
158
|
expect(html).not.toContain('not shown here')
|
|
158
159
|
})
|
|
159
160
|
})
|
|
161
|
+
|
|
162
|
+
// An older in-cluster Radar behind a newer frontend (Radar Hub) has no policy
|
|
163
|
+
// endpoint. That is an expected state, so it gets an upgrade prompt, not red.
|
|
164
|
+
describe('PolicySection — connected Radar predates policy results', () => {
|
|
165
|
+
const unsupported = Object.assign(new Error('Policy results need Radar v1.10.0 or newer'), {
|
|
166
|
+
radarUpgrade: { feature: 'Policy results', minimumVersion: 'v1.10.0', currentVersion: 'v1.7.2' },
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('prompts an upgrade instead of reporting a failure', () => {
|
|
170
|
+
const html = renderToString(<PolicySection data={null} error={unsupported} />)
|
|
171
|
+
expect(html).toContain('Policy results')
|
|
172
|
+
expect(html).toContain('need Radar v1.10 or newer on this cluster. It runs v1.7.2.')
|
|
173
|
+
expect(html).not.toContain('Could not load policy results')
|
|
174
|
+
expect(html).not.toContain('text-red-400')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('hands the upgrade to the host when it offers a flow', () => {
|
|
178
|
+
const html = renderToString(
|
|
179
|
+
<RadarUpgradeContext.Provider value={{ onRequestUpgrade: () => undefined }}>
|
|
180
|
+
<PolicySection data={null} error={unsupported} />
|
|
181
|
+
</RadarUpgradeContext.Provider>,
|
|
182
|
+
)
|
|
183
|
+
expect(html).toContain('<button')
|
|
184
|
+
expect(html).toContain('Upgrade Radar')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('links to instructions when the host has no flow', () => {
|
|
188
|
+
const html = renderToString(
|
|
189
|
+
<RadarUpgradeContext.Provider value={{ upgradeHref: 'https://radarhq.io/docs/configuration/in-cluster' }}>
|
|
190
|
+
<PolicySection data={null} error={unsupported} />
|
|
191
|
+
</RadarUpgradeContext.Provider>,
|
|
192
|
+
)
|
|
193
|
+
expect(html).toContain('href="https://radarhq.io/docs/configuration/in-cluster"')
|
|
194
|
+
expect(html).toContain('How to upgrade')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('keeps a genuine failure red', () => {
|
|
198
|
+
const html = renderToString(<PolicySection data={null} error={new Error('HTTP 502 (Bad Gateway)')} />)
|
|
199
|
+
expect(html).toContain('Could not load policy results: HTTP 502 (Bad Gateway)')
|
|
200
|
+
})
|
|
201
|
+
})
|
|
@@ -2,7 +2,8 @@ import { ShieldCheck, ShieldAlert, ShieldQuestion } from 'lucide-react'
|
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
3
|
import { Section } from '../../ui/drawer-components'
|
|
4
4
|
import { SEVERITY_BADGE } from '../../../utils/badge-colors'
|
|
5
|
-
import {
|
|
5
|
+
import { RadarUpgradeNote } from '../../ui/RadarUpgradeNote'
|
|
6
|
+
import { getRadarUpgradeRequirement, isForbiddenError } from '../../../types/fetch-error'
|
|
6
7
|
import type { PolicyResourceResponse, PolicyResourceFinding } from '../../../types/policy'
|
|
7
8
|
|
|
8
9
|
interface PolicySectionProps {
|
|
@@ -61,6 +62,14 @@ export function PolicySection({ data, loading, error }: PolicySectionProps) {
|
|
|
61
62
|
</Section>
|
|
62
63
|
)
|
|
63
64
|
}
|
|
65
|
+
const upgrade = getRadarUpgradeRequirement(error)
|
|
66
|
+
if (upgrade) {
|
|
67
|
+
return (
|
|
68
|
+
<Section title={TITLE} icon={ShieldQuestion}>
|
|
69
|
+
<RadarUpgradeNote requirement={upgrade} />
|
|
70
|
+
</Section>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
64
73
|
return (
|
|
65
74
|
<Section title={TITLE} icon={ShieldAlert}>
|
|
66
75
|
<div className="text-sm text-red-400">{`Could not load policy results: ${error.message}`}</div>
|
|
@@ -41,9 +41,9 @@ export function RayClusterRenderer({ data, onNavigate, podEvidence, podsLoading,
|
|
|
41
41
|
const scope = selection.workerGroup != null ? `Worker group: ${selection.workerGroup}` : selection.nodeType === 'head' ? 'Head' : 'All runtime Pods'
|
|
42
42
|
const select = (value: RayClusterPodSelection) => { setExpanded(false); onSelectPods?.(value) }
|
|
43
43
|
const row = (pod: WorkloadPodInfo) => <PodRow key={pod.name} name={pod.name} namespace={data.metadata.namespace} ready={pod.ready} healthLevel={pod.healthLevel} detail={workloadPodDetail(pod)} onNavigate={onNavigate} />
|
|
44
|
-
return
|
|
44
|
+
return <div className="space-y-4">
|
|
45
45
|
{stale && <AlertBanner variant="info" title="Status describes an earlier generation" message={`Specification generation ${data.metadata.generation}; controller observed ${status.observedGeneration}. Counts and conditions below retain that observation.`} />}
|
|
46
|
-
<Section title="Runtime Health" icon={Activity} defaultExpanded>
|
|
46
|
+
<Section variant="card" title="Runtime Health" icon={Activity} defaultExpanded>
|
|
47
47
|
<PropertyList>
|
|
48
48
|
<Property label="Ray Version" value={spec.rayVersion || 'Not specified'} />
|
|
49
49
|
<Property label="Head" value={<Badge severity={stale ? 'neutral' : head?.status === 'True' ? 'success' : head?.status === 'False' ? 'warning' : 'neutral'}>{head?.status === 'True' ? 'Ready' : head?.status === 'False' ? 'Not ready' : 'Not reported'}{stale ? ' (stale)' : ''}</Badge>} />
|
|
@@ -58,7 +58,7 @@ export function RayClusterRenderer({ data, onNavigate, podEvidence, podsLoading,
|
|
|
58
58
|
{status.observedGeneration == null && <p className="mt-2 text-xs text-theme-text-tertiary">Observed generation is not reported.</p>}
|
|
59
59
|
{conditions.some((c: any) => c.type === 'RayClusterProvisioned' && c.status === 'True') && <p className="mt-2 text-xs text-theme-text-tertiary">Provisioned records initial provisioning, not continuing runtime health.</p>}
|
|
60
60
|
</Section>
|
|
61
|
-
{onSelectPods && <div ref={podsRef}><Section title="Runtime Pods" icon={Server} defaultExpanded>
|
|
61
|
+
{onSelectPods && <div ref={podsRef}><Section variant="card" title="Runtime Pods" icon={Server} defaultExpanded>
|
|
62
62
|
<SelectMenu ariaLabel="Pod scope" className="mb-3" searchPlaceholder="Find a worker group…"
|
|
63
63
|
value={selection.workerGroup != null ? `group:${selection.workerGroup}` : selection.nodeType === 'head' ? 'head' : 'all'}
|
|
64
64
|
options={[{ value: 'all', label: 'All runtime Pods' }, { value: 'head', label: 'Head' }, ...groups.map((g: any) => ({ value: `group:${g.groupName}`, label: `Worker group: ${g.groupName}` }))]}
|
|
@@ -70,7 +70,7 @@ export function RayClusterRenderer({ data, onNavigate, podEvidence, podsLoading,
|
|
|
70
70
|
<PodListFrame expanded={expanded} hasOverflow={podEvidence.pods.length > 20} overflow={podEvidence.pods.slice(20).map(row)} toggle={<PodListToggle expanded={expanded} hiddenCount={podEvidence.pods.length - 20} label="Pods" onToggle={() => setExpanded(!expanded)} />}>{podEvidence.pods.slice(0,20).map(row)}</PodListFrame>
|
|
71
71
|
</> : <p className="text-sm text-theme-text-secondary">Pod evidence is not available.</p>}
|
|
72
72
|
</Section></div>}
|
|
73
|
-
<Section title={`Worker Groups (${groups.length})`} icon={Boxes} defaultExpanded>
|
|
73
|
+
<Section variant="card" title={`Worker Groups (${groups.length})`} icon={Boxes} defaultExpanded>
|
|
74
74
|
<p className="mb-3 text-xs text-theme-text-tertiary">Declared sizing, not observed group health. Replicas can span multiple hosts; controller-reported worker counts above are Pods. In-tree autoscaling: {spec.enableInTreeAutoscaling === true ? 'enabled' : 'disabled'}.</p>
|
|
75
75
|
<div className="space-y-3">{groups.map((group: any) => <div className="card-inner min-w-0" key={group.groupName}>
|
|
76
76
|
<div className="mb-2 flex items-start justify-between gap-3"><span className="break-all text-sm font-medium text-theme-text-primary">{group.groupName}</span>{onSelectPods && <button className="shrink-0 text-xs text-accent-text hover:underline" onClick={() => { select({ nodeType: 'worker', workerGroup: group.groupName }); podsRef.current?.scrollIntoView({ block: 'start' }) }}>View Pods</button>}</div>
|
|
@@ -83,11 +83,11 @@ export function RayClusterRenderer({ data, onNavigate, podEvidence, podsLoading,
|
|
|
83
83
|
</div>)}</div>
|
|
84
84
|
{groups.length === 0 && <p className="text-sm text-theme-text-secondary">No worker groups declared.</p>}
|
|
85
85
|
</Section>
|
|
86
|
-
{(owners.length > 0 || status.head?.podName || status.head?.serviceName) && <Section title="References" defaultExpanded><PropertyList>
|
|
86
|
+
{(owners.length > 0 || status.head?.podName || status.head?.serviceName) && <Section variant="card" title="References" defaultExpanded><PropertyList>
|
|
87
87
|
{owners.map((owner: any) => <Property key={owner.uid} label="Controller" value={<ResourceLink kind={owner.kind === 'RayService' ? 'rayservices' : 'rayjobs'} group="ray.io" namespace={data.metadata.namespace} name={owner.name} onNavigate={onNavigate} />} />)}
|
|
88
88
|
{status.head?.podName && <Property label="Reported Head Pod" value={<ResourceLink kind="pods" namespace={data.metadata.namespace} name={status.head.podName} onNavigate={onNavigate} />} />}
|
|
89
89
|
{status.head?.serviceName && <Property label="Reported Head Service" value={<ResourceLink kind="services" namespace={data.metadata.namespace} name={status.head.serviceName} onNavigate={onNavigate} />} />}
|
|
90
90
|
</PropertyList></Section>}
|
|
91
|
-
<ConditionsSection conditions={conditions} getConditionTone={c => stale ? 'unknown' : rayClusterConditionTone(c)} defaultExpanded />
|
|
92
|
-
|
|
91
|
+
<ConditionsSection variant="card" conditions={conditions} getConditionTone={c => stale ? 'unknown' : rayClusterConditionTone(c)} defaultExpanded />
|
|
92
|
+
</div>
|
|
93
93
|
}
|