@skyhook-io/k8s-ui 1.8.10 → 1.8.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/package.json +5 -4
  2. package/src/assets/gitops/argocd.png +0 -0
  3. package/src/assets/gitops/flux.svg +7 -0
  4. package/src/components/applications/AppChips.tsx +10 -0
  5. package/src/components/applications/ApplicationDetail.test.tsx +569 -0
  6. package/src/components/applications/ApplicationDetail.tsx +2517 -450
  7. package/src/components/applications/ApplicationsView.tsx +62 -10
  8. package/src/components/applications/applications-view.test.tsx +47 -0
  9. package/src/components/applications/index.ts +1 -1
  10. package/src/components/audit/AuditAlerts.tsx +14 -10
  11. package/src/components/checks/ChecksView.tsx +2 -3
  12. package/src/components/checks/severity.ts +14 -10
  13. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
  14. package/src/components/compare/CompareResourcePicker.tsx +2 -2
  15. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  16. package/src/components/gitops/GitOpsTableView.tsx +3 -3
  17. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  18. package/src/components/gitops/SyncCountdown.tsx +26 -17
  19. package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
  20. package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
  21. package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
  22. package/src/components/gitops/insights/insights-helpers.ts +69 -1
  23. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
  24. package/src/components/issues/IssuesView.tsx +51 -38
  25. package/src/components/issues/ResourceIssuesSection.test.tsx +6 -5
  26. package/src/components/issues/ResourceIssuesSection.tsx +28 -146
  27. package/src/components/issues/index.ts +3 -0
  28. package/src/components/issues/issue-timing.ts +53 -0
  29. package/src/components/issues/issues.test.ts +102 -1
  30. package/src/components/issues/severity.ts +2 -0
  31. package/src/components/logs/LogCore.tsx +14 -4
  32. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  33. package/src/components/logs/useLogStream.ts +8 -1
  34. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  35. package/src/components/resources/ResourcesSidebar.test.tsx +71 -0
  36. package/src/components/resources/ResourcesSidebar.tsx +46 -46
  37. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  38. package/src/components/resources/ResourcesView.tsx +233 -51
  39. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  40. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  41. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  42. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  43. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  44. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  45. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  46. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  47. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  48. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  49. package/src/components/resources/renderers/index.ts +1 -0
  50. package/src/components/resources/resource-utils-capi.ts +1 -1
  51. package/src/components/resources/resource-utils.ts +31 -0
  52. package/src/components/shared/DetailShell.tsx +2 -2
  53. package/src/components/shared/ResourceActionsBar.tsx +140 -108
  54. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  55. package/src/components/timeline/TimelineList.tsx +371 -354
  56. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  57. package/src/components/timeline/TimelineStrip.tsx +836 -0
  58. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  59. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  60. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  61. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  62. package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
  63. package/src/components/timeline/index.ts +23 -0
  64. package/src/components/timeline/scrubber-math.test.ts +235 -0
  65. package/src/components/timeline/scrubber-math.ts +249 -0
  66. package/src/components/timeline/shared.test.tsx +219 -0
  67. package/src/components/timeline/shared.tsx +232 -447
  68. package/src/components/timeline/timeline-filters.test.ts +196 -0
  69. package/src/components/timeline/timeline-filters.ts +180 -0
  70. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  71. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  72. package/src/components/timeline/timeline-live.test.ts +131 -0
  73. package/src/components/timeline/timeline-live.ts +77 -0
  74. package/src/components/topology/GroupNode.tsx +9 -2
  75. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  76. package/src/components/topology/K8sResourceNode.tsx +387 -243
  77. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  78. package/src/components/topology/TopologyGraph.tsx +89 -16
  79. package/src/components/topology/TopologySearch.tsx +2 -2
  80. package/src/components/topology/layout.ts +10 -2
  81. package/src/components/topology/topology.css +0 -92
  82. package/src/components/ui/Badge.tsx +5 -0
  83. package/src/components/ui/CodeViewer.tsx +2 -2
  84. package/src/components/ui/Collapse.tsx +59 -0
  85. package/src/components/ui/ConfirmDialog.tsx +7 -3
  86. package/src/components/ui/DialogPortal.tsx +31 -9
  87. package/src/components/ui/Input.tsx +24 -0
  88. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  89. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  90. package/src/components/ui/PaneLoader.tsx +10 -2
  91. package/src/components/ui/ResourceBar.tsx +68 -25
  92. package/src/components/ui/SearchBox.tsx +18 -4
  93. package/src/components/ui/SearchPillInput.tsx +2 -2
  94. package/src/components/ui/drawer-components.tsx +39 -26
  95. package/src/components/ui/index.ts +4 -0
  96. package/src/components/ui/severity-tone.ts +11 -4
  97. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  98. package/src/components/workload/WorkloadView.tsx +2682 -472
  99. package/src/components/workload/index.ts +1 -1
  100. package/src/theme/variables.css +1 -1
  101. package/src/types/core.ts +57 -4
  102. package/src/utils/application-topology.test.ts +198 -0
  103. package/src/utils/application-topology.ts +261 -0
  104. package/src/utils/applications.test.ts +167 -1
  105. package/src/utils/applications.ts +1157 -393
  106. package/src/utils/index.ts +2 -0
  107. package/src/utils/navigation.test.ts +32 -1
  108. package/src/utils/navigation.ts +70 -0
  109. package/src/utils/resource-hierarchy.test.ts +990 -1
  110. package/src/utils/resource-hierarchy.ts +905 -267
  111. package/src/utils/resource-icons.ts +2 -0
  112. package/src/utils/topology-neighborhood.test.ts +202 -1
  113. package/src/utils/topology-neighborhood.ts +194 -13
  114. package/src/utils/workflow-execution.test.ts +556 -0
  115. package/src/utils/workflow-execution.ts +814 -0
  116. package/src/utils/workload-colors.ts +2 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.8.10",
