@skyhook-io/k8s-ui 1.7.12 → 1.7.13

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 (31) hide show
  1. package/package.json +1 -1
  2. package/src/components/applications/AppChips.tsx +109 -0
  3. package/src/components/applications/AppTooltips.tsx +199 -0
  4. package/src/components/applications/ApplicationDetail.tsx +671 -0
  5. package/src/components/applications/ApplicationsList.tsx +569 -0
  6. package/src/components/applications/ReadyBar.tsx +22 -0
  7. package/src/components/applications/index.ts +8 -0
  8. package/src/components/audit/AuditFindingsTable.tsx +3 -25
  9. package/src/components/logs/WorkloadLogsViewer.tsx +8 -5
  10. package/src/components/resources/renderers/WorkloadRenderer.tsx +5 -4
  11. package/src/components/shared/DetailShell.tsx +14 -7
  12. package/src/components/shared/EditableYamlView.tsx +37 -17
  13. package/src/components/timeline/TimelineList.tsx +3 -32
  14. package/src/components/timeline/TimelineSwimlanes.tsx +3 -31
  15. package/src/components/topology/K8sResourceNode.tsx +26 -5
  16. package/src/components/topology/TopologyGraph.tsx +102 -3
  17. package/src/components/topology/layout.ts +36 -11
  18. package/src/components/ui/CenteredEmpty.tsx +27 -0
  19. package/src/components/ui/SearchBox.tsx +85 -0
  20. package/src/components/ui/index.ts +1 -0
  21. package/src/components/workload/WorkloadView.tsx +167 -33
  22. package/src/components/workload/index.ts +1 -1
  23. package/src/hooks/useKeyboardShortcuts.tsx +3 -1
  24. package/src/index.ts +4 -0
  25. package/src/utils/applications.test.ts +207 -0
  26. package/src/utils/applications.ts +674 -0
  27. package/src/utils/format.ts +11 -0
  28. package/src/utils/index.ts +2 -0
  29. package/src/utils/topology-neighborhood.test.ts +185 -0
  30. package/src/utils/topology-neighborhood.ts +262 -0
  31. package/src/utils/workload-colors.ts +36 -0
