@skyhook-io/k8s-ui 1.8.10 → 1.8.12

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 (168) hide show
  1. package/package.json +7 -6
  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 +697 -0
  6. package/src/components/applications/ApplicationDetail.tsx +3269 -474
  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/GitOpsDetailLayout.tsx +13 -3
  16. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  17. package/src/components/gitops/GitOpsTableView.tsx +13 -6
  18. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  19. package/src/components/gitops/SyncCountdown.tsx +26 -17
  20. package/src/components/gitops/SyncOptionsDialog.test.ts +48 -0
  21. package/src/components/gitops/SyncOptionsDialog.tsx +190 -46
  22. package/src/components/gitops/insights/ArgoResourceDiff.tsx +172 -0
  23. package/src/components/gitops/insights/GitOpsInsightViews.tsx +474 -121
  24. package/src/components/gitops/insights/index.ts +1 -0
  25. package/src/components/gitops/insights/insights-helpers.test.ts +102 -1
  26. package/src/components/gitops/insights/insights-helpers.ts +74 -1
  27. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +10 -2
  28. package/src/components/issues/IssuesView.tsx +91 -52
  29. package/src/components/issues/ResourceIssuesSection.test.tsx +39 -6
  30. package/src/components/issues/ResourceIssuesSection.tsx +46 -144
  31. package/src/components/issues/index.ts +3 -0
  32. package/src/components/issues/issue-timing.ts +53 -0
  33. package/src/components/issues/issues.test.ts +102 -1
  34. package/src/components/issues/severity.ts +3 -0
  35. package/src/components/logs/LogCore.tsx +14 -4
  36. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  37. package/src/components/logs/useLogStream.ts +8 -1
  38. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  39. package/src/components/resources/ResourcesSidebar.test.tsx +71 -0
  40. package/src/components/resources/ResourcesSidebar.tsx +46 -46
  41. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  42. package/src/components/resources/ResourcesView.tsx +233 -51
  43. package/src/components/resources/renderers/AWSMachineRenderer.tsx +3 -2
  44. package/src/components/resources/renderers/AWSManagedClusterRenderer.tsx +3 -2
  45. package/src/components/resources/renderers/AWSManagedControlPlaneRenderer.tsx +3 -2
  46. package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +3 -2
  47. package/src/components/resources/renderers/AzureMachineRenderer.tsx +3 -2
  48. package/src/components/resources/renderers/AzureManagedControlPlaneRenderer.tsx +3 -2
  49. package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -2
  50. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  51. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +3 -2
  52. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +3 -2
  53. package/src/components/resources/renderers/CAPIMachineHealthCheckRenderer.tsx +3 -2
  54. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +3 -2
  55. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +3 -2
  56. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +3 -2
  57. package/src/components/resources/renderers/CertificateRenderer.tsx +3 -2
  58. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +3 -2
  59. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  60. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -2
  61. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  62. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -2
  63. package/src/components/resources/renderers/GCPMachineRenderer.tsx +3 -2
  64. package/src/components/resources/renderers/GCPManagedControlPlaneRenderer.tsx +3 -2
  65. package/src/components/resources/renderers/GCPManagedMachinePoolRenderer.tsx +3 -2
  66. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +7 -3
  67. package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -2
  68. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +7 -3
  69. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  70. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +3 -2
  71. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +3 -2
  72. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +3 -2
  73. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -2
  74. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +3 -2
  75. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +3 -2
  76. package/src/components/resources/renderers/PVCRenderer.tsx +3 -2
  77. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  78. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  79. package/src/components/resources/renderers/SealedSecretRenderer.tsx +3 -2
  80. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  81. package/src/components/resources/renderers/SecretStoreRenderer.tsx +3 -2
  82. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  83. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +7 -3
  84. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  85. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  86. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  87. package/src/components/resources/renderers/index.ts +1 -0
  88. package/src/components/resources/resource-utils-argo.test.ts +18 -1
  89. package/src/components/resources/resource-utils-argo.ts +8 -0
  90. package/src/components/resources/resource-utils-capi.ts +1 -1
  91. package/src/components/resources/resource-utils.ts +31 -0
  92. package/src/components/shared/DetailShell.tsx +2 -2
  93. package/src/components/shared/ResourceActionsBar.tsx +142 -111
  94. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  95. package/src/components/shared/UnifiedDiff.tsx +51 -0
  96. package/src/components/shared/index.ts +1 -0
  97. package/src/components/timeline/TimelineList.tsx +375 -354
  98. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  99. package/src/components/timeline/TimelineStrip.tsx +836 -0
  100. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  101. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  102. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  103. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  104. package/src/components/timeline/TimelineToolbarState.test.tsx +88 -0
  105. package/src/components/timeline/index.ts +23 -0
  106. package/src/components/timeline/scrubber-math.test.ts +235 -0
  107. package/src/components/timeline/scrubber-math.ts +249 -0
  108. package/src/components/timeline/shared.test.tsx +219 -0
  109. package/src/components/timeline/shared.tsx +232 -447
  110. package/src/components/timeline/timeline-filters.test.ts +196 -0
  111. package/src/components/timeline/timeline-filters.ts +180 -0
  112. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  113. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  114. package/src/components/timeline/timeline-live.test.ts +131 -0
  115. package/src/components/timeline/timeline-live.ts +77 -0
  116. package/src/components/topology/GroupNode.tsx +9 -2
  117. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  118. package/src/components/topology/K8sResourceNode.tsx +387 -243
  119. package/src/components/topology/TopologyBreadcrumb.tsx +30 -0
  120. package/src/components/topology/TopologyControls.tsx +3 -1
  121. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  122. package/src/components/topology/TopologyGraph.tsx +149 -90
  123. package/src/components/topology/TopologyOverlayBar.tsx +30 -0
  124. package/src/components/topology/TopologySearch.tsx +25 -17
  125. package/src/components/topology/index.ts +2 -0
  126. package/src/components/topology/layout.ts +10 -2
  127. package/src/components/topology/topology.css +0 -92
  128. package/src/components/ui/Badge.tsx +6 -1
  129. package/src/components/ui/CodeViewer.tsx +2 -2
  130. package/src/components/ui/Collapse.tsx +59 -0
  131. package/src/components/ui/ConfirmDialog.tsx +7 -3
  132. package/src/components/ui/DialogPortal.tsx +31 -9
  133. package/src/components/ui/Input.tsx +24 -0
  134. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  135. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  136. package/src/components/ui/PaneLoader.tsx +10 -2
  137. package/src/components/ui/ResourceBar.tsx +68 -25
  138. package/src/components/ui/SearchBox.tsx +18 -4
  139. package/src/components/ui/SearchPillInput.tsx +2 -2
  140. package/src/components/ui/SelectMenu.tsx +81 -0
  141. package/src/components/ui/drawer-components.tsx +48 -32
  142. package/src/components/ui/index.ts +6 -0
  143. package/src/components/ui/severity-tone.ts +11 -4
  144. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  145. package/src/components/workload/WorkloadView.tsx +2706 -475
  146. package/src/components/workload/index.ts +1 -1
  147. package/src/theme/tailwind-theme.css +1 -0
  148. package/src/theme/variables.css +2 -1
  149. package/src/types/core.ts +57 -4
  150. package/src/types/gitops-insights.ts +69 -1
  151. package/src/types/gitops.ts +3 -3
  152. package/src/utils/application-history.test.ts +538 -0
  153. package/src/utils/application-history.ts +257 -0
  154. package/src/utils/application-topology.test.ts +198 -0
  155. package/src/utils/application-topology.ts +261 -0
  156. package/src/utils/applications.test.ts +201 -1
  157. package/src/utils/applications.ts +1182 -393
  158. package/src/utils/index.ts +3 -0
  159. package/src/utils/navigation.test.ts +32 -1
  160. package/src/utils/navigation.ts +70 -0
  161. package/src/utils/resource-hierarchy.test.ts +1024 -1
  162. package/src/utils/resource-hierarchy.ts +925 -267
  163. package/src/utils/resource-icons.ts +2 -0
  164. package/src/utils/topology-neighborhood.test.ts +202 -1
  165. package/src/utils/topology-neighborhood.ts +194 -13
  166. package/src/utils/workflow-execution.test.ts +556 -0
  167. package/src/utils/workflow-execution.ts +814 -0
  168. package/src/utils/workload-colors.ts +2 -3
@@ -1,3 +1,5 @@
1
+ import type { ResourceRef } from '../types/core'
2
+
1
3
  // Shared model for the Applications surface — host-agnostic. The OSS single-
2
4
  // cluster view and (eventually) the Cloud fleet view both build on these types
3
5
  // and helpers. No React, no fetching.
@@ -5,145 +7,251 @@
5
7
  // The wire shape mirrors radar OSS's GET /api/applications response
6
8
  // (internal/server/applications.go). Field names match the Go json tags.
7
9
 
8
- export type AppWorkloadClass = 'service' | 'worker' | 'job' | 'mixed' | 'unknown'
9
- export type AppHealth = 'healthy' | 'degraded' | 'unhealthy' | 'neutral' | 'unknown'
10
+ export type AppWorkloadClass =
11
+ "service" | "worker" | "job" | "mixed" | "unknown";
12
+ export type AppHealth =
13
+ "healthy" | "degraded" | "unhealthy" | "neutral" | "unknown";
10
14
 
11
15
  export interface AppWorkload {
12
- kind: string
13
- namespace: string
14
- name: string
15
- workload_class?: AppWorkloadClass
16
- image?: string
17
- version?: string
18
- appVersion?: string
19
- health: string
20
- ready: number
21
- desired: number
22
- restarts: number
23
- reason?: string
16
+ kind: string;
17
+ group?: string;
18
+ namespace: string;
19
+ name: string;
20
+ workload_class?: AppWorkloadClass;
21
+ image?: string;
22
+ version?: string;
23
+ appVersion?: string;
24
+ health: string;
25
+ ready: number;
26
+ desired: number;
27
+ restarts: number;
28
+ reason?: string;
29
+ batch?: AppBatchSummary;
30
+ }
31
+
32
+ export interface AppBatchSummary {
33
+ schedule?: string;
34
+ suspended?: boolean;
35
+ activeRuns?: number;
36
+ retainedRuns?: number;
37
+ failedRuns?: number;
38
+ succeededRuns?: number;
39
+ latestRunName?: string;
40
+ latestRunPhase?: string;
41
+ latestStartedAt?: string;
42
+ latestFinishedAt?: string;
43
+ lastScheduledAt?: string;
44
+ lastSuccessfulAt?: string;
45
+ message?: string;
24
46
  }
25
47
 
26
48
  export interface AppRelationships {
27
- services?: string[]
28
- ingresses?: string[]
49
+ services?: string[];
50
+ ingresses?: string[];
51
+ /** "Kind/name" — routes are polymorphic (HTTPRoute/GRPCRoute/…). */
29
52
  routes?: string[]
30
53
  configs?: number
31
54
  scalers?: number
55
+ storage?: number
32
56
  pdbs?: number
57
+ networkPolicies?: number
58
+ serviceRefs?: ResourceRef[]
59
+ ingressRefs?: ResourceRef[]
60
+ routeRefs?: ResourceRef[]
61
+ configRefs?: ResourceRef[]
62
+ scalerRefs?: ResourceRef[]
63
+ storageRefs?: ResourceRef[]
64
+ pdbRefs?: ResourceRef[]
65
+ networkPolicyRefs?: ResourceRef[]
33
66
  }
34
67
 
35
68
  export interface AppEvent {
36
- type: string
37
- reason: string
38
- message?: string
39
- count: number
40
- object: string
41
- lastSeen?: string
69
+ type: string;
70
+ reason: string;
71
+ message?: string;
72
+ count: number;
73
+ object: string;
74
+ firstSeen?: string;
75
+ lastSeen?: string;
76
+ }
77
+
78
+ export interface AppSourceRef {
79
+ type: "gitops" | "helm";
80
+ tool?: "argocd" | "fluxcd" | "helm" | string;
81
+ group?: string;
82
+ kind: string;
83
+ namespace: string;
84
+ name: string;
85
+ }
86
+
87
+ export interface AppSourceStatus {
88
+ sync?: string
89
+ health?: string
90
+ }
91
+
92
+ export interface AppHistorySummary {
93
+ state: "change" | "incident" | "none";
94
+ title: string;
95
+ detail?: string;
96
+ timestamp?: string;
97
+ }
98
+
99
+ export interface AppHistoryAnchor {
100
+ type: "gitops" | "helm" | string;
101
+ title: string;
102
+ status?: string;
103
+ revision?: string;
104
+ message?: string;
105
+ source?: string;
106
+ initiatedBy?: string;
107
+ timestamp?: string;
108
+ }
109
+
110
+ export interface AppHistoryIncident {
111
+ severity: "warning" | "error" | "info" | string;
112
+ title: string;
113
+ object: string;
114
+ message?: string;
115
+ count?: number;
116
+ firstSeen?: string;
117
+ lastSeen?: string;
118
+ }
119
+
120
+ export interface AppHistory {
121
+ appKey: string;
122
+ sourceRef?: AppSourceRef;
123
+ summary?: AppHistorySummary;
124
+ anchors?: AppHistoryAnchor[];
125
+ incidents?: AppHistoryIncident[];
126
+ partialSources?: string[];
42
127
  }
