@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.
- package/package.json +1 -1
- package/src/components/applications/AppChips.tsx +109 -0
- package/src/components/applications/AppTooltips.tsx +199 -0
- package/src/components/applications/ApplicationDetail.tsx +671 -0
- package/src/components/applications/ApplicationsList.tsx +569 -0
- package/src/components/applications/ReadyBar.tsx +22 -0
- package/src/components/applications/index.ts +8 -0
- package/src/components/audit/AuditFindingsTable.tsx +3 -25
- package/src/components/logs/WorkloadLogsViewer.tsx +8 -5
- package/src/components/resources/renderers/WorkloadRenderer.tsx +5 -4
- package/src/components/shared/DetailShell.tsx +14 -7
- package/src/components/shared/EditableYamlView.tsx +37 -17
- package/src/components/timeline/TimelineList.tsx +3 -32
- package/src/components/timeline/TimelineSwimlanes.tsx +3 -31
- package/src/components/topology/K8sResourceNode.tsx +26 -5
- package/src/components/topology/TopologyGraph.tsx +102 -3
- package/src/components/topology/layout.ts +36 -11
- package/src/components/ui/CenteredEmpty.tsx +27 -0
- package/src/components/ui/SearchBox.tsx +85 -0
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/WorkloadView.tsx +167 -33
- package/src/components/workload/index.ts +1 -1
- package/src/hooks/useKeyboardShortcuts.tsx +3 -1
- package/src/index.ts +4 -0
- package/src/utils/applications.test.ts +207 -0
- package/src/utils/applications.ts +674 -0
- package/src/utils/format.ts +11 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +185 -0
- package/src/utils/topology-neighborhood.ts +262 -0
- 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
|
+
}
|
package/src/utils/format.ts
CHANGED
|
@@ -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
|
+
}
|