@skyhook-io/k8s-ui 1.13.0 → 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 (110) hide show
  1. package/package.json +2 -2
  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 +41 -9
  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 +35 -7
  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 +973 -100
  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/NamespaceRenderer.test.ts +30 -0
  36. package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -3
  37. package/src/components/resources/renderers/PVCRenderer.test.tsx +44 -0
  38. package/src/components/resources/renderers/PVCRenderer.tsx +19 -2
  39. package/src/components/resources/renderers/RolloutRenderer.test.tsx +35 -0
  40. package/src/components/resources/renderers/RolloutRenderer.tsx +22 -3
  41. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
  42. package/src/components/resources/renderers/WorkloadRenderer.tsx +25 -65
  43. package/src/components/resources/renderers/amd-gpu-cells.tsx +36 -0
  44. package/src/components/resources/renderers/inference-gateway-cells.tsx +62 -0
  45. package/src/components/resources/renderers/jobset-lws-cells.tsx +82 -0
  46. package/src/components/resources/renderers/kai-cells.tsx +67 -0
  47. package/src/components/resources/renderers/kaito-cells.tsx +62 -0
  48. package/src/components/resources/renderers/kserve-cells.tsx +145 -0
  49. package/src/components/resources/renderers/kubeflow-training-cells.tsx +72 -0
  50. package/src/components/resources/renderers/kueue-cells.tsx +176 -0
  51. package/src/components/resources/renderers/nim-cells.tsx +81 -0
  52. package/src/components/resources/renderers/ray-cells.tsx +124 -0
  53. package/src/components/resources/renderers/volcano-cells.tsx +140 -0
  54. package/src/components/resources/resource-utils-amd-gpu.ts +61 -0
  55. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +222 -0
  56. package/src/components/resources/resource-utils-hpa.test.ts +4 -0
  57. package/src/components/resources/resource-utils-hpa.ts +6 -2
  58. package/src/components/resources/resource-utils-inference-gateway.ts +119 -0
  59. package/src/components/resources/resource-utils-jobset-lws.ts +117 -0
  60. package/src/components/resources/resource-utils-kai.ts +105 -0
  61. package/src/components/resources/resource-utils-kaito.ts +114 -0
  62. package/src/components/resources/resource-utils-kserve.ts +186 -0
  63. package/src/components/resources/resource-utils-kubeflow-training.ts +121 -0
  64. package/src/components/resources/resource-utils-kueue.ts +198 -0
  65. package/src/components/resources/resource-utils-nim.ts +109 -0
  66. package/src/components/resources/resource-utils-ray.ts +214 -0
  67. package/src/components/resources/resource-utils-rollout.test.ts +183 -0
  68. package/src/components/resources/resource-utils-velero.test.ts +30 -0
  69. package/src/components/resources/resource-utils-volcano.ts +176 -0
  70. package/src/components/resources/resource-utils.ts +156 -47
  71. package/src/components/resources/resources-column-filter.test.ts +27 -1
  72. package/src/components/resources/workload-rollout-golden.test.ts +55 -0
  73. package/src/components/shared/CreateResourceDialog.tsx +1 -0
  74. package/src/components/shared/EditableYamlView.tsx +4 -2
  75. package/src/components/shared/ResourceActionsBar.tsx +170 -25
  76. package/src/components/shared/ResourceRendererDispatch.test.tsx +206 -1
  77. package/src/components/shared/ResourceRendererDispatch.tsx +84 -40
  78. package/src/components/shared/SetImageDialog.test.ts +129 -0
  79. package/src/components/shared/SetImageDialog.tsx +577 -0
  80. package/src/components/shared/index.ts +1 -0
  81. package/src/components/shared/revision-role-badges.test.ts +56 -4
  82. package/src/components/timeline/TimelineList.tsx +19 -3
  83. package/src/components/timeline/TimelineSwimlanes.tsx +11 -3
  84. package/src/components/trace/ReachabilityView.tsx +1 -1
  85. package/src/components/trace/TracePanel.tsx +1 -0
  86. package/src/components/ui/InClusterConsentDialog.tsx +5 -4
  87. package/src/components/ui/SelectMenu.tsx +202 -28
  88. package/src/components/ui/drawer-components.tsx +12 -0
  89. package/src/components/workload/WorkloadRolloutNotice.tsx +60 -0
  90. package/src/components/workload/WorkloadView.tsx +146 -83
  91. package/src/hooks/useProgressiveRefresh.ts +37 -0
  92. package/src/source-hygiene.test.ts +28 -0
  93. package/src/theme/tailwind-theme.css +4 -0
  94. package/src/types/core.ts +62 -4
  95. package/src/utils/api-resources.test.ts +40 -1
  96. package/src/utils/api-resources.ts +11 -1
  97. package/src/utils/applications.ts +50 -0
  98. package/src/utils/bulk-workload-actions.test.ts +20 -0
  99. package/src/utils/bulk-workload-actions.ts +15 -0
  100. package/src/utils/clipboard.test.ts +86 -0
  101. package/src/utils/clipboard.ts +50 -0
  102. package/src/utils/extended-resources.ts +4 -7
  103. package/src/utils/format.test.ts +83 -1
  104. package/src/utils/format.ts +80 -1
  105. package/src/utils/inClusterConsent.test.ts +9 -0
  106. package/src/utils/inClusterConsent.ts +9 -9
  107. package/src/utils/index.ts +1 -0
  108. package/src/utils/navigation.test.ts +31 -1
  109. package/src/utils/navigation.ts +21 -2
  110. package/src/utils/workload-rollout.ts +292 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.13.0",
