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