@skyhook-io/k8s-ui 1.13.3 → 1.13.4
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 +1 -1
- package/src/components/applications/AppTooltips.tsx +13 -1
- package/src/components/applications/ApplicationDetail.test.tsx +25 -0
- package/src/components/applications/ApplicationDetail.tsx +39 -7
- package/src/components/applications/ApplicationsView.tsx +18 -3
- package/src/components/applications/ReadyBar.tsx +3 -2
- package/src/components/applications/applications-view.test.tsx +91 -0
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -1
- package/src/components/issues/IssuesView.tsx +40 -31
- package/src/components/issues/ResourceIssuesSection.tsx +3 -2
- package/src/components/issues/index.ts +3 -1
- package/src/components/issues/issue-timing.ts +123 -4
- package/src/components/issues/issues.test.ts +221 -10
- package/src/components/issues/types.ts +31 -5
- package/src/components/namespace-switcher/NamespacePicker.tsx +21 -13
- package/src/components/resources/ResourcesView.tsx +964 -96
- package/src/components/resources/column-settings-key.test.ts +98 -0
- package/src/components/resources/column-visibility-merge.test.ts +64 -0
- package/src/components/resources/curated-column-ownership.test.ts +236 -0
- package/src/components/resources/gateway-policy-status.test.ts +291 -0
- package/src/components/resources/gateway-policy-status.ts +237 -0
- package/src/components/resources/generic-status.test.ts +199 -0
- package/src/components/resources/generic-status.ts +171 -0
- package/src/components/resources/gpu-ecosystem-registry.test.ts +63 -0
- package/src/components/resources/index.ts +9 -1
- package/src/components/resources/istio-gateway-filter.test.ts +97 -0
- package/src/components/resources/printer-columns.test.ts +298 -0
- package/src/components/resources/printer-columns.ts +249 -0
- package/src/components/resources/renderers/EventRenderer.test.tsx +50 -1
- package/src/components/resources/renderers/EventRenderer.tsx +3 -5
- package/src/components/resources/renderers/GenericRenderer.tsx +18 -83
- package/src/components/resources/renderers/HPARenderer.test.tsx +28 -0
- package/src/components/resources/renderers/HPARenderer.tsx +6 -1
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +2 -1
- package/src/components/resources/renderers/PVCRenderer.test.tsx +44 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -2
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +35 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +22 -3
- package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +25 -65
- package/src/components/resources/renderers/amd-gpu-cells.tsx +36 -0
- package/src/components/resources/renderers/inference-gateway-cells.tsx +62 -0
- package/src/components/resources/renderers/jobset-lws-cells.tsx +82 -0
- package/src/components/resources/renderers/kai-cells.tsx +67 -0
- package/src/components/resources/renderers/kaito-cells.tsx +62 -0
- package/src/components/resources/renderers/kserve-cells.tsx +145 -0
- package/src/components/resources/renderers/kubeflow-training-cells.tsx +72 -0
- package/src/components/resources/renderers/kueue-cells.tsx +176 -0
- package/src/components/resources/renderers/nim-cells.tsx +81 -0
- package/src/components/resources/renderers/ray-cells.tsx +124 -0
- package/src/components/resources/renderers/volcano-cells.tsx +140 -0
- package/src/components/resources/resource-utils-amd-gpu.ts +61 -0
- package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +222 -0
- package/src/components/resources/resource-utils-hpa.test.ts +4 -0
- package/src/components/resources/resource-utils-hpa.ts +6 -2
- package/src/components/resources/resource-utils-inference-gateway.ts +119 -0
- package/src/components/resources/resource-utils-jobset-lws.ts +117 -0
- package/src/components/resources/resource-utils-kai.ts +105 -0
- package/src/components/resources/resource-utils-kaito.ts +114 -0
- package/src/components/resources/resource-utils-kserve.ts +186 -0
- package/src/components/resources/resource-utils-kubeflow-training.ts +121 -0
- package/src/components/resources/resource-utils-kueue.ts +198 -0
- package/src/components/resources/resource-utils-nim.ts +109 -0
- package/src/components/resources/resource-utils-ray.ts +214 -0
- package/src/components/resources/resource-utils-rollout.test.ts +183 -0
- package/src/components/resources/resource-utils-velero.test.ts +30 -0
- package/src/components/resources/resource-utils-volcano.ts +176 -0
- package/src/components/resources/resource-utils.ts +156 -47
- package/src/components/resources/resources-column-filter.test.ts +27 -1
- package/src/components/resources/workload-rollout-golden.test.ts +55 -0
- package/src/components/shared/CreateResourceDialog.tsx +1 -0
- package/src/components/shared/EditableYamlView.tsx +4 -2
- package/src/components/shared/ResourceActionsBar.tsx +170 -25
- package/src/components/shared/ResourceRendererDispatch.test.tsx +206 -1
- package/src/components/shared/ResourceRendererDispatch.tsx +84 -40
- package/src/components/shared/SetImageDialog.test.ts +129 -0
- package/src/components/shared/SetImageDialog.tsx +577 -0
- package/src/components/shared/index.ts +1 -0
- package/src/components/shared/revision-role-badges.test.ts +56 -4
- package/src/components/timeline/TimelineList.tsx +19 -3
- package/src/components/timeline/TimelineSwimlanes.tsx +11 -3
- package/src/components/trace/ReachabilityView.tsx +1 -1
- package/src/components/ui/drawer-components.tsx +12 -0
- package/src/components/workload/WorkloadRolloutNotice.tsx +60 -0
- package/src/components/workload/WorkloadView.tsx +144 -81
- package/src/hooks/useProgressiveRefresh.ts +37 -0
- package/src/source-hygiene.test.ts +28 -0
- package/src/theme/tailwind-theme.css +4 -0
- package/src/types/core.ts +55 -0
- package/src/utils/api-resources.test.ts +40 -1
- package/src/utils/api-resources.ts +11 -1
- package/src/utils/applications.ts +50 -0
- package/src/utils/bulk-workload-actions.test.ts +20 -0
- package/src/utils/bulk-workload-actions.ts +15 -0
- package/src/utils/navigation.test.ts +31 -1
- package/src/utils/navigation.ts +21 -2
- package/src/utils/workload-rollout.ts +292 -0
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { formatRelativeAgeTime } from '../../utils/format';
|
|
2
1
|
import { isDeploymentLikeWorkloadKind } from '../../types';
|
|
3
2
|
import type { Issue } from './types';
|
|
4
3
|
|
|
@@ -11,6 +10,27 @@ export interface IssueTimingDisplay {
|
|
|
11
10
|
tooltip: string;
|
|
12
11
|
}
|
|
13
12
|
|
|
13
|
+
export function issueFirstSeenTitle(issue: Issue): string | null {
|
|
14
|
+
if (!issue.first_seen) return null;
|
|
15
|
+
const coverage = issue.onset_coverage;
|
|
16
|
+
const unknown = coverage?.unknown ?? 0;
|
|
17
|
+
const base = `Active at least since ${new Date(issue.first_seen).toLocaleString()}`;
|
|
18
|
+
if (unknown === 0) return `${base}.`;
|
|
19
|
+
const total = (coverage?.known ?? 0) + unknown;
|
|
20
|
+
return `${base}; timing unknown for ${unknown} of ${total} signals.`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function issueResourceCreatedTitle(issue: Issue): string | null {
|
|
24
|
+
if (!issue.resource_created_at) return null;
|
|
25
|
+
const affectedCount = Math.max(issue.count ?? 0, issue.members?.length ?? 0);
|
|
26
|
+
const label = affectedCount > 1
|
|
27
|
+
? 'Oldest affected resource created'
|
|
28
|
+
: affectedCount === 1
|
|
29
|
+
? 'Affected resource created'
|
|
30
|
+
: 'Resource created';
|
|
31
|
+
return `${label} ${new Date(issue.resource_created_at).toLocaleString()}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
14
34
|
function isDeploymentLikeCreation(issue: Issue): boolean {
|
|
15
35
|
const group = issue.group ?? '';
|
|
16
36
|
if (issue.kind === 'Pod') {
|
|
@@ -20,18 +40,117 @@ function isDeploymentLikeCreation(issue: Issue): boolean {
|
|
|
20
40
|
return isDeploymentLikeWorkloadKind(issue.kind, group);
|
|
21
41
|
}
|
|
22
42
|
|
|
43
|
+
function issueTimingIndependentOfOnset(issue: Issue): boolean {
|
|
44
|
+
return issue.issue_timing_basis === 'owner_condition'
|
|
45
|
+
|| issue.issue_timing_basis === 'pod_creation'
|
|
46
|
+
|| issue.issue_timing_basis === 'spec';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function onsetUncertaintyMeta(issue: Issue): string {
|
|
50
|
+
const coverage = issue.onset_coverage;
|
|
51
|
+
const unknown = coverage?.unknown ?? 0;
|
|
52
|
+
if (issue.first_seen && (coverage?.known ?? 0) > 0 && unknown > 0) {
|
|
53
|
+
return `exact onset unknown for ${unknown} of ${(coverage?.known ?? 0) + unknown} signals`;
|
|
54
|
+
}
|
|
55
|
+
return 'exact onset unknown';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function onsetUncertaintyTooltip(issue: Issue): string {
|
|
59
|
+
const coverage = issue.onset_coverage;
|
|
60
|
+
const unknown = coverage?.unknown ?? 0;
|
|
61
|
+
if (issue.first_seen && (coverage?.known ?? 0) > 0 && unknown > 0) {
|
|
62
|
+
return `Exact onset is unavailable for ${unknown} of ${(coverage?.known ?? 0) + unknown} signals.`;
|
|
63
|
+
}
|
|
64
|
+
return 'Exact onset of this issue is unavailable.';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function issueTimingForDisplay(issue: Issue): IssueTimingDisplay | null {
|
|
68
|
+
const partialOnset = Boolean(issue.first_seen && (issue.onset_coverage?.unknown ?? 0) > 0);
|
|
69
|
+
if (partialOnset && !issueTimingIndependentOfOnset(issue)) return null;
|
|
70
|
+
return issueTiming(issue);
|
|
71
|
+
}
|
|
72
|
+
|
|
23
73
|
export function issueTiming(issue: Issue): IssueTimingDisplay | null {
|
|
74
|
+
const coverage = issue.onset_coverage;
|
|
75
|
+
const onsetUnknown = issue.onset_unknown || (coverage?.unknown ?? 0) > 0;
|
|
76
|
+
const mixedOnset = Boolean(issue.first_seen && (coverage?.known ?? 0) > 0 && (coverage?.unknown ?? 0) > 0);
|
|
77
|
+
const grouped = (coverage?.known ?? 0) + (coverage?.unknown ?? 0) > 1;
|
|
78
|
+
const uncertaintyMeta = onsetUncertaintyMeta(issue);
|
|
79
|
+
const uncertaintyTooltip = onsetUncertaintyTooltip(issue);
|
|
24
80
|
switch (issue.issue_timing) {
|
|
25
81
|
case 'started_after_resource_was_healthy': {
|
|
26
|
-
|
|
82
|
+
if (issue.issue_timing_basis === 'owner_condition') {
|
|
83
|
+
if (onsetUnknown) {
|
|
84
|
+
return {
|
|
85
|
+
kind: 'regression',
|
|
86
|
+
chip: mixedOnset ? 'health regressed' : 'workload regressed · onset unknown',
|
|
87
|
+
meta: `${uncertaintyMeta}; owner workload was healthy before its current health regression`,
|
|
88
|
+
tooltip: `The owner workload had a healthy period before its current failing condition. ${uncertaintyTooltip}`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
kind: 'regression',
|
|
93
|
+
chip: 'health regressed',
|
|
94
|
+
meta: 'workload health regressed',
|
|
95
|
+
tooltip: 'The owner workload had a healthy period before its current failing condition. This does not date or attribute this specific issue.',
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (onsetUnknown && issue.issue_timing_basis === 'pod_creation') {
|
|
99
|
+
if (grouped) {
|
|
100
|
+
return {
|
|
101
|
+
kind: 'regression',
|
|
102
|
+
chip: 'new pods failed after healthy',
|
|
103
|
+
meta: `${uncertaintyMeta}; failing pods were created after an earlier healthy period`,
|
|
104
|
+
tooltip: `Failing pods were created after an earlier healthy period. ${uncertaintyTooltip}`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
kind: 'regression',
|
|
109
|
+
chip: 'later rollout regressed',
|
|
110
|
+
meta: `${uncertaintyMeta}; affected pod belongs to a later rollout after an earlier healthy period`,
|
|
111
|
+
tooltip: `The affected pod belongs to a later workload revision after an earlier healthy period. ${uncertaintyTooltip}`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
27
114
|
return {
|
|
28
115
|
kind: 'regression',
|
|
29
116
|
chip: 'after healthy',
|
|
30
|
-
meta:
|
|
31
|
-
tooltip: '
|
|
117
|
+
meta: 'failing evidence followed a healthy period',
|
|
118
|
+
tooltip: 'A healthy period preceded this failing evidence.',
|
|
32
119
|
};
|
|
33
120
|
}
|
|
34
121
|
case 'started_at_resource_creation': {
|
|
122
|
+
if (onsetUnknown && issue.issue_timing_basis === 'owner_condition') {
|
|
123
|
+
return {
|
|
124
|
+
kind: 'creation',
|
|
125
|
+
chip: 'workload never healthy',
|
|
126
|
+
meta: `${uncertaintyMeta}; owner workload never became healthy after deployment`,
|
|
127
|
+
tooltip: `The owner workload never became healthy after deployment. ${uncertaintyTooltip}`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (onsetUnknown && issue.issue_timing_basis === 'pod_creation') {
|
|
131
|
+
if (grouped) {
|
|
132
|
+
return {
|
|
133
|
+
kind: 'creation',
|
|
134
|
+
chip: 'startup pod failures',
|
|
135
|
+
meta: `${uncertaintyMeta}; failures occurred in pods created during workload startup`,
|
|
136
|
+
tooltip: `Failures occurred in pods created during workload startup. ${uncertaintyTooltip}`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
kind: 'creation',
|
|
141
|
+
chip: 'pod failed at startup',
|
|
142
|
+
meta: `${uncertaintyMeta}; affected pod failed during workload startup`,
|
|
143
|
+
tooltip: `The affected pod failed during startup of its workload revision. ${uncertaintyTooltip}`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
if (onsetUnknown && issue.issue_timing_basis === 'spec') {
|
|
147
|
+
return {
|
|
148
|
+
kind: 'creation',
|
|
149
|
+
chip: 'invalid at first reconcile',
|
|
150
|
+
meta: `${uncertaintyMeta}; initial spec was failing from first reconciliation`,
|
|
151
|
+
tooltip: `The initial specification establishes that the resource was failing from its first reconciliation. ${uncertaintyTooltip}`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
35
154
|
if (isDeploymentLikeCreation(issue)) {
|
|
36
155
|
return {
|
|
37
156
|
kind: 'creation',
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { afterEach, describe, it, expect, vi } from 'vitest'
|
|
2
2
|
import { createElement } from 'react'
|
|
3
3
|
import { renderToString } from 'react-dom/server'
|
|
4
|
-
import { compareIssues, subjectRef, memberRef, normalizeImagePullMessage, issueMessageParts, type Issue } from './types'
|
|
4
|
+
import { compareIssues, issueSortAnchor, subjectRef, memberRef, normalizeImagePullMessage, issueMessageParts, type Issue } from './types'
|
|
5
5
|
import { categoryLabel, groupBadgeClass, groupLabel } from './severity'
|
|
6
6
|
import { IssueRow } from './IssuesView'
|
|
7
|
-
import { issueTiming } from './issue-timing'
|
|
7
|
+
import { issueFirstSeenTitle, issueResourceCreatedTitle, issueTiming } from './issue-timing'
|
|
8
8
|
|
|
9
9
|
const base: Issue = {
|
|
10
10
|
id: 'id-0',
|
|
@@ -36,6 +36,12 @@ describe('compareIssues', () => {
|
|
|
36
36
|
expect([older, newer].sort(compareIssues).map((i) => i.id)).toEqual(['n', 'o'])
|
|
37
37
|
})
|
|
38
38
|
|
|
39
|
+
it('compares sort anchors as instants across timezone formats', () => {
|
|
40
|
+
const newerLocal = mk({ id: 'local', first_seen: '2026-08-09T23:00:00-07:00' })
|
|
41
|
+
const olderUTC = mk({ id: 'utc', onset_unknown: true, resource_created_at: '2026-08-10T01:00:00Z' })
|
|
42
|
+
expect([olderUTC, newerLocal].sort(compareIssues).map((i) => i.id)).toEqual(['local', 'utc'])
|
|
43
|
+
})
|
|
44
|
+
|
|
39
45
|
it('orders direct startup blockers before generic problem rows at same severity', () => {
|
|
40
46
|
const generic = mk({ id: 'generic', source: 'problem', first_seen: '2026-05-01T00:00:00Z' })
|
|
41
47
|
const blocker = mk({ id: 'blocker', source: 'scheduling', first_seen: '2026-01-01T00:00:00Z' })
|
|
@@ -56,6 +62,21 @@ describe('compareIssues', () => {
|
|
|
56
62
|
const after = [aRefetched, b].sort(compareIssues).map((i) => i.id)
|
|
57
63
|
expect(after).toEqual(before)
|
|
58
64
|
})
|
|
65
|
+
|
|
66
|
+
it('uses resource creation as the stable fallback only when onset is unknown', () => {
|
|
67
|
+
const unknownOlder = mk({ id: 'unknown-old', name: 'unknown-old', onset_unknown: true, resource_created_at: '2026-01-01T00:00:00Z' })
|
|
68
|
+
const unknownNewer = mk({ id: 'unknown-new', name: 'unknown-new', onset_unknown: true, resource_created_at: '2026-05-01T00:00:00Z' })
|
|
69
|
+
const known = mk({ id: 'known', name: 'known', first_seen: '2026-03-01T00:00:00Z', resource_created_at: '2025-01-01T00:00:00Z' })
|
|
70
|
+
const noMetadata = mk({ id: 'none', name: 'none', onset_unknown: true })
|
|
71
|
+
|
|
72
|
+
expect(issueSortAnchor(known)).toBe('2026-03-01T00:00:00Z')
|
|
73
|
+
expect([unknownOlder, noMetadata, known, unknownNewer].sort(compareIssues).map((i) => i.id)).toEqual([
|
|
74
|
+
'unknown-new',
|
|
75
|
+
'known',
|
|
76
|
+
'unknown-old',
|
|
77
|
+
'none',
|
|
78
|
+
])
|
|
79
|
+
})
|
|
59
80
|
})
|
|
60
81
|
|
|
61
82
|
describe('category/group label fallbacks', () => {
|
|
@@ -127,16 +148,148 @@ describe('IssueRow', () => {
|
|
|
127
148
|
expect(html).toContain(groupBadgeClass('control_plane'))
|
|
128
149
|
})
|
|
129
150
|
|
|
130
|
-
it('
|
|
151
|
+
it('does not fill collapsed rows with low-information unknown-onset labels', () => {
|
|
131
152
|
const html = renderToString(createElement(IssueRow, {
|
|
132
153
|
issue: mk({ onset_unknown: true }),
|
|
133
154
|
open: false,
|
|
134
155
|
onToggle: () => undefined,
|
|
135
156
|
}))
|
|
136
157
|
|
|
137
|
-
expect(html).
|
|
158
|
+
expect(html).not.toMatch(/onset unknown/i)
|
|
138
159
|
expect(html).not.toContain('0s')
|
|
139
160
|
})
|
|
161
|
+
|
|
162
|
+
it('shows independent timing alongside an unknown onset', () => {
|
|
163
|
+
const html = renderToString(createElement(IssueRow, {
|
|
164
|
+
issue: mk({
|
|
165
|
+
onset_unknown: true,
|
|
166
|
+
issue_timing: 'started_at_resource_creation',
|
|
167
|
+
issue_timing_basis: 'owner_condition',
|
|
168
|
+
}),
|
|
169
|
+
open: true,
|
|
170
|
+
onToggle: () => undefined,
|
|
171
|
+
}))
|
|
172
|
+
|
|
173
|
+
expect(html).toContain('workload never healthy')
|
|
174
|
+
expect(html).toContain('exact onset unknown; owner workload never became healthy after deployment')
|
|
175
|
+
expect(html).not.toContain('since deploy')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('qualifies a workload regression when the issue onset is unknown', () => {
|
|
179
|
+
const html = renderToString(createElement(IssueRow, {
|
|
180
|
+
issue: mk({
|
|
181
|
+
onset_unknown: true,
|
|
182
|
+
issue_timing: 'started_after_resource_was_healthy',
|
|
183
|
+
issue_timing_basis: 'owner_condition',
|
|
184
|
+
}),
|
|
185
|
+
open: true,
|
|
186
|
+
onToggle: () => undefined,
|
|
187
|
+
}))
|
|
188
|
+
|
|
189
|
+
expect(html).toContain('workload regressed')
|
|
190
|
+
expect(html).toContain('onset unknown')
|
|
191
|
+
expect(html).toContain('exact onset unknown; owner workload was healthy before its current health regression')
|
|
192
|
+
expect(html).not.toContain('workload health regressed')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('renders mixed groups as a lower-bound age and suppresses a group-wide timing claim', () => {
|
|
196
|
+
vi.useFakeTimers()
|
|
197
|
+
vi.setSystemTime(new Date('2026-06-30T12:00:00Z'))
|
|
198
|
+
|
|
199
|
+
const html = renderToString(createElement(IssueRow, {
|
|
200
|
+
issue: mk({
|
|
201
|
+
first_seen: '2026-06-30T10:00:00Z',
|
|
202
|
+
onset_coverage: { known: 2, unknown: 1 },
|
|
203
|
+
issue_timing: 'started_after_resource_was_healthy',
|
|
204
|
+
}),
|
|
205
|
+
open: true,
|
|
206
|
+
onToggle: () => undefined,
|
|
207
|
+
}))
|
|
208
|
+
|
|
209
|
+
expect(html).toContain('≥')
|
|
210
|
+
expect(html).toContain('timing unknown for 1 of 3 signals')
|
|
211
|
+
expect(html).not.toContain('after healthy')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('keeps onset-independent workload evidence visible on mixed groups', () => {
|
|
215
|
+
vi.useFakeTimers()
|
|
216
|
+
vi.setSystemTime(new Date('2026-06-30T12:00:00Z'))
|
|
217
|
+
|
|
218
|
+
const html = renderToString(createElement(IssueRow, {
|
|
219
|
+
issue: mk({
|
|
220
|
+
first_seen: '2026-06-30T10:00:00Z',
|
|
221
|
+
onset_coverage: { known: 1, unknown: 1 },
|
|
222
|
+
issue_timing: 'started_after_resource_was_healthy',
|
|
223
|
+
issue_timing_basis: 'owner_condition',
|
|
224
|
+
}),
|
|
225
|
+
open: true,
|
|
226
|
+
onToggle: () => undefined,
|
|
227
|
+
}))
|
|
228
|
+
|
|
229
|
+
expect(html).toContain('≥')
|
|
230
|
+
expect(html).toContain('health regressed')
|
|
231
|
+
expect(html).not.toContain('workload regressed')
|
|
232
|
+
expect(html).not.toContain('workload regressed · onset unknown')
|
|
233
|
+
expect(html).toContain('exact onset unknown for 1 of 2 signals')
|
|
234
|
+
expect(html).toContain('some signals active at least 2h ago')
|
|
235
|
+
expect(html).not.toContain('exact onset unknown;')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('keeps lower-bound age, unknown count, and freshness for mixed creation groups', () => {
|
|
239
|
+
vi.useFakeTimers()
|
|
240
|
+
vi.setSystemTime(new Date('2026-06-30T12:00:00Z'))
|
|
241
|
+
|
|
242
|
+
const html = renderToString(createElement(IssueRow, {
|
|
243
|
+
issue: mk({
|
|
244
|
+
first_seen: '2026-06-30T10:00:00Z',
|
|
245
|
+
last_seen: '2026-06-30T11:30:00Z',
|
|
246
|
+
onset_coverage: { known: 1, unknown: 1 },
|
|
247
|
+
issue_timing: 'started_at_resource_creation',
|
|
248
|
+
issue_timing_basis: 'owner_condition',
|
|
249
|
+
}),
|
|
250
|
+
open: true,
|
|
251
|
+
onToggle: () => undefined,
|
|
252
|
+
}))
|
|
253
|
+
|
|
254
|
+
expect(html).toContain('workload never healthy')
|
|
255
|
+
expect(html).toContain('exact onset unknown for 1 of 2 signals')
|
|
256
|
+
expect(html).toContain('some signals active at least 2h ago')
|
|
257
|
+
expect(html).toContain('last seen 30m ago')
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('presents ordinary first-seen time as a conservative active lower bound', () => {
|
|
261
|
+
vi.useFakeTimers()
|
|
262
|
+
vi.setSystemTime(new Date('2026-06-30T12:00:00Z'))
|
|
263
|
+
|
|
264
|
+
const html = renderToString(createElement(IssueRow, {
|
|
265
|
+
issue: mk({ first_seen: '2026-06-30T10:00:00Z' }),
|
|
266
|
+
open: true,
|
|
267
|
+
onToggle: () => undefined,
|
|
268
|
+
}))
|
|
269
|
+
|
|
270
|
+
expect(html).toContain('active at least 2h ago')
|
|
271
|
+
expect(html).not.toContain('started 2h ago')
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
describe('onset provenance copy', () => {
|
|
276
|
+
it('describes a known first-seen value as an active lower bound', () => {
|
|
277
|
+
const title = issueFirstSeenTitle(mk({ first_seen: '2026-06-30T10:00:00Z' }))
|
|
278
|
+
|
|
279
|
+
expect(title).toContain('Active at least since')
|
|
280
|
+
expect(title).not.toContain('started')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('labels a one-member workload rollup as affected-resource context', () => {
|
|
284
|
+
const title = issueResourceCreatedTitle(mk({
|
|
285
|
+
kind: 'Deployment',
|
|
286
|
+
count: 1,
|
|
287
|
+
members: [{ kind: 'Pod', namespace: 'prod', name: 'web-abc' }],
|
|
288
|
+
resource_created_at: '2026-06-30T10:00:00Z',
|
|
289
|
+
}))
|
|
290
|
+
|
|
291
|
+
expect(title).toContain('Affected resource created')
|
|
292
|
+
})
|
|
140
293
|
})
|
|
141
294
|
|
|
142
295
|
describe('issueTiming', () => {
|
|
@@ -184,6 +337,51 @@ describe('issueTiming', () => {
|
|
|
184
337
|
})
|
|
185
338
|
})
|
|
186
339
|
|
|
340
|
+
it('uses self-explanatory copy when exact onset and independent timing have different scopes', () => {
|
|
341
|
+
expect(issueTiming(mk({
|
|
342
|
+
kind: 'Pod',
|
|
343
|
+
onset_unknown: true,
|
|
344
|
+
issue_timing: 'started_at_resource_creation',
|
|
345
|
+
issue_timing_basis: 'pod_creation',
|
|
346
|
+
}))).toMatchObject({
|
|
347
|
+
chip: 'pod failed at startup',
|
|
348
|
+
meta: 'exact onset unknown; affected pod failed during workload startup',
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
expect(issueTiming(mk({
|
|
352
|
+
kind: 'Rollout',
|
|
353
|
+
group: 'argoproj.io',
|
|
354
|
+
onset_unknown: true,
|
|
355
|
+
issue_timing: 'started_at_resource_creation',
|
|
356
|
+
issue_timing_basis: 'spec',
|
|
357
|
+
}))).toMatchObject({
|
|
358
|
+
chip: 'invalid at first reconcile',
|
|
359
|
+
meta: 'exact onset unknown; initial spec was failing from first reconciliation',
|
|
360
|
+
})
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it('uses group-safe pod timing copy for multiple contributing signals', () => {
|
|
364
|
+
expect(issueTiming(mk({
|
|
365
|
+
kind: 'Deployment',
|
|
366
|
+
onset_coverage: { known: 0, unknown: 3 },
|
|
367
|
+
issue_timing: 'started_at_resource_creation',
|
|
368
|
+
issue_timing_basis: 'pod_creation',
|
|
369
|
+
}))).toMatchObject({
|
|
370
|
+
chip: 'startup pod failures',
|
|
371
|
+
meta: 'exact onset unknown; failures occurred in pods created during workload startup',
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
expect(issueTiming(mk({
|
|
375
|
+
kind: 'Deployment',
|
|
376
|
+
onset_coverage: { known: 0, unknown: 3 },
|
|
377
|
+
issue_timing: 'started_after_resource_was_healthy',
|
|
378
|
+
issue_timing_basis: 'pod_creation',
|
|
379
|
+
}))).toMatchObject({
|
|
380
|
+
chip: 'new pods failed after healthy',
|
|
381
|
+
meta: 'exact onset unknown; failing pods were created after an earlier healthy period',
|
|
382
|
+
})
|
|
383
|
+
})
|
|
384
|
+
|
|
187
385
|
it('uses resource creation wording for non-deployment creation failures', () => {
|
|
188
386
|
expect(issueTiming(mk({
|
|
189
387
|
kind: 'PersistentVolumeClaim',
|
|
@@ -196,10 +394,7 @@ describe('issueTiming', () => {
|
|
|
196
394
|
})
|
|
197
395
|
})
|
|
198
396
|
|
|
199
|
-
it('describes after-healthy timing without
|
|
200
|
-
vi.useFakeTimers()
|
|
201
|
-
vi.setSystemTime(new Date('2026-06-30T12:00:00Z'))
|
|
202
|
-
|
|
397
|
+
it('describes direct after-healthy timing without coupling it to first_seen', () => {
|
|
203
398
|
const display = issueTiming(mk({
|
|
204
399
|
first_seen: '2026-06-30T10:00:00Z',
|
|
205
400
|
issue_timing: 'started_after_resource_was_healthy',
|
|
@@ -209,12 +404,28 @@ describe('issueTiming', () => {
|
|
|
209
404
|
expect(display).toMatchObject({
|
|
210
405
|
kind: 'regression',
|
|
211
406
|
chip: 'after healthy',
|
|
212
|
-
meta: '
|
|
213
|
-
tooltip: 'Previously healthy before this failing signal.',
|
|
407
|
+
meta: 'failing evidence followed a healthy period',
|
|
214
408
|
})
|
|
409
|
+
expect(`${display?.chip} ${display?.meta} ${display?.tooltip}`).not.toContain('2h')
|
|
215
410
|
expect(`${display?.chip} ${display?.meta} ${display?.tooltip}`).not.toMatch(/baseline|safe|ignore/i)
|
|
216
411
|
})
|
|
217
412
|
|
|
413
|
+
it('presents owner-condition timing as workload-level rather than cause-specific', () => {
|
|
414
|
+
const display = issueTiming(mk({
|
|
415
|
+
first_seen: '2026-06-30T10:00:00Z',
|
|
416
|
+
issue_timing: 'started_after_resource_was_healthy',
|
|
417
|
+
issue_timing_basis: 'owner_condition',
|
|
418
|
+
}))
|
|
419
|
+
|
|
420
|
+
expect(display).toMatchObject({
|
|
421
|
+
kind: 'regression',
|
|
422
|
+
chip: 'health regressed',
|
|
423
|
+
meta: 'workload health regressed',
|
|
424
|
+
})
|
|
425
|
+
expect(display?.tooltip).toContain('does not date or attribute this specific issue')
|
|
426
|
+
expect(`${display?.chip} ${display?.meta} ${display?.tooltip}`).not.toContain('2h')
|
|
427
|
+
})
|
|
428
|
+
|
|
218
429
|
it('returns null when there is no confident timing signal', () => {
|
|
219
430
|
expect(issueTiming(mk({ issue_timing: undefined, issue_timing_basis: undefined }))).toBeNull()
|
|
220
431
|
})
|
|
@@ -197,10 +197,20 @@ export interface Issue {
|
|
|
197
197
|
* message. The Issues view uses it to link these — and only these — to the
|
|
198
198
|
* Capacity / Demand view, so a generic scheduling failure never links. */
|
|
199
199
|
capacity_relevant?: boolean;
|
|
200
|
+
/** Earliest evidence-backed time the issue was active. This may be when Radar
|
|
201
|
+
* first observed the state rather than exact onset: read as "active at least
|
|
202
|
+
* since", never as proof of a healthy-duration boundary. */
|
|
200
203
|
first_seen?: string;
|
|
201
204
|
/** Radar can confirm the issue is active, but current cluster evidence does
|
|
202
205
|
* not establish when the failing state began. */
|
|
203
206
|
onset_unknown?: boolean;
|
|
207
|
+
onset_coverage?: {
|
|
208
|
+
known: number;
|
|
209
|
+
unknown: number;
|
|
210
|
+
};
|
|
211
|
+
/** Kubernetes object creation time. Context and a stable sort fallback only;
|
|
212
|
+
* it is not evidence that the issue began then. */
|
|
213
|
+
resource_created_at?: string;
|
|
204
214
|
last_seen?: string;
|
|
205
215
|
/** Affected-resource fan-out, EXCLUDING the subject (the row header).
|
|
206
216
|
* 0/omitted for a single-resource issue; e.g. 50 for one Deployment's
|
|
@@ -226,11 +236,15 @@ export interface Issue {
|
|
|
226
236
|
* "started_at_resource_creation" — failing state began during resource
|
|
227
237
|
* creation or first reconciliation.
|
|
228
238
|
* "started_after_resource_was_healthy" — a meaningful healthy window preceded
|
|
229
|
-
* the failing state.
|
|
239
|
+
* the failing state. Owner-condition
|
|
240
|
+
* evidence is workload-level, not timing
|
|
241
|
+
* or attribution for this specific reason.
|
|
230
242
|
*/
|
|
231
243
|
issue_timing?: 'started_at_resource_creation' | 'started_after_resource_was_healthy';
|
|
232
244
|
/** The evidence that determined issue_timing (for auditability). */
|
|
233
245
|
issue_timing_basis?: 'condition' | 'owner_condition' | 'pod_creation' | 'deletion' | 'phase' | 'spec';
|
|
246
|
+
/** Plain-language explanation when timing fields describe different scopes. */
|
|
247
|
+
timing_summary?: string;
|
|
234
248
|
|
|
235
249
|
/** Recent non-status changes (spec/config and lifecycle) on this issue's
|
|
236
250
|
* subject (and, for workload subjects, its referenced ConfigMaps) —
|
|
@@ -273,20 +287,32 @@ export function memberRef(issue: Issue, member: IssueResourceRef): IssueResource
|
|
|
273
287
|
/**
|
|
274
288
|
* compareIssues is the queue's stable sort order (extracted from IssuesView so
|
|
275
289
|
* it can be unit-tested). Severity first (critical before warning), then
|
|
276
|
-
* direct-blocker source priority, then
|
|
290
|
+
* direct-blocker source priority, then the evidence-backed active-time anchor —
|
|
291
|
+
* first_seen DESC, deliberately
|
|
277
292
|
* NOT last_seen: last_seen bumps to compose-time on every poll, so sorting by
|
|
278
293
|
* it would reshuffle same-severity rows on each refetch. The remaining keys
|
|
279
294
|
* (cluster → namespace → name → id) are a fully deterministic tiebreak so the
|
|
280
295
|
* order never churns under auto-refresh.
|
|
296
|
+
* JavaScript timestamp comparison is millisecond-precision; sub-millisecond
|
|
297
|
+
* ties fall through to the stable keys below.
|
|
281
298
|
*/
|
|
299
|
+
export function issueSortAnchor(issue: Issue): string {
|
|
300
|
+
return issue.first_seen ?? issue.resource_created_at ?? '';
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function compareIssueSortAnchors(a: Issue, b: Issue): number {
|
|
304
|
+
const ta = Date.parse(issueSortAnchor(a)) || 0;
|
|
305
|
+
const tb = Date.parse(issueSortAnchor(b)) || 0;
|
|
306
|
+
return tb - ta;
|
|
307
|
+
}
|
|
308
|
+
|
|
282
309
|
export function compareIssues(a: Issue, b: Issue): number {
|
|
283
310
|
const r = ISSUE_SEVERITY_RANK[b.severity] - ISSUE_SEVERITY_RANK[a.severity];
|
|
284
311
|
if (r !== 0) return r;
|
|
285
312
|
const sr = (ISSUE_SOURCE_RANK[b.source] ?? 0) - (ISSUE_SOURCE_RANK[a.source] ?? 0);
|
|
286
313
|
if (sr !== 0) return sr;
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
if (fa !== fb) return fb.localeCompare(fa);
|
|
314
|
+
const onset = compareIssueSortAnchors(a, b);
|
|
315
|
+
if (onset !== 0) return onset;
|
|
290
316
|
const c = (a.cluster_name ?? '').localeCompare(b.cluster_name ?? '');
|
|
291
317
|
if (c !== 0) return c;
|
|
292
318
|
const ns = (a.namespace ?? '').localeCompare(b.namespace ?? '');
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
3
|
import { ChevronDown, Globe, Search, AlertTriangle } from 'lucide-react'
|
|
4
|
+
import { Badge } from '../ui/Badge'
|
|
4
5
|
import { Tooltip } from '../ui/Tooltip'
|
|
5
6
|
import { MultiSelectPicker } from '../ui/MultiSelectPicker'
|
|
6
7
|
|
|
@@ -14,6 +15,8 @@ export interface NamespaceScopeView {
|
|
|
14
15
|
actives: string[]
|
|
15
16
|
/** Namespaces the user may pick from. */
|
|
16
17
|
accessibleNamespaces: string[]
|
|
18
|
+
/** Namespaces where Radar cannot list pods or deployments. */
|
|
19
|
+
deniedNamespaces?: string[]
|
|
17
20
|
mode?: 'cluster-wide' | 'namespace' | 'restricted' | string
|
|
18
21
|
/** Single-namespace cache-scope control instead of a multi-select filter. */
|
|
19
22
|
cacheScoped?: boolean
|
|
@@ -115,6 +118,7 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
|
|
|
115
118
|
if (!scope) return [] as string[]
|
|
116
119
|
return [...(scope.accessibleNamespaces ?? [])].sort((a, b) => a.localeCompare(b))
|
|
117
120
|
}, [scope])
|
|
121
|
+
const deniedNamespaces = useMemo(() => new Set(scope?.deniedNamespaces ?? []), [scope?.deniedNamespaces])
|
|
118
122
|
|
|
119
123
|
const filteredItems = useMemo(() => {
|
|
120
124
|
const q = search.trim().toLowerCase()
|
|
@@ -215,6 +219,21 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
|
|
|
215
219
|
? `View is filtered to namespace ${scopeActives[0]}. Click to switch or reset.`
|
|
216
220
|
: `View is filtered to ${activeCount} namespaces. Click to adjust or reset.`
|
|
217
221
|
|
|
222
|
+
const renderNamespaceMeta = (ns: string) => (
|
|
223
|
+
<>
|
|
224
|
+
{deniedNamespaces.has(ns) && (
|
|
225
|
+
<Badge severity="error" size="sm" title="Radar cannot list pods or deployments in this namespace.">
|
|
226
|
+
No access
|
|
227
|
+
</Badge>
|
|
228
|
+
)}
|
|
229
|
+
{ns === scope.kubeconfigNamespace && ns !== '' && (
|
|
230
|
+
<span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary shrink-0">
|
|
231
|
+
kubeconfig
|
|
232
|
+
</span>
|
|
233
|
+
)}
|
|
234
|
+
</>
|
|
235
|
+
)
|
|
236
|
+
|
|
218
237
|
return (
|
|
219
238
|
<>
|
|
220
239
|
<Tooltip
|
|
@@ -286,7 +305,6 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
|
|
|
286
305
|
|
|
287
306
|
{filteredItems.map(ns => {
|
|
288
307
|
const isChecked = draft.has(ns)
|
|
289
|
-
const isContextDefault = ns === scope.kubeconfigNamespace && ns !== ''
|
|
290
308
|
return (
|
|
291
309
|
<li key={ns}>
|
|
292
310
|
<label className="w-full flex items-center justify-between px-3 py-1.5 text-sm hover:bg-theme-hover text-left text-theme-text-primary cursor-pointer">
|
|
@@ -299,11 +317,7 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
|
|
|
299
317
|
className="shrink-0 accent-current"
|
|
300
318
|
/>
|
|
301
319
|
<span className="truncate">{ns}</span>
|
|
302
|
-
{
|
|
303
|
-
<span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary shrink-0">
|
|
304
|
-
kubeconfig
|
|
305
|
-
</span>
|
|
306
|
-
)}
|
|
320
|
+
{renderNamespaceMeta(ns)}
|
|
307
321
|
</span>
|
|
308
322
|
</label>
|
|
309
323
|
</li>
|
|
@@ -335,13 +349,7 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
|
|
|
335
349
|
noItemsLabel="No namespaces available."
|
|
336
350
|
clearAllDisabled={!canClearAll || activeCount === 0}
|
|
337
351
|
clearAllAriaLabel="Clear namespace selection"
|
|
338
|
-
renderItemMeta={
|
|
339
|
-
ns === scope.kubeconfigNamespace && ns !== '' ? (
|
|
340
|
-
<span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary shrink-0">
|
|
341
|
-
kubeconfig
|
|
342
|
-
</span>
|
|
343
|
-
) : null
|
|
344
|
-
}
|
|
352
|
+
renderItemMeta={renderNamespaceMeta}
|
|
345
353
|
/>
|
|
346
354
|
)}
|
|
347
355
|
|