@skyhook-io/k8s-ui 1.8.9 → 1.8.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/package.json +5 -4
  2. package/src/assets/gitops/argocd.png +0 -0
  3. package/src/assets/gitops/flux.svg +7 -0
  4. package/src/components/applications/AppChips.tsx +10 -0
  5. package/src/components/applications/ApplicationDetail.test.tsx +569 -0
  6. package/src/components/applications/ApplicationDetail.tsx +2517 -450
  7. package/src/components/applications/ApplicationsList.tsx +4 -1
  8. package/src/components/applications/ApplicationsView.tsx +101 -18
  9. package/src/components/applications/applications-view.test.tsx +47 -0
  10. package/src/components/applications/index.ts +1 -1
  11. package/src/components/audit/AuditAlerts.tsx +14 -10
  12. package/src/components/checks/ChecksView.tsx +81 -51
  13. package/src/components/checks/severity.ts +39 -45
  14. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
  15. package/src/components/compare/CompareResourcePicker.tsx +2 -2
  16. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  17. package/src/components/gitops/GitOpsTableView.tsx +14 -59
  18. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  19. package/src/components/gitops/SyncCountdown.tsx +26 -17
  20. package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
  21. package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
  22. package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
  23. package/src/components/gitops/insights/insights-helpers.ts +69 -1
  24. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
  25. package/src/components/issues/IssuesView.tsx +219 -143
  26. package/src/components/issues/ResourceIssuesSection.test.tsx +40 -0
  27. package/src/components/issues/ResourceIssuesSection.tsx +28 -128
  28. package/src/components/issues/index.ts +3 -2
  29. package/src/components/issues/issue-timing.ts +53 -0
  30. package/src/components/issues/issues.test.ts +146 -4
  31. package/src/components/issues/severity.ts +29 -32
  32. package/src/components/logs/LogCore.tsx +14 -4
  33. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  34. package/src/components/logs/useLogStream.ts +8 -1
  35. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  36. package/src/components/resources/ResourcesSidebar.test.tsx +107 -0
  37. package/src/components/resources/ResourcesSidebar.tsx +70 -52
  38. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  39. package/src/components/resources/ResourcesView.tsx +243 -53
  40. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  41. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  42. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  43. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
  44. package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
  45. package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
  46. package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
  47. package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
  48. package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
  49. package/src/components/resources/renderers/PodRenderer.tsx +81 -70
  50. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  51. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  52. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  53. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  54. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  55. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  56. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  57. package/src/components/resources/renderers/index.ts +1 -0
  58. package/src/components/resources/resource-utils-capi.ts +1 -1
  59. package/src/components/resources/resource-utils.ts +31 -0
  60. package/src/components/shared/DetailShell.tsx +2 -2
  61. package/src/components/shared/ResourceActionsBar.tsx +140 -108
  62. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  63. package/src/components/timeline/TimelineList.tsx +371 -354
  64. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  65. package/src/components/timeline/TimelineStrip.tsx +836 -0
  66. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  67. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  68. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  69. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  70. package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
  71. package/src/components/timeline/index.ts +23 -0
  72. package/src/components/timeline/scrubber-math.test.ts +235 -0
  73. package/src/components/timeline/scrubber-math.ts +249 -0
  74. package/src/components/timeline/shared.test.tsx +219 -0
  75. package/src/components/timeline/shared.tsx +232 -447
  76. package/src/components/timeline/timeline-filters.test.ts +196 -0
  77. package/src/components/timeline/timeline-filters.ts +180 -0
  78. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  79. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  80. package/src/components/timeline/timeline-live.test.ts +131 -0
  81. package/src/components/timeline/timeline-live.ts +77 -0
  82. package/src/components/topology/GroupNode.tsx +9 -2
  83. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  84. package/src/components/topology/K8sResourceNode.tsx +387 -243
  85. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  86. package/src/components/topology/TopologyGraph.tsx +89 -16
  87. package/src/components/topology/TopologySearch.tsx +2 -2
  88. package/src/components/topology/layout.ts +10 -2
  89. package/src/components/topology/topology.css +0 -92
  90. package/src/components/ui/Badge.tsx +5 -0
  91. package/src/components/ui/BoardSkeleton.tsx +47 -0
  92. package/src/components/ui/CardSection.tsx +117 -0
  93. package/src/components/ui/CodeViewer.tsx +2 -2
  94. package/src/components/ui/Collapse.tsx +59 -0
  95. package/src/components/ui/ConfirmDialog.tsx +7 -3
  96. package/src/components/ui/DialogPortal.tsx +31 -9
  97. package/src/components/ui/Input.tsx +24 -0
  98. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  99. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  100. package/src/components/ui/PaneLoader.tsx +10 -2
  101. package/src/components/ui/ResourceBar.tsx +68 -25
  102. package/src/components/ui/SearchBox.tsx +18 -4
  103. package/src/components/ui/SearchPillInput.tsx +2 -2
  104. package/src/components/ui/Toast.tsx +1 -1
  105. package/src/components/ui/drawer-components.tsx +39 -26
  106. package/src/components/ui/index.ts +6 -0
  107. package/src/components/ui/severity-tone.ts +63 -0
  108. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  109. package/src/components/workload/WorkloadView.tsx +2682 -472
  110. package/src/components/workload/index.ts +1 -1
  111. package/src/theme/variables.css +15 -1
  112. package/src/types/core.ts +57 -4
  113. package/src/utils/api-resources.test.ts +54 -0
  114. package/src/utils/api-resources.ts +68 -1
  115. package/src/utils/application-topology.test.ts +198 -0
  116. package/src/utils/application-topology.ts +261 -0
  117. package/src/utils/applications.test.ts +167 -1
  118. package/src/utils/applications.ts +1157 -393
  119. package/src/utils/index.ts +2 -0
  120. package/src/utils/navigation.test.ts +32 -1
  121. package/src/utils/navigation.ts +70 -0
  122. package/src/utils/resource-hierarchy.test.ts +990 -1
  123. package/src/utils/resource-hierarchy.ts +905 -267
  124. package/src/utils/resource-icons.ts +2 -0
  125. package/src/utils/topology-neighborhood.test.ts +202 -1
  126. package/src/utils/topology-neighborhood.ts +194 -13
  127. package/src/utils/workflow-execution.test.ts +556 -0
  128. package/src/utils/workflow-execution.ts +814 -0
  129. package/src/utils/workload-colors.ts +2 -3
  130. package/tsconfig.json +2 -2