3
+ "version": "1.13.4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -98,7 +98,7 @@
98
98
  "clsx": "^2.1.1",
99
99
  "diff": "^9.0.0",
100
100
  "elkjs": "^0.11.1",
101
- "lucide-react": "^1.30.0",
101
+ "lucide-react": "^1.33.0",
102
102
  "monaco-editor": "0.55.1",
103
103
  "react": "^19.2.8",
104
104
  "react-dom": "^19.2.8",
@@ -1,4 +1,4 @@
1
- import { provenanceSource, HEALTH_META, appSourceLabel, isDeclaredAppSource, APP_IDENTITY_ANNOTATION, type AppWorkload } from '../../utils/applications'
1
+ import { provenanceSource, HEALTH_META, appSourceLabel, isDeclaredAppSource, APP_IDENTITY_ANNOTATION, type AppRolloutSummary, type AppWorkload } from '../../utils/applications'
2
2
  import { midTruncate } from '../../utils/format'
3
3
 
4
4
  // Structured tooltip content for the application chips. Both render a short
@@ -59,6 +59,18 @@ export function VersionTooltip({ workloads }: { workloads: Pick<AppWorkload, 'na
59
59
  )
60
60
  }
61
61
 
62
+ export function RolloutSummaryTooltip({ summary }: { summary: AppRolloutSummary }) {
63
+ const details = summary.details.slice(0, 8)
64
+ return (
65
+ <div className="max-w-xs space-y-1">
66
+ {details.map((detail, index) => <div key={index}>{detail}</div>)}
67
+ {summary.details.length > details.length && (
68
+ <div className="text-theme-text-tertiary">+{summary.details.length - details.length} more</div>
69
+ )}
70
+ </div>
71
+ )
72
+ }
73
+
62
74
  export function CategoryTooltip({
63
75
  category,
64
76
  addonReason,
@@ -76,6 +76,31 @@ function renderDetail(props: Partial<ApplicationDetailProps> = {}) {
76
76
  }
77
77
 
78
78
  describe("ApplicationDetail shell", () => {
79
+ it("shows rollout activity in the application summary and workload matrix", () => {
80
+ const rolling: AppRow = {
81
+ ...app,
82
+ workloads: app.workloads.map((workload, index) => index === 0 ? {
83
+ ...workload,
84
+ rollout: {
85
+ phase: 'progressing',
86
+ active: true,
87
+ manual: false,
88
+ label: 'Rolling out',
89
+ detail: '2/3 updated · 3 available',
90
+ desired: 3,
91
+ updated: 2,
92
+ ready: 3,
93
+ available: 3,
94
+ },
95
+ } : workload),
96
+ }
97
+ const html = renderDetail({ app: rolling })
98
+
99
+ expect(html).toContain('Rolling out')
100
+ expect(html).toContain('1 workload affected · 4/4 ready')
101
+ expect(html).toContain('2/3 updated · 3 available')
102
+ })
103
+
79
104
  it("preserves the built-in issue surface when the host slot is omitted", () => {
80
105
  const html = renderDetail({ renderOverviewIssues: undefined });
81
106
 
@@ -13,7 +13,7 @@ import {
13
13
  Boxes,
14
14
  ChevronDown,
15
15
  Clock3,
16
- DollarSign,
16
+ Coins,
17
17
  ExternalLink,
18
18
  GitCommit,
19
19
  Layers,
@@ -31,7 +31,7 @@ import { EmptyState } from "../ui/EmptyState";
31
31
  import { ResourceRefBadge } from "../ui/drawer-components";
32
32
  import { TopologyGraph } from "../topology/TopologyGraph";
33
33
  import { pluralize } from "../../utils/pluralize";
34
- import { kindToPlural, refToSelectedResource } from "../../utils/navigation";
34
+ import { kindToPluralWithGroup, refToSelectedResource } from "../../utils/navigation";
35
35
  import {
36
36
  batchRunParentNodes,
37
37
  tagWorkloadOwnership,
@@ -67,6 +67,8 @@ import {
67
67
  batchSignalForApp,
68
68
  batchActivityForApp,
69
69
  batchRuntimeForApp,
70
+ rolloutSummaryForApps,
71
+ rolloutDisplayHealth,
70
72
  applicationDisplayHealth,
71
73
  sourceReportedHealth,
72
74
  sourceSyncHealth,
@@ -80,7 +82,7 @@ import {
80
82
  } from "../../utils/applications";
81
83
  import { PaneLoader } from "../ui/PaneLoader";
82
84
  import { midTruncate } from "../../utils/format";
83
- import { VersionTooltip, AppIdentityTooltip } from "./AppTooltips";
85
+ import { VersionTooltip, AppIdentityTooltip, RolloutSummaryTooltip } from "./AppTooltips";
84
86
  import {
85
87
  ProvenanceBadge,
86
88
  ClassBadge,
@@ -359,6 +361,10 @@ export function ApplicationDetail({
359
361
  );
360
362
  const workloadClass = workloadClassOf(app.workload_class);
361
363
  const batchRuntime = batchRuntimeForApp(app);
364
+ const rolloutSummary = useMemo(
365
+ () => rolloutSummaryForApps([{ ...app, workloads }]),
366
+ [app, workloads],
367
+ );
362
368
  const overall = applicationDisplayHealth(app);
363
369
  const verdictTone = HEALTH_META[overall].pill;
364
370
  const verdictLabel =
@@ -545,11 +551,12 @@ export function ApplicationDetail({
545
551
  onSelectWorkloadRun(parentWorkload, node);
546
552
  return;
547
553
  }
554
+ const group = topologyGroup(node);
548
555
  onNavigateToResource?.({
549
- kind: kindToPlural(node.kind),
556
+ kind: kindToPluralWithGroup(node.kind, group ?? ""),
550
557
  namespace: ns,
551
558
  name: node.name,
552
- group: topologyGroup(node),
559
+ group,
553
560
  });
554
561
  },
555
562
  [
@@ -702,8 +709,16 @@ export function ApplicationDetail({
702
709
  {workloadClass === "job" ? (
703
710
  <ContextFact label="Runtime">{batchRuntime.label}</ContextFact>
704
711
  ) : desired > 0 ? (
705
- <ContextFact label="Ready">
706
- <ReadyBar ready={ready} desired={desired} width="w-16" />
712
+ <ContextFact label="Ready"><ReadyBar ready={ready} desired={desired} width="w-16" /></ContextFact>
713
+ ) : null}
714
+ {workloadClass !== "job" && rolloutSummary ? (
715
+ <ContextFact label="Runtime">
716
+ <Tooltip content={<RolloutSummaryTooltip summary={rolloutSummary} />} delay={150}>
717
+ <span className="inline-flex items-center gap-1.5">
718
+ <StatusDot tone={mapHealthToTone(rolloutSummary.health)} />
719
+ {rolloutSummary.label}
720
+ </span>
721
+ </Tooltip>
707
722
  </ContextFact>
708
723
  ) : null}
709
724
  {(app.appVersion || versions.length > 0) && (
@@ -988,7 +1003,7 @@ function ApplicationViewTabs({
988
1003
  : "border-transparent text-theme-text-secondary hover:border-theme-border-light hover:text-theme-text-primary",
989
1004
  )}
990
1005
  >
991
- <DollarSign className="h-4 w-4" />
1006
+ <Coins className="h-4 w-4" />
992
1007
  Cost
993
1008
  </button>
994
1009
  )}
@@ -1074,6 +1089,10 @@ function ApplicationOverview({
1074
1089
  app.runtimeHealth ??
1075
1090
  worstHealth(workloads.map((workload) => workload.health)),
1076
1091
  );
1092
+ const rolloutSummary = useMemo(
1093
+ () => rolloutSummaryForApps([{ ...app, workloads }]),
1094
+ [app, workloads],
1095
+ );
1077
1096
  const hasDeliveryStatus = Boolean(
1078
1097
  app.sourceStatus?.sync || app.sourceStatus?.health,
1079
1098
  );
@@ -1116,11 +1135,17 @@ function ApplicationOverview({
1116
1135
  value={
1117
1136
  pureBatch
1118
1137
  ? batchRuntimeForApp(app).label
1119
- : HEALTH_META[runtimeHealth].label
1138
+ : rolloutSummary
1139
+ ? runtimeHealth === 'healthy' || runtimeHealth === 'neutral'
1140
+ ? rolloutSummary.label
1141
+ : `${HEALTH_META[runtimeHealth].label} · ${rolloutSummary.label}`
1142
+ : HEALTH_META[runtimeHealth].label
1120
1143
  }
1121
1144
  detail={
1122
1145
  pureBatch
1123
1146
  ? batchStats.activeDetail
1147
+ : rolloutSummary
1148
+ ? `${rolloutSummary.count} workload${rolloutSummary.count === 1 ? "" : "s"} affected · ${ready}/${desired} ready`
1124
1149
  : desired > 0
1125
1150
  ? `${ready}/${desired} ready`
1126
1151
  : "No desired replicas"
@@ -2023,6 +2048,12 @@ function workloadRuntimeStatus(workload: AppWorkload): {
2023
2048
  label: string;
2024
2049
  health: AppHealth;
2025
2050
  } {
2051
+ if (workload.rollout && (workload.rollout.active || workload.rollout.phase === 'stalled')) {
2052
+ return {
2053
+ label: workload.rollout.label,
2054
+ health: rolloutDisplayHealth(workload.rollout, workload.health),
2055
+ }
2056
+ }
2026
2057
  const batch = workload.batch;
2027
2058
  if (!batch) {
2028
2059
  const health = healthOf(workload.health);
@@ -2039,6 +2070,7 @@ function workloadRuntimeStatus(workload: AppWorkload): {
2039
2070
  }
2040
2071
 
2041
2072
  function workloadRuntimeDetail(workload: AppWorkload): string {
2073
+ if (workload.rollout && (workload.rollout.active || workload.rollout.phase === 'stalled')) return workload.rollout.detail || `${workload.ready}/${workload.desired} ready`
2042
2074
  const batch = workload.batch;
2043
2075
  if (!batch) return `${workload.ready}/${workload.desired} ready`;
2044
2076
  if ((batch.activeRuns ?? 0) > 0)
@@ -39,14 +39,29 @@ import {
39
39
  foldAppGroups,
40
40
  batchSignalForApp,
41
41
  batchRuntimeForApp,
42
+ rolloutSummaryForApps,
42
43
  type FoldedRow,
43
44
  } from '../../utils/applications'
44
45
  import { ReadyBar } from './ReadyBar'
45
46
  import { ProvenanceBadge, ClassBadge, CategoryChip, VersionInfo, BatchSignalChip } from './AppChips'
46
- import { AppIdentityTooltip, EnvHint } from './AppTooltips'
47
+ import { AppIdentityTooltip, EnvHint, RolloutSummaryTooltip } from './AppTooltips'
47
48
 
48
49
  function ApplicationRuntimeCell({ apps, workloadClass, ready, desired }: { apps: AppRow[]; workloadClass: AppWorkloadClass; ready: number; desired: number }) {
49
- if (workloadClass !== 'job') return <ReadyBar ready={ready} desired={desired} />
50
+ if (workloadClass !== 'job') {
51
+ const rollout = rolloutSummaryForApps(apps)
52
+ if (!rollout) return <ReadyBar ready={ready} desired={desired} />
53
+ return (
54
+ <span className="inline-flex max-w-full items-center gap-3">
55
+ <ReadyBar ready={ready} desired={desired} />
56
+ <Tooltip content={<RolloutSummaryTooltip summary={rollout} />} delay={150}>
57
+ <span className="inline-flex min-w-0 items-center gap-1.5 text-xs text-theme-text-secondary">
58
+ <StatusDot tone={mapHealthToTone(rollout.health)} />
59
+ <span className="truncate">{rollout.label}</span>
60
+ </span>
61
+ </Tooltip>
62
+ </span>
63
+ )
64
+ }
50
65
  const runtimes = apps.map(batchRuntimeForApp)
51
66
  const worst = runtimes.reduce((current, runtime) => (HEALTH_RANK[runtime.health] ?? 0) > (HEALTH_RANK[current.health] ?? 0) ? runtime : current, runtimes[0])
52
67
  if (!worst) return <span className="text-xs text-theme-text-tertiary">Idle</span>
@@ -60,7 +75,7 @@ function ApplicationRuntimeCell({ apps, workloadClass, ready, desired }: { apps:
60
75
  }
61
76
 
62
77
  function applicationRuntimeLabel(apps: AppRow[], workloadClass: AppWorkloadClass, health: AppHealth): string {
63
- if (workloadClass !== 'job') return HEALTH_META[health].label
78
+ if (workloadClass !== 'job') return rolloutSummaryForApps(apps)?.detail || HEALTH_META[health].label
64
79
  const labels = Array.from(new Set(apps.map((app) => batchRuntimeForApp(app).label)))
65
80
  return labels.length === 1 ? labels[0] : 'Multiple batch states'
66
81
  }
@@ -6,7 +6,8 @@ export function ReadyBar({ ready, desired, width = 'w-12' }: { ready: number; de
6
6
  if (desired <= 0) {
7
7
  return <span className="font-mono text-xs tabular-nums text-theme-text-tertiary">—</span>
8
8
  }
9
- const pct = Math.min(100, Math.round((ready / desired) * 100))
9
+ const targetReady = Math.min(ready, desired)
10
+ const pct = Math.min(100, Math.round((targetReady / desired) * 100))
10
11
  const ok = ready >= desired
11
12
  // Text matches the bar's tier: amber for partial readiness, red only when
12
13
  // nothing is ready — partial must not read as fully down.
@@ -16,7 +17,7 @@ export function ReadyBar({ ready, desired, width = 'w-12' }: { ready: number; de
16
17
  <span className={`inline-block h-1.5 ${width} rounded-full bg-theme-hover`}>
17
18
  <span className={`block h-1.5 rounded-full ${tier.bar}`} style={{ width: `${pct}%` }} />
18
19
  </span>
19
- <span className={`font-mono text-xs tabular-nums ${ok ? 'text-theme-text-secondary' : tier.text}`}>{ready}/{desired || '—'}</span>
20
+ <span className={`font-mono text-xs tabular-nums ${ok ? 'text-theme-text-secondary' : tier.text}`}>{targetReady}/{desired || '—'}</span>
20
21
  </span>
21
22
  )
22
23
  }
@@ -1,11 +1,14 @@
1
1
  import { describe, it, expect } from 'vitest'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
2
3
  import {
3
4
  buildSingleAppEntry,
4
5
  searchTextForEntry,
5
6
  foldAppGroups,
6
7
  type AppRow,
7
8
  type SingleAppEntry,
9
+ rolloutSummaryForApps,
8
10
  } from '../../utils/applications'
11
+ import { ReadyBar } from './ReadyBar'
9
12
 
10
13
  // Parity tests for the pure core the shared ApplicationsView is built on:
11
14
  // buildSingleAppEntry (the extracted former buildEntry), the search-text helper,
@@ -116,6 +119,94 @@ describe('buildSingleAppEntry', () => {
116
119
  })
117
120
  })
118
121
 
122
+ describe('ReadyBar', () => {
123
+ it('caps target readiness during a surge', () => {
124
+ const html = renderToStaticMarkup(<ReadyBar ready={2} desired={1} />)
125
+ expect(html).toContain('1/1')
126
+ expect(html).not.toContain('2/1')
127
+ })
128
+ })
129
+
130
+ describe('rolloutSummaryForApps', () => {
131
+ it('surfaces a healthy surge rollout as activity rather than degradation', () => {
132
+ const summary = rolloutSummaryForApps([app({
133
+ workloads: [wl({
134
+ ready: 3,
135
+ desired: 3,
136
+ rollout: { phase: 'progressing', active: true, manual: false, label: 'Rolling out', detail: '2/3 updated · 3 available', desired: 3, updated: 2, ready: 3, available: 3 },
137
+ })],
138
+ })])
139
+
140
+ expect(summary).toMatchObject({ label: 'Rolling out', health: 'neutral', count: 1 })
141
+ expect(summary?.detail).toContain('2/3 updated')
142
+ })
143
+
144
+ it('keeps rollout copy while preserving a worse workload health tone', () => {
145
+ const summary = rolloutSummaryForApps([app({
146
+ workloads: [wl({
147
+ health: 'unhealthy',
148
+ rollout: { phase: 'progressing', active: true, manual: false, label: 'Rolling out', desired: 3, updated: 1, ready: 0, available: 0 },
149
+ })],
150
+ })])
151
+
152
+ expect(summary).toMatchObject({ label: 'Rolling out', health: 'unhealthy' })
153
+ })
154
+
155
+ it('escalates a stalled revision without changing stable app health data', () => {
156
+ const summary = rolloutSummaryForApps([app({
157
+ workloads: [wl({
158
+ rollout: { phase: 'stalled', active: false, manual: false, label: 'New revision cannot start', detail: 'ImagePullBackOff', desired: 1, updated: 0, ready: 1, available: 1 },
159
+ })],
160
+ })])
161
+
162
+ expect(summary).toMatchObject({ label: 'New revision cannot start', health: 'unhealthy' })
163
+ })
164
+
165
+ it('names stalled work before the other pending changes in a grouped app', () => {
166
+ const summary = rolloutSummaryForApps([app({
167
+ workloads: [
168
+ wl({ name: 'broken', rollout: { phase: 'stalled', active: false, manual: false, label: 'Rollout stalled', desired: 1, updated: 0, ready: 1, available: 1 } }),
169
+ wl({ name: 'manual', rollout: { phase: 'paused', active: true, manual: false, label: 'Rollout paused', desired: 1, updated: 0, ready: 1, available: 1 } }),
170
+ ],
171
+ })])
172
+
173
+ expect(summary).toMatchObject({ label: '1 stalled · 1 waiting', health: 'unhealthy', count: 2 })
174
+ })
175
+
176
+ it('does not report zero waiting workloads when every change is stalled', () => {
177
+ const stalled = { phase: 'stalled' as const, active: false, manual: false, label: 'Rollout stalled', desired: 1, updated: 0, ready: 1, available: 1 }
178
+ const summary = rolloutSummaryForApps([app({
179
+ workloads: [wl({ name: 'one', rollout: stalled }), wl({ name: 'two', rollout: stalled })],
180
+ })])
181
+
182
+ expect(summary?.label).toBe('2 stalled')
183
+ })
184
+
185
+ it('does not promote inactive rollout policies to app-level activity', () => {
186
+ const summary = rolloutSummaryForApps([app({
187
+ workloads: [
188
+ wl({ rollout: { phase: 'paused', active: false, manual: false, label: 'Rollout paused', desired: 1, updated: 1, ready: 0, available: 0 } }),
189
+ wl({ rollout: { phase: 'partition-reached', active: false, manual: false, label: 'Partition reached', desired: 3, updated: 2, ready: 2, available: 2 } }),
190
+ ],
191
+ })])
192
+
193
+ expect(summary).toBeNull()
194
+ })
195
+
196
+ it('caps grouped rollout details while preserving the full structured list', () => {
197
+ const summary = rolloutSummaryForApps([app({
198
+ workloads: Array.from({ length: 10 }, (_, index) => wl({
199
+ name: `worker-${index}`,
200
+ rollout: { phase: 'progressing', active: true, manual: false, label: 'Rolling out', desired: 1, updated: 0, ready: 1, available: 1 },
201
+ })),
202
+ })])
203
+
204
+ expect(summary).toMatchObject({ label: '10 updating', count: 10 })
205
+ expect(summary?.details).toHaveLength(10)
206
+ expect(summary?.detail).toContain('+2 more')
207
+ })
208
+ })
209
+
119
210
  describe('searchTextForEntry (single)', () => {
120
211
  it('matches on name, namespace, version, env, and workload kind', () => {
121
212
  const e = buildSingleAppEntry(
@@ -18,6 +18,9 @@ export interface ClusterSwitcherItem {
18
18
  /** Raw context / display string. ClusterName collapses GKE/EKS/AKS
19
19
  * shapes; user-named clusters pass through unchanged. */
20
20
  name: string
21
+ /** Short qualifier rendered after the parsed name without affecting
22
+ * provider detection, e.g. a source suffix for colliding contexts. */
23
+ nameQualifier?: string
21
24
  secondary?: string
22
25
  badge?: string
23
26
  /** Origin label, rendered as a folder-icon line under the name.
@@ -44,6 +47,8 @@ export interface ClusterSwitcherProps {
44
47
  /** Raw context / display string. Pass it as-is — the trigger renders
45
48
  * through ClusterName, which handles parse + provider badge + tooltip. */
46
49
  currentName: string
50
+ /** Trigger-side counterpart to {@link ClusterSwitcherItem.nameQualifier}. */
51
+ currentNameQualifier?: string
47
52
  /** Trigger-side counterpart to {@link ClusterSwitcherItem.sourceLabel}.
48
53
  * Only pass when 2+ kubeconfig sources are loaded. */
49
54
  currentSourceLabel?: string
@@ -80,6 +85,7 @@ const TRIGGER_NAME_MAX_WIDTH = 'max-w-[140px] sm:max-w-[220px] xl:max-w-[340px]'
80
85
  export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcherProps>(({
81
86
  currentId,
82
87
  currentName,
88
+ currentNameQualifier,
83
89
  currentSourceLabel,
84
90
  items,
85
91
  onSelect,
@@ -114,6 +120,7 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
114
120
  if (!q) return true
115
121
  return (
116
122
  item.name.toLowerCase().includes(q) ||
123
+ item.nameQualifier?.toLowerCase().includes(q) ||
117
124
  item.secondary?.toLowerCase().includes(q) ||
118
125
  item.badge?.toLowerCase().includes(q) ||
119
126
  item.sourceLabel?.toLowerCase().includes(q) ||
@@ -209,7 +216,7 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
209
216
  disabled={disabled || loading}
210
217
  className={
211
218
  variant === 'segment'
212
- ? `flex items-center gap-1.5 px-3 py-1.5 h-full min-w-[150px] max-w-[264px] text-[13px] font-medium
219
+ ? `flex items-center gap-1.5 px-3 py-1.5 h-full min-w-[150px] max-w-[264px] overflow-hidden text-[13px] font-medium
213
220
  text-theme-text-primary hover:bg-theme-hover transition-colors cursor-pointer
214
221
  disabled:opacity-50 disabled:cursor-not-allowed`
215
222
  : `flex items-center gap-1.5 px-2.5 py-1.5 min-w-[140px]
@@ -236,12 +243,25 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
236
243
  // context inline (per-row secondary line), and an extra hover
237
244
  // tooltip would just overlap the search input.
238
245
  <>
239
- <ClusterName
240
- name={currentName}
241
- fallbackBadge={<Server className="w-3.5 h-3.5 text-theme-text-secondary" />}
242
- className={variant === 'segment' ? 'min-w-0 max-w-[214px]' : TRIGGER_NAME_MAX_WIDTH}
243
- noTooltip={isOpen}
244
- />
246
+ {/* flex (not a plain span): ClusterName is inline-flex and would
247
+ otherwise baseline-align inside this wrapper's line box,
248
+ sitting a couple px above the segment's vertical center. */}
249
+ <span className={variant === 'segment' ? 'flex items-center min-w-0 flex-1' : 'flex items-center min-w-0'}>
250
+ <ClusterName
251
+ name={currentName}
252
+ fallbackBadge={<Server className="w-3.5 h-3.5 text-theme-text-secondary" />}
253
+ className={variant === 'segment' ? 'min-w-0 max-w-full' : TRIGGER_NAME_MAX_WIDTH}
254
+ noTooltip={isOpen}
255
+ />
256
+ </span>
257
+ {currentNameQualifier && (
258
+ <span
259
+ className="shrink-0 max-w-[80px] truncate text-xs font-normal text-theme-text-tertiary"
260
+ title={currentNameQualifier}
261
+ >
262
+ {currentNameQualifier}
263
+ </span>
264
+ )}
245
265
  {currentSourceLabel && (
246
266
  // Icon-only on the trigger: long folder paths (the very case
247
267
  // that motivates the chip) middle-truncate to something
@@ -358,6 +378,14 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
358
378
  : 'text-theme-text-primary'
359
379
  }`}
360
380
  />
381
+ {item.nameQualifier && (
382
+ <span
383
+ className="shrink-0 max-w-[120px] truncate text-xs font-normal text-theme-text-tertiary"
384
+ title={item.nameQualifier}
385
+ >
386
+ {item.nameQualifier}
387
+ </span>
388
+ )}
361
389
  {item.badge && (
362
390
  <span className="shrink-0 text-[10px] text-theme-text-tertiary bg-theme-elevated px-1 rounded">
363
391
  {item.badge}
@@ -4,7 +4,7 @@ import { CardBody, CardSection, ClusterName, EmptyState, KIND_CHIP_CLASS, Termin
4
4
  import { Tooltip } from '../ui/Tooltip';
5
5
  import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format';
6
6
  import { diagnosticRoleLabel, diagnosticFactLabel, confidenceTitle, incidentParentLabel } from './diagnostic';
7
- import { issueTiming } from './issue-timing';
7
+ import { issueFirstSeenTitle, issueResourceCreatedTitle, issueTimingForDisplay } from './issue-timing';
8
8
  import {
9
9
  ISSUE_SEVERITY_BADGE_CLASS,
10
10
  ISSUE_SEVERITY_HEADER_BAND_CLASS,
@@ -212,7 +212,9 @@ export function IssueRow({
212
212
  const severity = normalizeIssueSeverity(issue.severity);
213
213
  const SeverityIcon = ISSUE_SEVERITY_ICON[severity];
214
214
  const slotCtx = { issue, open };
215
- const timing = issueTiming(issue);
215
+ const partialUnknown = issue.onset_coverage?.unknown ?? 0;
216
+ const partialOnset = Boolean(issue.first_seen && partialUnknown > 0);
217
+ const timing = issueTimingForDisplay(issue);
216
218
 
217
219
  // Severity pill + age + timing chip. Rendered in two positions the container
218
220
  // query toggles: inline at the row's right edge on a wide container, and on a
@@ -224,28 +226,19 @@ export function IssueRow({
224
226
  {ISSUE_SEVERITY_LABEL[severity]}
225
227
  </span>
226
228
  {issue.first_seen ? (
227
- <>
228
- <Tooltip content={ageTitle(issue)} delay={200} wrapperClassName="shrink-0">
229
- <time
230
- dateTime={issue.first_seen}
231
- className="flex items-center gap-1 text-xs tabular-nums text-theme-text-tertiary"
232
- >
233
- <Clock className="h-3 w-3" aria-hidden />
234
- {formatCompactAge(issue.first_seen)}
235
- </time>
236
- </Tooltip>
237
- {timing ? (
238
- <Tooltip content={timing.tooltip} delay={200}>
239
- <span className="badge-sm text-[10px] text-theme-text-secondary">{timing.chip}</span>
240
- </Tooltip>
241
- ) : null}
242
- </>
243
- ) : issue.onset_unknown ? (
244
- <Tooltip content="Radar can confirm this issue is active, but current Kubernetes state does not reveal when it began." delay={200} wrapperClassName="shrink-0">
245
- <span className="flex items-center gap-1 text-xs text-theme-text-tertiary">
229
+ <Tooltip content={ageTitle(issue)} delay={200} wrapperClassName="shrink-0">
230
+ <time
231
+ dateTime={issue.first_seen}
232
+ className="flex items-center gap-1 text-xs tabular-nums text-theme-text-tertiary"
233
+ >
246
234
  <Clock className="h-3 w-3" aria-hidden />
247
- Onset unknown
248
- </span>
235
+ {partialOnset ? '≥' : ''}{formatCompactAge(issue.first_seen)}
236
+ </time>
237
+ </Tooltip>
238
+ ) : null}
239
+ {timing ? (
240
+ <Tooltip content={timing.tooltip} delay={200}>
241
+ <span className="badge-sm text-[10px] text-theme-text-secondary">{timing.chip}</span>
249
242
  </Tooltip>
250
243
  ) : null}
251
244
  </div>
@@ -437,7 +430,9 @@ function Diagnosis({ issue, source }: { issue: Issue; source?: IssueDiagnosisSou
437
430
  // present, else headline+detail) rather than a string that may never appear.
438
431
  const visibleMessage = [headline, detail].filter(Boolean).join(' ');
439
432
  const shownText = issue.cause ?? visibleMessage;
440
- const timing = issueTiming(issue);
433
+ const partialUnknown = issue.onset_coverage?.unknown ?? 0;
434
+ const partialOnset = Boolean(issue.first_seen && partialUnknown > 0);
435
+ const timing = issueTimingForDisplay(issue);
441
436
  const rawError = [
442
437
  ...new Set(
443
438
  [issue.raw_message ?? (issue.cause ? issue.message : undefined), detail].filter(
@@ -455,14 +450,24 @@ function Diagnosis({ issue, source }: { issue: Issue; source?: IssueDiagnosisSou
455
450
  if (issue.operation_retry_count) meta.push(`retried ${issue.operation_retry_count}×`);
456
451
  if (crash) meta.push(crash);
457
452
  if (timing) {
453
+ if (partialOnset && issue.first_seen) {
454
+ meta.push(`some signals active at least ${formatRelativeAgeTime(issue.first_seen)}`);
455
+ }
458
456
  meta.push(timing.meta);
457
+ if (!partialOnset && timing.kind === 'regression' && issue.first_seen) {
458
+ meta.push(`active at least ${formatRelativeAgeTime(issue.first_seen)}`);
459
+ }
460
+ } else if (partialOnset && issue.first_seen) {
461
+ const total = (issue.onset_coverage?.known ?? 0) + partialUnknown;
462
+ meta.push(`some signals active at least ${formatRelativeAgeTime(issue.first_seen)}; timing unknown for ${partialUnknown} of ${total} signals`);
459
463
  } else if (issue.first_seen) {
460
- meta.push(`started ${formatRelativeAgeTime(issue.first_seen)}`);
461
- } else if (issue.onset_unknown) {
462
- meta.push('onset unknown');
464
+ meta.push(`active at least ${formatRelativeAgeTime(issue.first_seen)}`);
465
+ }
466
+ if (issue.onset_unknown && !timing && !partialOnset) {
467
+ meta.push('exact onset unknown');
463
468
  }
464
469
  if (issue.first_seen) {
465
- if (issue.last_seen && timing?.kind !== 'creation') meta.push(`last seen ${formatRelativeAgeTime(issue.last_seen)}`);
470
+ if (issue.last_seen && (timing?.kind !== 'creation' || partialOnset)) meta.push(`last seen ${formatRelativeAgeTime(issue.last_seen)}`);
466
471
  }
467
472
  if (issue.change_context) meta.push(changeContextText(issue.change_context));
468
473
 
@@ -631,9 +636,13 @@ function DiagnosticContext({
631
636
  // freshness, the two facts the compact "2h" hides.
632
637
  function ageTitle(issue: Issue): string {
633
638
  const parts: string[] = [];
634
- const timing = issueTiming(issue);
635
- if (timing) parts.push(timing.tooltip);
636
- if (issue.first_seen) parts.push(`First seen ${new Date(issue.first_seen).toLocaleString()}`);
639
+ const timing = issueTimingForDisplay(issue);
640
+ const partialUnknown = issue.onset_coverage?.unknown ?? 0;
641
+ if (timing && partialUnknown === 0) parts.push(timing.tooltip);
642
+ const firstSeenTitle = issueFirstSeenTitle(issue);
643
+ if (firstSeenTitle) parts.push(firstSeenTitle);
644
+ const resourceContext = issueResourceCreatedTitle(issue);
645
+ if (resourceContext) parts.push(resourceContext);
637
646
  if (issue.last_seen) parts.push(`Last seen ${formatRelativeAgeTime(issue.last_seen)}`);
638
647
  return parts.join('\n');
639
648
  }
@@ -1,6 +1,6 @@
1
1
  import { useMemo, useState } from 'react'
2
2
  import { AlertTriangle } from 'lucide-react'
3
- import { kindToPlural } from '../../utils/navigation'
3
+ import { kindToPluralWithGroup } from '../../utils/navigation'
4
4
  import { IssueRow } from './IssuesView'
5
5
  import { compareIssues, subjectRef, type Issue, type IssueResourceRef } from './types'
6
6
 
@@ -56,7 +56,8 @@ export function sameResource(a: IssueResourceRef, b: IssueResourceRef): boolean
56
56
  return (
57
57
  a.name === b.name &&
58
58
  (a.namespace ?? '') === (b.namespace ?? '') &&
59
- kindToPlural(a.kind).toLowerCase() === kindToPlural(b.kind).toLowerCase()
59
+ kindToPluralWithGroup(a.kind, a.group ?? '').toLowerCase() ===
60
+ kindToPluralWithGroup(b.kind, b.group ?? '').toLowerCase()
60
61
  )
61
62
  }
62
63
 
@@ -5,7 +5,7 @@
5
5
  export { IssueRow, IssuesView } from './IssuesView';
6
6
  export type { IssueDiagnosisSource, IssueRowProps, IssueRowSlotContext, IssuesViewProps } from './IssuesView';
7
7
  export { ResourceIssuesSection } from './ResourceIssuesSection';
8
- export { issueTiming } from './issue-timing';
8
+ export { issueFirstSeenTitle, issueResourceCreatedTitle, issueTiming, issueTimingForDisplay } from './issue-timing';
9
9
  export type { IssueTimingDisplay, IssueTimingDisplayKind } from './issue-timing';
10
10
  export {
11
11
  ISSUE_SEVERITIES,
@@ -13,6 +13,8 @@ export {
13
13
  isIssueSeverity,
14
14
  subjectRef,
15
15
  memberRef,
16
+ issueSortAnchor,
17
+ compareIssueSortAnchors,
16
18
  } from './types';
17
19
  export type { Issue, IssueSeverity, IssueAffected, IssueResourceRef, IssueDiagnosticContext, IssueDiagnosticFact, IssueDiagnosticConfidence, IssueDiagnosticIssueRef, IssueDiagnosticRole, IssueIncidentParent, IssueChangeContext, IssueRecentChange, IssueRecentChangeField } from './types';
18
20
  export {