@skyhook-io/k8s-ui 1.8.0 → 1.8.2

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.
@@ -0,0 +1,602 @@
1
+ import { useMemo, useState, useEffect, useRef, useCallback } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import { ChevronRight, Layers, Boxes, HeartPulse, Shapes, Globe, Tag } from 'lucide-react'
4
+ import { clsx } from 'clsx'
5
+ import { StatusDot, mapHealthToTone } from '../ui/status-tone'
6
+ import { Tooltip } from '../ui/Tooltip'
7
+ import { EmptyState } from '../ui/EmptyState'
8
+ import { SearchBox } from '../ui/SearchBox'
9
+ import { PageHeader } from '../ui/PageHeader'
10
+ import { SummaryTile, type SummaryTone } from '../ui/SummaryTile'
11
+ import { Facet, type FacetTone } from '../ui/Facet'
12
+ import { SortableTh, TH_CLASS } from '../ui/SortableTh'
13
+ import { DistributionBar } from '../ui/DistributionBar'
14
+ import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
15
+ import { pluralize } from '../../utils/pluralize'
16
+ import {
17
+ type AppEntry,
18
+ type AppHealth,
19
+ type AppWorkloadClass,
20
+ type AppSource,
21
+ type AppCategory,
22
+ HEALTH_ORDER,
23
+ HEALTH_RANK,
24
+ HEALTH_META,
25
+ CLASS_ORDER,
26
+ CLASS_META,
27
+ CATEGORY_ORDER,
28
+ CATEGORY_META,
29
+ CHIP,
30
+ CHIP_TONE,
31
+ SOURCE_ORDER,
32
+ SOURCE_META,
33
+ envRank,
34
+ isSystemNamespace,
35
+ searchTextForEntry,
36
+ foldAppGroups,
37
+ type FoldedRow,
38
+ } from '../../utils/applications'
39
+ import { ReadyBar } from './ReadyBar'
40
+ import { ProvenanceBadge, ClassBadge, CategoryChip, VersionInfo } from './AppChips'
41
+ import { AppIdentityTooltip, EnvHint } from './AppTooltips'
42
+
43
+ // ApplicationsView — the shared, variant-agnostic core behind the Applications
44
+ // surface. It owns the entire list chassis: a health hero header (PageHeader +
45
+ // SummaryTiles + DistributionBar), a left facet rail (Availability / Class /
46
+ // Type / Environment / Source + a single-cluster-only Show-system toggle), a
47
+ // search toolbar, sortable columns, app-group folding, and j/k keyboard nav.
48
+ // Data is injected as a discriminated AppEntry[] — the OSS single-cluster list
49
+ // and the Cloud fleet list both drive it, branching only on column headers and
50
+ // the per-instance row. Styling mirrors the Resources table so the surfaces
51
+ // read as one design.
52
+
53
+ // Availability is the one status facet — map app health onto the shared facet
54
+ // tone so its dots read red/amber/green/grey like the GitOps sync+health facets.
55
+ const HEALTH_TONE: Record<AppHealth, FacetTone> = {
56
+ unhealthy: 'error',
57
+ degraded: 'warning',
58
+ healthy: 'success',
59
+ unknown: 'neutral',
60
+ }
61
+
62
+ // Sortable columns. `health` is the implicit default (worst-first then name);
63
+ // clicking a sortable header cycles asc → desc → off (back to default).
64
+ type SortKey = 'name' | 'ready' | 'version'
65
+ type SortDir = 'asc' | 'desc'
66
+
67
+ function compareEntries(a: AppEntry, b: AppEntry, key: SortKey): number {
68
+ switch (key) {
69
+ case 'name':
70
+ return a.row.name.localeCompare(b.row.name)
71
+ case 'ready':
72
+ return a.readyRatio - b.readyRatio
73
+ case 'version': {
74
+ // Sort by distinct-version count first (skewed apps cluster), then the
75
+ // first tag for a stable, human-meaningful order.
76
+ const byCount = a.versions.length - b.versions.length
77
+ if (byCount !== 0) return byCount
78
+ return (a.versions[0] ?? '').localeCompare(b.versions[0] ?? '')
79
+ }
80
+ }
81
+ }
82
+
83
+ // The env token an entry filters under. Single entries carry one env; fleet
84
+ // entries carry several — a fleet row matches an env facet if ANY of its slices
85
+ // do (the same inclusive policy as the Class facet).
86
+ function entryEnvs(e: AppEntry): string[] {
87
+ return e.variant === 'single' ? [e.env || 'none'] : e.envs.map((s) => s.env || 'none')
88
+ }
89
+
90
+ export interface ApplicationsViewProps {
91
+ entries: AppEntry[]
92
+ variant: 'single' | 'fleet'
93
+ onSelect: (key: string) => void
94
+ title?: string
95
+ description?: string
96
+ /** Rendered instead of the built-in EmptyState when there are zero entries
97
+ * pre-filter (the fleet host injects a coverage/offline-aware empty). */
98
+ emptySlot?: ReactNode
99
+ }
100
+
101
+ export function ApplicationsView({ entries: allEntries, variant, onSelect, title = 'Applications', description, emptySlot }: ApplicationsViewProps) {
102
+ const [textFilter, setTextFilter] = useState('')
103
+ const [fHealth, setFHealth] = useState<Set<AppHealth>>(new Set())
104
+ const [fEnv, setFEnv] = useState<Set<string>>(new Set())
105
+ const [fSource, setFSource] = useState<Set<AppSource>>(new Set())
106
+ const [fClass, setFClass] = useState<Set<AppWorkloadClass>>(new Set())
107
+ const [fType, setFType] = useState<Set<AppCategory>>(new Set())
108
+ const [showSystem, setShowSystem] = useState(false)
109
+ const [sort, setSort] = useState<{ key: SortKey; dir: SortDir } | null>(null)
110
+
111
+ // The Show-system toggle keys off per-entry workload namespaces, which only
112
+ // single-cluster entries carry — fleet entries have no namespace facet, so
113
+ // the toggle is computed and shown for the single variant only.
114
+ // An app counts as system only when EVERY workload namespace is system —
115
+ // hiding a partly-user app would be worse than showing a partly-system one.
116
+ const isSystemApp = (e: AppEntry) =>
117
+ e.variant === 'single' && e.namespaces.length > 0 && e.namespaces.every(isSystemNamespace)
118
+ const all = useMemo(
119
+ () => (variant !== 'single' || showSystem ? allEntries : allEntries.filter((e) => !isSystemApp(e))),
120
+ [allEntries, showSystem, variant],
121
+ )
122
+ const systemCount = useMemo(() => (variant === 'single' ? allEntries.filter(isSystemApp).length : 0), [allEntries, variant])
123
+
124
+ const entries = useMemo(() => {
125
+ const t = textFilter.trim().toLowerCase()
126
+ const filtered = all.filter((e) => {
127
+ if (t && !searchTextForEntry(e).includes(t)) return false
128
+ if (fHealth.size && !fHealth.has(e.health)) return false
129
+ // Inclusive: a mixed app matches the filter of ANY class it contains.
130
+ if (fClass.size && !e.classSet.some((c) => fClass.has(c))) return false
131
+ if (fType.size && !fType.has(e.category)) return false
132
+ if (fSource.size && !fSource.has(e.source)) return false
133
+ if (fEnv.size && !entryEnvs(e).some((env) => fEnv.has(env))) return false
134
+ return true
135
+ })
136
+ if (sort) {
137
+ const factor = sort.dir === 'asc' ? 1 : -1
138
+ filtered.sort((a, b) => compareEntries(a, b, sort.key) * factor || a.row.name.localeCompare(b.row.name))
139
+ } else {
140
+ filtered.sort((a, b) => (HEALTH_RANK[b.health] ?? 0) - (HEALTH_RANK[a.health] ?? 0) || a.row.name.localeCompare(b.row.name))
141
+ }
142
+ return filtered
143
+ }, [all, textFilter, fHealth, fEnv, fSource, fClass, fType, sort])
144
+
145
+ // ── App groups ──────────────────────────────────────────────────────────────
146
+ // Instances sharing a wire `identity` fold into one ladder row (foldAppGroups).
147
+ // THE COLLAPSE EXPERIMENT: default collapsed, contingent on (a) text search
148
+ // auto-expanding into hidden instances, (b) instance rows one chevron away,
149
+ // (c) the group chip visibly carrying confidence. If heuristic precision
150
+ // disappoints in the field, default-expand by seeding expandedGroups with
151
+ // every group key instead of an empty set.
152
+ const FAMILY_AUTO_EXPAND_ON_SEARCH = true
153
+ const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
154
+ const toggleGroup = (key: string) =>
155
+ setExpandedGroups((s) => {
156
+ const n = new Set(s)
157
+ n.has(key) ? n.delete(key) : n.add(key)
158
+ return n
159
+ })
160
+
161
+ // Fleet rows must scope non-portable identities to the cluster set that
162
+ // produced them — only a portable (declared / cross-cluster-unified) key may
163
+ // fold across clusters. Single-cluster has no such concern, so no localScope.
164
+ const visibleRows = useMemo<FoldedRow<AppEntry>[]>(
165
+ () => foldAppGroups(entries, expandedGroups, FAMILY_AUTO_EXPAND_ON_SEARCH && textFilter.trim() !== '',
166
+ variant === 'fleet'
167
+ ? {
168
+ // Fall back to the row key so the scope is never empty — an empty
169
+ // localScope would make foldAppGroups treat a non-portable identity
170
+ // as un-scoped and fold it across unrelated rows.
171
+ localScope: (e) => (e.variant === 'fleet' ? e.clusters.map((c) => c.id).sort().join(',') || e.row.key : ''),
172
+ // The fold ladder reads the host's per-cluster env slices, not the
173
+ // single identity.env (which the hub can stale when it joins one
174
+ // overlay key across clusters with different envs). Fall back to the
175
+ // identity env if a row somehow has no slices, so the ladder never
176
+ // collapses to a misleading "0 envs".
177
+ envsOf: (e) =>
178
+ e.variant === 'fleet'
179
+ ? e.envs.length
180
+ ? e.envs.map((s) => ({ env: s.env, health: s.health }))
181
+ : [{ env: e.row.identity?.env ?? '', health: e.health }]
182
+ : [],
183
+ }
184
+ : undefined,
185
+ ),
186
+ [entries, expandedGroups, textFilter, variant],
187
+ )
188
+
189
+ // Row keyboard navigation — same contract as the Resources table: j/k or
190
+ // arrows move a highlight, g g / G jump, Enter opens, Escape clears the
191
+ // highlight. The search box hands off via ArrowDown.
192
+ const [highlightedIndex, setHighlightedIndex] = useState(-1)
193
+ const rowsRef = useRef(visibleRows)
194
+ rowsRef.current = visibleRows
195
+ useEffect(() => setHighlightedIndex(-1), [visibleRows])
196
+ const firstOpenableVisibleRow = useCallback(() => {
197
+ const first = rowsRef.current[0]
198
+ if (!first) return null
199
+ return first.kind === 'group' ? first.cells[0]?.firstKey ?? null : first.entry.row.key
200
+ }, [])
201
+ const moveHighlight = (delta: number) =>
202
+ setHighlightedIndex((i) => Math.min(Math.max(i + delta, 0), rowsRef.current.length - 1))
203
+ useRegisterShortcuts([
204
+ { id: 'applications-nav-down', keys: 'j', description: 'Next row', category: 'Table', scope: 'applications', handler: () => moveHighlight(1) },
205
+ { id: 'applications-nav-down-arrow', keys: 'ArrowDown', description: 'Next row', category: 'Table', scope: 'applications', handler: () => moveHighlight(1) },
206
+ { id: 'applications-nav-up', keys: 'k', description: 'Previous row', category: 'Table', scope: 'applications', handler: () => moveHighlight(-1) },
207
+ { id: 'applications-nav-up-arrow', keys: 'ArrowUp', description: 'Previous row', category: 'Table', scope: 'applications', handler: () => moveHighlight(-1) },
208
+ { id: 'applications-nav-top', keys: 'g g', description: 'Jump to first row', category: 'Table', scope: 'applications', handler: () => setHighlightedIndex(rowsRef.current.length > 0 ? 0 : -1) },
209
+ { id: 'applications-nav-bottom', keys: 'G', description: 'Jump to last row', category: 'Table', scope: 'applications', handler: () => setHighlightedIndex(rowsRef.current.length - 1) },
210
+ {
211
+ id: 'applications-open', keys: 'Enter', description: 'Open application', category: 'Table', scope: 'applications',
212
+ handler: () => {
213
+ const r = rowsRef.current[highlightedIndex]
214
+ if (!r) return
215
+ // Enter on a group toggles it; on an instance, opens it.
216
+ if (r.kind === 'group') toggleGroup(r.key)
217
+ else onSelect(r.entry.row.key)
218
+ },
219
+ enabled: highlightedIndex >= 0,
220
+ },
221
+ {
222
+ id: 'applications-escape', keys: 'Escape', description: 'Clear row highlight', category: 'Table', scope: 'applications',
223
+ handler: () => setHighlightedIndex(-1),
224
+ enabled: highlightedIndex >= 0,
225
+ },
226
+ ])
227
+
228
+ const counts = useMemo(() => {
229
+ const health: Record<string, number> = {}
230
+ const env: Record<string, number> = {}
231
+ const source: Record<string, number> = {}
232
+ const workloadClass: Record<string, number> = {}
233
+ const category: Record<string, number> = {}
234
+ for (const e of all) {
235
+ health[e.health] = (health[e.health] ?? 0) + 1
236
+ for (const c of e.classSet) workloadClass[c] = (workloadClass[c] ?? 0) + 1
237
+ source[e.source] = (source[e.source] ?? 0) + 1
238
+ category[e.category] = (category[e.category] ?? 0) + 1
239
+ for (const en of entryEnvs(e)) env[en] = (env[en] ?? 0) + 1
240
+ }
241
+ return { health, env, source, workloadClass, category }
242
+ }, [all])
243
+
244
+ const toggle = <T,>(set: Set<T>, setter: (s: Set<T>) => void, v: T) => {
245
+ const next = new Set(set)
246
+ next.has(v) ? next.delete(v) : next.add(v)
247
+ setter(next)
248
+ }
249
+
250
+ // asc → desc → off (null = default health-worst-first sort).
251
+ const onSort = (key: SortKey) => {
252
+ setSort((prev) => {
253
+ if (!prev || prev.key !== key) return { key, dir: 'asc' }
254
+ if (prev.dir === 'asc') return { key, dir: 'desc' }
255
+ return null
256
+ })
257
+ }
258
+
259
+ const total = all.length
260
+ const envOptions = Object.entries(counts.env)
261
+ .sort((a, b) => (envRank(b[0] === 'none' ? undefined : b[0]) ?? -1) - (envRank(a[0] === 'none' ? undefined : a[0]) ?? -1))
262
+ .map(([env, count]) => ({ value: env, label: env === 'none' ? 'unlabeled' : env, count }))
263
+
264
+ // Clickable status tile wired to the health facet — tap to filter to that tier.
265
+ const healthTile = (h: AppHealth, tone: SummaryTone) =>
266
+ counts.health[h] ? (
267
+ <SummaryTile key={h} label={HEALTH_META[h].label} value={counts.health[h]} tone={tone} active={fHealth.has(h)} onClick={() => toggle(fHealth, setFHealth, h)} />
268
+ ) : null
269
+
270
+ // showSystem lives in the Filters rail, so Clear resets it too (and its
271
+ // non-default ON state counts as an active filter that surfaces the button).
272
+ const anyFilterActive = !!(textFilter || fHealth.size || fClass.size || fType.size || fSource.size || fEnv.size || showSystem)
273
+ const clearAllFilters = () => {
274
+ setTextFilter('')
275
+ setFHealth(new Set())
276
+ setFClass(new Set())
277
+ setFType(new Set())
278
+ setFSource(new Set())
279
+ setFEnv(new Set())
280
+ setShowSystem(false)
281
+ }
282
+
283
+ return (
284
+ <div className="flex h-full w-full min-w-0 flex-1 flex-col overflow-hidden bg-theme-base">
285
+ {/* Header band: title + description + clickable status tiles + slim health
286
+ bar. Same chassis as the GitOps view so the two list surfaces read as
287
+ siblings (status in the header, filters in a left rail, search in a
288
+ toolbar). */}
289
+ <div className="shrink-0 border-b border-theme-border px-4 py-4">
290
+ <PageHeader
291
+ icon={Boxes}
292
+ title={title}
293
+ description={description}
294
+ actions={
295
+ <>
296
+ <SummaryTile label={total === 1 ? 'application' : 'applications'} value={total} />
297
+ {healthTile('unhealthy', 'error')}
298
+ {healthTile('degraded', 'warning')}
299
+ {healthTile('healthy', 'success')}
300
+ {healthTile('unknown', 'neutral')}
301
+ </>
302
+ }
303
+ />
304
+ <DistributionBar
305
+ className="mt-3"
306
+ ariaLabel="Health distribution"
307
+ segments={HEALTH_ORDER.map((h) => ({ key: h, count: counts.health[h] ?? 0, fillClass: HEALTH_META[h].bar }))}
308
+ />
309
+ </div>
310
+
311
+ {/* Body: filter sidebar | content (toolbar + table). */}
312
+ <div className="flex min-w-0 flex-1 overflow-hidden max-[899px]:flex-col">
313
+ {/* Filters sidebar — titled, with Clear; mirrors the GitOps facet rail.
314
+ Arbitrary 900px breakpoint (not `sm`) so the collapse is independent of
315
+ the consuming app's Tailwind breakpoint config (Hub uses defaults). */}
316
+ <aside className="flex w-52 shrink-0 flex-col overflow-hidden border-r border-theme-border bg-theme-surface/90 max-[899px]:max-h-72 max-[899px]:w-full max-[899px]:border-b max-[899px]:border-r-0">
317
+ <div className="flex items-center justify-between border-b border-theme-border px-3 py-2">
318
+ <span className="text-sm font-medium text-theme-text-secondary">Filters</span>
319
+ {anyFilterActive && (
320
+ <button type="button" onClick={clearAllFilters} className="text-[10px] font-medium text-blue-500 hover:text-blue-400">Clear</button>
321
+ )}
322
+ </div>
323
+ <div className="flex-1 overflow-y-auto">
324
+ <Facet icon={HeartPulse} title="Availability" options={HEALTH_ORDER.map((h) => ({ value: h, label: HEALTH_META[h].label, count: counts.health[h] ?? 0, tone: HEALTH_TONE[h] }))} selected={fHealth} onToggle={(v) => toggle(fHealth, setFHealth, v)} />
325
+ <Facet icon={Layers} title="Class" options={CLASS_ORDER.map((c) => ({ value: c, label: CLASS_META[c].label, count: counts.workloadClass[c] ?? 0 }))} selected={fClass} onToggle={(v) => toggle(fClass, setFClass, v)} />
326
+ <Facet icon={Shapes} title="Type" options={CATEGORY_ORDER.map((c) => ({ value: c, label: CATEGORY_META[c].label, count: counts.category[c] ?? 0, tooltip: CATEGORY_META[c].tooltip }))} selected={fType} onToggle={(v) => toggle(fType, setFType, v)} />
327
+ <Facet icon={Globe} title="Environment" info={<EnvHint />} options={envOptions} selected={fEnv} onToggle={(v) => toggle(fEnv, setFEnv, v)} />
328
+ <Facet icon={Tag} title="Source" options={SOURCE_ORDER.map((s) => ({ value: s, label: SOURCE_META[s].label, count: counts.source[s] ?? 0 }))} selected={fSource} onToggle={(v) => toggle(fSource, setFSource, v)} />
329
+ {systemCount > 0 && (
330
+ <label className="flex cursor-pointer items-center gap-2 border-b border-theme-border px-3 py-2 text-[11px] text-theme-text-secondary hover:bg-theme-hover">
331
+ <input type="checkbox" checked={showSystem} onChange={(e) => setShowSystem(e.target.checked)} className="accent-skyhook-500" />
332
+ <span>Show system namespaces</span>
333
+ <span className="ml-auto tabular-nums text-theme-text-tertiary">{systemCount}</span>
334
+ </label>
335
+ )}
336
+ </div>
337
+ </aside>
338
+
339
+ {/* Content: toolbar (search + sort) over the scrollable table. */}
340
+ <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
341
+ <div className="flex shrink-0 items-center gap-3 border-b border-theme-border px-4 py-3">
342
+ <SearchBox
343
+ value={textFilter}
344
+ onChange={setTextFilter}
345
+ scope="applications"
346
+ shortcutId="applications-search"
347
+ className="max-w-md flex-1"
348
+ onEnter={() => {
349
+ const key = highlightedIndex >= 0 && rowsRef.current[highlightedIndex]?.kind === 'instance'
350
+ ? (rowsRef.current[highlightedIndex] as Extract<FoldedRow<AppEntry>, { kind: 'instance' }>).entry.row.key
351
+ : firstOpenableVisibleRow()
352
+ if (key) onSelect(key)
353
+ }}
354
+ onArrowDown={() => {
355
+ if (visibleRows.length > 0) setHighlightedIndex(0)
356
+ }}
357
+ />
358
+ {/* Sorting is via the clickable column headers (Resources-table
359
+ pattern) — no separate sort control. */}
360
+ </div>
361
+
362
+ <div className="min-w-0 flex-1 overflow-auto bg-theme-base">
363
+ {entries.length === 0 ? (
364
+ emptySlot && allEntries.length === 0 ? (
365
+ emptySlot
366
+ ) : (
367
+ <div className="p-4">
368
+ <EmptyState
369
+ tone="filtered"
370
+ variant="card"
371
+ headline={total === 0 ? 'No applications detected yet' : 'No applications match the filters'}
372
+ body={total === 0 ? 'Deploy services, workers, or jobs to this cluster to see them grouped by app.' : 'Clear the filters above.'}
373
+ />
374
+ </div>
375
+ )
376
+ ) : (
377
+ <table className="w-full text-left text-sm">
378
+ <thead className="sticky top-0 z-10 bg-theme-base">
379
+ <tr>
380
+ <SortableTh label="Application" sortKey="name" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} />
381
+ {variant === 'single' ? (
382
+ <>
383
+ <th className={TH_CLASS}>Namespace</th>
384
+ <th className={TH_CLASS}>Env</th>
385
+ </>
386
+ ) : (
387
+ <>
388
+ <th className={TH_CLASS}>Cluster</th>
389
+ <th className={TH_CLASS}>Envs</th>
390
+ </>
391
+ )}
392
+ <th className={TH_CLASS}>Class</th>
393
+ <SortableTh label="Ready" sortKey="ready" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} />
394
+ <SortableTh label="Version" sortKey="version" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} />
395
+ <th className={TH_CLASS}>Workloads</th>
396
+ <th className={clsx(TH_CLASS, 'w-8')} />
397
+ </tr>
398
+ </thead>
399
+ <tbody>
400
+ {visibleRows.map((r, idx) => r.kind === 'group' ? (
401
+ <tr
402
+ key={`group:${r.key}`}
403
+ ref={idx === highlightedIndex ? (el) => el?.scrollIntoView({ block: 'nearest' }) : undefined}
404
+ aria-expanded={r.expanded}
405
+ className={clsx(
406
+ 'group/row cursor-pointer border-b-subtle',
407
+ idx === highlightedIndex ? 'selection selection-ring' : 'hover:bg-theme-hover',
408
+ )}
409
+ onClick={() => toggleGroup(r.key)}
410
+ >
411
+ <td className="py-2.5 pl-3 pr-2">
412
+ <span className="flex items-center gap-2">
413
+ {/* Left status stripe (worst-child health) — the row status
414
+ gutter shared with the GitOps table. */}
415
+ <Tooltip content={HEALTH_META[r.health].label} delay={150}>
416
+ <span className={clsx('h-8 w-1 shrink-0 rounded-full', HEALTH_META[r.health].bar)} />
417
+ </Tooltip>
418
+ <ChevronRight className={clsx('h-3.5 w-3.5 shrink-0 text-theme-text-tertiary transition-transform', r.expanded && 'rotate-90')} aria-hidden />
419
+ <span className="truncate font-semibold text-theme-text-primary">{r.label}</span>
420
+ <Tooltip
421
+ content={<AppIdentityTooltip identityKey={r.label} source={r.members[0]?.row.identity?.source} portable={r.members[0]?.row.identity?.portable} fleet={variant === 'fleet'} members={r.members.map((m) => ({ name: m.row.name, env: m.row.identity!.env, confidence: m.row.identity!.confidence, evidence: m.row.identity!.evidence }))} />}
422
+ delay={150}
423
+ >
424
+ <span className={`${CHIP} ${r.confidence === 'high' ? CHIP_TONE.emerald : CHIP_TONE.neutral}`}>
425
+ <Layers className="mr-1 h-3 w-3" aria-hidden />{r.cells.length} envs
426
+ </span>
427
+ </Tooltip>
428
+ </span>
429
+ </td>
430
+ <td className="px-2 py-2.5">
431
+ <span className="text-xs text-theme-text-tertiary">{pluralize(r.members.length, 'instance')}</span>
432
+ </td>
433
+ <td className="px-2 py-2.5">
434
+ {/* The ladder: env-ordered cells; click drills into that env's instance. */}
435
+ <span className="flex flex-wrap items-center gap-1">
436
+ {/* Ladder cells scale-capped: a handful inline, the
437
+ rest behind "+N" (expand shows every instance). */}
438
+ {r.cells.slice(0, 4).map((c) => (
439
+ <Tooltip key={c.env} content={`${c.env}${c.version ? ` · ${c.version}` : ''}${c.count > 1 ? ` · ${c.count} instances — expand to choose` : ' — open'}`} delay={150}>
440
+ <button
441
+ type="button"
442
+ onClick={(ev) => {
443
+ ev.stopPropagation()
444
+ if (c.count > 1) toggleGroup(r.key)
445
+ else onSelect(c.firstKey)
446
+ }}
447
+ className={`${CHIP} ${CHIP_TONE.neutral} gap-1 hover:bg-theme-hover`}
448
+ >
449
+ <StatusDot tone={mapHealthToTone(c.health)} />{c.env}
450
+ </button>
451
+ </Tooltip>
452
+ ))}
453
+ {r.cells.length > 4 && (
454
+ <Tooltip content={`${r.cells.length - 4} more environments — expand to see all instances`} delay={150}>
455
+ <button
456
+ type="button"
457
+ onClick={(ev) => { ev.stopPropagation(); toggleGroup(r.key) }}
458
+ className={`${CHIP} ${CHIP_TONE.muted} hover:bg-theme-hover`}
459
+ >
460
+ +{r.cells.length - 4}
461
+ </button>
462
+ </Tooltip>
463
+ )}
464
+ </span>
465
+ </td>
466
+ <td className="px-2 py-2.5"><ClassBadge workloadClass={r.workloadClass} composition={r.classComposition} /></td>
467
+ <td className="px-2 py-2.5"><ReadyBar ready={r.ready} desired={r.desired} /></td>
468
+ <td className="px-2 py-2.5">
469
+ {r.lag ? (
470
+ <Tooltip content={`Promotion lag: ${r.lag} (${r.cells.filter((c) => c.version).map((c) => `${c.env}=${c.version}`).join(', ')})`} delay={150}>
471
+ <span className={`${CHIP} ${CHIP_TONE.amber}`}>{r.lag}</span>
472
+ </Tooltip>
473
+ ) : (
474
+ <span className="text-theme-text-tertiary">—</span>
475
+ )}
476
+ </td>
477
+ <td className="px-2 py-2.5">
478
+ <span className="text-xs text-theme-text-secondary">{Object.entries(r.kinds).map(([k, n]) => pluralize(n, k)).join(' · ')}</span>
479
+ </td>
480
+ <td className="pr-2 text-right" />
481
+ </tr>
482
+ ) : ((e) => (
483
+ <tr
484
+ key={e.row.key}
485
+ ref={idx === highlightedIndex ? (el) => el?.scrollIntoView({ block: 'nearest' }) : undefined}
486
+ className={clsx(
487
+ 'group/row cursor-pointer border-b-subtle',
488
+ idx === highlightedIndex ? 'selection selection-ring' : 'hover:bg-theme-hover',
489
+ )}
490
+ onClick={() => onSelect(e.row.key)}
491
+ >
492
+ <td className={clsx('py-2.5 pr-2', r.child ? 'pl-10' : 'pl-3')}>
493
+ <span className="flex items-center gap-2">
494
+ <Tooltip content={HEALTH_META[e.health].label} delay={150}>
495
+ <span className={clsx('h-8 w-1 shrink-0 rounded-full', HEALTH_META[e.health].bar)} />
496
+ </Tooltip>
497
+ <span className="truncate font-medium text-theme-text-primary">{e.row.name}</span>
498
+ <ProvenanceBadge tier={e.row.tier} appKey={e.row.key} confidence={e.row.confidence} />
499
+ <CategoryChip category={e.category} addonReason={e.row.addonReason} />
500
+ </span>
501
+ </td>
502
+ {e.variant === 'single' ? (
503
+ <>
504
+ <td className="px-2 py-2.5">
505
+ {e.namespace ? (
506
+ <span className="truncate font-mono text-xs text-theme-text-secondary">{e.namespace}</span>
507
+ ) : e.namespaces.length > 1 ? (
508
+ <Tooltip content={e.namespaces.join(', ')} delay={150}>
509
+ <span className="text-xs text-theme-text-secondary">{e.namespaces.length} namespaces</span>
510
+ </Tooltip>
511
+ ) : (
512
+ <span className="text-theme-text-tertiary">—</span>
513
+ )}
514
+ </td>
515
+ <td className="px-2 py-2.5">
516
+ {e.env ? (
517
+ e.envInferred ? (
518
+ <Tooltip content={`Inferred from namespace "${e.namespace || e.env}" — confirm with an environment label.`} delay={150}>
519
+ <span className={`${CHIP} italic ${CHIP_TONE.muted}`}>~{e.env}</span>
520
+ </Tooltip>
521
+ ) : (
522
+ <span className={`${CHIP} ${CHIP_TONE.neutral}`}>{e.env}</span>
523
+ )
524
+ ) : (
525
+ <Tooltip content={<EnvHint unlabeled />} delay={300}>
526
+ <span className="cursor-default text-theme-text-tertiary">—</span>
527
+ </Tooltip>
528
+ )}
529
+ </td>
530
+ </>
531
+ ) : (
532
+ <>
533
+ <td className="px-2 py-2.5">
534
+ {e.clusters.length === 1 ? (
535
+ <span className="truncate text-xs text-theme-text-secondary">{e.clusters[0].name}</span>
536
+ ) : e.clusters.length > 1 ? (
537
+ <Tooltip content={e.clusters.map((c) => c.name).join(', ')} delay={150}>
538
+ <span className="text-xs text-theme-text-secondary">{e.clusters.length} clusters</span>
539
+ </Tooltip>
540
+ ) : (
541
+ <span className="text-theme-text-tertiary">—</span>
542
+ )}
543
+ </td>
544
+ <td className="px-2 py-2.5">
545
+ {e.envs.length > 0 ? (
546
+ <span className="flex flex-wrap items-center gap-1">
547
+ {e.envs.map((s) =>
548
+ s.inferred ? (
549
+ <Tooltip key={s.env || 'none'} content={`${s.env || 'unlabeled'} — inferred from namespace; confirm with an environment label.`} delay={150}>
550
+ <span className={`${CHIP} italic ${CHIP_TONE.muted} gap-1`}><StatusDot tone={mapHealthToTone(s.health)} />~{s.env}</span>
551
+ </Tooltip>
552
+ ) : (
553
+ <span key={s.env || 'none'} className={`${CHIP} ${CHIP_TONE.neutral} gap-1`}><StatusDot tone={mapHealthToTone(s.health)} />{s.env || 'unlabeled'}</span>
554
+ ),
555
+ )}
556
+ </span>
557
+ ) : (
558
+ <span className="text-theme-text-tertiary">—</span>
559
+ )}
560
+ </td>
561
+ </>
562
+ )}
563
+ <td className="px-2 py-2.5"><ClassBadge workloadClass={e.workloadClass} composition={e.classComposition} /></td>
564
+ <td className="px-2 py-2.5"><ReadyBar ready={e.ready} desired={e.desired} /></td>
565
+ <td className="px-2 py-2.5">
566
+ {e.variant === 'fleet' ? (
567
+ e.versionSkew ? (
568
+ <Tooltip content={`Version skew across clusters: ${e.versions.join(', ')}`} delay={150}>
569
+ <span className={`${CHIP} ${CHIP_TONE.amber}`}>version skew</span>
570
+ </Tooltip>
571
+ ) : e.versions.length === 1 ? (
572
+ <span className="font-mono text-xs text-theme-text-secondary">{e.versions[0]}</span>
573
+ ) : e.versions.length > 1 ? (
574
+ <Tooltip content={e.versions.join(', ')} delay={150}>
575
+ <span className={`${CHIP} ${CHIP_TONE.neutral}`}>{e.versions.length} versions</span>
576
+ </Tooltip>
577
+ ) : (
578
+ <span className="text-theme-text-tertiary">—</span>
579
+ )
580
+ ) : (
581
+ <VersionInfo app={e.row} variant="cell" />
582
+ )}
583
+ </td>
584
+ <td className="px-2 py-2.5">
585
+ {Object.keys(e.kinds).length === 0 ? (
586
+ <span className="text-xs text-theme-text-tertiary">—</span>
587
+ ) : (
588
+ <span className="text-xs text-theme-text-secondary">{Object.entries(e.kinds).map(([k, n]) => pluralize(n, k)).join(' · ')}</span>
589
+ )}
590
+ </td>
591
+ <td className="pr-2 text-right"><ChevronRight className="inline h-4 w-4 text-theme-text-tertiary" /></td>
592
+ </tr>
593
+ ))(r.entry))}
594
+ </tbody>
595
+ </table>
596
+ )}
597
+ </div>
598
+ </div>
599
+ </div>
600
+ </div>
601
+ )
602
+ }