@@ -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,119 @@ 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
+ for (const mk of row.matchKeys || []) {
645
+ if (mk.startsWith("name-stem:")) continue;
646
+ setFirst(byEvidence, mk, membership);
647
+ }
648
+ }
649
+ return { byResource, byEvidence };
389
650
  }
390
651
 
391
652
  // -----------------------------------------------------------------------------
@@ -401,20 +662,35 @@ export function namespaceOf(app: AppRow): string {
401
662
  * overview. Callers extend it with the group's own (discovered) env tokens
402
663
  * via the extraTokens parameter. */
403
664
  const NAME_ENV_TOKENS = new Set([
404
- 'dev', 'development', 'staging', 'stage', 'stg', 'prod', 'production', 'prd',
405
- 'qa', 'uat', 'preprod', 'preview', 'canary',
406
- ])
665
+ "dev",
666
+ "development",
667
+ "staging",
668
+ "stage",
669
+ "stg",
670
+ "prod",
671
+ "production",
672
+ "prd",
673
+ "qa",
674
+ "uat",
675
+ "preprod",
676
+ "preview",
677
+ "canary",
678
+ ]);
407
679
 
408
680
  /** Strip a recognized env affix from a workload/app name —
409
681
  * "billing-staging" → "billing", "qa-koala-backend" → "koala-backend".
410
682
  * 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
683
+ export function stripEnvAffix(
684
+ name: string,
685
+ extraTokens?: ReadonlySet<string>,
686
+ ): string {
687
+ const isEnv = (tok: string) =>
688
+ NAME_ENV_TOKENS.has(tok) || (extraTokens?.has(tok) ?? false);
689
+ const i = name.lastIndexOf("-");
690
+ if (i > 0 && isEnv(name.slice(i + 1).toLowerCase())) return name.slice(0, i);
691
+ const j = name.indexOf("-");
692
+ if (j > 0 && isEnv(name.slice(0, j).toLowerCase())) return name.slice(j + 1);
693
+ return name;
418
694
  }
419
695
 
420
696
  /** Find "the same workload" in a sibling env instance: exact kind+name first,
@@ -423,61 +699,77 @@ export function stripEnvAffix(name: string, extraTokens?: ReadonlySet<string>):
423
699
  * strip too. Null = no counterpart — the switch shows the instance overview. */
424
700
  export function matchWorkloadAcrossInstances(
425
701
  workloadKey: string,
426
- targetWorkloads: Pick<AppWorkload, 'kind' | 'namespace' | 'name'>[] | undefined,
702
+ targetWorkloads:
703
+ Pick<AppWorkload, "kind" | "namespace" | "name">[] | undefined,
427
704
  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 ?? []
705
+ ): Pick<AppWorkload, "kind" | "namespace" | "name"> | null {
706
+ const [kind, namespace, name] = workloadKey.split("/");
707
+ if (!kind || !name) return null;
708
+ const ws = targetWorkloads ?? [];
432
709
  return (
433
- ws.find((w) => w.kind === kind && w.namespace === namespace && w.name === name) ??
710
+ ws.find(
711
+ (w) => w.kind === kind && w.namespace === namespace && w.name === name,
712
+ ) ??
434
713
  uniqueMatch(ws, (w) => w.kind === kind && w.name === name) ??
435
- uniqueMatch(ws, (w) => w.kind === kind && stripEnvAffix(w.name, extraTokens) === stripEnvAffix(name, extraTokens)) ??
714
+ uniqueMatch(
715
+ ws,
716
+ (w) =>
717
+ w.kind === kind &&
718
+ stripEnvAffix(w.name, extraTokens) === stripEnvAffix(name, extraTokens),
719
+ ) ??
436
720
  null
437
- )
721
+ );
438
722
  }
439
723
 
440
724
  function uniqueMatch<T>(items: T[], pred: (item: T) => boolean): T | null {
441
- let found: T | null = null
725
+ let found: T | null = null;
442
726
  for (const item of items) {
443
- if (!pred(item)) continue
444
- if (found) return null
445
- found = item
727
+ if (!pred(item)) continue;
728
+ if (found) return null;
729
+ found = item;
446
730
  }
447
- return found
731
+ return found;
448
732
  }
449
733
 
450
734
  /** Ladder order: ranked envs by rank (dev → staging → prod), then
451
735
  * recognized-but-unranked alphabetically (qa, …). */
452
736
  export function orderEnvs(envs: string[]): string[] {
453
737
  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
- })
738
+ const ra = envRank(a);
739
+ const rb = envRank(b);
740
+ if (ra !== null && rb !== null) return ra - rb;
741
+ if (ra !== null) return -1;
742
+ if (rb !== null) return 1;
743
+ return a.localeCompare(b);
744
+ });
461
745
  }
462
746
 
463
747
  /** Promotion lag across an app group's env cells: fires only between RANKED envs,
464
748
  * when a strictly-lower env runs a strictly-newer comparable version.
465
749
  * Returns the human message ("staging is behind dev") or null. */
466
- export function appGroupLagMessage(cells: { env: string; version?: string }[]): string | null {
750
+ export function appGroupLagMessage(
751
+ cells: { env: string; version?: string }[],
752
+ ): string | null {
467
753
  const ranked = cells
468
754
  .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)
755
+ .filter(
756
+ (c): c is { env: string; version?: string; rank: number } =>
757
+ c.rank !== null && !!c.version,
758
+ )
759
+ .sort((a, b) => a.rank - b.rank);
471
760
  for (let i = 0; i < ranked.length; i++) {
472
761
  for (let j = i + 1; j < ranked.length; j++) {
473
762
  // Strict rank inequality: two instances of the SAME env are siblings,
474
763
  // 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}`
764
+ if (
765
+ ranked[i].rank < ranked[j].rank &&
766
+ compareVersions(ranked[i].version, ranked[j].version) === 1
767
+ ) {
768
+ return `${ranked[j].env} is behind ${ranked[i].env}`;
477
769
  }
478
770
  }
479
771
  }
480
- return null
772
+ return null;
481
773
  }
482
774
 
483
775
  // -----------------------------------------------------------------------------
@@ -490,53 +782,59 @@ export function appGroupLagMessage(cells: { env: string; version?: string }[]):
490
782
 
491
783
  /** The slice of a list entry the fold needs. */
492
784
  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 }[]
785
+ row: {
786
+ key: string;
787
+ name: string;
788
+ identity?: AppIdentity;
789
+ appVersion?: string;
790
+ };
791
+ health: AppHealth;
792
+ versions: string[];
793
+ ready: number;
794
+ desired: number;
795
+ kinds: Record<string, number>;
796
+ classComposition: { cls: AppWorkloadClass; count: number }[];
500
797
  }