3
+ "version": "1.8.11",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -61,14 +61,15 @@
61
61
  "./assets/radar/radar-icon-loading.svg": "./src/assets/radar/radar-icon-loading.svg"
62
62
  },
63
63
  "scripts": {
64
- "tsc": "tsc --noEmit",
64
+ "tsc": "node ../../node_modules/typescript-7/bin/tsc --noEmit",
65
+ "tsc:ts6": "tsc --noEmit",
65
66
  "test": "vitest run",
66
67
  "format": "prettier --write \"src/**/*.{ts,tsx}\""
67
68
  },
68
69
  "dependencies": {
69
70
  "@monaco-editor/react": "^4.7.0",
70
71
  "html-to-image": "^1.11.0",
71
- "react-virtuoso": "^4.18.7",
72
+ "react-virtuoso": "^4.18.10",
72
73
  "shiki": "^4.2.0"
73
74
  },
74
75
  "peerDependencies": {
@@ -95,7 +96,7 @@
95
96
  "clsx": "^2.1.1",
96
97
  "diff": "^9.0.0",
97
98
  "elkjs": "^0.11.1",
98
- "lucide-react": "^1.22.0",
99
+ "lucide-react": "^1.23.0",
99
100
  "react": "^19.2.7",
100
101
  "react-dom": "^19.2.7",
101
102
  "typescript": "^6.0.2",
Binary file
@@ -0,0 +1,7 @@
1
+ <svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
2
+ <g transform="translate(11 2)" fill="none" fill-rule="evenodd">
3
+ <path fill="#326CE5" d="M.803 15.779c-1.049-.682-1.049-2.219 0-2.901L20.182.28a1.73 1.73 0 0 1 1.886 0l19.379 12.599c1.05.682 1.05 2.218 0 2.901L22.068 28.378a1.73 1.73 0 0 1-1.886 0L.803 15.779Z"/>
4
+ <path fill="#C1D2F7" d="M24.185 18.002h1.344a1.02 1.02 0 0 0 .884-1.529l-4.405-7.629a1.02 1.02 0 0 0-1.766 0l-4.404 7.629a1.02 1.02 0 0 0 .883 1.529h1.345c.563 0 1.019.457 1.019 1.02v8.708l.902.587a2.08 2.08 0 0 0 2.276 0l.902-.587v-8.708c0-.563.456-1.02 1.02-1.02Z"/>
5
+ <path fill="#326CE5" d="m27.839 34.838-4.674-3.039v1.44c1.525.653 3.094 1.162 4.674 1.599Zm-4.674.99v2.022c1.545.471 3.107.858 4.654 1.239 5.052 1.246 9.823 2.421 13.659 6.259.195.194.378.392.559.592.394-.752.197-1.744-.59-2.255l-7.591-4.936c-1.843-.608-3.72-1.073-5.575-1.53-1.75-.432-3.461-.862-5.116-1.391Zm-4.08-1.658a22.93 22.93 0 0 1-1.922-1.121l-1.736 1.128a20.12 20.12 0 0 0 3.658 2.145V34.17Zm5.809 6.435c-.58-.143-1.157-.285-1.729-.431v1.987c.424.106.847.211 1.268.315 5.051 1.245 9.822 2.421 13.659 6.258l.019.02 1.641-1.067c-.1-.105-.194-.212-.298-.315-4.216-4.217-9.475-5.513-14.56-6.767Zm-5.809-1.614c-2.195-.752-4.276-1.72-6.174-3.178l-1.699 1.105c2.416 1.993 5.107 3.202 7.873 4.099v-2.026Zm0 4.39c-3.778-.99-7.332-2.199-10.367-4.843l-1.671 1.087c3.566 3.285 7.807 4.658 12.038 5.746v-1.99Zm4.08 3.013c3.953 1.02 7.67 2.24 10.817 5.044l1.662-1.08c-3.679-3.458-8.093-4.839-12.479-5.954v1.99ZM4.579 41.23 2.93 42.302c.058.06.11.121.17.18 4.216 4.217 9.474 5.513 14.56 6.767 4.404 1.085 8.594 2.12 12.139 4.91l1.69-1.1c-3.993-3.362-8.75-4.543-13.367-5.68-5.001-1.233-9.726-2.403-13.543-6.149Zm-3.503 3.276a14.75 14.75 0 0 1-.552-.584c-.752.753-.663 2.05.28 2.663l5.706 3.71c2.525 1.046 5.166 1.7 7.764 2.34 4.054.999 7.927 1.957 11.284 4.281l1.725-1.123c-3.815-2.869-8.242-3.968-12.548-5.029-5.051-1.245-9.822-2.421-13.659-6.258Zm18.568 14.328c.38.185.756.38 1.125.588.44.093.908.016 1.299-.238l.958-.623c-3.077-2.058-6.498-3.1-9.906-3.968l6.524 4.241Z"/>
6
+ </g>
7
+ </svg>
@@ -2,6 +2,7 @@ import { Tooltip } from '../ui/Tooltip'
2
2
  import {
3
3
  type AppRow,
4
4
  type AppWorkloadClass,
5
+ type BatchSignal,
5
6
  CHIP,
6
7
  CHIP_TONE,
7
8
  CLASS_META,
@@ -69,6 +70,15 @@ export function CategoryChip({ category, addonReason }: { category?: string; add
69
70
  )
70
71
  }
71
72
 
73
+ export function BatchSignalChip({ signal }: { signal: BatchSignal | null }) {
74
+ if (!signal) return null
75
+ return (
76
+ <Tooltip content={signal.detail} delay={150}>
77
+ <span className={`${CHIP} ${CHIP_TONE[signal.tone]}`}>{signal.label}</span>
78
+ </Tooltip>
79
+ )
80
+ }
81
+
72
82
  /** The version display: appVersion when the workloads agree on one upstream
73
83
  * version, the single image tag, or "N versions" (amber only on real skew).
74
84
  * `cell` = table chip styling, `fact` = the detail context strip. */
@@ -0,0 +1,569 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { ApplicationDetail, type ApplicationDetailProps } from './ApplicationDetail'
4
+ import type { AppHistory, AppRow, AppSourceRef } from '../../utils/applications'
5
+ import { workloadKey } from '../../utils/topology-neighborhood'
6
+
7
+ const app: AppRow = {
8
+ key: "app:prod:checkout",
9
+ name: "checkout",
10
+ namespace: "prod",
11
+ health: "healthy",
12
+ workload_class: "mixed",
13
+ appVersion: "1.2.3",
14
+ workloads: [
15
+ {
16
+ kind: "Deployment",
17
+ namespace: "prod",
18
+ name: "checkout-api",
19
+ workload_class: "service",
20
+ health: "healthy",
21
+ ready: 3,
22
+ desired: 3,
23
+ restarts: 0,
24
+ version: "1.2.3",
25
+ },
26
+ {
27
+ kind: "Deployment",
28
+ namespace: "prod",
29
+ name: "checkout-worker",
30
+ workload_class: "worker",
31
+ health: "healthy",
32
+ ready: 1,
33
+ desired: 1,
34
+ restarts: 0,
35
+ version: "1.2.3",
36
+ },
37
+ ],
38
+ relationships: {
39
+ services: ["checkout-api"],
40
+ ingresses: ["checkout"],
41
+ configs: 2,
42
+ scalers: 1,
43
+ storage: 1,
44
+ networkPolicies: 1,
45
+ serviceRefs: [{ kind: 'Service', namespace: 'prod', name: 'checkout-api' }],
46
+ ingressRefs: [{ kind: 'Ingress', namespace: 'prod', name: 'checkout' }],
47
+ configRefs: [
48
+ { kind: 'ConfigMap', namespace: 'prod', name: 'checkout-config' },
49
+ { kind: 'Secret', namespace: 'prod', name: 'checkout-secret' },
50
+ ],
51
+ scalerRefs: [{ kind: 'HorizontalPodAutoscaler', namespace: 'prod', name: 'checkout-api' }],
52
+ storageRefs: [{ kind: 'PersistentVolumeClaim', namespace: 'prod', name: 'checkout-data' }],
53
+ networkPolicyRefs: [{ kind: 'NetworkPolicy', namespace: 'prod', name: 'checkout-ingress' }],
54
+ },
55
+ events: [
56
+ {
57
+ type: "Warning",
58
+ reason: "BackOff",
59
+ object: "Deployment/checkout-api",
60
+ message: "Back-off restarting failed container",
61
+ count: 2,
62
+ lastSeen: "1m ago",
63
+ },
64
+ ],
65
+ };
66
+
67
+ function renderDetail(props: Partial<ApplicationDetailProps> = {}) {
68
+ const componentProps = {
69
+ app,
70
+ onBack: () => {},
71
+ renderWorkload: (workload) => <div>Runtime for {workload.name}</div>,
72
+ renderOverviewIssues: () => null,
73
+ ...props,
74
+ } as ApplicationDetailProps;
75
+ return renderToString(<ApplicationDetail {...componentProps} />);
76
+ }
77
+
78
+ describe("ApplicationDetail shell", () => {
79
+ it("preserves the built-in issue surface when the host slot is omitted", () => {
80
+ const html = renderDetail({ renderOverviewIssues: undefined });
81
+
82
+ expect(html).toContain('Application views')
83
+ expect(html).toContain('Overview')
84
+ expect(html).toContain('Topology')
85
+ expect(html).toContain('History')
86
+ expect(html).toContain('BackOff on checkout-api')
87
+ expect(html).toContain('Application identity')
88
+ expect(html).toContain('Grouped from Kubernetes ownership')
89
+ expect(html).toContain('Entrypoints')
90
+ expect(html).toContain('Dependencies')
91
+ expect(html).toContain('svc<!-- -->/</span>checkout-api')
92
+ expect(html).toContain('ing<!-- -->/</span>checkout')
93
+ expect(html).toContain('Configuration')
94
+ expect(html).toContain('Autoscaling')
95
+ expect(html).toContain('Storage')
96
+ expect(html).toContain('Network policy')
97
+ expect(html).not.toContain('Related resources')
98
+ expect(html).toContain('Workloads')
99
+ expect(html).not.toContain('Runtime for')
100
+ expect(html).not.toContain('Components')
101
+ expect(html).not.toContain('Changes')
102
+ expect(html).not.toContain('YAML')
103
+ expect(html).not.toContain('Cost')
104
+ expect(html).not.toContain('>Deploy<')
105
+ })
106
+
107
+ it("defaults to the application overview scope", () => {
108
+ const html = renderDetail();
109
+
110
+ expect(html).toContain("Application views");
111
+ expect(html).toContain("Overview");
112
+ expect(html).toContain("Topology");
113
+ expect(html).toContain("History");
114
+ expect(html).not.toContain("BackOff on checkout-api");
115
+ expect(html).toContain("Application identity");
116
+ expect(html).toContain("Entrypoints");
117
+ expect(html).toContain("Dependencies");
118
+ expect(html).toContain("svc<!-- -->/</span>checkout-api");
119
+ expect(html).toContain("ing<!-- -->/</span>checkout");
120
+ expect(html).toContain("Configuration");
121
+ expect(html).toContain("Autoscaling");
122
+ expect(html).toContain("Storage");
123
+ expect(html).not.toContain("Related resources");
124
+ expect(html).toContain("Workloads");
125
+ expect(html).not.toContain("Runtime for");
126
+ expect(html).not.toContain("Components");
127
+ expect(html).not.toContain("Changes");
128
+ expect(html).not.toContain("YAML");
129
+ expect(html).not.toContain("Cost");
130
+ expect(html).not.toContain(">Deploy<");
131
+ });
132
+
133
+ it("shows workload scope without application view tabs when the URL names a workload", () => {
134
+ const selected = app.workloads[1];
135
+ const html = renderDetail({
136
+ selectedView: "overview",
137
+ onSelectView: () => {},
138
+ selectedWorkloadKey: workloadKey(selected),
139
+ onSelectWorkload: () => {},
140
+ });
141
+
142
+ expect(html).toContain('Runtime for')
143
+ expect(html).toContain('checkout-worker')
144
+ expect(html).not.toContain('Application views')
145
+ expect(html).not.toContain('Overview')
146
+ expect(html).not.toContain('Topology')
147
+ expect(html).not.toContain('History')
148
+ expect(html).not.toContain('Application identity')
149
+ })
150
+
151
+ it("does not render a workload selector for a single-workload app", () => {
152
+ const singleApp: AppRow = {
153
+ ...app,
154
+ workloads: [app.workloads[0]],
155
+ };
156
+ const selected = singleApp.workloads[0];
157
+ const html = renderDetail({
158
+ app: singleApp,
159
+ selectedWorkloadKey: workloadKey(selected),
160
+ onSelectWorkload: () => {},
161
+ });
162
+
163
+ expect(html).toContain("Runtime for");
164
+ expect(html).toContain("checkout-api");
165
+ expect(html).toContain("Workload");
166
+ expect(html).toContain("Deployment<!-- -->/<!-- -->checkout-api");
167
+ expect(html).not.toContain('aria-haspopup="listbox"');
168
+ expect(html).not.toContain("Application views");
169
+ });
170
+
171
+ it("opens the workload directly for uncontrolled single-workload apps", () => {
172
+ const singleApp: AppRow = {
173
+ ...app,
174
+ workloads: [app.workloads[0]],
175
+ };
176
+ const html = renderDetail({ app: singleApp });
177
+
178
+ expect(html).toContain("Runtime for");
179
+ expect(html).toContain("checkout-api");
180
+ expect(html).not.toContain("Application views");
181
+ });
182
+
183
+ it("renders application incidents in History", () => {
184
+ const html = renderDetail({
185
+ selectedView: "history",
186
+ onSelectView: () => {},
187
+ selectedWorkloadKey: null,
188
+ onSelectWorkload: () => {},
189
+ });
190
+
191
+ expect(html).toContain("Current incidents");
192
+ expect(html).toContain("BackOff on Deployment/checkout-api");
193
+ expect(html).toContain("Deployment/checkout-api");
194
+ expect(html).toContain("Open workload");
195
+ });
196
+
197
+ it("previews the latest retained deployment change in Overview", () => {
198
+ const healthyApp: AppRow = { ...app, events: [] };
199
+ const history: AppHistory = {
200
+ appKey: healthyApp.key,
201
+ sourceRef: {
202
+ type: "gitops",
203
+ tool: "argocd",
204
+ group: "argoproj.io",
205
+ kind: "Application",
206
+ namespace: "argocd",
207
+ name: "checkout",
208
+ },
209
+ summary: {
210
+ state: "change",
211
+ title: "Argo CD sync",
212
+ detail: "Succeeded · abc123",
213
+ timestamp: "2026-07-08T12:00:00Z",
214
+ },
215
+ anchors: [
216
+ {
217
+ type: "gitops",
218
+ title: "Argo CD sync",
219
+ status: "Succeeded",
220
+ revision: "abc123",
221
+ timestamp: "2026-07-08T12:00:00Z",
222
+ },
223
+ ],
224
+ };
225
+ const html = renderDetail({
226
+ app: healthyApp,
227
+ history,
228
+ onOpenSource: () => {},
229
+ });
230
+
231
+ expect(html).toContain('Latest change')
232
+ expect(html).toContain('Argo CD sync')
233
+ expect(html).toContain('History')
234
+ expect(html).toContain('View Argo CD application')
235
+ expect(html).not.toContain('No application issues detected')
236
+ })
237
+
238
+ it('shows an authoritative deployment source without duplicating its action in history', () => {
239
+ const sourceRef: AppSourceRef = { type: 'gitops', tool: 'flux', group: 'kustomize.toolkit.fluxcd.io', kind: 'Kustomization', namespace: 'flux-system', name: 'checkout' }
240
+ const sourceApp: AppRow = { ...app, events: [], sourceRef }
241
+ const history: AppHistory = {
242
+ appKey: sourceApp.key,
243
+ sourceRef,
244
+ summary: { state: 'change', title: 'Flux reconciliation', timestamp: '2026-07-08T12:00:00Z' },
245
+ }
246
+ const html = renderDetail({ app: sourceApp, history, onOpenSource: () => {} })
247
+
248
+ expect(html).toContain('Deployment source')
249
+ expect(html).toContain('Flux Kustomization')
250
+ expect(html).toContain('flux-system/checkout')
251
+ expect(html.match(/View Flux Kustomization/g)).toHaveLength(1)
252
+ expect(html).not.toContain('App key')
253
+ expect(html).not.toContain('Confidence')
254
+ expect(html).not.toContain('Source &amp; provenance')
255
+ })
256
+
257
+ it('separates healthy runtime from degraded delivery and rolls up the application status', () => {
258
+ const sourceRef: AppSourceRef = { type: 'gitops', tool: 'argocd', group: 'argoproj.io', kind: 'Application', namespace: 'argocd', name: 'checkout' }
259
+ const html = renderDetail({
260
+ app: {
261
+ ...app,
262
+ health: 'degraded',
263
+ runtimeHealth: 'healthy',
264
+ sourceRef,
265
+ sourceStatus: { sync: 'Synced', health: 'Degraded' },
266
+ },
267
+ onOpenSource: () => {},
268
+ })
269
+
270
+ expect(html).toContain('Degraded')
271
+ expect(html).toContain('Runtime')
272
+ expect(html).toContain('Healthy')
273
+ expect(html).toContain('4/4 ready')
274
+ expect(html).toContain('Delivery')
275
+ expect(html.match(/Synced/g)).toHaveLength(2)
276
+ expect((html.match(/Degraded/g) ?? []).length).toBeGreaterThanOrEqual(3)
277
+ })
278
+
279
+ it('warns when application workloads resolve to different deployment sources', () => {
280
+ const html = renderDetail({ app: { ...app, sourceConflict: true } })
281
+
282
+ expect(html).toContain('Deployment source')
283
+ expect(html).toContain('Multiple deployment sources detected')
284
+ expect(html).toContain('do not share one deployment manager')
285
+ })
286
+
287
+ it('distinguishes inferred application identity from declared identity', () => {
288
+ const inferred = renderDetail({
289
+ app: {
290
+ ...app,
291
+ identity: { key: 'checkout', env: 'prod', confidence: 'medium', evidence: 'name stem checkout + shared image repo', source: 'name-stem' },
292
+ },
293
+ })
294
+ expect(inferred).toContain('Application identity')
295
+ expect(inferred).toContain('Inferred application boundary')
296
+ expect(inferred).toContain('a shared name + image')
297
+
298
+ const declared = renderDetail({
299
+ app: {
300
+ ...app,
301
+ identity: { key: 'checkout', env: 'prod', confidence: 'high', evidence: 'app.skyhook.io/app=checkout', source: 'explicit' },
302
+ },
303
+ })
304
+ expect(declared).toContain('Identified by the app.skyhook.io/app annotation')
305
+ expect(declared).not.toContain('Inferred application boundary')
306
+ })
307
+
308
+ it('uses tier provenance when a grouped app has no cross-app identity', () => {
309
+ const html = renderDetail({ app: { ...app, tier: 5 } })
310
+
311
+ expect(html).toContain('Application identity')
312
+ expect(html).toContain('Grouped by Helm metadata')
313
+ expect(html).not.toContain('Grouped from Kubernetes ownership')
314
+ })
315
+
316
+ it('does not present completed init containers as a long-running workload state', () => {
317
+ const statefulApp: AppRow = {
318
+ ...app,
319
+ workloads: [
320
+ { ...app.workloads[0], kind: 'StatefulSet', name: 'redis-master', reason: 'Completed' },
321
+ { ...app.workloads[1], kind: 'StatefulSet', name: 'redis-replicas', reason: 'Completed' },
322
+ ],
323
+ }
324
+ const jobApp: AppRow = {
325
+ ...app,
326
+ workloads: [
327
+ { ...app.workloads[0], kind: 'Job', name: 'database-migration', workload_class: 'job', reason: 'Completed' },
328
+ { ...app.workloads[1], kind: 'CronJob', name: 'database-migration-schedule', workload_class: 'job' },
329
+ ],
330
+ }
331
+
332
+ expect(renderDetail({ app: statefulApp })).not.toMatch(/redis-master<\/button><div[^>]*>Completed/)
333
+ expect(renderDetail({ app: jobApp })).toMatch(/database-migration<\/button><div[^>]*>Completed/)
334
+ })
335
+
336
+ it("does not duplicate current incidents in the Overview history preview", () => {
337
+ const history: AppHistory = {
338
+ appKey: app.key,
339
+ summary: {
340
+ state: "incident",
341
+ title: "Current incident: BackOff on Deployment/checkout-api",
342
+ detail: "Back-off restarting failed container",
343
+ },
344
+ incidents: [
345
+ {
346
+ severity: "warning",
347
+ title: "BackOff on Deployment/checkout-api",
348
+ object: "Deployment/checkout-api",
349
+ },
350
+ ],
351
+ };
352
+ const html = renderDetail({ history });
353
+
354
+ expect(html).not.toContain("BackOff on checkout-api");
355
+ expect(html).not.toContain(
356
+ "Current incident: BackOff on Deployment/checkout-api",
357
+ );
358
+ expect(html).not.toContain("Latest change");
359
+ });
360
+
361
+ it('previews retained incidents in Overview when no current issue card exists', () => {
362
+ const healthyApp: AppRow = { ...app, events: [] }
363
+ const history: AppHistory = {
364
+ appKey: healthyApp.key,
365
+ summary: { state: 'incident', title: 'Current incident: FailedScheduling on Pod/checkout-api-abc', detail: '0/9 nodes are available' },
366
+ incidents: [{ severity: 'warning', title: 'FailedScheduling on Pod/checkout-api-abc', object: 'Pod/checkout-api-abc' }],
367
+ }
368
+ const html = renderDetail({
369
+ app: healthyApp,
370
+ history,
371
+ })
372
+
373
+ expect(html).toContain('Latest incident')
374
+ expect(html).toContain('Current incident: FailedScheduling on Pod/checkout-api-abc')
375
+ expect(html).toContain('View history')
376
+ })
377
+
378
+ it('does not duplicate retained incidents when the host renders current issues', () => {
379
+ const healthyApp: AppRow = { ...app, events: [] }
380
+ const history: AppHistory = {
381
+ appKey: healthyApp.key,
382
+ summary: { state: 'incident', title: 'Current incident: FailedScheduling on Pod/checkout-api-abc' },
383
+ }
384
+ const html = renderDetail({
385
+ app: healthyApp,
386
+ history,
387
+ renderOverviewIssues: () => <div>Operational Issues (1)</div>,
388
+ hasOverviewIssues: true,
389
+ })
390
+
391
+ expect(html).toContain('Operational Issues (1)')
392
+ expect(html).not.toContain('Latest incident')
393
+ expect(html).not.toContain('Current incident: FailedScheduling on Pod/checkout-api-abc')
394
+ })
395
+
396
+ it('does not report idle zero-replica workloads as application issues', () => {
397
+ const idleApp: AppRow = {
398
+ ...app,
399
+ health: "healthy",
400
+ events: [],
401
+ workloads: [
402
+ {
403
+ kind: "CronJob",
404
+ namespace: "prod",
405
+ name: "checkout-cleanup",
406
+ workload_class: "job",
407
+ health: "neutral",
408
+ ready: 0,
409
+ desired: 0,
410
+ restarts: 0,
411
+ version: "1.2.3",
412
+ },
413
+ {
414
+ kind: "Job",
415
+ namespace: "prod",
416
+ name: "checkout-cleanup-123",
417
+ workload_class: "job",
418
+ health: "unknown",
419
+ ready: 0,
420
+ desired: 0,
421
+ restarts: 0,
422
+ version: "1.2.3",
423
+ },
424
+ ],
425
+ };
426
+ const html = renderDetail({ app: idleApp });
427
+
428
+ expect(html).not.toContain('No application issues detected')
429
+ expect(html).not.toContain('needs attention')
430
+ })
431
+
432
+ it("does not link ambiguous event objects to a workload", () => {
433
+ const ambiguousApp: AppRow = {
434
+ ...app,
435
+ workloads: [
436
+ ...app.workloads,
437
+ {
438
+ kind: "Deployment",
439
+ namespace: "canary",
440
+ name: "checkout-api",
441
+ workload_class: "service",
442
+ health: "healthy",
443
+ ready: 1,
444
+ desired: 1,
445
+ restarts: 0,
446
+ version: "1.2.3",
447
+ },
448
+ ],
449
+ };
450
+ const html = renderDetail({
451
+ app: ambiguousApp,
452
+ selectedView: "history",
453
+ onSelectView: () => {},
454
+ selectedWorkloadKey: null,
455
+ onSelectWorkload: () => {},
456
+ });
457
+
458
+ expect(html).toContain("Deployment/checkout-api");
459
+ expect(html).not.toContain("Open workload");
460
+ });
461
+
462
+ it("shows batch activity and injected operational issues on the application overview", () => {
463
+ const batchApp: AppRow = {
464
+ key: "app:prod:batch",
465
+ name: "batch",
466
+ namespace: "prod",
467
+ health: "unhealthy",
468
+ workload_class: "job",
469
+ workloads: [
470
+ {
471
+ kind: "CronJob",
472
+ namespace: "prod",
473
+ name: "nightly",
474
+ workload_class: "job",
475
+ health: "healthy",
476
+ ready: 0,
477
+ desired: 0,
478
+ restarts: 0,
479
+ batch: {
480
+ schedule: "0 3 * * *",
481
+ retainedRuns: 3,
482
+ succeededRuns: 3,
483
+ failedRuns: 0,
484
+ activeRuns: 0,
485
+ latestRunName: "nightly-29720340",
486
+ latestRunPhase: "Succeeded",
487
+ latestStartedAt: "2026-07-05T10:25:00Z",
488
+ lastSuccessfulAt: "2026-07-05T10:29:00Z",
489
+ },
490
+ },
491
+ {
492
+ kind: "Job",
493
+ namespace: "prod",
494
+ name: "ad-hoc-import",
495
+ workload_class: "job",
496
+ health: "unhealthy",
497
+ ready: 0,
498
+ desired: 1,
499
+ restarts: 0,
500
+ batch: {
501
+ retainedRuns: 1,
502
+ succeededRuns: 0,
503
+ failedRuns: 1,
504
+ activeRuns: 0,
505
+ latestRunName: "ad-hoc-import",
506
+ latestRunPhase: "Failed",
507
+ latestStartedAt: "2026-07-05T09:25:00Z",
508
+ message: "Job has reached the specified backoff limit",
509
+ },
510
+ },
511
+ ],
512
+ };
513
+ const html = renderDetail({
514
+ app: batchApp,
515
+ renderOverviewIssues: () => <div>Operational Issues (1)</div>,
516
+ });
517
+
518
+ expect(html).toContain("Application views");
519
+ expect(html).toContain("Operational Issues (1)");
520
+ expect(html).not.toContain("Batch activity");
521
+ const workloadsPanel = html.indexOf(">Workloads</h2>");
522
+ expect(workloadsPanel).toBeGreaterThanOrEqual(0);
523
+ expect(html).toContain("Batch resources");
524
+ expect(html).toContain("Active runs");
525
+ expect(html).toContain("Retained runs");
526
+ expect(html).toContain("1 CronJob / 1 Job");
527
+ expect(html).toContain("3 succeeded / 1 failed");
528
+ expect(html).toContain("nightly-29720340");
529
+ expect(html).toContain("ad-hoc-import");
530
+ expect(html).not.toContain("ad-hoc-import is down");
531
+ expect(html).not.toContain("Runtime for");
532
+ expect(html).toContain('2xl:grid-cols-4');
533
+ });
534
+
535
+ it("uses the delivery verdict in a successful batch application header", () => {
536
+ const batchApp: AppRow = {
537
+ key: "app:prod:batch",
538
+ name: "batch",
539
+ namespace: "prod",
540
+ health: "unhealthy",
541
+ runtimeHealth: "unhealthy",
542
+ workload_class: "job",
543
+ sourceStatus: { sync: "Synced", health: "Degraded" },
544
+ workloads: [
545
+ {
546
+ kind: "CronJob",
547
+ namespace: "prod",
548
+ name: "nightly",
549
+ workload_class: "job",
550
+ health: "unhealthy",
551
+ ready: 0,
552
+ desired: 0,
553
+ restarts: 0,
554
+ batch: {
555
+ retainedRuns: 2,
556
+ succeededRuns: 1,
557
+ failedRuns: 1,
558
+ activeRuns: 0,
559
+ latestRunPhase: "Succeeded",
560
+ },
561
+ },
562
+ ],
563
+ };
564
+ const html = renderDetail({ app: batchApp });
565
+
566
+ expect(html).toMatch(/<span class="text-sm font-semibold">Degraded<\/span>/);
567
+ expect(html).toContain("Succeeded");
568
+ });
569
+ });