43
128
 
44
129
  export interface AppIdentity {
45
130
  /** Shared display identity (the name stem). Derived classification — never
46
131
  * an address; instance keys remain the only URLs. */
47
- key: string
132
+ key: string;
48
133
  /** This instance's canonical env token (dev | staging | prod | …). */
49
- env: string
134
+ env: string;
50
135
  /** high (declared source path) | medium (name stem + shared image repo). */
51
- confidence: string
136
+ confidence: string;
52
137
  /** Human-readable why, for the app group chip tooltip. */
53
- evidence: string
138
+ evidence: string;
54
139
  /** True when the key is backed by declared upstream identity and can group across clusters. */
55
- portable?: boolean
140
+ portable?: boolean;
56
141
  /** Machine-readable provenance tier (radar applications_identity.go):
57
142
  * explicit | argo-path | argo-appset | flux-source (declared origins, portable)
58
143
  * · label | name-stem | namespace (NAMEs, per-cluster). */
59
- source?: string
144
+ source?: string;
60
145
  /** Declared source-path stem (argo-path / flux-source only). The display `key`
61
146
  * is the name stem; this disambiguates same-name/different-path apps in the
62
147
  * portable cross-cluster fold. */
63
- pathKey?: string
148
+ pathKey?: string;
64
149
  }
65
150
 
66
151
  /** The user-set annotation that forces cross-cluster grouping — the canonical
67
152
  * answer to "how do I make this fold?" for non-GitOps apps. */
68
- export const APP_IDENTITY_ANNOTATION = 'app.skyhook.io/app'
153
+ export const APP_IDENTITY_ANNOTATION = "app.skyhook.io/app";
69
154
 
70
155
  /** Whether a Source is a declared cross-cluster origin (mirrors the hub's
71
156
  * isDeclaredPortableSource). NAMEs collide across clusters and stay per-cluster. */
72
157
  export function isDeclaredAppSource(source?: string): boolean {
73
- return source === 'explicit' || source === 'argo-path' || source === 'argo-appset' || source === 'flux-source'
158
+ return (
159
+ source === "explicit" ||
160
+ source === "argo-path" ||
161
+ source === "argo-appset" ||
162
+ source === "flux-source"
163
+ );
74
164
  }
75
165
 
76
166
  /** A short label for how an app's identity was determined, by Source — the
77
167
  * plain-language "grouped by …" phrase shown in the identity tooltip. */
78
168
  export function appSourceLabel(source?: string): string {
79
169
  switch (source) {
80
- case 'explicit':
81
- return `the ${APP_IDENTITY_ANNOTATION} annotation`
82
- case 'argo-path':
83
- return 'its Argo CD source path'
84
- case 'argo-appset':
85
- return 'its ApplicationSet (env fan-out)'
86
- case 'flux-source':
87
- return 'its Flux source'
88
- case 'addon':
89
- return 'a shared add-on name + chart/image'
90
- case 'label':
91
- return 'the app.kubernetes.io/name label'
92
- case 'name-stem':
93
- return 'a shared name + image'
94
- case 'namespace':
95
- return 'a shared namespace + image'
170
+ case "explicit":
171
+ return `the ${APP_IDENTITY_ANNOTATION} annotation`;
172
+ case "argo-path":
173
+ return "its Argo CD source path";
174
+ case "argo-appset":
175
+ return "its ApplicationSet (env fan-out)";
176
+ case "flux-source":
177
+ return "its Flux source";
178
+ case "addon":
179
+ return "a shared add-on name + chart/image";
180
+ case "label":
181
+ return "the app.kubernetes.io/name label";
182
+ case "name-stem":
183
+ return "a shared name + image";
184
+ case "namespace":
185
+ return "a shared namespace + image";
96
186
  default:
97
- return 'its workload grouping'
187
+ return "its workload grouping";
98
188
  }
99
189
  }
100
190
 
101
191
  /** The explainer for "why isn't this grouped across clusters, and how do I fix
102
192
  * it?" — drives the per-cluster row hint. `folds` is true when the identity is a
103
193
  * declared origin (folds cross-cluster); otherwise `fix` says what to add. */
104
- export function appGroupingExplainer(identity?: AppIdentity): { folds: boolean; how: string; fix?: string } {
105
- const folds = isDeclaredAppSource(identity?.source)
194
+ export function appGroupingExplainer(identity?: AppIdentity): {
195
+ folds: boolean;
196
+ how: string;
197
+ fix?: string;
198
+ } {
199
+ const folds = isDeclaredAppSource(identity?.source);
106
200
  return {
107
201
  folds,
108
202
  how: `Grouped by ${appSourceLabel(identity?.source)}.`,
109
203
  fix: folds
110
204
  ? undefined
111
205
  : `This app only has a per-cluster name, which can't prove two clusters run the same app. To fold it across clusters, set ${APP_IDENTITY_ANNOTATION} on its workloads in each cluster (same value), or deploy it via Argo CD / Flux with the environment in the source path.`,
112
- }
206
+ };
113
207
  }
114
208
 
115
209
  export interface AppRow {
116
- key: string
117
- name: string
210
+ key: string;
211
+ name: string;
118
212
  /** The single namespace the app's WORKLOADS run in; absent/empty when they
119
213
  * span several (use `namespaces`). Residence, not the GitOps manager's home. */
120
- namespace?: string
214
+ namespace?: string;
121
215
  /** All distinct workload namespaces, sorted. */
122
- namespaces?: string[]
216
+ namespaces?: string[];
123
217
  /** App identity grouping evidence — instances of one logical app across
124
218
  * environments share identity.key. See applications_identity.go. */
125
- identity?: AppIdentity
219
+ identity?: AppIdentity;
126
220
  /** pkg/subject overlay tier (0 = raw, no signal); 1-9. */
127
- tier?: number
221
+ tier?: number;
128
222
  /** high | medium | low */
129
- confidence?: string
223
+ confidence?: string;
130
224
  /** app | addon | mixed — classification hint, never identity. */
131
225
  category?: string
132
226
  addonReason?: string
133
227
  workload_class?: AppWorkloadClass
134
- /** worst-of across workloads: healthy | degraded | unhealthy | unknown. */
228
+ /** Worst-of runtime health and exact deployment-source status. */
135
229
  health: string
230
+ /** Worst-of across workloads, independent of the deployment source. */
231
+ runtimeHealth?: string
136
232
  /** distinct image tags. */
137
- versions?: string[]
233
+ versions?: string[];
138
234
  /** True when the SAME image runs different tags across workloads — real
139
235
  * drift. Multiple components on different images is normal, not skew. */
140
- versionSkew?: boolean
236
+ versionSkew?: boolean;
141
237
  /** Single upstream version (app.kubernetes.io/version) when all workloads
142
238
  * agree — the app's "main version". Empty for multi-chart umbrellas. */
143
- appVersion?: string
239
+ appVersion?: string;
240
+ /** Exact source-system object when the grouping signal names one. Label/name
241
+ * inferred apps intentionally omit this instead of guessing. */
242
+ sourceRef?: AppSourceRef
243
+ /** Controller-reported delivery state for the exact source. */
244
+ sourceStatus?: AppSourceStatus
245
+ /** Workloads resolve to different Helm/GitOps source objects. */
246
+ sourceConflict?: boolean
144
247
  workloads: AppWorkload[]
145
248
  events?: AppEvent[]
146
249
  relationships?: AppRelationships
250
+ /** Exact grouping-signal evidence keys the server grouped by
251
+ * ("instance:billing-prod", "part-of:x", "name:x", "app:x", "helm:release",
252
+ * "argo:appname") plus informational "name-stem:<stem>". The timeline joins
253
+ * events to this app by these keys — see buildAppMembershipIndex. */
254
+ matchKeys?: string[];
147
255
  }
148
256
 
149
257
  // -----------------------------------------------------------------------------