501
798
 
502
799
  export interface AppGroupEnvCell {
503
- env: string
504
- health: AppHealth
505
- version?: string
506
- count: number
507
- firstKey: string
800
+ env: string;
801
+ health: AppHealth;
802
+ version?: string;
803
+ count: number;
804
+ firstKey: string;
508
805
  }
509
806
 
510
807
  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 }
808
+ kind: "group";
809
+ key: string;
810
+ label: string;
811
+ members: T[];
812
+ expanded: boolean;
813
+ cells: AppGroupEnvCell[];
814
+ lag: string | null;
815
+ health: AppHealth;
816
+ ready: number;
817
+ desired: number;
818
+ kinds: Record<string, number>;
819
+ classComposition: { cls: AppWorkloadClass; count: number }[];
820
+ workloadClass: AppWorkloadClass;
821
+ confidence: string;
822
+ }
823
+
824
+ export type FoldedRow<T extends AppGroupFoldEntry> =
825
+ FoldedAppGroupRow<T> | { kind: "instance"; entry: T; child?: boolean };
528
826
 
529
827
  export interface FoldAppGroupsOptions<T extends AppGroupFoldEntry> {
530
828
  /** Scope for non-portable identities. OSS leaves this empty; fleet callers
531
829
  * should include the cluster id so local name/repo evidence cannot merge
532
830
  * unrelated clusters. Portable identities ignore the scope. */
533
- localScope?: (entry: T) => string | undefined
831
+ localScope?: (entry: T) => string | undefined;
534
832
  /** Per-member env slices for the env ladder. A fleet member spans several
535
833
  * per-cluster envs, so the host supplies them (cluster-coverage derived,
536
834
  * authoritative) rather than the single `identity.env` — which the hub can
537
835
  * stale when it joins the same overlay key across clusters. Defaults to the
538
836
  * member's single identity env. */
539
- envsOf?: (entry: T) => Array<{ env: string; health: AppHealth }>
837
+ envsOf?: (entry: T) => Array<{ env: string; health: AppHealth }>;
540
838
  }
541
839
 
542
840
  export function foldAppGroups<T extends AppGroupFoldEntry>(
@@ -546,78 +844,105 @@ export function foldAppGroups<T extends AppGroupFoldEntry>(
546
844
  options: FoldAppGroupsOptions<T> = {},
547
845
  ): FoldedRow<T>[] {
548
846
  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
847
+ e.versions.reduce<string | undefined>(
848
+ (best, v) => (!best || compareVersions(v, best) === 1 ? v : best),
849
+ undefined,
850
+ ) ?? e.row.appVersion;
550
851
  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
852
+ const id = e.row.identity;
853
+ if (!id) return null;
854
+ const scope = options.localScope?.(e);
855
+ if (!scope) return id.key;
555
856
  // A declared-PATH identity displays its name stem as the key but carries the
556
857
  // path stem in pathKey; two different apps can share a name while declaring
557
858
  // 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[]>()
859
+ return id.portable
860
+ ? `portable:${id.key}${id.pathKey ? `:${id.pathKey}` : ""}`
861
+ : `local:${scope}:${id.key}`;
862
+ };
863
+ const byGroup = new Map<string, T[]>();
561
864
  for (const e of entries) {
562
- const k = groupKey(e)
563
- if (k) byGroup.set(k, [...(byGroup.get(k) ?? []), e])
865
+ const k = groupKey(e);
866
+ if (k) byGroup.set(k, [...(byGroup.get(k) ?? []), e]);
564
867
  }
565
- const emitted = new Set<string>()
566
- const out: FoldedRow<T>[] = []
868
+ const emitted = new Set<string>();
869
+ const out: FoldedRow<T>[] = [];
567
870
  for (const e of entries) {
568
- const id = e.row.identity
569
- const k = groupKey(e)
871
+ const id = e.row.identity;
872
+ const k = groupKey(e);
570
873
  // A group needs ≥2 SURVIVING members — filters can orphan one, which
571
874
  // then renders as the plain instance it is.
572
875
  if (!id || !k || (byGroup.get(k)?.length ?? 0) < 2) {
573
- out.push({ kind: 'instance', entry: e })
574
- continue
876
+ out.push({ kind: "instance", entry: e });
877
+ continue;
575
878
  }
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
879
+ if (emitted.has(k)) continue;
880
+ emitted.add(k);
881
+ const members = byGroup.get(k)!;
882
+
883
+ const cellMap = new Map<string, AppGroupEnvCell>();
884
+ const kinds: Record<string, number> = {};
885
+ const compMap = new Map<AppWorkloadClass, number>();
886
+ let ready = 0;
887
+ let desired = 0;
585
888
  // Seed with the most-benign tier (rank 0) so the max-fold below has a valid
586
889
  // identity now that `unknown` ranks above healthy — see worstHealth.
587
- let health: AppHealth = 'neutral'
890
+ let health: AppHealth = "neutral";
588
891
  for (const m of members) {
589
- const v = newest(m)
892
+ const v = newest(m);
590
893
  // A fleet member spans several per-cluster envs; the host supplies them so
591
894
  // the ladder reflects every env, not just the member's (possibly stale)
592
895
  // single identity env. Default: the one identity env.
593
- const slices = options.envsOf?.(m) ?? [{ env: m.row.identity!.env, health: m.health }]
896
+ const slices = options.envsOf?.(m) ?? [
897
+ { env: m.row.identity!.env, health: m.health },
898
+ ];
594
899
  for (const slice of slices) {
595
- const cur = cellMap.get(slice.env)
900
+ const cur = cellMap.get(slice.env);
596
901
  if (!cur) {
597
- cellMap.set(slice.env, { env: slice.env, health: slice.health, version: v, count: 1, firstKey: m.row.key })
902
+ cellMap.set(slice.env, {
903
+ env: slice.env,
904
+ health: slice.health,
905
+ version: v,
906
+ count: 1,
907
+ firstKey: m.row.key,
908
+ });
598
909
  } 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
910
+ cur.count++;
911
+ if ((HEALTH_RANK[slice.health] ?? 0) > (HEALTH_RANK[cur.health] ?? 0))
912
+ cur.health = slice.health;
913
+ if (v && (!cur.version || compareVersions(v, cur.version) === 1))
914
+ cur.version = v;
602
915
  }
603
916
  }
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)
917
+ if ((HEALTH_RANK[m.health] ?? 0) > (HEALTH_RANK[health] ?? 0))
918
+ health = m.health;
919
+ ready += m.ready;
920
+ desired += m.desired;
921
+ for (const [k, n] of Object.entries(m.kinds))
922
+ kinds[k] = (kinds[k] ?? 0) + n;
923
+ for (const c of m.classComposition)
924
+ compMap.set(c.cls, (compMap.get(c.cls) ?? 0) + c.count);
609
925
  }
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')
926
+ const cells = orderEnvs([...cellMap.keys()]).map((env) =>
927
+ cellMap.get(env)!,
928
+ );
929
+ const classComposition = CLASS_ORDER.filter((c) => compMap.has(c)).map(
930
+ (c) => ({ cls: c, count: compMap.get(c)! }),
931
+ );
932
+ const known = classComposition
933
+ .map((c) => c.cls)
934
+ .filter((c) => c !== "unknown");
613
935
  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)