@@ -0,0 +1,674 @@
1
+ // Shared model for the Applications surface — host-agnostic. The OSS single-
2
+ // cluster view and (eventually) the Cloud fleet view both build on these types
3
+ // and helpers. No React, no fetching.
4
+ //
5
+ // The wire shape mirrors radar OSS's GET /api/applications response
6
+ // (internal/server/applications.go). Field names match the Go json tags.
7
+
8
+ export type AppWorkloadClass = 'service' | 'worker' | 'job' | 'mixed' | 'unknown'
9
+ export type AppHealth = 'healthy' | 'degraded' | 'unhealthy' | 'unknown'
10
+
11
+ 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
24
+ }
25
+
26
+ export interface AppRelationships {
27
+ services?: string[]
28
+ ingresses?: string[]
29
+ routes?: string[]
30
+ configs?: number
31
+ scalers?: number
32
+ pdbs?: number
33
+ }
34
+
35
+ export interface AppEvent {
36
+ type: string
37
+ reason: string
38
+ message?: string
39
+ count: number
40
+ object: string
41
+ lastSeen?: string
42
+ }
43
+
44
+ export interface AppIdentity {
45
+ /** Shared display identity (the name stem). Derived classification — never
46
+ * an address; instance keys remain the only URLs. */
47
+ key: string
48
+ /** This instance's canonical env token (dev | staging | prod | …). */
49
+ env: string
50
+ /** high (declared source path) | medium (name stem + shared image repo). */
51
+ confidence: string
52
+ /** Human-readable why, for the app group chip tooltip. */
53
+ evidence: string
54
+ /** True when the key is backed by declared upstream identity and can group across clusters. */
55
+ portable?: boolean
56
+ }
57
+
58
+ export interface AppRow {
59
+ key: string
60
+ name: string
61
+ /** The single namespace the app's WORKLOADS run in; absent/empty when they
62
+ * span several (use `namespaces`). Residence, not the GitOps manager's home. */
63
+ namespace?: string
64
+ /** All distinct workload namespaces, sorted. */
65
+ namespaces?: string[]
66
+ /** App identity grouping evidence — instances of one logical app across
67
+ * environments share identity.key. See applications_identity.go. */
68
+ identity?: AppIdentity
69
+ /** pkg/subject overlay tier (0 = raw, no signal); 1-9. */
70
+ tier?: number
71
+ /** high | medium | low */
72
+ confidence?: string
73
+ /** app | addon | mixed — classification hint, never identity. */
74
+ category?: string
75
+ addonReason?: string
76
+ workload_class?: AppWorkloadClass
77
+ /** worst-of across workloads: healthy | degraded | unhealthy | unknown. */
78
+ health: string
79
+ /** distinct image tags. */
80
+ versions?: string[]
81
+ /** True when the SAME image runs different tags across workloads — real
82
+ * drift. Multiple components on different images is normal, not skew. */
83
+ versionSkew?: boolean
84
+ /** Single upstream version (app.kubernetes.io/version) when all workloads
85
+ * agree — the app's "main version". Empty for multi-chart umbrellas. */
86
+ appVersion?: string
87
+ workloads: AppWorkload[]
88
+ events?: AppEvent[]
89
+ relationships?: AppRelationships
90
+ }
91
+
92
+ // -----------------------------------------------------------------------------
93
+ // Environment ladder. Higher rank = more-promoted; prod is the top. Unranked
94
+ // envs sort trailing.
95
+ // -----------------------------------------------------------------------------
96
+
97
+ export const ENV_RANK: Record<string, number> = { dev: 0, staging: 1, prod: 2 }
98
+
99
+ /** Rank for an environment label, or null when it isn't on the ladder. */
100
+ export function envRank(env: string | undefined): number | null {
101
+ if (!env) return null
102
+ const r = ENV_RANK[env.toLowerCase()]
103
+ return r === undefined ? null : r
104
+ }
105
+
106
+ // Namespace-name token → canonical env. Matched on the whole name first, then
107
+ // on `-`/`_`-delimited segments (so `myapp-prod`, `staging-svc` resolve), which
108
+ // avoids substring false-hits like `prod` inside `product`.
109
+ // Only the universal trio is hardcoded (same trio-only policy as
110
+ // applications_identity.go; this synonym list is slightly more permissive) —
111
+ // every other env token is DISCOVERED by the server's app identity resolver and
112
+ // arrives on the wire as identity.env; callers pass those through extraTokens,
113
+ // so a "loadtest" namespace labels once the cluster itself proves the token is
114
+ // an env. Zero local vocabulary beyond the trio.
115
+ const ENV_NS_TOKENS: Record<string, string> = {
116
+ dev: 'dev', devel: 'dev', develop: 'dev', development: 'dev',
117
+ stg: 'staging', stage: 'staging', staging: 'staging',
118
+ prd: 'prod', prod: 'prod', production: 'prod', live: 'prod',
119
+ }
120
+
121
+ /** Infer a canonical environment from a namespace name, or null when nothing
122
+ * recognizable is present (conservative — `kube-system`, `billing` → null). */
123
+ export function envFromNamespace(namespace: string | undefined, extraTokens?: ReadonlySet<string>): string | null {
124
+ if (!namespace) return null
125
+ const lower = namespace.toLowerCase()
126
+ const hit = (tok: string): string | null => ENV_NS_TOKENS[tok] ?? (extraTokens?.has(tok) ? tok : null)
127
+ const whole = hit(lower)
128
+ if (whole) return whole
129
+ for (const seg of lower.split(/[-_]/).filter(Boolean)) {
130
+ const h = hit(seg)
131
+ if (h) return h
132
+ }
133
+ return null
134
+ }
135
+
136
+ export interface ResolvedAppEnv {
137
+ /** Canonical env token (lowercased), or '' when unlabeled. */
138
+ env: string
139
+ /** True when derived from the namespace heuristic (not an explicit label). */
140
+ inferred: boolean
141
+ }
142
+
143
+ /** Resolve an environment via the precedence cascade: an explicit env wins;
144
+ * otherwise the namespace heuristic (tagged inferred); otherwise unlabeled. */
145
+ export function resolveEnv(explicitEnv: string | undefined, namespace: string | undefined, extraTokens?: ReadonlySet<string>): ResolvedAppEnv {
146
+ const explicit = (explicitEnv || '').trim()
147
+ if (explicit) return { env: explicit.toLowerCase(), inferred: false }
148
+ const inferred = envFromNamespace(namespace, extraTokens)
149
+ if (inferred) return { env: inferred, inferred: true }
150
+ return { env: '', inferred: false }
151
+ }
152
+
153
+ export function identityEnvInferred(identity: AppIdentity | undefined): boolean {
154
+ if (!identity) return false
155
+ return identity.evidence.startsWith('namespace stem ')
156
+ }
157
+
158
+ // -----------------------------------------------------------------------------
159
+ // System namespaces — cluster plumbing hidden by default on the app surface.
160
+ // -----------------------------------------------------------------------------
161
+
162
+ const SYSTEM_NAMESPACES = new Set(['kube-system', 'kube-public', 'kube-node-lease', 'kube-flannel', 'local-path-storage'])
163
+
164
+ /** True for cluster-plumbing namespaces (kube-*, *-system operators) the app
165
+ * list hides by default. The `-system` suffix catches operator namespaces like
166
+ * `cert-manager`'s `gatekeeper-system`, `kourier-system`, etc.; `gke-managed-`
167
+ * is Google's documented prefix for GKE-managed component namespaces. */
168
+ export function isSystemNamespace(ns: string | undefined): boolean {
169
+ if (!ns) return false
170
+ const lower = ns.toLowerCase()
171
+ return SYSTEM_NAMESPACES.has(lower) || lower.endsWith('-system') || lower.startsWith('gke-managed-')
172
+ }
173
+
174
+ // -----------------------------------------------------------------------------
175
+ // Category — the app/add-on/mixed classification hint (never identity).
176
+ // -----------------------------------------------------------------------------
177
+
178
+ export type AppCategory = 'app' | 'addon' | 'mixed'
179
+
180
+ export const CATEGORY_ORDER: AppCategory[] = ['app', 'addon', 'mixed']
181
+
182
+ export const CATEGORY_META: Record<AppCategory, { label: string; tooltip: string }> = {
183
+ app: { label: 'App', tooltip: 'Software you deploy and run — services, workers, jobs.' },
184
+ addon: { label: 'Add-on', tooltip: 'Platform machinery (controllers, operators, system charts), classified by chart/label evidence. Shown for completeness.' },
185
+ mixed: { label: 'Mixed', tooltip: 'Has both app and add-on evidence. Kept visible — classification is informational, not identity.' },
186
+ }
187
+
188
+ /** The category bucket for a row — apps with no category default to 'app'. */
189
+ export function categoryOf(category: string | undefined): AppCategory {
190
+ return category === 'addon' || category === 'mixed' ? category : 'app'
191
+ }
192
+
193
+ // -----------------------------------------------------------------------------
194
+ // Version comparison. Conservative semver-ish: compares only clean numeric
195
+ // versions (optional leading `v`). Anything non-numeric — a range, a branch, a
196
+ // git SHA — returns null so callers render "no lag" rather than guessing.
197
+ // -----------------------------------------------------------------------------
198
+
199
+ function parseVersion(v: string | undefined): number[] | null {
200
+ if (!v) return null
201
+ const t = v.trim().replace(/^v/i, '')
202
+ if (!/^\d+(\.\d+)*$/.test(t)) return null
203
+ return t.split('.').map((n) => parseInt(n, 10))
204
+ }
205
+
206
+ /** Date-stamped CI tags ("main_2026-03-26_05", "billing_main_2026-05-18_00"):
207
+ * same prefix + extractable date (+ optional sequence) gives a total order.
208
+ * Different prefixes or no date → not comparable; never guess. */
209
+ const DATE_TAG = /^(.*?)[-_](\d{4})[-_.](\d{2})[-_.](\d{2})(?:[-_.](\d+))?$/
210
+
211
+ function parseDateTag(v: string): { prefix: string; ord: number } | null {
212
+ const m = DATE_TAG.exec(v)
213
+ if (!m) return null
214
+ const [, prefix, y, mo, d, seq] = m
215
+ const ord = Number(y) * 1e8 + Number(mo) * 1e6 + Number(d) * 1e4 + Number(seq ?? 0)
216
+ return { prefix, ord }
217
+ }
218
+
219
+ /** -1 if a<b, 1 if a>b, 0 if equal, null if either isn't a comparable version. */
220
+ export function compareVersions(a: string | undefined, b: string | undefined): number | null {
221
+ // Date-stamped pipeline tags first — semver parsing would misread them.
222
+ if (a && b) {
223
+ const da = parseDateTag(a)
224
+ const db = parseDateTag(b)
225
+ if (da && db) {
226
+ if (da.prefix !== db.prefix) return null
227
+ return da.ord === db.ord ? 0 : da.ord < db.ord ? -1 : 1
228
+ }
229
+ if (da || db) return null
230
+ }
231
+ const pa = parseVersion(a)
232
+ const pb = parseVersion(b)
233
+ if (!pa || !pb) return null
234
+ const len = Math.max(pa.length, pb.length)
235
+ for (let i = 0; i < len; i++) {
236
+ const x = pa[i] ?? 0
237
+ const y = pb[i] ?? 0
238
+ if (x !== y) return x < y ? -1 : 1
239
+ }
240
+ return 0
241
+ }
242
+
243
+ // -----------------------------------------------------------------------------
244
+ // Provenance — overlay tier → everything tier-derived, mirroring pkg/subject's
245
+ // Tier constants (1-9). ONE table: the badge label, the Source facet bucket,
246
+ // and the tooltip phrase all read from TIER_META, so a new tier added in
247
+ // pkg/subject has exactly one place to land here.
248
+ // -----------------------------------------------------------------------------
249
+
250
+ /** Coarse provenance bucket for the Source facet. Stable ids — display labels
251
+ * live in SOURCE_META (the house meta-map pattern), so they can be re-worded
252
+ * without breaking facet state or future URL serialization. */
253
+ export type AppSource = 'argocd' | 'flux' | 'helm' | 'label' | 'ungrouped'
254
+
255
+ export const SOURCE_ORDER: AppSource[] = ['argocd', 'flux', 'helm', 'label', 'ungrouped']
256
+
257
+ export const SOURCE_META: Record<AppSource, { label: string }> = {
258
+ argocd: { label: 'Argo CD' },
259
+ flux: { label: 'Flux' },
260
+ helm: { label: 'Helm' },
261
+ label: { label: 'Label' },
262
+ ungrouped: { label: 'Ungrouped' },
263
+ }
264
+
265
+ interface TierMeta {
266
+ source: AppSource
267
+ /** Tooltip phrase pieces: "Grouped by {lead} `{code(name)}` {trail}". */
268
+ lead: string
269
+ code: (name: string) => string
270
+ trail?: string
271
+ }
272
+
273
+ const TIER_META: Record<number, TierMeta> = {
274
+ 1: { source: 'flux', lead: 'its Flux HelmRelease', code: (n) => n },
275
+ 2: { source: 'flux', lead: 'its Flux Kustomization', code: (n) => n },
276
+ 3: { source: 'argocd', lead: 'its Argo CD Application', code: (n) => n },
277
+ 4: { source: 'argocd', lead: 'its Argo CD Application', code: (n) => n },
278
+ 5: { source: 'helm', lead: 'its Helm release', code: (n) => n },
279
+ 6: { source: 'label', lead: 'the', code: () => 'app.kubernetes.io/instance', trail: 'label' },
280
+ 7: { source: 'label', lead: 'the', code: () => 'app.kubernetes.io/part-of', trail: 'label' },
281
+ 8: { source: 'label', lead: 'the', code: () => 'app.kubernetes.io/name', trail: 'label' },
282
+ 9: { source: 'label', lead: 'the', code: () => 'app', trail: 'label' },
283
+ }
284
+
285
+ export function sourceOf(tier: number | undefined): AppSource {
286
+ if (!tier) return 'ungrouped'
287
+ return TIER_META[tier]?.source ?? 'label'
288
+ }
289
+
290
+ /** Short badge label for an app's provenance tier (which tool/source grouped it). */
291
+ export function overlayProvenance(tier: number | undefined): string {
292
+ return SOURCE_META[sourceOf(tier)].label
293
+ }
294
+
295
+ function appNameFromKey(key: string): string {
296
+ const slash = key.lastIndexOf('/')
297
+ return slash >= 0 && slash < key.length - 1 ? key.slice(slash + 1) : key
298
+ }
299
+
300
+ // How an app was grouped, decomposed so the tooltip can render the source
301
+ // resource / label key in an inline-code chip rather than a run-on sentence.
302
+ // `lead` + `code` + `trail` reads as a phrase: "its Flux HelmRelease `argocd`"
303
+ // or "the `app.kubernetes.io/part-of` label". `code` empty → no chip.
304
+ export interface ProvenanceSource {
305
+ lead: string
306
+ code: string
307
+ trail?: string
308
+ }
309
+
310
+ export function provenanceSource(tier: number | undefined, key: string): ProvenanceSource {
311
+ const meta = tier ? TIER_META[tier] : undefined
312
+ if (!meta) return { lead: 'cluster-native evidence', code: '' }
313
+ return { lead: meta.lead, code: meta.code(appNameFromKey(key)), trail: meta.trail }
314
+ }
315
+
316
+
317
+ /** The distinct namespaces an app's workloads run in, sorted. Prefers the
318
+ * server's `namespaces` field, deriving from workloads for older payloads. */
319
+ export function namespacesOf(app: AppRow): string[] {
320
+ if (app.namespaces && app.namespaces.length > 0) return app.namespaces
321
+ const nss = Array.from(new Set((app.workloads || []).map((w) => w.namespace).filter(Boolean))).sort()
322
+ if (nss.length > 0) return nss
323
+ return app.namespace ? [app.namespace] : []
324
+ }
325
+
326
+ /** An app's single namespace, or '' when it spans several — callers must not
327
+ * pick an arbitrary one (env inference and the system-namespace filter both
328
+ * key off this; a wrong pick misleads). Use namespacesOf for the full list. */
329
+ export function namespaceOf(app: AppRow): string {
330
+ const nss = namespacesOf(app)
331
+ return nss.length === 1 ? nss[0] : ''
332
+ }
333
+
334
+ // -----------------------------------------------------------------------------
335
+ // App identity groups — client helpers over the wire `identity` block. Folding
336
+ // rows into groups is presentation; these helpers keep the semantics (ladder
337
+ // order, lag, affix stripping) in one place for the list, the detail band,
338
+ // and later the hub.
339
+ // -----------------------------------------------------------------------------
340
+
341
+ /** Client-only token set for matching "the same workload" across an app group's
342
+ * env instances during an env switch. Deliberately broader than what the
343
+ * server discovers — a miss only means the switch lands on the instance
344
+ * overview. Callers extend it with the group's own (discovered) env tokens
345
+ * via the extraTokens parameter. */
346
+ const NAME_ENV_TOKENS = new Set([
347
+ 'dev', 'development', 'staging', 'stage', 'stg', 'prod', 'production', 'prd',
348
+ 'qa', 'uat', 'preprod', 'preview', 'canary',
349
+ ])
350
+
351
+ /** Strip a recognized env affix from a workload/app name —
352
+ * "billing-staging" → "billing", "qa-koala-backend" → "koala-backend".
353
+ * Used to match "the same workload" across app group env instances. */
354
+ export function stripEnvAffix(name: string, extraTokens?: ReadonlySet<string>): string {
355
+ const isEnv = (tok: string) => NAME_ENV_TOKENS.has(tok) || (extraTokens?.has(tok) ?? false)
356
+ const i = name.lastIndexOf('-')
357
+ if (i > 0 && isEnv(name.slice(i + 1).toLowerCase())) return name.slice(0, i)
358
+ const j = name.indexOf('-')
359
+ if (j > 0 && isEnv(name.slice(0, j).toLowerCase())) return name.slice(j + 1)
360
+ return name
361
+ }
362
+
363
+ /** Find "the same workload" in a sibling env instance: exact kind+name first,
364
+ * then the env-affix-stripped stem (billing-staging ↔ billing). extraTokens
365
+ * should carry the app group's env tokens so discovered envs (loadtest, …)
366
+ * strip too. Null = no counterpart — the switch shows the instance overview. */
367
+ export function matchWorkloadAcrossInstances(
368
+ workloadKey: string,
369
+ targetWorkloads: Pick<AppWorkload, 'kind' | 'namespace' | 'name'>[] | undefined,
370
+ extraTokens?: ReadonlySet<string>,
371
+ ): Pick<AppWorkload, 'kind' | 'namespace' | 'name'> | null {
372
+ const [kind, namespace, name] = workloadKey.split('/')
373
+ if (!kind || !name) return null
374
+ const ws = targetWorkloads ?? []
375
+ return (
376
+ ws.find((w) => w.kind === kind && w.namespace === namespace && w.name === name) ??
377
+ uniqueMatch(ws, (w) => w.kind === kind && w.name === name) ??
378
+ uniqueMatch(ws, (w) => w.kind === kind && stripEnvAffix(w.name, extraTokens) === stripEnvAffix(name, extraTokens)) ??
379
+ null
380
+ )
381
+ }
382
+
383
+ function uniqueMatch<T>(items: T[], pred: (item: T) => boolean): T | null {
384
+ let found: T | null = null
385
+ for (const item of items) {
386
+ if (!pred(item)) continue
387
+ if (found) return null
388
+ found = item
389
+ }
390
+ return found
391
+ }
392
+
393
+ /** Ladder order: ranked envs by rank (dev → staging → prod), then
394
+ * recognized-but-unranked alphabetically (qa, …). */
395
+ export function orderEnvs(envs: string[]): string[] {
396
+ return [...envs].sort((a, b) => {
397
+ const ra = envRank(a)
398
+ const rb = envRank(b)
399
+ if (ra !== null && rb !== null) return ra - rb
400
+ if (ra !== null) return -1
401
+ if (rb !== null) return 1
402
+ return a.localeCompare(b)
403
+ })
404
+ }
405
+
406
+ /** Promotion lag across an app group's env cells: fires only between RANKED envs,
407
+ * when a strictly-lower env runs a strictly-newer comparable version.
408
+ * Returns the human message ("staging is behind dev") or null. */
409
+ export function appGroupLagMessage(cells: { env: string; version?: string }[]): string | null {
410
+ const ranked = cells
411
+ .map((c) => ({ ...c, rank: envRank(c.env) }))
412
+ .filter((c): c is { env: string; version?: string; rank: number } => c.rank !== null && !!c.version)
413
+ .sort((a, b) => a.rank - b.rank)
414
+ for (let i = 0; i < ranked.length; i++) {
415
+ for (let j = i + 1; j < ranked.length; j++) {
416
+ // Strict rank inequality: two instances of the SAME env are siblings,
417
+ // not a promotion pair — without this, "prod is behind prod" can fire.
418
+ if (ranked[i].rank < ranked[j].rank && compareVersions(ranked[i].version, ranked[j].version) === 1) {
419
+ return `${ranked[j].env} is behind ${ranked[i].env}`
420
+ }
421
+ }
422
+ }
423
+ return null
424
+ }
425
+
426
+ // -----------------------------------------------------------------------------
427
+ // App group folding — turns a filtered+sorted entry list into the rows the list
428
+ // renders: one ladder row per app group (emitted at its first member's position,
429
+ // so the active sort still governs placement) with instances nested under it.
430
+ // Pure so the collapse experiment's safety rails (search auto-expansion,
431
+ // orphans rendering flat, per-env aggregation) stay pinned by tests.
432
+ // -----------------------------------------------------------------------------
433
+
434
+ /** The slice of a list entry the fold needs. */
435
+ export interface AppGroupFoldEntry {
436
+ row: { key: string; name: string; identity?: AppIdentity; appVersion?: string }
437
+ health: AppHealth
438
+ versions: string[]
439
+ ready: number
440
+ desired: number
441
+ kinds: Record<string, number>
442
+ classComposition: { cls: AppWorkloadClass; count: number }[]
443
+ }
444
+
445
+ export interface AppGroupEnvCell {
446
+ env: string
447
+ health: AppHealth
448
+ version?: string
449
+ count: number
450
+ firstKey: string
451
+ }
452
+
453
+ export interface FoldedAppGroupRow<T extends AppGroupFoldEntry> {
454
+ kind: 'group'
455
+ key: string
456
+ label: string
457
+ members: T[]
458
+ expanded: boolean
459
+ cells: AppGroupEnvCell[]
460
+ lag: string | null
461
+ health: AppHealth
462
+ ready: number
463
+ desired: number
464
+ kinds: Record<string, number>
465
+ classComposition: { cls: AppWorkloadClass; count: number }[]
466
+ workloadClass: AppWorkloadClass
467
+ confidence: string
468
+ }
469
+
470
+ export type FoldedRow<T extends AppGroupFoldEntry> = FoldedAppGroupRow<T> | { kind: 'instance'; entry: T; child?: boolean }
471
+
472
+ export interface FoldAppGroupsOptions<T extends AppGroupFoldEntry> {
473
+ /** Scope for non-portable identities. OSS leaves this empty; fleet callers
474
+ * should include the cluster id so local name/repo evidence cannot merge
475
+ * unrelated clusters. Portable identities ignore the scope. */
476
+ localScope?: (entry: T) => string | undefined
477
+ }
478
+
479
+ export function foldAppGroups<T extends AppGroupFoldEntry>(
480
+ entries: T[],
481
+ expandedKeys: ReadonlySet<string>,
482
+ autoExpand: boolean,
483
+ options: FoldAppGroupsOptions<T> = {},
484
+ ): FoldedRow<T>[] {
485
+ const newest = (e: T): string | undefined =>
486
+ e.versions.reduce<string | undefined>((best, v) => (!best || compareVersions(v, best) === 1 ? v : best), undefined) ?? e.row.appVersion
487
+ const groupKey = (e: T): string | null => {
488
+ const id = e.row.identity
489
+ if (!id) return null
490
+ const scope = options.localScope?.(e)
491
+ if (!scope) return id.key
492
+ return id.portable ? `portable:${id.key}` : `local:${scope}:${id.key}`
493
+ }
494
+ const byGroup = new Map<string, T[]>()
495
+ for (const e of entries) {
496
+ const k = groupKey(e)
497
+ if (k) byGroup.set(k, [...(byGroup.get(k) ?? []), e])
498
+ }
499
+ const emitted = new Set<string>()
500
+ const out: FoldedRow<T>[] = []
501
+ for (const e of entries) {
502
+ const id = e.row.identity
503
+ const k = groupKey(e)
504
+ // A group needs ≥2 SURVIVING members — filters can orphan one, which
505
+ // then renders as the plain instance it is.
506
+ if (!id || !k || (byGroup.get(k)?.length ?? 0) < 2) {
507
+ out.push({ kind: 'instance', entry: e })
508
+ continue
509
+ }
510
+ if (emitted.has(k)) continue
511
+ emitted.add(k)
512
+ const members = byGroup.get(k)!
513
+
514
+ const cellMap = new Map<string, AppGroupEnvCell>()
515
+ const kinds: Record<string, number> = {}
516
+ const compMap = new Map<AppWorkloadClass, number>()
517
+ let ready = 0
518
+ let desired = 0
519
+ let health: AppHealth = 'unknown'
520
+ for (const m of members) {
521
+ const env = m.row.identity!.env
522
+ const v = newest(m)
523
+ const cur = cellMap.get(env)
524
+ if (!cur) {
525
+ cellMap.set(env, { env, health: m.health, version: v, count: 1, firstKey: m.row.key })
526
+ } else {
527
+ cur.count++
528
+ if ((HEALTH_RANK[m.health] ?? 0) > (HEALTH_RANK[cur.health] ?? 0)) cur.health = m.health
529
+ if (v && (!cur.version || compareVersions(v, cur.version) === 1)) cur.version = v
530
+ }
531
+ if ((HEALTH_RANK[m.health] ?? 0) > (HEALTH_RANK[health] ?? 0)) health = m.health
532
+ ready += m.ready
533
+ desired += m.desired
534
+ for (const [k, n] of Object.entries(m.kinds)) kinds[k] = (kinds[k] ?? 0) + n
535
+ for (const c of m.classComposition) compMap.set(c.cls, (compMap.get(c.cls) ?? 0) + c.count)
536
+ }
537
+ const cells = orderEnvs([...cellMap.keys()]).map((env) => cellMap.get(env)!)
538
+ const classComposition = CLASS_ORDER.filter((c) => compMap.has(c)).map((c) => ({ cls: c, count: compMap.get(c)! }))
539
+ const known = classComposition.map((c) => c.cls).filter((c) => c !== 'unknown')
540
+ const workloadClass: AppWorkloadClass =
541
+ known.length === 0 ? 'unknown'
542
+ : known.includes('service') && !known.includes('job') ? 'service'
543
+ : known.length === 1 ? known[0]
544
+ : 'mixed'
545
+ const expanded = autoExpand || expandedKeys.has(k)
546
+ out.push({
547
+ kind: 'group',
548
+ key: k,
549
+ label: id.key,
550
+ members,
551
+ expanded,
552
+ cells,
553
+ lag: appGroupLagMessage(cells),
554
+ health,
555
+ ready,
556
+ desired,
557
+ kinds,
558
+ classComposition,
559
+ workloadClass,
560
+ confidence: members.some((m) => m.row.identity!.confidence === 'high') ? 'high' : 'medium',
561
+ })
562
+ if (expanded) {
563
+ for (const m of members) out.push({ kind: 'instance', entry: m, child: true })
564
+ }
565
+ }
566
+ return out
567
+ }
568
+
569
+ /** Normalize a wire health string to the AppHealth union (the health twin of
570
+ * workloadClassOf — keeps `as AppHealth` casts out of components). */
571
+ export function healthOf(value: string | undefined): AppHealth {
572
+ return value === 'unhealthy' || value === 'degraded' || value === 'healthy' ? value : 'unknown'
573
+ }
574
+
575
+ // -----------------------------------------------------------------------------
576
+ // Health + class meta. Health uses theme tokens for the unknown/neutral end and
577
+ // pale-pastel pills (which have no theme token) for the colored tiers.
578
+ // -----------------------------------------------------------------------------
579
+
580
+ export const HEALTH_ORDER: AppHealth[] = ['unhealthy', 'degraded', 'healthy', 'unknown']
581
+ export const HEALTH_RANK: Record<string, number> = { unhealthy: 3, degraded: 2, healthy: 1, unknown: 0 }
582
+
583
+ export interface HealthMeta {
584
+ label: string
585
+ bar: string
586
+ text: string
587
+ pill: string
588
+ }
589
+
590
+ // ─── Chip dialect ────────────────────────────────────────────────────────────
591
+ // The Applications surface renders dense metadata as pale pastel chips —
592
+ // deliberately lighter than <Badge>'s severity palette, which is sized for
593
+ // standalone status pills. A local dialect, but defined ONCE here: call sites
594
+ // compose `CHIP` (chrome) + a `CHIP_TONE` (color), never inline the strings.
595
+ // Literal class strings are required for Tailwind's content scanner.
596
+ export const CHIP = 'inline-flex items-center rounded-sm px-1.5 py-px text-[10px] font-medium ring-1 ring-inset'
597
+ export const CHIP_TONE = {
598
+ rose: 'bg-rose-50 text-rose-700 ring-rose-200 dark:bg-rose-950/40 dark:text-rose-300 dark:ring-rose-900',
599
+ amber: 'bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-950/40 dark:text-amber-300 dark:ring-amber-900',
600
+ emerald: 'bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-300 dark:ring-emerald-900',
601
+ blue: 'bg-blue-50 text-blue-700 ring-blue-200 dark:bg-blue-950/40 dark:text-blue-300 dark:ring-blue-900',
602
+ violet: 'bg-violet-50 text-violet-700 ring-violet-200 dark:bg-violet-950/40 dark:text-violet-300 dark:ring-violet-900',
603
+ neutral: 'bg-theme-hover text-theme-text-secondary ring-theme-border',
604
+ muted: 'bg-theme-hover text-theme-text-tertiary ring-theme-border',
605
+ } as const
606
+
607
+ export const HEALTH_META: Record<AppHealth, HealthMeta> = {
608
+ unhealthy: { label: 'Down', bar: 'bg-rose-500', text: 'text-rose-600 dark:text-rose-400', pill: CHIP_TONE.rose },
609
+ degraded: { label: 'Degraded', bar: 'bg-amber-500', text: 'text-amber-600 dark:text-amber-400', pill: CHIP_TONE.amber },
610
+ healthy: { label: 'Healthy', bar: 'bg-emerald-500', text: 'text-emerald-600 dark:text-emerald-400', pill: CHIP_TONE.emerald },
611
+ unknown: { label: 'Unknown', bar: 'bg-slate-400', text: 'text-theme-text-tertiary', pill: CHIP_TONE.muted },
612
+ }
613
+
614
+ export const CLASS_ORDER: AppWorkloadClass[] = ['service', 'worker', 'job', 'unknown']
615
+
616
+ export const CLASS_META: Record<AppWorkloadClass, { label: string; pill: string; tooltip: string }> = {
617
+ 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.' },
618
+ worker: { label: 'Worker', pill: CHIP_TONE.violet, tooltip: 'Long-running background processor (no serving edge). Inferred from the workload shape.' },
619
+ job: { label: 'Job', pill: CHIP_TONE.amber, tooltip: 'Finite or scheduled work (Job/CronJob).' },
620
+ mixed: { label: 'Mixed', pill: CHIP_TONE.neutral, tooltip: 'Contains workloads of more than one class (e.g. a service plus its scheduled jobs).' },
621
+ unknown: { label: 'Unknown', pill: CHIP_TONE.muted, tooltip: "Couldn't infer a runtime class from the workload." },
622
+ }
623
+
624
+ /** Per-class workload counts for an app, in CLASS_ORDER — the composition
625
+ * behind a "Mixed" badge and the inclusive Class facet (filtering "Service"
626
+ * matches mixed apps that contain a service). */
627
+ export function classCompositionOf(app: AppRow): { cls: AppWorkloadClass; count: number }[] {
628
+ const counts = new Map<AppWorkloadClass, number>()
629
+ for (const w of app.workloads || []) {
630
+ const c = workloadClassOf(w.workload_class)
631
+ counts.set(c, (counts.get(c) ?? 0) + 1)
632
+ }
633
+ return CLASS_ORDER.filter((c) => counts.has(c)).map((c) => ({ cls: c, count: counts.get(c)! }))
634
+ }
635
+
636
+ /** The distinct KNOWN classes an app contains — the facet-matching set. Falls
637
+ * back to the app-level class when there are no classifiable workloads. */
638
+ export function classSetOf(app: AppRow): AppWorkloadClass[] {
639
+ const known = classCompositionOf(app)
640
+ .map((c) => c.cls)
641
+ .filter((c) => c === 'service' || c === 'worker' || c === 'job')
642
+ if (known.length > 0) return known
643
+ return [workloadClassOf(app.workload_class)]
644
+ }
645
+
646
+ export function workloadClassOf(value?: AppWorkloadClass): AppWorkloadClass {
647
+ switch (value) {
648
+ case 'service':
649
+ case 'worker':
650
+ case 'job':
651
+ case 'mixed':
652
+ return value
653
+ default:
654
+ return 'unknown'
655
+ }
656
+ }
657
+
658
+ /** Worst health across a set of raw health strings. */
659
+ export function worstHealth(hs: string[]): AppHealth {
660
+ let w: AppHealth = 'unknown'
661
+ for (const h of hs) if ((HEALTH_RANK[h] ?? 0) > (HEALTH_RANK[w] ?? 0)) w = h as AppHealth
662
+ return w
663
+ }
664
+
665
+ export function newestTag(versions: string[]): string | undefined {
666
+ let best: string | undefined
667
+ for (const v of versions) {
668
+ const t = v?.trim()
669
+ if (!t) continue
670
+ if (best === undefined) best = t
671
+ else if (compareVersions(t, best) === 1) best = t
672
+ }
673
+ return best
674
+ }
@@ -259,3 +259,14 @@ export function formatBytes(bytes: number): string {
259
259
  const i = Math.floor(Math.log(bytes) / Math.log(k))
260
260
  return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
261
261
  }
262
+
263
+ /** Middle-ellipsis for long identifiers (image tags, pod names): keeps the
264
+ * start and the differentiating suffix. Returns the input when it fits. */
265
+ export function midTruncate(s: string, max = 24): string {
266
+ if (s.length <= max) return s
267
+ if (max <= 1) return '…'.slice(0, Math.max(0, max))
268
+ if (max <= 3) return `${s.slice(0, max - 1)}…`
269
+ const tail = Math.min(10, Math.floor(max / 2) - 1)
270
+ const head = max - tail - 1
271
+ return `${s.slice(0, head)}…${s.slice(-tail)}`
272
+ }