@@ -151,13 +259,13 @@ export interface AppRow {
151
259
  // envs sort trailing.
152
260
  // -----------------------------------------------------------------------------
153
261
 
154
- export const ENV_RANK: Record<string, number> = { dev: 0, staging: 1, prod: 2 }
262
+ export const ENV_RANK: Record<string, number> = { dev: 0, staging: 1, prod: 2 };
155
263
 
156
264
  /** Rank for an environment label, or null when it isn't on the ladder. */
157
265
  export function envRank(env: string | undefined): number | null {
158
- if (!env) return null
159
- const r = ENV_RANK[env.toLowerCase()]
160
- return r === undefined ? null : r
266
+ if (!env) return null;
267
+ const r = ENV_RANK[env.toLowerCase()];
268
+ return r === undefined ? null : r;
161
269
  }
162
270
 
163
271
  // Namespace-name token → canonical env. Matched on the whole name first, then
@@ -170,81 +278,123 @@ export function envRank(env: string | undefined): number | null {
170
278
  // so a "loadtest" namespace labels once the cluster itself proves the token is
171
279
  // an env. Zero local vocabulary beyond the trio.
172
280
  const ENV_NS_TOKENS: Record<string, string> = {
173
- dev: 'dev', devel: 'dev', develop: 'dev', development: 'dev',
174
- stg: 'staging', stage: 'staging', staging: 'staging',
175
- prd: 'prod', prod: 'prod', production: 'prod', live: 'prod',
176
- }
281
+ dev: "dev",
282
+ devel: "dev",
283
+ develop: "dev",
284
+ development: "dev",
285
+ stg: "staging",
286
+ stage: "staging",
287
+ staging: "staging",
288
+ prd: "prod",
289
+ prod: "prod",
290
+ production: "prod",
291
+ live: "prod",
292
+ };
177
293
 
178
294
  /** Infer a canonical environment from a namespace name, or null when nothing
179
295
  * recognizable is present (conservative — `kube-system`, `billing` → null). */
180
- export function envFromNamespace(namespace: string | undefined, extraTokens?: ReadonlySet<string>): string | null {
181
- if (!namespace) return null
182
- const lower = namespace.toLowerCase()
183
- const hit = (tok: string): string | null => ENV_NS_TOKENS[tok] ?? (extraTokens?.has(tok) ? tok : null)
184
- const whole = hit(lower)
185
- if (whole) return whole
296
+ export function envFromNamespace(
297
+ namespace: string | undefined,
298
+ extraTokens?: ReadonlySet<string>,
299
+ ): string | null {
300
+ if (!namespace) return null;
301
+ const lower = namespace.toLowerCase();
302
+ const hit = (tok: string): string | null =>
303
+ ENV_NS_TOKENS[tok] ?? (extraTokens?.has(tok) ? tok : null);
304
+ const whole = hit(lower);
305
+ if (whole) return whole;
186
306
  for (const seg of lower.split(/[-_]/).filter(Boolean)) {
187
- const h = hit(seg)
188
- if (h) return h
307
+ const h = hit(seg);
308
+ if (h) return h;
189
309
  }
190
- return null
310
+ return null;
191
311
  }
192
312
 
193
313
  export interface ResolvedAppEnv {
194
314
  /** Canonical env token (lowercased), or '' when unlabeled. */
195
- env: string
315
+ env: string;
196
316
  /** True when derived from the namespace heuristic (not an explicit label). */
197
- inferred: boolean
317
+ inferred: boolean;
198
318
  }
199
319
 
200
320
  /** Resolve an environment via the precedence cascade: an explicit env wins;
201
321
  * otherwise the namespace heuristic (tagged inferred); otherwise unlabeled. */
202
- export function resolveEnv(explicitEnv: string | undefined, namespace: string | undefined, extraTokens?: ReadonlySet<string>): ResolvedAppEnv {
203
- const explicit = (explicitEnv || '').trim()
204
- if (explicit) return { env: explicit.toLowerCase(), inferred: false }
205
- const inferred = envFromNamespace(namespace, extraTokens)
206
- if (inferred) return { env: inferred, inferred: true }
207
- return { env: '', inferred: false }
322
+ export function resolveEnv(
323
+ explicitEnv: string | undefined,
324
+ namespace: string | undefined,
325
+ extraTokens?: ReadonlySet<string>,
326
+ ): ResolvedAppEnv {
327
+ const explicit = (explicitEnv || "").trim();
328
+ if (explicit) return { env: explicit.toLowerCase(), inferred: false };
329
+ const inferred = envFromNamespace(namespace, extraTokens);
330
+ if (inferred) return { env: inferred, inferred: true };
331
+ return { env: "", inferred: false };
208
332
  }
209
333
 
210
- export function identityEnvInferred(identity: AppIdentity | undefined): boolean {
211
- if (!identity) return false
212
- return identity.evidence.startsWith('namespace stem ')
334
+ export function identityEnvInferred(
335
+ identity: AppIdentity | undefined,
336
+ ): boolean {
337
+ if (!identity) return false;
338
+ return identity.evidence.startsWith("namespace stem ");
213
339
  }
214
340
 
215
341
  // -----------------------------------------------------------------------------
216
342
  // System namespaces — cluster plumbing hidden by default on the app surface.
217
343
  // -----------------------------------------------------------------------------
218
344
 
219
- const SYSTEM_NAMESPACES = new Set(['kube-system', 'kube-public', 'kube-node-lease', 'kube-flannel', 'local-path-storage'])
345
+ const SYSTEM_NAMESPACES = new Set([
346
+ "kube-system",
347
+ "kube-public",
348
+ "kube-node-lease",
349
+ "kube-flannel",
350
+ "local-path-storage",
351
+ ]);
220
352
 
221
353
  /** True for cluster-plumbing namespaces (kube-*, *-system operators) the app
222
354
  * list hides by default. The `-system` suffix catches operator namespaces like
223
355
  * `cert-manager`'s `gatekeeper-system`, `kourier-system`, etc.; `gke-managed-`
224
356
  * is Google's documented prefix for GKE-managed component namespaces. */
225
357
  export function isSystemNamespace(ns: string | undefined): boolean {
226
- if (!ns) return false
227
- const lower = ns.toLowerCase()
228
- return SYSTEM_NAMESPACES.has(lower) || lower.endsWith('-system') || lower.startsWith('gke-managed-')
358
+ if (!ns) return false;
359
+ const lower = ns.toLowerCase();
360
+ return (
361
+ SYSTEM_NAMESPACES.has(lower) ||
362
+ lower.endsWith("-system") ||
363
+ lower.startsWith("gke-managed-")
364
+ );
229
365
  }
230
366
 
231
367
  // -----------------------------------------------------------------------------
232
368
  // Category — the app/add-on/mixed classification hint (never identity).
233
369
  // -----------------------------------------------------------------------------
234
370
 
235
- export type AppCategory = 'app' | 'addon' | 'mixed'
236
-
237
- export const CATEGORY_ORDER: AppCategory[] = ['app', 'addon', 'mixed']
238
-
239
- export const CATEGORY_META: Record<AppCategory, { label: string; tooltip: string }> = {
240
- app: { label: 'App', tooltip: 'Software you deploy and run — services, workers, jobs.' },
241
- addon: { label: 'Add-on', tooltip: 'Platform machinery (controllers, operators, system charts), classified by chart/label evidence. Shown for completeness.' },
242
- mixed: { label: 'Mixed', tooltip: 'Has both app and add-on evidence. Kept visible — classification is informational, not identity.' },
243
- }
371
+ export type AppCategory = "app" | "addon" | "mixed";
372
+
373
+ export const CATEGORY_ORDER: AppCategory[] = ["app", "addon", "mixed"];
374
+
375
+ export const CATEGORY_META: Record<
376
+ AppCategory,
377
+ { label: string; tooltip: string }
378
+ > = {
379
+ app: {
380
+ label: "App",
381
+ tooltip: "Software you deploy and run — services, workers, jobs.",
382
+ },
383
+ addon: {
384
+ label: "Add-on",
385
+ tooltip:
386
+ "Platform machinery (controllers, operators, system charts), classified by chart/label evidence. Shown for completeness.",
387
+ },
388
+ mixed: {
389
+ label: "Mixed",
390
+ tooltip:
391
+ "Has both app and add-on evidence. Kept visible — classification is informational, not identity.",
392
+ },
393
+ };
244
394
 
245
395
  /** The category bucket for a row — apps with no category default to 'app'. */
246
396
  export function categoryOf(category: string | undefined): AppCategory {
247
- return category === 'addon' || category === 'mixed' ? category : 'app'
397
+ return category === "addon" || category === "mixed" ? category : "app";
248
398
  }
249
399
 
250
400
  // -----------------------------------------------------------------------------
@@ -254,47 +404,51 @@ export function categoryOf(category: string | undefined): AppCategory {
254
404
  // -----------------------------------------------------------------------------
255
405
 
256
406
  function parseVersion(v: string | undefined): number[] | null {
257
- if (!v) return null
258
- const t = v.trim().replace(/^v/i, '')
259
- if (!/^\d+(\.\d+)*$/.test(t)) return null
260
- return t.split('.').map((n) => parseInt(n, 10))
407
+ if (!v) return null;
408
+ const t = v.trim().replace(/^v/i, "");
409
+ if (!/^\d+(\.\d+)*$/.test(t)) return null;
410
+ return t.split(".").map((n) => parseInt(n, 10));
261
411
  }
262
412
 
263
413
  /** Date-stamped CI tags ("main_2026-03-26_05", "billing_main_2026-05-18_00"):
264
414
  * same prefix + extractable date (+ optional sequence) gives a total order.
265
415
  * Different prefixes or no date → not comparable; never guess. */
266
- const DATE_TAG = /^(.*?)[-_](\d{4})[-_.](\d{2})[-_.](\d{2})(?:[-_.](\d+))?$/
416
+ const DATE_TAG = /^(.*?)[-_](\d{4})[-_.](\d{2})[-_.](\d{2})(?:[-_.](\d+))?$/;
267
417
 
268
418
  function parseDateTag(v: string): { prefix: string; ord: number } | null {
269
- const m = DATE_TAG.exec(v)
270
- if (!m) return null
271
- const [, prefix, y, mo, d, seq] = m
272
- const ord = Number(y) * 1e8 + Number(mo) * 1e6 + Number(d) * 1e4 + Number(seq ?? 0)
273
- return { prefix, ord }
419
+ const m = DATE_TAG.exec(v);
420
+ if (!m) return null;
421
+ const [, prefix, y, mo, d, seq] = m;
422
+ const ord =
423
+ Number(y) * 1e8 + Number(mo) * 1e6 + Number(d) * 1e4 + Number(seq ?? 0);
424
+ return { prefix, ord };
274
425
  }
275
426
 
276
427
  /** -1 if a<b, 1 if a>b, 0 if equal, null if either isn't a comparable version. */
277
- export function compareVersions(a: string | undefined, b: string | undefined): number | null {
428
+ export function compareVersions(
429
+ a: string | undefined,
430
+ b: string | undefined,
431
+ ): number | null {
278
432
  // Date-stamped pipeline tags first — semver parsing would misread them.
279
433
  if (a && b) {
280
- const da = parseDateTag(a)
281
- const db = parseDateTag(b)
434
+ const da = parseDateTag(a);
435
+ const db = parseDateTag(b);
282
436
  if (da && db) {
283
- if (da.prefix !== db.prefix) return null
284
- return da.ord === db.ord ? 0 : da.ord < db.ord ? -1 : 1
437
+ if (da.prefix !== db.prefix) return null;
438
+ return da.ord === db.ord ? 0 : da.ord < db.ord ? -1 : 1;
285
439
  }
286
- if (da || db) return null
440
+ if (da || db) return null;
287
441
  }
288
- const pa = parseVersion(a)
289
- const pb = parseVersion(b)
290
- if (!pa || !pb) return null
291
- const len = Math.max(pa.length, pb.length)
442
+ const pa = parseVersion(a);
443
+ const pb = parseVersion(b);
444
+ if (!pa || !pb) return null;
445
+ const len = Math.max(pa.length, pb.length);
292
446
  for (let i = 0; i < len; i++) {
293
- const x = pa[i] ?? 0
294
- const y = pb[i] ?? 0
295
- if (x !== y) return x < y ? -1 : 1
447
+ const x = pa[i] ?? 0;
448
+ const y = pb[i] ?? 0;
449
+ if (x !== y) return x < y ? -1 : 1;
296
450
  }
297
- return 0
451
+ return 0;
298
452
  }
299
453
 
300
454
  // -----------------------------------------------------------------------------
@@ -307,51 +461,72 @@ export function compareVersions(a: string | undefined, b: string | undefined): n
307
461
  /** Coarse provenance bucket for the Source facet. Stable ids — display labels
308
462
  * live in SOURCE_META (the house meta-map pattern), so they can be re-worded
309
463
  * without breaking facet state or future URL serialization. */
310
- export type AppSource = 'argocd' | 'flux' | 'helm' | 'label' | 'ungrouped'
464
+ export type AppSource = "argocd" | "flux" | "helm" | "label" | "ungrouped";
311
465
 
312
- export const SOURCE_ORDER: AppSource[] = ['argocd', 'flux', 'helm', 'label', 'ungrouped']
466
+ export const SOURCE_ORDER: AppSource[] = [
467
+ "argocd",
468
+ "flux",
469
+ "helm",
470
+ "label",
471
+ "ungrouped",
472
+ ];
313
473
 
314
474
  export const SOURCE_META: Record<AppSource, { label: string }> = {
315
- argocd: { label: 'Argo CD' },
316
- flux: { label: 'Flux' },
317
- helm: { label: 'Helm' },
318
- label: { label: 'Label' },
319
- ungrouped: { label: 'Ungrouped' },
320
- }
475
+ argocd: { label: "Argo CD" },
476
+ flux: { label: "Flux" },
477
+ helm: { label: "Helm" },
478
+ label: { label: "Label" },
479
+ ungrouped: { label: "Ungrouped" },
480
+ };
321
481
 
322
482
  interface TierMeta {
323
- source: AppSource
483
+ source: AppSource;
324
484
  /** Tooltip phrase pieces: "Grouped by {lead} `{code(name)}` {trail}". */
325
- lead: string
326
- code: (name: string) => string
327
- trail?: string
485
+ lead: string;
486
+ code: (name: string) => string;
487
+ trail?: string;
328
488
  }
329
489
 
330
490
  const TIER_META: Record<number, TierMeta> = {
331
- 1: { source: 'flux', lead: 'its Flux HelmRelease', code: (n) => n },
332
- 2: { source: 'flux', lead: 'its Flux Kustomization', code: (n) => n },
333
- 3: { source: 'argocd', lead: 'its Argo CD Application', code: (n) => n },
334
- 4: { source: 'argocd', lead: 'its Argo CD Application', code: (n) => n },
335
- 5: { source: 'helm', lead: 'its Helm release', code: (n) => n },
336
- 6: { source: 'label', lead: 'the', code: () => 'app.kubernetes.io/instance', trail: 'label' },
337
- 7: { source: 'label', lead: 'the', code: () => 'app.kubernetes.io/part-of', trail: 'label' },
338
- 8: { source: 'label', lead: 'the', code: () => 'app.kubernetes.io/name', trail: 'label' },
339
- 9: { source: 'label', lead: 'the', code: () => 'app', trail: 'label' },
340
- }
491
+ 1: { source: "flux", lead: "its Flux HelmRelease", code: (n) => n },
492
+ 2: { source: "flux", lead: "its Flux Kustomization", code: (n) => n },
493
+ 3: { source: "argocd", lead: "its Argo CD Application", code: (n) => n },
494
+ 4: { source: "argocd", lead: "its Argo CD Application", code: (n) => n },
495
+ 5: { source: "helm", lead: "its Helm release", code: (n) => n },
496
+ 6: {
497
+ source: "label",
498
+ lead: "the",
499
+ code: () => "app.kubernetes.io/instance",
500
+ trail: "label",
501
+ },
502
+ 7: {
503
+ source: "label",
504
+ lead: "the",
505
+ code: () => "app.kubernetes.io/part-of",
506
+ trail: "label",
507
+ },
508
+ 8: {
509
+ source: "label",
510
+ lead: "the",
511
+ code: () => "app.kubernetes.io/name",
512
+ trail: "label",
513
+ },
514
+ 9: { source: "label", lead: "the", code: () => "app", trail: "label" },
515
+ };
341
516
 
342
517
  export function sourceOf(tier: number | undefined): AppSource {
343
- if (!tier) return 'ungrouped'
344
- return TIER_META[tier]?.source ?? 'label'
518
+ if (!tier) return "ungrouped";
519
+ return TIER_META[tier]?.source ?? "label";
345
520
  }
346
521
 
347
522
  /** Short badge label for an app's provenance tier (which tool/source grouped it). */
348
523
  export function overlayProvenance(tier: number | undefined): string {
349
- return SOURCE_META[sourceOf(tier)].label
524
+ return SOURCE_META[sourceOf(tier)].label;
350
525
  }
351
526
 
352
527
  function appNameFromKey(key: string): string {
353
- const slash = key.lastIndexOf('/')
354
- return slash >= 0 && slash < key.length - 1 ? key.slice(slash + 1) : key
528
+ const slash = key.lastIndexOf("/");
529
+ return slash >= 0 && slash < key.length - 1 ? key.slice(slash + 1) : key;
355
530
  }
356
531
 
357
532
  // How an app was grouped, decomposed so the tooltip can render the source
@@ -359,33 +534,144 @@ function appNameFromKey(key: string): string {
359
534
  // `lead` + `code` + `trail` reads as a phrase: "its Flux HelmRelease `argocd`"
360
535
  // or "the `app.kubernetes.io/part-of` label". `code` empty → no chip.
361
536
  export interface ProvenanceSource {
362
- lead: string
363
- code: string
364
- trail?: string
537
+ lead: string;
538
+ code: string;
539
+ trail?: string;
365
540
  }
366
541
 
367
- export function provenanceSource(tier: number | undefined, key: string): ProvenanceSource {
368
- const meta = tier ? TIER_META[tier] : undefined
369
- if (!meta) return { lead: 'cluster-native evidence', code: '' }
370
- return { lead: meta.lead, code: meta.code(appNameFromKey(key)), trail: meta.trail }
542
+ export function provenanceSource(
543
+ tier: number | undefined,
544
+ key: string,
545
+ ): ProvenanceSource {
546
+ const meta = tier ? TIER_META[tier] : undefined;
547
+ if (!meta) return { lead: "cluster-native evidence", code: "" };
548
+ return {
549
+ lead: meta.lead,
550
+ code: meta.code(appNameFromKey(key)),
551
+ trail: meta.trail,
552
+ };
371
553
  }
372
554
 
373
-
374
555
  /** The distinct namespaces an app's workloads run in, sorted. Prefers the
375
556
  * server's `namespaces` field, deriving from workloads for older payloads. */
376
557
  export function namespacesOf(app: AppRow): string[] {
377
- if (app.namespaces && app.namespaces.length > 0) return app.namespaces
378
- const nss = Array.from(new Set((app.workloads || []).map((w) => w.namespace).filter(Boolean))).sort()
379
- if (nss.length > 0) return nss
380
- return app.namespace ? [app.namespace] : []
558
+ if (app.namespaces && app.namespaces.length > 0) return app.namespaces;
559
+ const nss = Array.from(
560
+ new Set((app.workloads || []).map((w) => w.namespace).filter(Boolean)),
561
+ ).sort();
562
+ if (nss.length > 0) return nss;
563
+ return app.namespace ? [app.namespace] : [];
381
564
  }
382
565
 
383
566
  /** An app's single namespace, or '' when it spans several — callers must not
384
567
  * pick an arbitrary one (env inference and the system-namespace filter both
385
568
  * key off this; a wrong pick misleads). Use namespacesOf for the full list. */
386
569
  export function namespaceOf(app: AppRow): string {
387
- const nss = namespacesOf(app)
388
- return nss.length === 1 ? nss[0] : ''
570
+ const nss = namespacesOf(app);
571
+ return nss.length === 1 ? nss[0] : "";
572
+ }
573
+
574
+ // -----------------------------------------------------------------------------
575
+ // App membership index — the client-side join between the server's application
576
+ // grouping (GET /api/applications) and the timeline's per-resource lanes. The
577
+ // server is the single grouping authority (union-find + 9-tier evidence); this
578
+ // index lets the timeline attribute a lane (or a DELETED resource's events) to
579
+ // the same app without re-deriving anything. See plan-timeline-app-grouping.md.
580
+ // -----------------------------------------------------------------------------
581
+
582
+ /** One app a timeline lane can belong to. `appKey` is stable per AppRow (the
583
+ * server row key), so two env-instances that share identity.key stay distinct
584
+ * header lanes (each with its own env chip). */
585
+ export interface AppMembership {
586
+ appKey: string;
587
+ appName: string;
588
+ env?: string;
589
+ evidence?: string;
590
+ }
591
+
592
+ export interface AppMembershipIndex {
593
+ /** 'Kind/namespace/name' → app. Built from row.workloads + satellite arrays. */
594
+ byResource: Map<string, AppMembership>;
595
+ /** 'kind:namespace:value' (instance/part-of/name/app/helm/argo) → app. Built
596
+ * from row.matchKeys EXCLUDING name-stem (v1 matches exact kinds only). Keys
597
+ * are namespace-scoped so a shared label value across namespaces can't
598
+ * cross-join; lookups use the event's namespace. Catches events from
599
+ * resources deleted before the current snapshot. */
600
+ byEvidence: Map<string, AppMembership>;
601
+ }
602
+
603
+ // Collision policy: FIRST row wins (deterministic — rows arrive in the server's
604
+ // sorted order). A satellite/evidence shared by two apps attributes to the first.
605
+ function setFirst<V>(map: Map<string, V>, key: string, value: V): void {
606
+ if (!map.has(key)) map.set(key, value);
607
+ }
608
+
609
+ /** Build the resource + evidence membership index from the applications rows.
610
+ * Pure; no fetching. `byResource` keys workloads by their own kind/ns/name and
611
+ * satellites (Services/Ingresses/Routes) by the app's single namespace — a
612
+ * multi-namespace app can't place its satellites unambiguously, so those are
613
+ * skipped (workloads still index fine). `byEvidence` drops name-stem keys: the
614
+ * server ships them for display, but v1 only joins events on exact label kinds. */
615
+ export function buildAppMembershipIndex(rows: AppRow[]): AppMembershipIndex {
616
+ const byResource = new Map<string, AppMembership>();
617
+ const byEvidence = new Map<string, AppMembership>();
618
+ for (const row of rows) {
619
+ const membership: AppMembership = {
620
+ appKey: row.key,
621
+ appName: row.name,
622
+ env: row.identity?.env || undefined,
623
+ evidence: row.identity?.evidence || undefined,
624
+ };
625
+ for (const w of row.workloads || []) {
626
+ if (w.kind && w.name)
627
+ setFirst(byResource, `${w.kind}/${w.namespace}/${w.name}`, membership);
628
+ }
629
+ const ns = namespaceOf(row);
630
+ if (ns && row.relationships) {
631
+ for (const s of row.relationships.services || [])
632
+ setFirst(byResource, `Service/${ns}/${s}`, membership);
633
+ for (const i of row.relationships.ingresses || [])
634
+ setFirst(byResource, `Ingress/${ns}/${i}`, membership);
635
+ // Routes ship as "Kind/name" (HTTPRoute/GRPCRoute/…); index under the
636
+ // concrete kind so the key matches the route lane's id, not a generic "Route".
637
+ for (const r of row.relationships.routes || []) {
638
+ const slash = r.indexOf("/");
639
+ const routeKind = slash > 0 ? r.slice(0, slash) : "Route";
640
+ const routeName = slash > 0 ? r.slice(slash + 1) : r;
641
+ setFirst(byResource, `${routeKind}/${ns}/${routeName}`, membership);
642
+ }
643
+ }
644
+ if (row.relationships) {
645
+ const explicitRefs = [
646
+ row.relationships.serviceRefs,
647
+ row.relationships.ingressRefs,
648
+ row.relationships.routeRefs,
649
+ row.relationships.configRefs,
650
+ row.relationships.scalerRefs,
651
+ row.relationships.storageRefs,
652
+ row.relationships.pdbRefs,
653
+ row.relationships.networkPolicyRefs,
654
+ ];
655
+ for (const refs of explicitRefs) {
656
+ for (const ref of refs ?? []) {
657
+ if (ref.kind && ref.name)
658
+ setFirst(byResource, `${ref.kind}/${ref.namespace}/${ref.name}`, membership);
659
+ }
660
+ }
661
+ }
662
+ if (row.sourceRef?.kind && row.sourceRef.name) {
663
+ setFirst(
664
+ byResource,
665
+ `${row.sourceRef.kind}/${row.sourceRef.namespace}/${row.sourceRef.name}`,
666
+ membership,
667
+ );
668
+ }
669
+ for (const mk of row.matchKeys || []) {
670
+ if (mk.startsWith("name-stem:")) continue;
671
+ setFirst(byEvidence, mk, membership);
672
+ }
673
+ }
674
+ return { byResource, byEvidence };
389
675
  }
390
676
 
391
677
  // -----------------------------------------------------------------------------
@@ -401,20 +687,35 @@ export function namespaceOf(app: AppRow): string {
401
687
  * overview. Callers extend it with the group's own (discovered) env tokens
402
688
  * via the extraTokens parameter. */
403
689
  const NAME_ENV_TOKENS = new Set([
404
- 'dev', 'development', 'staging', 'stage', 'stg', 'prod', 'production', 'prd',
405
- 'qa', 'uat', 'preprod', 'preview', 'canary',
406
- ])
690
+ "dev",
691
+ "development",
692
+ "staging",
693
+ "stage",
694
+ "stg",
695
+ "prod",
696
+ "production",
697
+ "prd",
698
+ "qa",
699
+ "uat",
700
+ "preprod",
701
+ "preview",
702
+ "canary",
703
+ ]);
407
704
 
408
705
  /** Strip a recognized env affix from a workload/app name —
409
706
  * "billing-staging" → "billing", "qa-koala-backend" → "koala-backend".
410
707
  * Used to match "the same workload" across app group env instances. */
411
- export function stripEnvAffix(name: string, extraTokens?: ReadonlySet<string>): string {
412
- const isEnv = (tok: string) => NAME_ENV_TOKENS.has(tok) || (extraTokens?.has(tok) ?? false)
413
- const i = name.lastIndexOf('-')
414
- if (i > 0 && isEnv(name.slice(i + 1).toLowerCase())) return name.slice(0, i)
415
- const j = name.indexOf('-')
416
- if (j > 0 && isEnv(name.slice(0, j).toLowerCase())) return name.slice(j + 1)
417
- return name
708
+ export function stripEnvAffix(
709
+ name: string,
710
+ extraTokens?: ReadonlySet<string>,
711
+ ): string {
712
+ const isEnv = (tok: string) =>
713
+ NAME_ENV_TOKENS.has(tok) || (extraTokens?.has(tok) ?? false);
714
+ const i = name.lastIndexOf("-");
715
+ if (i > 0 && isEnv(name.slice(i + 1).toLowerCase())) return name.slice(0, i);
716
+ const j = name.indexOf("-");
717
+ if (j > 0 && isEnv(name.slice(0, j).toLowerCase())) return name.slice(j + 1);
718
+ return name;
418
719
  }
419
720
 
420
721
  /** Find "the same workload" in a sibling env instance: exact kind+name first,
@@ -423,61 +724,77 @@ export function stripEnvAffix(name: string, extraTokens?: ReadonlySet<string>):
423
724
  * strip too. Null = no counterpart — the switch shows the instance overview. */
424
725
  export function matchWorkloadAcrossInstances(
425
726
  workloadKey: string,
426
- targetWorkloads: Pick<AppWorkload, 'kind' | 'namespace' | 'name'>[] | undefined,
727
+ targetWorkloads:
728
+ Pick<AppWorkload, "kind" | "namespace" | "name">[] | undefined,
427
729
  extraTokens?: ReadonlySet<string>,
428
- ): Pick<AppWorkload, 'kind' | 'namespace' | 'name'> | null {
429
- const [kind, namespace, name] = workloadKey.split('/')
430
- if (!kind || !name) return null
431
- const ws = targetWorkloads ?? []
730
+ ): Pick<AppWorkload, "kind" | "namespace" | "name"> | null {
731
+ const [kind, namespace, name] = workloadKey.split("/");
732
+ if (!kind || !name) return null;
733
+ const ws = targetWorkloads ?? [];
432
734
  return (
433
- ws.find((w) => w.kind === kind && w.namespace === namespace && w.name === name) ??
735
+ ws.find(
736
+ (w) => w.kind === kind && w.namespace === namespace && w.name === name,
737
+ ) ??
434
738
  uniqueMatch(ws, (w) => w.kind === kind && w.name === name) ??
435
- uniqueMatch(ws, (w) => w.kind === kind && stripEnvAffix(w.name, extraTokens) === stripEnvAffix(name, extraTokens)) ??
739
+ uniqueMatch(
740
+ ws,
741
+ (w) =>
742
+ w.kind === kind &&
743
+ stripEnvAffix(w.name, extraTokens) === stripEnvAffix(name, extraTokens),
744
+ ) ??
436
745
  null
437
- )
746
+ );
438
747
  }
439
748
 
440
749
  function uniqueMatch<T>(items: T[], pred: (item: T) => boolean): T | null {
441
- let found: T | null = null
750
+ let found: T | null = null;
442
751
  for (const item of items) {
443
- if (!pred(item)) continue
444
- if (found) return null
445
- found = item
752
+ if (!pred(item)) continue;
753
+ if (found) return null;
754
+ found = item;
446
755
  }
447
- return found
756
+ return found;
448
757
  }
449
758
 
450
759
  /** Ladder order: ranked envs by rank (dev → staging → prod), then
451
760
  * recognized-but-unranked alphabetically (qa, …). */
452
761
  export function orderEnvs(envs: string[]): string[] {
453
762
  return [...envs].sort((a, b) => {
454
- const ra = envRank(a)
455
- const rb = envRank(b)
456
- if (ra !== null && rb !== null) return ra - rb
457
- if (ra !== null) return -1
458
- if (rb !== null) return 1
459
- return a.localeCompare(b)
460
- })
763
+ const ra = envRank(a);
764
+ const rb = envRank(b);
765
+ if (ra !== null && rb !== null) return ra - rb;
766
+ if (ra !== null) return -1;
767
+ if (rb !== null) return 1;
768
+ return a.localeCompare(b);
769
+ });
461
770
  }
462
771
 
463
772
  /** Promotion lag across an app group's env cells: fires only between RANKED envs,
464
773
  * when a strictly-lower env runs a strictly-newer comparable version.
465
774
  * Returns the human message ("staging is behind dev") or null. */
466
- export function appGroupLagMessage(cells: { env: string; version?: string }[]): string | null {
775
+ export function appGroupLagMessage(
776
+ cells: { env: string; version?: string }[],
777
+ ): string | null {
467
778
  const ranked = cells
468
779
  .map((c) => ({ ...c, rank: envRank(c.env) }))
469
- .filter((c): c is { env: string; version?: string; rank: number } => c.rank !== null && !!c.version)
470
- .sort((a, b) => a.rank - b.rank)
780
+ .filter(
781
+ (c): c is { env: string; version?: string; rank: number } =>
782
+ c.rank !== null && !!c.version,
783
+ )
784
+ .sort((a, b) => a.rank - b.rank);
471
785
  for (let i = 0; i < ranked.length; i++) {
472
786
  for (let j = i + 1; j < ranked.length; j++) {
473
787
  // Strict rank inequality: two instances of the SAME env are siblings,
474
788
  // not a promotion pair — without this, "prod is behind prod" can fire.
475
- if (ranked[i].rank < ranked[j].rank && compareVersions(ranked[i].version, ranked[j].version) === 1) {
476
- return `${ranked[j].env} is behind ${ranked[i].env}`
789
+ if (
790
+ ranked[i].rank < ranked[j].rank &&
791
+ compareVersions(ranked[i].version, ranked[j].version) === 1
792
+ ) {
793
+ return `${ranked[j].env} is behind ${ranked[i].env}`;
477
794
  }
478
795
  }
479
796
  }
480
- return null
797
+ return null;
481
798
  }
482
799
 
483
800
  // -----------------------------------------------------------------------------
@@ -490,53 +807,59 @@ export function appGroupLagMessage(cells: { env: string; version?: string }[]):
490
807
 
491
808
  /** The slice of a list entry the fold needs. */
492
809
  export interface AppGroupFoldEntry {
493
- row: { key: string; name: string; identity?: AppIdentity; appVersion?: string }
494
- health: AppHealth
495
- versions: string[]
496
- ready: number
497
- desired: number
498
- kinds: Record<string, number>
499
- classComposition: { cls: AppWorkloadClass; count: number }[]
810
+ row: {
811
+ key: string;
812
+ name: string;
813
+ identity?: AppIdentity;
814
+ appVersion?: string;
815
+ };
816
+ health: AppHealth;
817
+ versions: string[];
818
+ ready: number;
819
+ desired: number;
820
+ kinds: Record<string, number>;
821
+ classComposition: { cls: AppWorkloadClass; count: number }[];
500
822
  }
501
823
 
502
824
  export interface AppGroupEnvCell {
503
- env: string
504
- health: AppHealth
505
- version?: string
506
- count: number
507
- firstKey: string
825
+ env: string;
826
+ health: AppHealth;
827
+ version?: string;
828
+ count: number;
829
+ firstKey: string;
508
830
  }
509
831
 
510
832
  export interface FoldedAppGroupRow<T extends AppGroupFoldEntry> {
511
- kind: 'group'
512
- key: string
513
- label: string
514
- members: T[]
515
- expanded: boolean
516
- cells: AppGroupEnvCell[]
517
- lag: string | null
518
- health: AppHealth
519
- ready: number
520
- desired: number
521
- kinds: Record<string, number>
522
- classComposition: { cls: AppWorkloadClass; count: number }[]
523
- workloadClass: AppWorkloadClass
524
- confidence: string
525
- }
526
-
527
- export type FoldedRow<T extends AppGroupFoldEntry> = FoldedAppGroupRow<T> | { kind: 'instance'; entry: T; child?: boolean }
833
+ kind: "group";
834
+ key: string;
835
+ label: string;
836
+ members: T[];
837
+ expanded: boolean;
838
+ cells: AppGroupEnvCell[];
839
+ lag: string | null;
840
+ health: AppHealth;
841
+ ready: number;
842
+ desired: number;
843
+ kinds: Record<string, number>;
844
+ classComposition: { cls: AppWorkloadClass; count: number }[];
845
+ workloadClass: AppWorkloadClass;
846
+ confidence: string;
847
+ }
848
+
849
+ export type FoldedRow<T extends AppGroupFoldEntry> =
850
+ FoldedAppGroupRow<T> | { kind: "instance"; entry: T; child?: boolean };
528
851
 
529
852
  export interface FoldAppGroupsOptions<T extends AppGroupFoldEntry> {
530
853
  /** Scope for non-portable identities. OSS leaves this empty; fleet callers
531
854
  * should include the cluster id so local name/repo evidence cannot merge
532
855
  * unrelated clusters. Portable identities ignore the scope. */
533
- localScope?: (entry: T) => string | undefined
856
+ localScope?: (entry: T) => string | undefined;
534
857
  /** Per-member env slices for the env ladder. A fleet member spans several
535
858
  * per-cluster envs, so the host supplies them (cluster-coverage derived,
536
859
  * authoritative) rather than the single `identity.env` — which the hub can
537
860
  * stale when it joins the same overlay key across clusters. Defaults to the
538
861
  * member's single identity env. */
539
- envsOf?: (entry: T) => Array<{ env: string; health: AppHealth }>
862
+ envsOf?: (entry: T) => Array<{ env: string; health: AppHealth }>;
540
863
  }
541
864
 
542
865
  export function foldAppGroups<T extends AppGroupFoldEntry>(
@@ -546,78 +869,105 @@ export function foldAppGroups<T extends AppGroupFoldEntry>(
546
869
  options: FoldAppGroupsOptions<T> = {},
547
870
  ): FoldedRow<T>[] {
548
871
  const newest = (e: T): string | undefined =>
549
- e.versions.reduce<string | undefined>((best, v) => (!best || compareVersions(v, best) === 1 ? v : best), undefined) ?? e.row.appVersion
872
+ e.versions.reduce<string | undefined>(
873
+ (best, v) => (!best || compareVersions(v, best) === 1 ? v : best),
874
+ undefined,
875
+ ) ?? e.row.appVersion;
550
876
  const groupKey = (e: T): string | null => {
551
- const id = e.row.identity
552
- if (!id) return null
553
- const scope = options.localScope?.(e)
554
- if (!scope) return id.key
877
+ const id = e.row.identity;
878
+ if (!id) return null;
879
+ const scope = options.localScope?.(e);
880
+ if (!scope) return id.key;
555
881
  // A declared-PATH identity displays its name stem as the key but carries the
556
882
  // path stem in pathKey; two different apps can share a name while declaring
557
883
  // different paths, so disambiguate the portable cross-cluster fold by pathKey.
558
- return id.portable ? `portable:${id.key}${id.pathKey ? `:${id.pathKey}` : ''}` : `local:${scope}:${id.key}`
559
- }
560
- const byGroup = new Map<string, T[]>()
884
+ return id.portable
885
+ ? `portable:${id.key}${id.pathKey ? `:${id.pathKey}` : ""}`
886
+ : `local:${scope}:${id.key}`;
887
+ };
888
+ const byGroup = new Map<string, T[]>();
561
889
  for (const e of entries) {
562
- const k = groupKey(e)
563
- if (k) byGroup.set(k, [...(byGroup.get(k) ?? []), e])
890
+ const k = groupKey(e);
891
+ if (k) byGroup.set(k, [...(byGroup.get(k) ?? []), e]);
564
892
  }
565
- const emitted = new Set<string>()
566
- const out: FoldedRow<T>[] = []
893
+ const emitted = new Set<string>();
894
+ const out: FoldedRow<T>[] = [];
567
895
  for (const e of entries) {
568
- const id = e.row.identity
569
- const k = groupKey(e)
896
+ const id = e.row.identity;
897
+ const k = groupKey(e);
570
898
  // A group needs ≥2 SURVIVING members — filters can orphan one, which
571
899
  // then renders as the plain instance it is.
572
900
  if (!id || !k || (byGroup.get(k)?.length ?? 0) < 2) {
573
- out.push({ kind: 'instance', entry: e })
574
- continue
901
+ out.push({ kind: "instance", entry: e });
902
+ continue;
575
903
  }
576
- if (emitted.has(k)) continue
577
- emitted.add(k)
578
- const members = byGroup.get(k)!
579
-
580
- const cellMap = new Map<string, AppGroupEnvCell>()
581
- const kinds: Record<string, number> = {}
582
- const compMap = new Map<AppWorkloadClass, number>()
583
- let ready = 0
584
- let desired = 0
904
+ if (emitted.has(k)) continue;
905
+ emitted.add(k);
906
+ const members = byGroup.get(k)!;
907
+
908
+ const cellMap = new Map<string, AppGroupEnvCell>();
909
+ const kinds: Record<string, number> = {};
910
+ const compMap = new Map<AppWorkloadClass, number>();
911
+ let ready = 0;
912
+ let desired = 0;
585
913
  // Seed with the most-benign tier (rank 0) so the max-fold below has a valid
586
914
  // identity now that `unknown` ranks above healthy — see worstHealth.
587
- let health: AppHealth = 'neutral'
915
+ let health: AppHealth = "neutral";
588
916
  for (const m of members) {
589
- const v = newest(m)
917
+ const v = newest(m);
590
918
  // A fleet member spans several per-cluster envs; the host supplies them so
591
919
  // the ladder reflects every env, not just the member's (possibly stale)
592
920
  // single identity env. Default: the one identity env.
593
- const slices = options.envsOf?.(m) ?? [{ env: m.row.identity!.env, health: m.health }]
921
+ const slices = options.envsOf?.(m) ?? [
922
+ { env: m.row.identity!.env, health: m.health },
923
+ ];
594
924
  for (const slice of slices) {
595
- const cur = cellMap.get(slice.env)
925
+ const cur = cellMap.get(slice.env);
596
926
  if (!cur) {
597
- cellMap.set(slice.env, { env: slice.env, health: slice.health, version: v, count: 1, firstKey: m.row.key })
927
+ cellMap.set(slice.env, {
928
+ env: slice.env,
929
+ health: slice.health,
930
+ version: v,
931
+ count: 1,
932
+ firstKey: m.row.key,
933
+ });
598
934
  } else {
599
- cur.count++
600
- if ((HEALTH_RANK[slice.health] ?? 0) > (HEALTH_RANK[cur.health] ?? 0)) cur.health = slice.health
601
- if (v && (!cur.version || compareVersions(v, cur.version) === 1)) cur.version = v
935
+ cur.count++;
936
+ if ((HEALTH_RANK[slice.health] ?? 0) > (HEALTH_RANK[cur.health] ?? 0))
937
+ cur.health = slice.health;
938
+ if (v && (!cur.version || compareVersions(v, cur.version) === 1))
939
+ cur.version = v;
602
940
  }
603
941
  }
604
- if ((HEALTH_RANK[m.health] ?? 0) > (HEALTH_RANK[health] ?? 0)) health = m.health
605
- ready += m.ready
606
- desired += m.desired
607
- for (const [k, n] of Object.entries(m.kinds)) kinds[k] = (kinds[k] ?? 0) + n
608
- for (const c of m.classComposition) compMap.set(c.cls, (compMap.get(c.cls) ?? 0) + c.count)
942
+ if ((HEALTH_RANK[m.health] ?? 0) > (HEALTH_RANK[health] ?? 0))
943
+ health = m.health;
944
+ ready += m.ready;
945
+ desired += m.desired;
946
+ for (const [k, n] of Object.entries(m.kinds))
947
+ kinds[k] = (kinds[k] ?? 0) + n;
948
+ for (const c of m.classComposition)
949
+ compMap.set(c.cls, (compMap.get(c.cls) ?? 0) + c.count);
609
950
  }
610
- const cells = orderEnvs([...cellMap.keys()]).map((env) => cellMap.get(env)!)
611
- const classComposition = CLASS_ORDER.filter((c) => compMap.has(c)).map((c) => ({ cls: c, count: compMap.get(c)! }))
612
- const known = classComposition.map((c) => c.cls).filter((c) => c !== 'unknown')
951
+ const cells = orderEnvs([...cellMap.keys()]).map((env) =>
952
+ cellMap.get(env)!,
953
+ );
954
+ const classComposition = CLASS_ORDER.filter((c) => compMap.has(c)).map(
955
+ (c) => ({ cls: c, count: compMap.get(c)! }),
956
+ );
957
+ const known = classComposition
958
+ .map((c) => c.cls)
959
+ .filter((c) => c !== "unknown");
613
960
  const workloadClass: AppWorkloadClass =
614
- known.length === 0 ? 'unknown'
615
- : known.includes('service') && !known.includes('job') ? 'service'
616
- : known.length === 1 ? known[0]
617
- : 'mixed'
618
- const expanded = autoExpand || expandedKeys.has(k)
961
+ known.length === 0
962
+ ? "unknown"
963
+ : known.includes("service") && !known.includes("job")
964
+ ? "service"
965
+ : known.length === 1
966
+ ? known[0]
967
+ : "mixed";
968
+ const expanded = autoExpand || expandedKeys.has(k);
619
969
  out.push({
620
- kind: 'group',
970
+ kind: "group",
621
971
  key: k,
622
972
  label: id.key,
623
973
  members,
@@ -630,21 +980,27 @@ export function foldAppGroups<T extends AppGroupFoldEntry>(
630
980
  kinds,
631
981
  classComposition,
632
982
  workloadClass,
633
- confidence: members.some((m) => m.row.identity!.confidence === 'high') ? 'high' : 'medium',
634
- })
983
+ confidence: members.some((m) => m.row.identity!.confidence === "high")
984
+ ? "high"
985
+ : "medium",
986
+ });
635
987
  if (expanded) {
636
- for (const m of members) out.push({ kind: 'instance', entry: m, child: true })
988
+ for (const m of members)
989
+ out.push({ kind: "instance", entry: m, child: true });
637
990
  }
638
991
  }
639
- return out
992
+ return out;
640
993
  }
641
994
 
642
995
  /** Normalize a wire health string to the AppHealth union (the health twin of
643
996
  * workloadClassOf — keeps `as AppHealth` casts out of components). */
644
997
  export function healthOf(value: string | undefined): AppHealth {
645
- return value === 'unhealthy' || value === 'degraded' || value === 'healthy' || value === 'neutral'
998
+ return value === "unhealthy" ||
999
+ value === "degraded" ||
1000
+ value === "healthy" ||
1001
+ value === "neutral"
646
1002
  ? value
647
- : 'unknown'
1003
+ : "unknown";
648
1004
  }
649
1005
 
650
1006
  // -----------------------------------------------------------------------------
@@ -658,16 +1014,28 @@ export function healthOf(value: string | undefined): AppHealth {
658
1014
  // healthy (a node-lost workload is worse than a running one); `neutral`
659
1015
  // (intentional/idle) is the most-benign tier, so an all-idle app rolls up to
660
1016
  // "Idle" while a mixed healthy+idle app still reads Healthy (healthy out-ranks
661
- // neutral in the max). NOTE: the previous map inverted this — it ranked
662
- // `unknown: 0` below `healthy`, disagreeing with the backend rollup.
663
- export const HEALTH_ORDER: AppHealth[] = ['unhealthy', 'degraded', 'unknown', 'healthy', 'neutral']
664
- export const HEALTH_RANK: Record<string, number> = { unhealthy: 4, degraded: 3, unknown: 2, healthy: 1, neutral: 0 }
1017
+ // neutral in the max). `unknown` must outrank `healthy` here to agree with the
1018
+ // backend rollup — a node-lost workload can't read as healthy.
1019
+ export const HEALTH_ORDER: AppHealth[] = [
1020
+ "unhealthy",
1021
+ "degraded",
1022
+ "unknown",
1023
+ "healthy",
1024
+ "neutral",
1025
+ ];
1026
+ export const HEALTH_RANK: Record<string, number> = {
1027
+ unhealthy: 4,
1028
+ degraded: 3,
1029
+ unknown: 2,
1030
+ healthy: 1,
1031
+ neutral: 0,
1032
+ };
665
1033
 
666
1034
  export interface HealthMeta {
667
- label: string
668
- bar: string
669
- text: string
670
- pill: string
1035
+ label: string;
1036
+ bar: string;
1037
+ text: string;
1038
+ pill: string;
671
1039
  }
672
1040
 
673
1041
  // ─── Chip dialect ────────────────────────────────────────────────────────────
@@ -676,48 +1044,114 @@ export interface HealthMeta {
676
1044
  // standalone status pills. A local dialect, but defined ONCE here: call sites
677
1045
  // compose `CHIP` (chrome) + a `CHIP_TONE` (color), never inline the strings.
678
1046
  // Literal class strings are required for Tailwind's content scanner.
679
- export const CHIP = 'inline-flex items-center rounded-sm px-1.5 py-px text-[10px] font-medium ring-1 ring-inset'
1047
+ export const CHIP =
1048
+ "inline-flex items-center rounded-sm px-1.5 py-px text-[10px] font-medium ring-1 ring-inset";
680
1049
  export const CHIP_TONE = {
681
- rose: 'bg-rose-50 text-rose-700 ring-rose-200 dark:bg-rose-950/40 dark:text-rose-300 dark:ring-rose-900',
682
- amber: 'bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-950/40 dark:text-amber-300 dark:ring-amber-900',
683
- emerald: 'bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-300 dark:ring-emerald-900',
684
- blue: 'bg-blue-50 text-blue-700 ring-blue-200 dark:bg-blue-950/40 dark:text-blue-300 dark:ring-blue-900',
685
- sky: 'bg-sky-50 text-sky-700 ring-sky-200 dark:bg-sky-950/40 dark:text-sky-300 dark:ring-sky-900',
686
- violet: 'bg-violet-50 text-violet-700 ring-violet-200 dark:bg-violet-950/40 dark:text-violet-300 dark:ring-violet-900',
687
- neutral: 'bg-theme-hover text-theme-text-secondary ring-theme-border',
688
- muted: 'bg-theme-hover text-theme-text-tertiary ring-theme-border',
689
- } as const
1050
+ rose: "bg-rose-50 text-rose-700 ring-rose-200 dark:bg-rose-950/40 dark:text-rose-300 dark:ring-rose-900",
1051
+ amber:
1052
+ "bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-950/40 dark:text-amber-300 dark:ring-amber-900",
1053
+ emerald:
1054
+ "bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-300 dark:ring-emerald-900",
1055
+ blue: "bg-blue-50 text-blue-700 ring-blue-200 dark:bg-blue-950/40 dark:text-blue-300 dark:ring-blue-900",
1056
+ sky: "bg-sky-50 text-sky-700 ring-sky-200 dark:bg-sky-950/40 dark:text-sky-300 dark:ring-sky-900",
1057
+ violet:
1058
+ "bg-violet-50 text-violet-700 ring-violet-200 dark:bg-violet-950/40 dark:text-violet-300 dark:ring-violet-900",
1059
+ neutral: "bg-theme-hover text-theme-text-secondary ring-theme-border",
1060
+ muted: "bg-theme-hover text-theme-text-tertiary ring-theme-border",
1061
+ } as const;
690
1062
 
691
1063
  export const HEALTH_META: Record<AppHealth, HealthMeta> = {
692
- unhealthy: { label: 'Down', bar: 'bg-rose-500', text: 'text-rose-600 dark:text-rose-400', pill: CHIP_TONE.rose },
693
- degraded: { label: 'Degraded', bar: 'bg-amber-500', text: 'text-amber-600 dark:text-amber-400', pill: CHIP_TONE.amber },
694
- healthy: { label: 'Healthy', bar: 'bg-emerald-500', text: 'text-emerald-600 dark:text-emerald-400', pill: CHIP_TONE.emerald },
1064
+ unhealthy: {
1065
+ label: "Unhealthy",
1066
+ bar: "bg-rose-500",
1067
+ text: "text-rose-600 dark:text-rose-400",
1068
+ pill: CHIP_TONE.rose,
1069
+ },
1070
+ degraded: {
1071
+ label: "Degraded",
1072
+ bar: "bg-amber-500",
1073
+ text: "text-amber-600 dark:text-amber-400",
1074
+ pill: CHIP_TONE.amber,
1075
+ },
1076
+ healthy: {
1077
+ label: "Healthy",
1078
+ bar: "bg-emerald-500",
1079
+ text: "text-emerald-600 dark:text-emerald-400",
1080
+ pill: CHIP_TONE.emerald,
1081
+ },
695
1082
  // neutral = intentionally idle/off (every workload suspended or scaled to 0).
696
1083
  // Sky, calm — not "Healthy" (it isn't serving) and not a problem to act on.
697
- neutral: { label: 'Idle', bar: 'bg-sky-500', text: 'text-sky-600 dark:text-sky-400', pill: CHIP_TONE.sky },
698
- unknown: { label: 'Unknown', bar: 'bg-slate-400', text: 'text-theme-text-tertiary', pill: CHIP_TONE.muted },
699
- }
700
-
701
- export const CLASS_ORDER: AppWorkloadClass[] = ['service', 'worker', 'job', 'unknown']
702
-
703
- export const CLASS_META: Record<AppWorkloadClass, { label: string; pill: string; tooltip: string }> = {
704
- service: { label: 'Service', pill: CHIP_TONE.blue, tooltip: 'Long-running, request-serving (a Deployment/StatefulSet behind a Service/Ingress/route). Inferred from the workload shape + routing.' },
705
- worker: { label: 'Worker', pill: CHIP_TONE.violet, tooltip: 'Long-running background processor (no serving edge). Inferred from the workload shape.' },
706
- job: { label: 'Job', pill: CHIP_TONE.amber, tooltip: 'Finite or scheduled work (Job/CronJob).' },
707
- mixed: { label: 'Mixed', pill: CHIP_TONE.neutral, tooltip: 'Contains workloads of more than one class (e.g. a service plus its scheduled jobs).' },
708
- unknown: { label: 'Unknown', pill: CHIP_TONE.muted, tooltip: "Couldn't infer a runtime class from the workload." },
709
- }
1084
+ neutral: {
1085
+ label: "Idle",
1086
+ bar: "bg-sky-500",
1087
+ text: "text-sky-600 dark:text-sky-400",
1088
+ pill: CHIP_TONE.sky,
1089
+ },
1090
+ unknown: {
1091
+ label: "Unknown",
1092
+ bar: "bg-slate-400",
1093
+ text: "text-theme-text-tertiary",
1094
+ pill: CHIP_TONE.muted,
1095
+ },
1096
+ };
1097
+
1098
+ export const CLASS_ORDER: AppWorkloadClass[] = [
1099
+ "service",
1100
+ "worker",
1101
+ "job",
1102
+ "unknown",
1103
+ ];
1104
+
1105
+ export const CLASS_META: Record<
1106
+ AppWorkloadClass,
1107
+ { label: string; pill: string; tooltip: string }
1108
+ > = {
1109
+ service: {
1110
+ label: "Service",
1111
+ pill: CHIP_TONE.blue,
1112
+ tooltip:
1113
+ "Long-running, request-serving (a Deployment/StatefulSet behind a Service/Ingress/route). Inferred from the workload shape + routing.",
1114
+ },
1115
+ worker: {
1116
+ label: "Worker",
1117
+ pill: CHIP_TONE.violet,
1118
+ tooltip:
1119
+ "Long-running background processor (no serving edge). Inferred from the workload shape.",
1120
+ },
1121
+ job: {
1122
+ label: "Job",
1123
+ pill: CHIP_TONE.amber,
1124
+ tooltip:
1125
+ "Finite or scheduled work (Job/CronJob/Workflow/CronWorkflow/WorkflowTemplate/ClusterWorkflowTemplate/ScaledJob).",
1126
+ },
1127
+ mixed: {
1128
+ label: "Mixed",
1129
+ pill: CHIP_TONE.neutral,
1130
+ tooltip:
1131
+ "Contains workloads of more than one class (e.g. a service plus its scheduled jobs).",
1132
+ },
1133
+ unknown: {
1134
+ label: "Unknown",
1135
+ pill: CHIP_TONE.muted,
1136
+ tooltip: "Couldn't infer a runtime class from the workload.",
1137
+ },
1138
+ };
710
1139
 
711
1140
  /** Per-class workload counts for an app, in CLASS_ORDER — the composition
712
1141
  * behind a "Mixed" badge and the inclusive Class facet (filtering "Service"
713
1142
  * matches mixed apps that contain a service). */
714
- export function classCompositionOf(app: AppRow): { cls: AppWorkloadClass; count: number }[] {
715
- const counts = new Map<AppWorkloadClass, number>()
1143
+ export function classCompositionOf(
1144
+ app: AppRow,
1145
+ ): { cls: AppWorkloadClass; count: number }[] {
1146
+ const counts = new Map<AppWorkloadClass, number>();
716
1147
  for (const w of app.workloads || []) {
717
- const c = workloadClassOf(w.workload_class)
718
- counts.set(c, (counts.get(c) ?? 0) + 1)
1148
+ const c = workloadClassOf(w.workload_class);
1149
+ counts.set(c, (counts.get(c) ?? 0) + 1);
719
1150
  }
720
- return CLASS_ORDER.filter((c) => counts.has(c)).map((c) => ({ cls: c, count: counts.get(c)! }))
1151
+ return CLASS_ORDER.filter((c) => counts.has(c)).map((c) => ({
1152
+ cls: c,
1153
+ count: counts.get(c)!,
1154
+ }));
721
1155
  }
722
1156
 
723
1157
  /** The distinct KNOWN classes an app contains — the facet-matching set. Falls
@@ -725,20 +1159,358 @@ export function classCompositionOf(app: AppRow): { cls: AppWorkloadClass; count:
725
1159
  export function classSetOf(app: AppRow): AppWorkloadClass[] {
726
1160
  const known = classCompositionOf(app)
727
1161
  .map((c) => c.cls)
728
- .filter((c) => c === 'service' || c === 'worker' || c === 'job')
729
- if (known.length > 0) return known
730
- return [workloadClassOf(app.workload_class)]
1162
+ .filter((c) => c === "service" || c === "worker" || c === "job");
1163
+ if (known.length > 0) return known;
1164
+ return [workloadClassOf(app.workload_class)];
1165
+ }
1166
+
1167
+ export interface BatchSignal {
1168
+ tone: "rose" | "amber" | "sky" | "emerald" | "muted";
1169
+ label: string;
1170
+ detail: string;
1171
+ workload: AppWorkload;
1172
+ }
1173
+
1174
+ export interface AppBatchActivity {
1175
+ workload: AppWorkload;
1176
+ tone: BatchSignal["tone"];
1177
+ rank: number;
1178
+ label: string;
1179
+ detail: string;
1180
+ latestRunName?: string;
1181
+ latestRunPhase?: string;
1182
+ latestStartedAt?: string;
1183
+ latestFinishedAt?: string;
1184
+ schedule?: string;
1185
+ activeRuns: number;
1186
+ retainedRuns: number;
1187
+ failedRuns: number;
1188
+ lastScheduledAt?: string;
1189
+ lastSuccessfulAt?: string;
1190
+ }
1191
+
1192
+ export function batchActivityForApp(app: AppRow): AppBatchActivity[] {
1193
+ return (app.workloads || [])
1194
+ .map(batchActivityForWorkload)
1195
+ .filter((activity): activity is AppBatchActivity => Boolean(activity))
1196
+ .sort(
1197
+ (a, b) =>
1198
+ b.rank - a.rank || a.workload.name.localeCompare(b.workload.name),
1199
+ );
1200
+ }
1201
+
1202
+ export function batchSignalForApp(app: AppRow): BatchSignal | null {
1203
+ let best: BatchSignal | null = null;
1204
+ for (const workload of app.workloads || []) {
1205
+ const signal = batchSignalForWorkload(workload);
1206
+ if (!signal) continue;
1207
+ if (!best || batchSignalRank(signal) > batchSignalRank(best)) best = signal;
1208
+ }
1209
+ return best;
1210
+ }
1211
+
1212
+ function batchSignalForWorkload(workload: AppWorkload): BatchSignal | null {
1213
+ const batch = workload.batch;
1214
+ if (!batch) return null;
1215
+ const latestPhase = batch.latestRunPhase || "";
1216
+ const name = `${workload.kind}/${workload.name}`;
1217
+ if (latestPhase === "Failed" || latestPhase === "Error") {
1218
+ return {
1219
+ tone: "rose",
1220
+ label: "batch failed",
1221
+ detail:
1222
+ batch.message ||
1223
+ `${name} latest run ${batch.latestRunName || ""} ended ${latestPhase}.`,
1224
+ workload,
1225
+ };
1226
+ }
1227
+ if (batch.suspended) {
1228
+ return {
1229
+ tone: "sky",
1230
+ label: "schedule suspended",
1231
+ detail: `${name} will not create new runs until resumed.`,
1232
+ workload,
1233
+ };
1234
+ }
1235
+ if ((batch.activeRuns ?? 0) > 0) {
1236
+ return {
1237
+ tone: "sky",
1238
+ label: `${batch.activeRuns} running`,
1239
+ detail: `${name} currently has ${batch.activeRuns} active run${batch.activeRuns === 1 ? "" : "s"}.`,
1240
+ workload,
1241
+ };
1242
+ }
1243
+ return null;
1244
+ }
1245
+
1246
+ export interface AppBatchRuntime {
1247
+ label: string;
1248
+ health: AppHealth;
1249
+ detail: string;
1250
+ }
1251
+
1252
+ export function batchRuntimeForApp(app: AppRow): AppBatchRuntime {
1253
+ const activity = batchActivityForApp(app);
1254
+ if (
1255
+ activity.some(
1256
+ (item) => item.activeRuns > 0 || item.latestRunPhase === "Running",
1257
+ )
1258
+ ) {
1259
+ const active = activity.reduce((sum, item) => sum + item.activeRuns, 0);
1260
+ return {
1261
+ label: active > 0 ? `${active} running` : "Running",
1262
+ health: "neutral",
1263
+ detail: "Batch work is executing now.",
1264
+ };
1265
+ }
1266
+ const failed = activity.filter(
1267
+ (item) =>
1268
+ item.latestRunPhase === "Failed" || item.latestRunPhase === "Error",
1269
+ );
1270
+ if (failed.length > 0) {
1271
+ const item = failed[0];
1272
+ const detail =
1273
+ failed.length === 1
1274
+ ? `${item.workload.kind}/${item.workload.name} latest retained run${item.latestRunName ? ` ${item.latestRunName}` : ""} failed.`
1275
+ : `${failed.length} batch workloads have failed latest runs.`;
1276
+ return { label: "Failed", health: "unhealthy", detail };
1277
+ }
1278
+ const latest = [...activity].sort(
1279
+ (a, b) => batchActivityTimestamp(b) - batchActivityTimestamp(a),
1280
+ )[0];
1281
+ if (latest?.latestRunPhase === "Succeeded") {
1282
+ return {
1283
+ label: "Succeeded",
1284
+ health: "healthy",
1285
+ detail: latest.latestRunName
1286
+ ? `Latest retained run: ${latest.latestRunName}`
1287
+ : "The latest retained run succeeded.",
1288
+ };
1289
+ }
1290
+ if (
1291
+ activity.length > 0 &&
1292
+ activity.every((item) => item.workload.batch?.suspended)
1293
+ ) {
1294
+ return {
1295
+ label: "Suspended",
1296
+ health: "neutral",
1297
+ detail: "All batch launchers are suspended.",
1298
+ };
1299
+ }
1300
+ if (activity.some((item) => item.retainedRuns > 0)) {
1301
+ return {
1302
+ label: latest?.latestRunPhase || "Idle",
1303
+ health: "neutral",
1304
+ detail: "No batch work is currently active.",
1305
+ };
1306
+ }
1307
+ return {
1308
+ label: "Idle",
1309
+ health: "neutral",
1310
+ detail: "No retained runs are present in Kubernetes.",
1311
+ };
1312
+ }
1313
+
1314
+ function batchActivityTimestamp(item: AppBatchActivity): number {
1315
+ const raw =
1316
+ item.latestStartedAt ||
1317
+ item.latestFinishedAt ||
1318
+ item.lastScheduledAt ||
1319
+ item.lastSuccessfulAt;
1320
+ if (!raw) return 0;
1321
+ const parsed = Date.parse(raw);
1322
+ return Number.isNaN(parsed) ? 0 : parsed;
1323
+ }
1324
+
1325
+ function applicationRuntimeHealth(app: AppRow): AppHealth {
1326
+ if (workloadClassOf(app.workload_class) === "job")
1327
+ return batchRuntimeForApp(app).health;
1328
+ const serving = (app.workloads || []).filter((workload) => !workload.batch);
1329
+ return serving.length > 0
1330
+ ? worstHealth(serving.map((workload) => workload.health))
1331
+ : healthOf(app.health);
1332
+ }
1333
+
1334
+ export function sourceSyncHealth(status: string): AppHealth {
1335
+ const normalized = status.toLowerCase();
1336
+ if (normalized === "synced") return "healthy";
1337
+ if (normalized === "outofsync") return "degraded";
1338
+ return "unknown";
1339
+ }
1340
+
1341
+ export function sourceReportedHealth(status: string): AppHealth {
1342
+ const normalized = status.toLowerCase();
1343
+ if (normalized === "healthy") return "healthy";
1344
+ if (normalized === "progressing") return "neutral";
1345
+ if (normalized === "degraded") return "degraded";
1346
+ if (normalized === "missing") return "unhealthy";
1347
+ if (normalized === "suspended") return "neutral";
1348
+ return "unknown";
1349
+ }
1350
+
1351
+ function applicationDeliveryHealth(status?: AppSourceStatus): AppHealth {
1352
+ if (!status) return "neutral";
1353
+ const signals = [
1354
+ status.sync ? sourceSyncHealth(status.sync) : null,
1355
+ status.health ? sourceReportedHealth(status.health) : null,
1356
+ ].filter((health): health is AppHealth => health !== null && health !== "unknown");
1357
+ return signals.length > 0 ? worstHealth(signals) : "neutral";
1358
+ }
1359
+
1360
+ export function applicationDisplayHealth(app: AppRow): AppHealth {
1361
+ return worstHealth([
1362
+ applicationRuntimeHealth(app),
1363
+ applicationDeliveryHealth(app.sourceStatus),
1364
+ ]);
1365
+ }
1366
+
1367
+ export function servingReadiness(workloads: AppWorkload[]): {
1368
+ ready: number;
1369
+ desired: number;
1370
+ } {
1371
+ let ready = 0;
1372
+ let desired = 0;
1373
+ for (const workload of workloads) {
1374
+ const cls = workloadClassOf(workload.workload_class);
1375
+ if (cls === "job" || workload.batch) continue;
1376
+ ready += workload.ready ?? 0;
1377
+ desired += workload.desired ?? 0;
1378
+ }
1379
+ return { ready, desired };
1380
+ }
1381
+
1382
+ function batchActivityForWorkload(
1383
+ workload: AppWorkload,
1384
+ ): AppBatchActivity | null {
1385
+ const batch = workload.batch;
1386
+ if (!batch) return null;
1387
+ const latestPhase = batch.latestRunPhase || "";
1388
+ const name = `${workload.kind}/${workload.name}`;
1389
+ const activeRuns = batch.activeRuns ?? 0;
1390
+ const retainedRuns = batch.retainedRuns ?? 0;
1391
+ const failedRuns = batch.failedRuns ?? 0;
1392
+ if (latestPhase === "Failed" || latestPhase === "Error") {
1393
+ return {
1394
+ workload,
1395
+ tone: "rose",
1396
+ rank: 50,
1397
+ label: "Latest run failed",
1398
+ detail:
1399
+ batch.message ||
1400
+ `${name} latest run ${batch.latestRunName || ""} ended ${latestPhase}.`,
1401
+ latestRunName: batch.latestRunName,
1402
+ latestRunPhase: latestPhase,
1403
+ latestStartedAt: batch.latestStartedAt,
1404
+ latestFinishedAt: batch.latestFinishedAt,
1405
+ schedule: batch.schedule,
1406
+ activeRuns,
1407
+ retainedRuns,
1408
+ failedRuns,
1409
+ lastScheduledAt: batch.lastScheduledAt,
1410
+ lastSuccessfulAt: batch.lastSuccessfulAt,
1411
+ };
1412
+ }
1413
+ if (failedRuns > 0) {
1414
+ return {
1415
+ workload,
1416
+ tone: "amber",
1417
+ rank: 40,
1418
+ label: `${failedRuns} retained failed ${failedRuns === 1 ? "run" : "runs"}`,
1419
+ detail: `${name} has retained failed executions in Kubernetes.`,
1420
+ latestRunName: batch.latestRunName,
1421
+ latestRunPhase: latestPhase,
1422
+ latestStartedAt: batch.latestStartedAt,
1423
+ latestFinishedAt: batch.latestFinishedAt,
1424
+ schedule: batch.schedule,
1425
+ activeRuns,
1426
+ retainedRuns,
1427
+ failedRuns,
1428
+ lastScheduledAt: batch.lastScheduledAt,
1429
+ lastSuccessfulAt: batch.lastSuccessfulAt,
1430
+ };
1431
+ }
1432
+ if (activeRuns > 0) {
1433
+ return {
1434
+ workload,
1435
+ tone: "sky",
1436
+ rank: 35,
1437
+ label: `${activeRuns} active ${activeRuns === 1 ? "run" : "runs"}`,
1438
+ detail: `${name} is executing now.`,
1439
+ latestRunName: batch.latestRunName,
1440
+ latestRunPhase: latestPhase,
1441
+ latestStartedAt: batch.latestStartedAt,
1442
+ latestFinishedAt: batch.latestFinishedAt,
1443
+ schedule: batch.schedule,
1444
+ activeRuns,
1445
+ retainedRuns,
1446
+ failedRuns,
1447
+ lastScheduledAt: batch.lastScheduledAt,
1448
+ lastSuccessfulAt: batch.lastSuccessfulAt,
1449
+ };
1450
+ }
1451
+ if (batch.suspended) {
1452
+ return {
1453
+ workload,
1454
+ tone: "sky",
1455
+ rank: 30,
1456
+ label: "Schedule suspended",
1457
+ detail: `${name} will not create new runs until resumed.`,
1458
+ latestRunName: batch.latestRunName,
1459
+ latestRunPhase: latestPhase,
1460
+ latestStartedAt: batch.latestStartedAt,
1461
+ latestFinishedAt: batch.latestFinishedAt,
1462
+ schedule: batch.schedule,
1463
+ activeRuns,
1464
+ retainedRuns,
1465
+ failedRuns,
1466
+ lastScheduledAt: batch.lastScheduledAt,
1467
+ lastSuccessfulAt: batch.lastSuccessfulAt,
1468
+ };
1469
+ }
1470
+ return {
1471
+ workload,
1472
+ tone: "muted",
1473
+ rank: latestPhase === "Succeeded" ? 20 : 10,
1474
+ label:
1475
+ latestPhase === "Succeeded" ? "Latest run succeeded" : "No active runs",
1476
+ detail: `${name} has ${retainedRuns} retained ${retainedRuns === 1 ? "run" : "runs"}.`,
1477
+ latestRunName: batch.latestRunName,
1478
+ latestRunPhase: latestPhase,
1479
+ latestStartedAt: batch.latestStartedAt,
1480
+ latestFinishedAt: batch.latestFinishedAt,
1481
+ schedule: batch.schedule,
1482
+ activeRuns,
1483
+ retainedRuns,
1484
+ failedRuns,
1485
+ lastScheduledAt: batch.lastScheduledAt,
1486
+ lastSuccessfulAt: batch.lastSuccessfulAt,
1487
+ };
1488
+ }
1489
+
1490
+ function batchSignalRank(signal: BatchSignal): number {
1491
+ switch (signal.tone) {
1492
+ case "rose":
1493
+ return 5;
1494
+ case "amber":
1495
+ return 4;
1496
+ case "sky":
1497
+ return 3;
1498
+ case "emerald":
1499
+ return 2;
1500
+ default:
1501
+ return 1;
1502
+ }
731
1503
  }
732
1504
 
733
1505
  export function workloadClassOf(value?: AppWorkloadClass): AppWorkloadClass {
734
1506
  switch (value) {
735
- case 'service':
736
- case 'worker':
737
- case 'job':
738
- case 'mixed':
739
- return value
1507
+ case "service":
1508
+ case "worker":
1509
+ case "job":
1510
+ case "mixed":
1511
+ return value;
740
1512
  default:
741
- return 'unknown'
1513
+ return "unknown";
742
1514
  }
743
1515
  }
744
1516
 
@@ -749,20 +1521,21 @@ export function workloadClassOf(value?: AppWorkloadClass): AppWorkloadClass {
749
1521
  * ranks ABOVE healthy — an all-healthy set would never beat it and return
750
1522
  * `unknown`. Mirrors pkg/health.WorseOf.) */
751
1523
  export function worstHealth(hs: string[]): AppHealth {
752
- let w: AppHealth = 'neutral'
753
- for (const h of hs) if ((HEALTH_RANK[h] ?? 0) > (HEALTH_RANK[w] ?? 0)) w = h as AppHealth
754
- return w
1524
+ let w: AppHealth = "neutral";
1525
+ for (const h of hs)
1526
+ if ((HEALTH_RANK[h] ?? 0) > (HEALTH_RANK[w] ?? 0)) w = h as AppHealth;
1527
+ return w;
755
1528
  }
756
1529
 
757
1530
  export function newestTag(versions: string[]): string | undefined {
758
- let best: string | undefined
1531
+ let best: string | undefined;
759
1532
  for (const v of versions) {
760
- const t = v?.trim()
761
- if (!t) continue
762
- if (best === undefined) best = t
763
- else if (compareVersions(t, best) === 1) best = t
1533
+ const t = v?.trim();
1534
+ if (!t) continue;
1535
+ if (best === undefined) best = t;
1536
+ else if (compareVersions(t, best) === 1) best = t;
764
1537
  }
765
- return best
1538
+ return best;
766
1539
  }
767
1540
 
768
1541
  // -----------------------------------------------------------------------------
@@ -775,78 +1548,85 @@ export function newestTag(versions: string[]): string | undefined {
775
1548
  // -----------------------------------------------------------------------------
776
1549
 
777
1550
  export interface AppEntryBase {
778
- row: AppRow
779
- health: AppHealth
780
- versions: string[]
781
- kinds: Record<string, number>
782
- workloadClass: AppWorkloadClass
1551
+ row: AppRow;
1552
+ health: AppHealth;
1553
+ versions: string[];
1554
+ kinds: Record<string, number>;
1555
+ workloadClass: AppWorkloadClass;
783
1556
  /** Distinct contained classes — the inclusive facet-matching set. */
784
- classSet: AppWorkloadClass[]
785
- classComposition: { cls: AppWorkloadClass; count: number }[]
786
- category: AppCategory
787
- ready: number
788
- desired: number
1557
+ classSet: AppWorkloadClass[];
1558
+ classComposition: { cls: AppWorkloadClass; count: number }[];
1559
+ category: AppCategory;
1560
+ ready: number;
1561
+ desired: number;
789
1562
  /** ready/desired as a fraction for sorting; -1 when nothing is desired. */
790
- readyRatio: number
791
- source: AppSource
1563
+ readyRatio: number;
1564
+ source: AppSource;
792
1565
  }
793
1566
 
794
1567
  export interface SingleAppEntry extends AppEntryBase {
795
- variant: 'single'
796
- namespace: string
797
- namespaces: string[]
798
- env: string
799
- envInferred: boolean
1568
+ variant: "single";
1569
+ namespace: string;
1570
+ namespaces: string[];
1571
+ env: string;
1572
+ envInferred: boolean;
800
1573
  }
801
1574
 
802
1575
  /** One environment instance within a fleet row (env name + worst health across
803
1576
  * its clusters + whether the env was inferred from the namespace). */
804
1577
  export interface EnvSlice {
805
- env: string
806
- health: AppHealth
807
- inferred: boolean
1578
+ env: string;
1579
+ health: AppHealth;
1580
+ inferred: boolean;
808
1581
  }
809
1582
 
810
1583
  /** A cluster this fleet row's app runs in. */
811
1584
  export interface AppClusterRef {
812
- id: string
813
- name: string
814
- health: AppHealth
1585
+ id: string;
1586
+ name: string;
1587
+ health: AppHealth;
815
1588
  }
816
1589
 
817
1590
  export interface FleetAppEntry extends AppEntryBase {
818
- variant: 'fleet'
819
- envs: EnvSlice[]
820
- clusters: AppClusterRef[]
1591
+ variant: "fleet";
1592
+ envs: EnvSlice[];
1593
+ clusters: AppClusterRef[];
821
1594
  /** True when the app runs different versions across its clusters/envs. */
822
- versionSkew: boolean
1595
+ versionSkew: boolean;
823
1596
  }
824
1597
 
825
- export type AppEntry = SingleAppEntry | FleetAppEntry
1598
+ export type AppEntry = SingleAppEntry | FleetAppEntry;
826
1599
 
827
1600
  /** Build a single-cluster list entry from a wire row. The env is resolved via
828
1601
  * the server's identity classification (authoritative) with a namespace
829
1602
  * heuristic fallback for plain rows. */
830
- export function buildSingleAppEntry(row: AppRow, discoveredEnvs?: ReadonlySet<string>): SingleAppEntry {
831
- const kinds: Record<string, number> = {}
832
- let ready = 0
833
- let desired = 0
1603
+ export function buildSingleAppEntry(
1604
+ row: AppRow,
1605
+ discoveredEnvs?: ReadonlySet<string>,
1606
+ ): SingleAppEntry {
1607
+ const kinds: Record<string, number> = {};
1608
+ let ready = 0;
1609
+ let desired = 0;
834
1610
  for (const wl of row.workloads || []) {
835
- kinds[wl.kind] = (kinds[wl.kind] ?? 0) + 1
836
- ready += wl.ready ?? 0
837
- desired += wl.desired ?? 0
1611
+ kinds[wl.kind] = (kinds[wl.kind] ?? 0) + 1;
1612
+ if (workloadClassOf(wl.workload_class) !== "job" && !wl.batch) {
1613
+ ready += wl.ready ?? 0;
1614
+ desired += wl.desired ?? 0;
1615
+ }
838
1616
  }
839
- const namespace = namespaceOf(row)
1617
+ const namespace = namespaceOf(row);
840
1618
  // The server's identity classification carries the authoritative env (label/
841
1619
  // declared/discovered); plain rows fall back to the trio + discovered-token
842
1620
  // namespace heuristic.
843
- const resolved = resolveEnv(undefined, namespace, discoveredEnvs)
844
- const env = row.identity?.env ?? resolved.env
845
- const inferred = row.identity ? identityEnvInferred(row.identity) : resolved.inferred
1621
+ const resolved = resolveEnv(undefined, namespace, discoveredEnvs);
1622
+ const env = row.identity?.env ?? resolved.env;
1623
+ const inferred = row.identity
1624
+ ? identityEnvInferred(row.identity)
1625
+ : resolved.inferred;
846
1626
  return {
847
- variant: 'single',
1627
+ variant: "single",
848
1628
  row,
849
- health: healthOf(row.health),
1629
+ health: applicationDisplayHealth(row),
850
1630
  versions: Array.from(new Set((row.versions || []).filter(Boolean))),
851
1631
  namespace,
852
1632
  namespaces: namespacesOf(row),
@@ -861,16 +1641,25 @@ export function buildSingleAppEntry(row: AppRow, discoveredEnvs?: ReadonlySet<st
861
1641
  desired,
862
1642
  readyRatio: desired > 0 ? ready / desired : -1,
863
1643
  source: sourceOf(row.tier),
864
- }
1644
+ };
865
1645
  }
866
1646
 
867
1647
  /** The text an entry matches free-text search against — name, key, namespace,
868
1648
  * source/class/category labels, versions, env, workload kinds + identity. Pure
869
1649
  * and exported so the list core and tests share one definition. */
870
1650
  export function searchTextForEntry(e: AppEntry): string {
871
- const workloadText = (e.row.workloads || []).flatMap((wl) => [wl.kind, wl.namespace, wl.name, wl.version])
872
- const envParts = e.variant === 'single' ? [e.env || 'unlabeled'] : e.envs.map((s) => s.env || 'unlabeled')
873
- const nsParts = e.variant === 'single' ? [e.namespace] : e.clusters.map((c) => c.name)
1651
+ const workloadText = (e.row.workloads || []).flatMap((wl) => [
1652
+ wl.kind,
1653
+ wl.namespace,
1654
+ wl.name,
1655
+ wl.version,
1656
+ ]);
1657
+ const envParts =
1658
+ e.variant === "single"
1659
+ ? [e.env || "unlabeled"]
1660
+ : e.envs.map((s) => s.env || "unlabeled");
1661
+ const nsParts =
1662
+ e.variant === "single" ? [e.namespace] : e.clusters.map((c) => c.name);
874
1663
  return [
875
1664
  e.row.name,
876
1665
  e.row.key,
@@ -885,6 +1674,6 @@ export function searchTextForEntry(e: AppEntry): string {
885
1674
  ...workloadText,
886
1675
  ]
887
1676
  .filter(Boolean)
888
- .join(' ')
889
- .toLowerCase()
1677
+ .join(" ")
1678
+ .toLowerCase();
890
1679
  }