936
+ known.length === 0
937
+ ? "unknown"
938
+ : known.includes("service") && !known.includes("job")
939
+ ? "service"
940
+ : known.length === 1
941
+ ? known[0]
942
+ : "mixed";
943
+ const expanded = autoExpand || expandedKeys.has(k);
619
944
  out.push({
620
- kind: 'group',
945
+ kind: "group",
621
946
  key: k,
622
947
  label: id.key,
623
948
  members,
@@ -630,21 +955,27 @@ export function foldAppGroups<T extends AppGroupFoldEntry>(
630
955
  kinds,
631
956
  classComposition,
632
957
  workloadClass,
633
- confidence: members.some((m) => m.row.identity!.confidence === 'high') ? 'high' : 'medium',
634
- })
958
+ confidence: members.some((m) => m.row.identity!.confidence === "high")
959
+ ? "high"
960
+ : "medium",
961
+ });
635
962
  if (expanded) {
636
- for (const m of members) out.push({ kind: 'instance', entry: m, child: true })
963
+ for (const m of members)
964
+ out.push({ kind: "instance", entry: m, child: true });
637
965
  }
638
966
  }
639
- return out
967
+ return out;
640
968
  }
641
969
 
642
970
  /** Normalize a wire health string to the AppHealth union (the health twin of
643
971
  * workloadClassOf — keeps `as AppHealth` casts out of components). */
644
972
  export function healthOf(value: string | undefined): AppHealth {
645
- return value === 'unhealthy' || value === 'degraded' || value === 'healthy' || value === 'neutral'
973
+ return value === "unhealthy" ||
974
+ value === "degraded" ||
975
+ value === "healthy" ||
976
+ value === "neutral"
646
977
  ? value
647
- : 'unknown'
978
+ : "unknown";
648
979
  }
649
980
 
650
981
  // -----------------------------------------------------------------------------
@@ -658,16 +989,28 @@ export function healthOf(value: string | undefined): AppHealth {
658
989
  // healthy (a node-lost workload is worse than a running one); `neutral`
659
990
  // (intentional/idle) is the most-benign tier, so an all-idle app rolls up to
660
991
  // "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 }
992
+ // neutral in the max). `unknown` must outrank `healthy` here to agree with the
993
+ // backend rollup — a node-lost workload can't read as healthy.
994
+ export const HEALTH_ORDER: AppHealth[] = [
995
+ "unhealthy",
996
+ "degraded",
997
+ "unknown",
998
+ "healthy",
999
+ "neutral",
1000
+ ];
1001
+ export const HEALTH_RANK: Record<string, number> = {
1002
+ unhealthy: 4,
1003
+ degraded: 3,
1004
+ unknown: 2,
1005
+ healthy: 1,
1006
+ neutral: 0,
1007
+ };
665
1008
 
666
1009
  export interface HealthMeta {
667
- label: string
668
- bar: string
669
- text: string
670
- pill: string
1010
+ label: string;
1011
+ bar: string;
1012
+ text: string;
1013
+ pill: string;
671
1014
  }
672
1015
 
673
1016
  // ─── Chip dialect ────────────────────────────────────────────────────────────
@@ -676,48 +1019,114 @@ export interface HealthMeta {
676
1019
  // standalone status pills. A local dialect, but defined ONCE here: call sites
677
1020
  // compose `CHIP` (chrome) + a `CHIP_TONE` (color), never inline the strings.
678
1021
  // 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'
1022
+ export const CHIP =
1023
+ "inline-flex items-center rounded-sm px-1.5 py-px text-[10px] font-medium ring-1 ring-inset";
680
1024
  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
1025
+ rose: "bg-rose-50 text-rose-700 ring-rose-200 dark:bg-rose-950/40 dark:text-rose-300 dark:ring-rose-900",
1026
+ amber:
1027
+ "bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-950/40 dark:text-amber-300 dark:ring-amber-900",
1028
+ emerald:
1029
+ "bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-300 dark:ring-emerald-900",
1030
+ blue: "bg-blue-50 text-blue-700 ring-blue-200 dark:bg-blue-950/40 dark:text-blue-300 dark:ring-blue-900",
1031
+ sky: "bg-sky-50 text-sky-700 ring-sky-200 dark:bg-sky-950/40 dark:text-sky-300 dark:ring-sky-900",
1032
+ violet:
1033
+ "bg-violet-50 text-violet-700 ring-violet-200 dark:bg-violet-950/40 dark:text-violet-300 dark:ring-violet-900",
1034
+ neutral: "bg-theme-hover text-theme-text-secondary ring-theme-border",
1035
+ muted: "bg-theme-hover text-theme-text-tertiary ring-theme-border",
1036
+ } as const;
690
1037
 
691
1038
  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 },
1039
+ unhealthy: {
1040
+ label: "Unhealthy",
1041
+ bar: "bg-rose-500",
1042
+ text: "text-rose-600 dark:text-rose-400",
1043
+ pill: CHIP_TONE.rose,
1044
+ },
1045
+ degraded: {
1046
+ label: "Degraded",
1047
+ bar: "bg-amber-500",
1048
+ text: "text-amber-600 dark:text-amber-400",
1049
+ pill: CHIP_TONE.amber,
1050
+ },
1051
+ healthy: {
1052
+ label: "Healthy",
1053
+ bar: "bg-emerald-500",
1054
+ text: "text-emerald-600 dark:text-emerald-400",
1055
+ pill: CHIP_TONE.emerald,
1056
+ },
695
1057
  // neutral = intentionally idle/off (every workload suspended or scaled to 0).
696
1058
  // 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
