@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.
Files changed (97) hide show
  1. package/package.json +1 -1
  2. package/src/components/applications/AppTooltips.tsx +13 -1
  3. package/src/components/applications/ApplicationDetail.test.tsx +25 -0
  4. package/src/components/applications/ApplicationDetail.tsx +39 -7
  5. package/src/components/applications/ApplicationsView.tsx +18 -3
  6. package/src/components/applications/ReadyBar.tsx +3 -2
  7. package/src/components/applications/applications-view.test.tsx +91 -0
  8. package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -1
  9. package/src/components/issues/IssuesView.tsx +40 -31
  10. package/src/components/issues/ResourceIssuesSection.tsx +3 -2
  11. package/src/components/issues/index.ts +3 -1
  12. package/src/components/issues/issue-timing.ts +123 -4
  13. package/src/components/issues/issues.test.ts +221 -10
  14. package/src/components/issues/types.ts +31 -5
  15. package/src/components/namespace-switcher/NamespacePicker.tsx +21 -13
  16. package/src/components/resources/ResourcesView.tsx +964 -96
  17. package/src/components/resources/column-settings-key.test.ts +98 -0
  18. package/src/components/resources/column-visibility-merge.test.ts +64 -0
  19. package/src/components/resources/curated-column-ownership.test.ts +236 -0
  20. package/src/components/resources/gateway-policy-status.test.ts +291 -0
  21. package/src/components/resources/gateway-policy-status.ts +237 -0
  22. package/src/components/resources/generic-status.test.ts +199 -0
  23. package/src/components/resources/generic-status.ts +171 -0
  24. package/src/components/resources/gpu-ecosystem-registry.test.ts +63 -0
  25. package/src/components/resources/index.ts +9 -1
  26. package/src/components/resources/istio-gateway-filter.test.ts +97 -0
  27. package/src/components/resources/printer-columns.test.ts +298 -0
  28. package/src/components/resources/printer-columns.ts +249 -0
  29. package/src/components/resources/renderers/EventRenderer.test.tsx +50 -1
  30. package/src/components/resources/renderers/EventRenderer.tsx +3 -5
  31. package/src/components/resources/renderers/GenericRenderer.tsx +18 -83
  32. package/src/components/resources/renderers/HPARenderer.test.tsx +28 -0
  33. package/src/components/resources/renderers/HPARenderer.tsx +6 -1
  34. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +2 -1
  35. package/src/components/resources/renderers/PVCRenderer.test.tsx +44 -0
  36. package/src/components/resources/renderers/PVCRenderer.tsx +19 -2
  37. package/src/components/resources/renderers/RolloutRenderer.test.tsx +35 -0
  38. package/src/components/resources/renderers/RolloutRenderer.tsx +22 -3
  39. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
  40. package/src/components/resources/renderers/WorkloadRenderer.tsx +25 -65
  41. package/src/components/resources/renderers/amd-gpu-cells.tsx +36 -0
  42. package/src/components/resources/renderers/inference-gateway-cells.tsx +62 -0
  43. package/src/components/resources/renderers/jobset-lws-cells.tsx +82 -0
  44. package/src/components/resources/renderers/kai-cells.tsx +67 -0
  45. package/src/components/resources/renderers/kaito-cells.tsx +62 -0
  46. package/src/components/resources/renderers/kserve-cells.tsx +145 -0
  47. package/src/components/resources/renderers/kubeflow-training-cells.tsx +72 -0
  48. package/src/components/resources/renderers/kueue-cells.tsx +176 -0
  49. package/src/components/resources/renderers/nim-cells.tsx +81 -0
  50. package/src/components/resources/renderers/ray-cells.tsx +124 -0
  51. package/src/components/resources/renderers/volcano-cells.tsx +140 -0
  52. package/src/components/resources/resource-utils-amd-gpu.ts +61 -0
  53. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +222 -0
  54. package/src/components/resources/resource-utils-hpa.test.ts +4 -0
  55. package/src/components/resources/resource-utils-hpa.ts +6 -2
  56. package/src/components/resources/resource-utils-inference-gateway.ts +119 -0
  57. package/src/components/resources/resource-utils-jobset-lws.ts +117 -0
  58. package/src/components/resources/resource-utils-kai.ts +105 -0
  59. package/src/components/resources/resource-utils-kaito.ts +114 -0
  60. package/src/components/resources/resource-utils-kserve.ts +186 -0
  61. package/src/components/resources/resource-utils-kubeflow-training.ts +121 -0
  62. package/src/components/resources/resource-utils-kueue.ts +198 -0
  63. package/src/components/resources/resource-utils-nim.ts +109 -0
  64. package/src/components/resources/resource-utils-ray.ts +214 -0
  65. package/src/components/resources/resource-utils-rollout.test.ts +183 -0
  66. package/src/components/resources/resource-utils-velero.test.ts +30 -0
  67. package/src/components/resources/resource-utils-volcano.ts +176 -0
  68. package/src/components/resources/resource-utils.ts +156 -47
  69. package/src/components/resources/resources-column-filter.test.ts +27 -1
  70. package/src/components/resources/workload-rollout-golden.test.ts +55 -0
  71. package/src/components/shared/CreateResourceDialog.tsx +1 -0
  72. package/src/components/shared/EditableYamlView.tsx +4 -2
  73. package/src/components/shared/ResourceActionsBar.tsx +170 -25
  74. package/src/components/shared/ResourceRendererDispatch.test.tsx +206 -1
  75. package/src/components/shared/ResourceRendererDispatch.tsx +84 -40
  76. package/src/components/shared/SetImageDialog.test.ts +129 -0
  77. package/src/components/shared/SetImageDialog.tsx +577 -0
  78. package/src/components/shared/index.ts +1 -0
  79. package/src/components/shared/revision-role-badges.test.ts +56 -4
  80. package/src/components/timeline/TimelineList.tsx +19 -3
  81. package/src/components/timeline/TimelineSwimlanes.tsx +11 -3
  82. package/src/components/trace/ReachabilityView.tsx +1 -1
  83. package/src/components/ui/drawer-components.tsx +12 -0
  84. package/src/components/workload/WorkloadRolloutNotice.tsx +60 -0
  85. package/src/components/workload/WorkloadView.tsx +144 -81
  86. package/src/hooks/useProgressiveRefresh.ts +37 -0
  87. package/src/source-hygiene.test.ts +28 -0
  88. package/src/theme/tailwind-theme.css +4 -0
  89. package/src/types/core.ts +55 -0
  90. package/src/utils/api-resources.test.ts +40 -1
  91. package/src/utils/api-resources.ts +11 -1
  92. package/src/utils/applications.ts +50 -0
  93. package/src/utils/bulk-workload-actions.test.ts +20 -0
  94. package/src/utils/bulk-workload-actions.ts +15 -0
  95. package/src/utils/navigation.test.ts +31 -1
  96. package/src/utils/navigation.ts +21 -2
  97. 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
- const started = issue.first_seen ? `started ${formatRelativeAgeTime(issue.first_seen)} after being healthy` : 'started after being healthy';
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: started,
31
- tooltip: 'Previously healthy before this failing signal.',
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('shows an explicit unknown-onset state instead of inventing an age', () => {
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).toContain('Onset unknown')
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 implying root cause or safety', () => {
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: 'started 2h ago after being healthy',
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 ONSET first_seen DESC, deliberately
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 fa = a.first_seen ?? '';
288
- const fb = b.first_seen ?? '';
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
- {isContextDefault && (
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={ns =>
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