- }
1059
+ neutral: {
1060
+ label: "Idle",
1061
+ bar: "bg-sky-500",
1062
+ text: "text-sky-600 dark:text-sky-400",
1063
+ pill: CHIP_TONE.sky,
1064
+ },
1065
+ unknown: {
1066
+ label: "Unknown",
1067
+ bar: "bg-slate-400",
1068
+ text: "text-theme-text-tertiary",
1069
+ pill: CHIP_TONE.muted,
1070
+ },
1071
+ };
1072
+
1073
+ export const CLASS_ORDER: AppWorkloadClass[] = [
1074
+ "service",
1075
+ "worker",
1076
+ "job",
1077
+ "unknown",
1078
+ ];
1079
+
1080
+ export const CLASS_META: Record<
1081
+ AppWorkloadClass,
1082
+ { label: string; pill: string; tooltip: string }
1083
+ > = {
1084
+ service: {
1085
+ label: "Service",
1086
+ pill: CHIP_TONE.blue,
1087
+ tooltip:
1088
+ "Long-running, request-serving (a Deployment/StatefulSet behind a Service/Ingress/route). Inferred from the workload shape + routing.",
1089
+ },
1090
+ worker: {
1091
+ label: "Worker",
1092
+ pill: CHIP_TONE.violet,
1093
+ tooltip:
1094
+ "Long-running background processor (no serving edge). Inferred from the workload shape.",
1095
+ },
1096
+ job: {
1097
+ label: "Job",
1098
+ pill: CHIP_TONE.amber,
1099
+ tooltip:
1100
+ "Finite or scheduled work (Job/CronJob/Workflow/CronWorkflow/WorkflowTemplate/ClusterWorkflowTemplate/ScaledJob).",
1101
+ },
1102
+ mixed: {
1103
+ label: "Mixed",
1104
+ pill: CHIP_TONE.neutral,
1105
+ tooltip:
1106
+ "Contains workloads of more than one class (e.g. a service plus its scheduled jobs).",
1107
+ },
1108
+ unknown: {
1109
+ label: "Unknown",
1110
+ pill: CHIP_TONE.muted,
1111
+ tooltip: "Couldn't infer a runtime class from the workload.",
1112
+ },
1113
+ };
710
1114
 
711
1115
  /** Per-class workload counts for an app, in CLASS_ORDER — the composition
712
1116
  * behind a "Mixed" badge and the inclusive Class facet (filtering "Service"
713
1117
  * matches mixed apps that contain a service). */
714
- export function classCompositionOf(app: AppRow): { cls: AppWorkloadClass; count: number }[] {
715
- const counts = new Map<AppWorkloadClass, number>()
1118
+ export function classCompositionOf(
1119
+ app: AppRow,
1120
+ ): { cls: AppWorkloadClass; count: number }[] {
1121
+ const counts = new Map<AppWorkloadClass, number>();
716
1122
  for (const w of app.workloads || []) {
717
- const c = workloadClassOf(w.workload_class)
718
- counts.set(c, (counts.get(c) ?? 0) + 1)
1123
+ const c = workloadClassOf(w.workload_class);
1124
+ counts.set(c, (counts.get(c) ?? 0) + 1);
719
1125
  }
720
- return CLASS_ORDER.filter((c) => counts.has(c)).map((c) => ({ cls: c, count: counts.get(c)! }))
1126
+ return CLASS_ORDER.filter((c) => counts.has(c)).map((c) => ({
1127
+ cls: c,
1128
+ count: counts.get(c)!,
1129
+ }));
721
1130
  }
722
1131
 
723
1132
  /** The distinct KNOWN classes an app contains — the facet-matching set. Falls
@@ -725,20 +1134,358 @@ export function classCompositionOf(app: AppRow): { cls: AppWorkloadClass; count:
725
1134
  export function classSetOf(app: AppRow): AppWorkloadClass[] {
726
1135
  const known = classCompositionOf(app)
727
1136
  .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)]
1137
+ .filter((c) => c === "service" || c === "worker" || c === "job");
1138
+ if (known.length > 0) return known;
1139
+ return [workloadClassOf(app.workload_class)];
1140
+ }
1141
+
1142
+ export interface BatchSignal {
1143
+ tone: "rose" | "amber" | "sky" | "emerald" | "muted";
1144
+ label: string;
1145
+ detail: string;
1146
+ workload: AppWorkload;
1147
+ }
1148
+
1149
+ export interface AppBatchActivity {
1150
+ workload: AppWorkload;
1151
+ tone: BatchSignal["tone"];
1152
+ rank: number;
1153
+ label: string;
1154
+ detail: string;
1155
+ latestRunName?: string;
1156
+ latestRunPhase?: string;
1157
+ latestStartedAt?: string;
1158
+ latestFinishedAt?: string;
1159
+ schedule?: string;
1160
+ activeRuns: number;
1161
+ retainedRuns: number;
1162
+ failedRuns: number;
1163
+ lastScheduledAt?: string;
1164
+ lastSuccessfulAt?: string;
1165
+ }
1166
+
1167
+ export function batchActivityForApp(app: AppRow): AppBatchActivity[] {
1168
+ return (app.workloads || [])
1169
+ .map(batchActivityForWorkload)
1170
+ .filter((activity): activity is AppBatchActivity => Boolean(activity))
1171
+ .sort(
1172
+ (a, b) =>
1173
+ b.rank - a.rank || a.workload.name.localeCompare(b.workload.name),
1174
+ );
1175
+ }
1176
+
1177
+ export function batchSignalForApp(app: AppRow): BatchSignal | null {
1178
+ let best: BatchSignal | null = null;
1179
+ for (const workload of app.workloads || []) {
1180
+ const signal = batchSignalForWorkload(workload);
1181
+ if (!signal) continue;
1182
+ if (!best || batchSignalRank(signal) > batchSignalRank(best)) best = signal;
1183
+ }
1184
+ return best;
1185
+ }
1186
+
1187
+ function batchSignalForWorkload(workload: AppWorkload): BatchSignal | null {
1188
+ const batch = workload.batch;
1189
+ if (!batch) return null;
1190
+ const latestPhase = batch.latestRunPhase || "";
1191
+ const name = `${workload.kind}/${workload.name}`;
1192
+ if (latestPhase === "Failed" || latestPhase === "Error") {
1193
+ return {
1194
+ tone: "rose",
1195
+ label: "batch failed",
1196
+ detail:
1197
+ batch.message ||
1198
+ `${name} latest run ${batch.latestRunName || ""} ended ${latestPhase}.`,
1199
+ workload,
1200
+ };
1201
+ }
1202
+ if (batch.suspended) {
1203
+ return {
1204
+ tone: "sky",
1205
+ label: "schedule suspended",
1206
+ detail: `${name} will not create new runs until resumed.`,
1207
+ workload,
1208
+ };
1209
+ }
1210
+ if ((batch.activeRuns ?? 0) > 0) {
1211
+ return {
1212
+ tone: "sky",
1213
+ label: `${batch.activeRuns} running`,
1214
+ detail: `${name} currently has ${batch.activeRuns} active run${batch.activeRuns === 1 ? "" : "s"}.`,
1215
+ workload,
1216
+ };
1217
+ }
1218
+ return null;
1219
+ }
1220
+
1221
+ export interface AppBatchRuntime {
1222
+ label: string;
1223
+ health: AppHealth;
1224
+ detail: string;
1225
+ }
1226
+
1227
+ export function batchRuntimeForApp(app: AppRow): AppBatchRuntime {
1228
+ const activity = batchActivityForApp(app);
1229
+ if (
1230
+ activity.some(
1231
+ (item) => item.activeRuns > 0 || item.latestRunPhase === "Running",
1232
+ )
1233
+ ) {
1234
+ const active = activity.reduce((sum, item) => sum + item.activeRuns, 0);
1235
+ return {
1236
+ label: active > 0 ? `${active} running` : "Running",
1237
+ health: "neutral",
1238
+ detail: "Batch work is executing now.",
1239
+ };
1240
+ }
1241
+ const failed = activity.filter(
1242
+ (item) =>
1243
+ item.latestRunPhase === "Failed" || item.latestRunPhase === "Error",
1244
+ );
1245
+ if (failed.length > 0) {
1246
+ const item = failed[0];
1247
+ const detail =
1248
+ failed.length === 1
1249
+ ? `${item.workload.kind}/${item.workload.name} latest retained run${item.latestRunName ? ` ${item.latestRunName}` : ""} failed.`
1250
+ : `${failed.length} batch workloads have failed latest runs.`;
1251
+ return { label: "Failed", health: "unhealthy", detail };
1252
+ }
1253
+ const latest = [...activity].sort(
1254
+ (a, b) => batchActivityTimestamp(b) - batchActivityTimestamp(a),
1255
+ )[0];
1256
+ if (latest?.latestRunPhase === "Succeeded") {
1257
+ return {
1258
+ label: "Succeeded",
1259
+ health: "healthy",
1260
+ detail: latest.latestRunName
1261
+ ? `Latest retained run: ${latest.latestRunName}`
1262
+ : "The latest retained run succeeded.",
1263
+ };
1264
+ }
1265
+ if (
1266
+ activity.length > 0 &&
1267
+ activity.every((item) => item.workload.batch?.suspended)
1268
+ ) {
1269
+ return {
1270
+ label: "Suspended",
1271
+ health: "neutral",
1272
+ detail: "All batch launchers are suspended.",
1273
+ };
1274
+ }
1275
+ if (activity.some((item) => item.retainedRuns > 0)) {
1276
+ return {
1277
+ label: latest?.latestRunPhase || "Idle",
1278
+ health: "neutral",
1279
+ detail: "No batch work is currently active.",
1280
+ };
1281
+ }
1282
+ return {
1283
+ label: "Idle",
1284
+ health: "neutral",
1285
+ detail: "No retained runs are present in Kubernetes.",
1286
+ };
1287
+ }
1288
+
1289
+ function batchActivityTimestamp(item: AppBatchActivity): number {
1290
+ const raw =
1291
+ item.latestStartedAt ||
1292
+ item.latestFinishedAt ||
1293
+ item.lastScheduledAt ||
1294
+ item.lastSuccessfulAt;
1295
+ if (!raw) return 0;
1296
+ const parsed = Date.parse(raw);
1297
+ return Number.isNaN(parsed) ? 0 : parsed;
1298
+ }
1299
+
1300
+ function applicationRuntimeHealth(app: AppRow): AppHealth {
1301
+ if (workloadClassOf(app.workload_class) === "job")
1302
+ return batchRuntimeForApp(app).health;
1303
+ const serving = (app.workloads || []).filter((workload) => !workload.batch);
1304
+ return serving.length > 0
1305
+ ? worstHealth(serving.map((workload) => workload.health))
1306
+ : healthOf(app.health);
1307
+ }
1308
+
1309
+ export function sourceSyncHealth(status: string): AppHealth {
1310
+ const normalized = status.toLowerCase();
1311
+ if (normalized === "synced") return "healthy";
1312
+ if (normalized === "outofsync") return "degraded";
1313
+ return "unknown";
1314
+ }
1315
+
1316
+ export function sourceReportedHealth(status: string): AppHealth {
1317
+ const normalized = status.toLowerCase();
1318
+ if (normalized === "healthy") return "healthy";
1319
+ if (normalized === "progressing") return "neutral";
1320
+ if (normalized === "degraded") return "degraded";
1321
+ if (normalized === "missing") return "unhealthy";
1322
+ if (normalized === "suspended") return "neutral";
1323
+ return "unknown";
1324
+ }
1325
+
1326
+ function applicationDeliveryHealth(status?: AppSourceStatus): AppHealth {
1327
+ if (!status) return "neutral";
1328
+ const signals = [
1329
+ status.sync ? sourceSyncHealth(status.sync) : null,
1330
+ status.health ? sourceReportedHealth(status.health) : null,
1331
+ ].filter((health): health is AppHealth => health !== null && health !== "unknown");
1332
+ return signals.length > 0 ? worstHealth(signals) : "neutral";
1333
+ }
1334
+
1335
+ export function applicationDisplayHealth(app: AppRow): AppHealth {
1336
+ return worstHealth([
1337
+ applicationRuntimeHealth(app),
1338
+ applicationDeliveryHealth(app.sourceStatus),
1339
+ ]);
1340
+ }
1341
+
1342
+ export function servingReadiness(workloads: AppWorkload[]): {
1343
+ ready: number;
1344
+ desired: number;
1345
+ } {
1346
+ let ready = 0;
1347
+ let desired = 0;
1348
+ for (const workload of workloads) {
1349
+ const cls = workloadClassOf(workload.workload_class);
1350
+ if (cls === "job" || workload.batch) continue;
1351
+ ready += workload.ready ?? 0;
1352
+ desired += workload.desired ?? 0;
1353
+ }
1354
+ return { ready, desired };
1355
+ }
1356
+
1357
+ function batchActivityForWorkload(
1358
+ workload: AppWorkload,
1359
+ ): AppBatchActivity | null {
1360
+ const batch = workload.batch;
1361
+ if (!batch) return null;
1362
+ const latestPhase = batch.latestRunPhase || "";
1363
+ const name = `${workload.kind}/${workload.name}`;
1364
+ const activeRuns = batch.activeRuns ?? 0;
1365
+ const retainedRuns = batch.retainedRuns ?? 0;
1366
+ const failedRuns = batch.failedRuns ?? 0;
1367
+ if (latestPhase === "Failed" || latestPhase === "Error") {
1368
+ return {
1369
+ workload,
1370
+ tone: "rose",
1371
+ rank: 50,
1372
+ label: "Latest run failed",
1373
+ detail:
1374
+ batch.message ||
1375
+ `${name} latest run ${batch.latestRunName || ""} ended ${latestPhase}.`,
1376
+ latestRunName: batch.latestRunName,
1377
+ latestRunPhase: latestPhase,
1378
+ latestStartedAt: batch.latestStartedAt,
1379
+ latestFinishedAt: batch.latestFinishedAt,
1380
+ schedule: batch.schedule,
1381
+ activeRuns,
1382
+ retainedRuns,
1383
+ failedRuns,
1384
+ lastScheduledAt: batch.lastScheduledAt,
1385
+ lastSuccessfulAt: batch.lastSuccessfulAt,
1386
+ };
1387
+ }
1388
+ if (failedRuns > 0) {
1389
+ return {
1390
+ workload,
1391
+ tone: "amber",
1392
+ rank: 40,
1393
+ label: `${failedRuns} retained failed ${failedRuns === 1 ? "run" : "runs"}`,
1394
+ detail: `${name} has retained failed executions in Kubernetes.`,
1395
+ latestRunName: batch.latestRunName,
1396
+ latestRunPhase: latestPhase,
1397
+ latestStartedAt: batch.latestStartedAt,
1398
+ latestFinishedAt: batch.latestFinishedAt,
1399
+ schedule: batch.schedule,
1400
+ activeRuns,
1401
+ retainedRuns,
1402
+ failedRuns,
1403
+ lastScheduledAt: batch.lastScheduledAt,
1404
+ lastSuccessfulAt: batch.lastSuccessfulAt,
1405
+ };
1406
+ }
1407
+ if (activeRuns > 0) {
1408
+ return {
1409
+ workload,
1410
+ tone: "sky",
1411
+ rank: 35,
1412
+ label: `${activeRuns} active ${activeRuns === 1 ? "run" : "runs"}`,
1413
+ detail: `${name} is executing now.`,
1414
+ latestRunName: batch.latestRunName,
1415
+ latestRunPhase: latestPhase,
1416
+ latestStartedAt: batch.latestStartedAt,
1417
+ latestFinishedAt: batch.latestFinishedAt,
1418
+ schedule: batch.schedule,
1419
+ activeRuns,
1420
+ retainedRuns,
1421
+ failedRuns,
1422
+ lastScheduledAt: batch.lastScheduledAt,
1423
+ lastSuccessfulAt: batch.lastSuccessfulAt,
1424
+ };
1425
+ }
1426
+ if (batch.suspended) {
1427
+ return {
1428
+ workload,
1429
+ tone: "sky",
1430
+ rank: 30,
1431
+ label: "Schedule suspended",
1432
+ detail: `${name} will not create new runs until resumed.`,
1433
+ latestRunName: batch.latestRunName,
1434
+ latestRunPhase: latestPhase,
1435
+ latestStartedAt: batch.latestStartedAt,
1436
+ latestFinishedAt: batch.latestFinishedAt,
1437
+ schedule: batch.schedule,
1438
+ activeRuns,
1439
+ retainedRuns,
1440
+ failedRuns,
1441
+ lastScheduledAt: batch.lastScheduledAt,
1442
+ lastSuccessfulAt: batch.lastSuccessfulAt,
1443
+ };
1444
+ }
1445
+ return {
1446
+ workload,
1447
+ tone: "muted",
1448
+ rank: latestPhase === "Succeeded" ? 20 : 10,
1449
+ label:
1450
+ latestPhase === "Succeeded" ? "Latest run succeeded" : "No active runs",
1451
+ detail: `${name} has ${retainedRuns} retained ${retainedRuns === 1 ? "run" : "runs"}.`,
1452
+ latestRunName: batch.latestRunName,
1453
+ latestRunPhase: latestPhase,
1454
+ latestStartedAt: batch.latestStartedAt,
1455
+ latestFinishedAt: batch.latestFinishedAt,
1456
+ schedule: batch.schedule,
1457
+ activeRuns,
1458
+ retainedRuns,
1459
+ failedRuns,
1460
+ lastScheduledAt: batch.lastScheduledAt,
1461
+ lastSuccessfulAt: batch.lastSuccessfulAt,
1462
+ };
1463
+ }
1464
+
1465
+ function batchSignalRank(signal: BatchSignal): number {
1466
+ switch (signal.tone) {
1467
+ case "rose":
1468
+ return 5;
1469
+ case "amber":
1470
+ return 4;
1471
+ case "sky":
1472
+ return 3;
1473
+ case "emerald":
1474
+ return 2;
1475
+ default:
1476
+ return 1;
1477
+ }
731
1478
  }
732
1479
 
733
1480
  export function workloadClassOf(value?: AppWorkloadClass): AppWorkloadClass {
734
1481
  switch (value) {
735
- case 'service':
736
- case 'worker':
737
- case 'job':
738
- case 'mixed':
739
- return value
1482
+ case "service":
1483
+ case "worker":
1484
+ case "job":
1485
+ case "mixed":
1486
+ return value;
740
1487
  default:
741
- return 'unknown'
1488
+ return "unknown";
742
1489
  }
743
1490
  }
744
1491
 
@@ -749,20 +1496,21 @@ export function workloadClassOf(value?: AppWorkloadClass): AppWorkloadClass {
749
1496
  * ranks ABOVE healthy — an all-healthy set would never beat it and return
750
1497
  * `unknown`. Mirrors pkg/health.WorseOf.) */
751
1498
  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
1499
+ let w: AppHealth = "neutral";
1500
+ for (const h of hs)
1501
+ if ((HEALTH_RANK[h] ?? 0) > (HEALTH_RANK[w] ?? 0)) w = h as AppHealth;
1502
+ return w;
755
1503
  }
756
1504
 
757
1505
  export function newestTag(versions: string[]): string | undefined {
758
- let best: string | undefined
1506
+ let best: string | undefined;
759
1507
  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
1508
+ const t = v?.trim();
1509
+ if (!t) continue;
1510
+ if (best === undefined) best = t;
1511
+ else if (compareVersions(t, best) === 1) best = t;
764
1512
  }
765
- return best
1513
+ return best;
766
1514
  }
767
1515
 
768
1516
  // -----------------------------------------------------------------------------
@@ -775,78 +1523,85 @@ export function newestTag(versions: string[]): string | undefined {
775
1523
  // -----------------------------------------------------------------------------
776
1524
 
777
1525
  export interface AppEntryBase {
778
- row: AppRow
779
- health: AppHealth
780
- versions: string[]
781
- kinds: Record<string, number>
782
- workloadClass: AppWorkloadClass
1526
+ row: AppRow;
1527
+ health: AppHealth;
1528
+ versions: string[];
1529
+ kinds: Record<string, number>;
1530
+ workloadClass: AppWorkloadClass;
783
1531
  /** 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
1532
+ classSet: AppWorkloadClass[];
1533
+ classComposition: { cls: AppWorkloadClass; count: number }[];
1534
+ category: AppCategory;
1535
+ ready: number;
1536
+ desired: number;
789
1537
  /** ready/desired as a fraction for sorting; -1 when nothing is desired. */
790
- readyRatio: number
791
- source: AppSource
1538
+ readyRatio: number;
1539
+ source: AppSource;
792
1540
  }
793
1541
 
794
1542
  export interface SingleAppEntry extends AppEntryBase {
795
- variant: 'single'
796
- namespace: string
797
- namespaces: string[]
798
- env: string
799
- envInferred: boolean
1543
+ variant: "single";
1544
+ namespace: string;
1545
+ namespaces: string[];
1546
+ env: string;
1547
+ envInferred: boolean;
800
1548
  }
801
1549
 
802
1550
  /** One environment instance within a fleet row (env name + worst health across
803
1551
  * its clusters + whether the env was inferred from the namespace). */
804
1552
  export interface EnvSlice {
805
- env: string
806
- health: AppHealth
807
- inferred: boolean
1553
+ env: string;
1554
+ health: AppHealth;
1555
+ inferred: boolean;
808
1556
  }
809
1557
 
810
1558
  /** A cluster this fleet row's app runs in. */
811
1559
  export interface AppClusterRef {
812
- id: string
813
- name: string
814
- health: AppHealth
1560
+ id: string;
1561
+ name: string;
1562
+ health: AppHealth;
815
1563
  }
816
1564
 
817
1565
  export interface FleetAppEntry extends AppEntryBase {
818
- variant: 'fleet'
819
- envs: EnvSlice[]
820
- clusters: AppClusterRef[]
1566
+ variant: "fleet";
1567
+ envs: EnvSlice[];
1568
+ clusters: AppClusterRef[];
821
1569
  /** True when the app runs different versions across its clusters/envs. */
822
- versionSkew: boolean
1570
+ versionSkew: boolean;
823
1571
  }
824
1572
 
825
- export type AppEntry = SingleAppEntry | FleetAppEntry
1573
+ export type AppEntry = SingleAppEntry | FleetAppEntry;
826
1574
 
827
1575
  /** Build a single-cluster list entry from a wire row. The env is resolved via
828
1576
  * the server's identity classification (authoritative) with a namespace
829
1577
  * 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
1578
+ export function buildSingleAppEntry(
1579
+ row: AppRow,
1580
+ discoveredEnvs?: ReadonlySet<string>,
1581
+ ): SingleAppEntry {
1582
+ const kinds: Record<string, number> = {};
1583
+ let ready = 0;
1584
+ let desired = 0;
834
1585
  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
1586
+ kinds[wl.kind] = (kinds[wl.kind] ?? 0) + 1;
1587
+ if (workloadClassOf(wl.workload_class) !== "job" && !wl.batch) {
1588
+ ready += wl.ready ?? 0;
1589
+ desired += wl.desired ?? 0;
1590
+ }
838
1591
  }
839
- const namespace = namespaceOf(row)
1592
+ const namespace = namespaceOf(row);
840
1593
  // The server's identity classification carries the authoritative env (label/
841
1594
  // declared/discovered); plain rows fall back to the trio + discovered-token
842
1595
  // 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
1596
+ const resolved = resolveEnv(undefined, namespace, discoveredEnvs);
1597
+ const env = row.identity?.env ?? resolved.env;
1598
+ const inferred = row.identity
1599
+ ? identityEnvInferred(row.identity)
1600
+ : resolved.inferred;
846
1601
  return {
847
- variant: 'single',
1602
+ variant: "single",
848
1603
  row,
849
- health: healthOf(row.health),
1604
+ health: applicationDisplayHealth(row),
850
1605
  versions: Array.from(new Set((row.versions || []).filter(Boolean))),
851
1606
  namespace,
852
1607
  namespaces: namespacesOf(row),
@@ -861,16 +1616,25 @@ export function buildSingleAppEntry(row: AppRow, discoveredEnvs?: ReadonlySet<st
861
1616
  desired,
862
1617
  readyRatio: desired > 0 ? ready / desired : -1,
863
1618
  source: sourceOf(row.tier),
864
- }
1619
+ };
865
1620
  }
866
1621
 
867
1622
  /** The text an entry matches free-text search against — name, key, namespace,
868
1623
  * source/class/category labels, versions, env, workload kinds + identity. Pure
869
1624
  * and exported so the list core and tests share one definition. */
870
1625
  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)
1626
+ const workloadText = (e.row.workloads || []).flatMap((wl) => [
1627
+ wl.kind,
1628
+ wl.namespace,
1629
+ wl.name,
1630
+ wl.version,
1631
+ ]);
1632
+ const envParts =
1633
+ e.variant === "single"
1634
+ ? [e.env || "unlabeled"]
1635
+ : e.envs.map((s) => s.env || "unlabeled");
1636
+ const nsParts =
1637
+ e.variant === "single" ? [e.namespace] : e.clusters.map((c) => c.name);
874
1638
  return [
875
1639
  e.row.name,
876
1640
  e.row.key,
@@ -885,6 +1649,6 @@ export function searchTextForEntry(e: AppEntry): string {
885
1649
  ...workloadText,
886
1650
  ]
887
1651
  .filter(Boolean)
888
- .join(' ')
889
- .toLowerCase()
1652
+ .join(" ")
1653
+ .toLowerCase();
890
1654
  }