@skyhook-io/k8s-ui 1.7.12 → 1.7.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/package.json +1 -1
  2. package/src/components/applications/AppChips.tsx +109 -0
  3. package/src/components/applications/AppTooltips.tsx +199 -0
  4. package/src/components/applications/ApplicationDetail.tsx +671 -0
  5. package/src/components/applications/ApplicationsList.tsx +569 -0
  6. package/src/components/applications/ReadyBar.tsx +22 -0
  7. package/src/components/applications/index.ts +8 -0
  8. package/src/components/audit/AuditFindingsTable.tsx +3 -25
  9. package/src/components/logs/WorkloadLogsViewer.tsx +8 -5
  10. package/src/components/resources/renderers/WorkloadRenderer.tsx +5 -4
  11. package/src/components/shared/DetailShell.tsx +14 -7
  12. package/src/components/shared/EditableYamlView.tsx +37 -17
  13. package/src/components/timeline/TimelineList.tsx +3 -32
  14. package/src/components/timeline/TimelineSwimlanes.tsx +3 -31
  15. package/src/components/topology/K8sResourceNode.tsx +26 -5
  16. package/src/components/topology/TopologyGraph.tsx +102 -3
  17. package/src/components/topology/layout.ts +36 -11
  18. package/src/components/ui/CenteredEmpty.tsx +27 -0
  19. package/src/components/ui/SearchBox.tsx +85 -0
  20. package/src/components/ui/index.ts +1 -0
  21. package/src/components/workload/WorkloadView.tsx +167 -33
  22. package/src/components/workload/index.ts +1 -1
  23. package/src/hooks/useKeyboardShortcuts.tsx +3 -1
  24. package/src/index.ts +4 -0
  25. package/src/utils/applications.test.ts +207 -0
  26. package/src/utils/applications.ts +674 -0
  27. package/src/utils/format.ts +11 -0
  28. package/src/utils/index.ts +2 -0
  29. package/src/utils/topology-neighborhood.test.ts +185 -0
  30. package/src/utils/topology-neighborhood.ts +262 -0
  31. package/src/utils/workload-colors.ts +36 -0
@@ -0,0 +1,569 @@
1
+ import { useMemo, useState, useEffect, useRef, useCallback } from 'react'
2
+ import { ChevronRight, ChevronUp, ChevronDown, Layers, Info } 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 { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
9
+ import { pluralize } from '../../utils/pluralize'
10
+ import {
11
+ type AppRow,
12
+ type AppHealth,
13
+ type AppWorkloadClass,
14
+ type AppSource,
15
+ type AppCategory,
16
+ HEALTH_ORDER,
17
+ HEALTH_RANK,
18
+ HEALTH_META,
19
+ CLASS_ORDER,
20
+ CLASS_META,
21
+ CATEGORY_ORDER,
22
+ CATEGORY_META,
23
+ CHIP,
24
+ CHIP_TONE,
25
+ SOURCE_ORDER,
26
+ SOURCE_META,
27
+ categoryOf,
28
+ envRank,
29
+ healthOf,
30
+ isSystemNamespace,
31
+ namespaceOf,
32
+ namespacesOf,
33
+ resolveEnv,
34
+ identityEnvInferred,
35
+ sourceOf,
36
+ workloadClassOf,
37
+ classSetOf,
38
+ classCompositionOf,
39
+ foldAppGroups,
40
+ type FoldedRow,
41
+ } from '../../utils/applications'
42
+ import { ReadyBar } from './ReadyBar'
43
+ import { ProvenanceBadge, ClassBadge, CategoryChip, VersionInfo } from './AppChips'
44
+ import { AppIdentityTooltip, EnvHint } from './AppTooltips'
45
+
46
+ // ApplicationsList — pure, single-cluster dense list of logical apps. Health
47
+ // dot + name + provenance/add-on/mixed chips; a Namespace column; an env pill
48
+ // (namespace-inferred shown as ~env); class; ready bar; version; workloads. A
49
+ // facet rail + a health hero bar sit alongside. Data + selection are injected.
50
+ // Styling mirrors the Resources table so the two read as one design.
51
+
52
+ interface AppEntry {
53
+ row: AppRow
54
+ health: AppHealth
55
+ versions: string[]
56
+ namespace: string
57
+ namespaces: string[]
58
+ env: string
59
+ envInferred: boolean
60
+ kinds: Record<string, number>
61
+ workloadClass: AppWorkloadClass
62
+ /** Distinct contained classes — the inclusive facet-matching set. */
63
+ classSet: AppWorkloadClass[]
64
+ classComposition: { cls: AppWorkloadClass; count: number }[]
65
+ category: AppCategory
66
+ ready: number
67
+ desired: number
68
+ /** ready/desired as a fraction for sorting; -1 when nothing is desired. */
69
+ readyRatio: number
70
+ source: AppSource
71
+ }
72
+
73
+ function buildEntry(row: AppRow, discoveredEnvs?: ReadonlySet<string>): AppEntry {
74
+ const kinds: Record<string, number> = {}
75
+ let ready = 0
76
+ let desired = 0
77
+ for (const wl of row.workloads || []) {
78
+ kinds[wl.kind] = (kinds[wl.kind] ?? 0) + 1
79
+ ready += wl.ready ?? 0
80
+ desired += wl.desired ?? 0
81
+ }
82
+ const namespace = namespaceOf(row)
83
+ // The server's identity classification carries the authoritative env (label/
84
+ // declared/discovered); plain rows fall back to the trio + discovered-token
85
+ // namespace heuristic.
86
+ const resolved = resolveEnv(undefined, namespace, discoveredEnvs)
87
+ const env = row.identity?.env ?? resolved.env
88
+ const inferred = row.identity ? identityEnvInferred(row.identity) : resolved.inferred
89
+ return {
90
+ row,
91
+ health: healthOf(row.health),
92
+ versions: Array.from(new Set((row.versions || []).filter(Boolean))),
93
+ namespace,
94
+ namespaces: namespacesOf(row),
95
+ env,
96
+ envInferred: inferred,
97
+ kinds,
98
+ workloadClass: workloadClassOf(row.workload_class),
99
+ classSet: classSetOf(row),
100
+ classComposition: classCompositionOf(row),
101
+ category: categoryOf(row.category),
102
+ ready,
103
+ desired,
104
+ readyRatio: desired > 0 ? ready / desired : -1,
105
+ source: sourceOf(row.tier),
106
+ }
107
+ }
108
+
109
+ const envLabel = (env: string) => (env ? env : 'unlabeled')
110
+
111
+ function searchTextForEntry(e: AppEntry): string {
112
+ const workloadText = (e.row.workloads || []).flatMap((wl) => [wl.kind, wl.namespace, wl.name, wl.version])
113
+ return [
114
+ e.row.name,
115
+ e.row.key,
116
+ e.namespace,
117
+ SOURCE_META[e.source].label,
118
+ CLASS_META[e.workloadClass].label,
119
+ ...e.classSet.map((c) => CLASS_META[c].label),
120
+ CATEGORY_META[e.category].label,
121
+ ...e.versions,
122
+ envLabel(e.env),
123
+ ...Object.keys(e.kinds),
124
+ ...workloadText,
125
+ ]
126
+ .filter(Boolean)
127
+ .join(' ')
128
+ .toLowerCase()
129
+ }
130
+
131
+ export function Facet<T extends string>({ title, info, options, selected, onToggle }: { title: string; info?: React.ReactNode; options: { value: T; label: string; count: number; tone?: string; tooltip?: string }[]; selected: Set<T>; onToggle: (v: T) => void }) {
132
+ const visible = options.filter((o) => o.count > 0)
133
+ if (visible.length === 0) return null
134
+ return (
135
+ <div className="flex flex-col gap-1">
136
+ <div className="flex items-center gap-1 px-1 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
137
+ {title}
138
+ {info && (
139
+ <Tooltip content={info} delay={150} position="right">
140
+ <Info className="h-3 w-3 cursor-default text-theme-text-tertiary/70 hover:text-theme-text-secondary" aria-label={`About ${title}`} />
141
+ </Tooltip>
142
+ )}
143
+ </div>
144
+ {visible.map((o) => {
145
+ const on = selected.has(o.value)
146
+ const button = (
147
+ <button
148
+ key={o.value}
149
+ type="button"
150
+ onClick={() => onToggle(o.value)}
151
+ className={`flex w-full items-center justify-between gap-2 rounded px-2 py-1 text-left text-xs ${on ? 'selection selection-ring text-theme-text-primary' : 'text-theme-text-secondary hover:bg-theme-hover'}`}
152
+ >
153
+ <span className={`truncate ${o.tone ?? ''}`}>{o.label}</span>
154
+ <span className="font-mono tabular-nums text-theme-text-tertiary">{o.count}</span>
155
+ </button>
156
+ )
157
+ return o.tooltip ? (
158
+ <Tooltip key={o.value} content={o.tooltip} delay={300} position="right" wrapperClassName="w-full">
159
+ {button}
160
+ </Tooltip>
161
+ ) : (
162
+ button
163
+ )
164
+ })}
165
+ </div>
166
+ )
167
+ }
168
+
169
+ // Sortable columns. `health` is the implicit default (worst-first then name);
170
+ // clicking a sortable header cycles asc → desc → off (back to default).
171
+ type SortKey = 'name' | 'ready' | 'version'
172
+ type SortDir = 'asc' | 'desc'
173
+
174
+ function compareEntries(a: AppEntry, b: AppEntry, key: SortKey): number {
175
+ switch (key) {
176
+ case 'name':
177
+ return a.row.name.localeCompare(b.row.name)
178
+ case 'ready':
179
+ return a.readyRatio - b.readyRatio
180
+ case 'version': {
181
+ // Sort by distinct-version count first (skewed apps cluster), then the
182
+ // first tag for a stable, human-meaningful order.
183
+ const byCount = a.versions.length - b.versions.length
184
+ if (byCount !== 0) return byCount
185
+ return (a.versions[0] ?? '').localeCompare(b.versions[0] ?? '')
186
+ }
187
+ }
188
+ }
189
+
190
+ function SortHeader({ label, sortKey, sort, onSort, className }: { label: string; sortKey: SortKey; sort: { key: SortKey; dir: SortDir } | null; onSort: (k: SortKey) => void; className?: string }) {
191
+ const active = sort?.key === sortKey
192
+ const ariaSort = active ? (sort!.dir === 'asc' ? 'ascending' : 'descending') : 'none'
193
+ return (
194
+ <th aria-sort={ariaSort} className={clsx('px-2 py-2 text-left text-[10px] font-medium uppercase tracking-wide cursor-pointer select-none text-theme-text-tertiary hover:text-theme-text-primary', className)} onClick={() => onSort(sortKey)}>
195
+ <span className="inline-flex items-center gap-1">
196
+ {label}
197
+ {active ? (sort!.dir === 'asc' ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />) : <span className="w-3" />}
198
+ </span>
199
+ </th>
200
+ )
201
+ }
202
+
203
+ export interface ApplicationsListProps {
204
+ apps: AppRow[]
205
+ onSelect: (key: string) => void
206
+ }
207
+
208
+ export function ApplicationsList({ apps, onSelect }: ApplicationsListProps) {
209
+ const [textFilter, setTextFilter] = useState('')
210
+ const [fHealth, setFHealth] = useState<Set<AppHealth>>(new Set())
211
+ const [fEnv, setFEnv] = useState<Set<string>>(new Set())
212
+ const [fSource, setFSource] = useState<Set<AppSource>>(new Set())
213
+ const [fClass, setFClass] = useState<Set<AppWorkloadClass>>(new Set())
214
+ const [fType, setFType] = useState<Set<AppCategory>>(new Set())
215
+ const [showSystem, setShowSystem] = useState(false)
216
+ const [sort, setSort] = useState<{ key: SortKey; dir: SortDir } | null>(null)
217
+
218
+ // Env tokens this CLUSTER proved (identity classifications on the wire) feed
219
+ // the namespace heuristic, so sibling-less rows in discovered env namespaces
220
+ // namespace still label without any hardcoded vocabulary.
221
+ const discoveredEnvs = useMemo(() => new Set(apps.map((a) => a.identity?.env).filter((e): e is string => !!e)), [apps])
222
+ const allRaw = useMemo<AppEntry[]>(() => apps.map((a) => buildEntry(a, discoveredEnvs)), [apps, discoveredEnvs])
223
+ // System namespaces are filtered before facet counts so the counts reflect
224
+ // what the user is actually looking at (consistent with the other facets).
225
+ // An app counts as system only when EVERY workload namespace is system —
226
+ // hiding a partly-user app would be worse than showing a partly-system one.
227
+ const isSystemApp = (e: AppEntry) => e.namespaces.length > 0 && e.namespaces.every(isSystemNamespace)
228
+ const all = useMemo(() => (showSystem ? allRaw : allRaw.filter((e) => !isSystemApp(e))), [allRaw, showSystem])
229
+ const systemCount = useMemo(() => allRaw.filter(isSystemApp).length, [allRaw])
230
+
231
+ const entries = useMemo(() => {
232
+ const t = textFilter.trim().toLowerCase()
233
+ const filtered = all.filter((e) => {
234
+ if (t && !searchTextForEntry(e).includes(t)) return false
235
+ if (fHealth.size && !fHealth.has(e.health)) return false
236
+ // Inclusive: a mixed app matches the filter of ANY class it contains.
237
+ if (fClass.size && !e.classSet.some((c) => fClass.has(c))) return false
238
+ if (fType.size && !fType.has(e.category)) return false
239
+ if (fSource.size && !fSource.has(e.source)) return false
240
+ if (fEnv.size && !fEnv.has(e.env || 'none')) return false
241
+ return true
242
+ })
243
+ if (sort) {
244
+ const factor = sort.dir === 'asc' ? 1 : -1
245
+ filtered.sort((a, b) => compareEntries(a, b, sort.key) * factor || a.row.name.localeCompare(b.row.name))
246
+ } else {
247
+ filtered.sort((a, b) => (HEALTH_RANK[b.health] ?? 0) - (HEALTH_RANK[a.health] ?? 0) || a.row.name.localeCompare(b.row.name))
248
+ }
249
+ return filtered
250
+ }, [all, textFilter, fHealth, fEnv, fSource, fClass, fType, sort])
251
+
252
+ // ── App groups ──────────────────────────────────────────────────────────────
253
+ // Instances sharing a wire `identity` fold into one ladder row (foldAppGroups).
254
+ // THE COLLAPSE EXPERIMENT: default collapsed, contingent on (a) text search
255
+ // auto-expanding into hidden instances, (b) instance rows one chevron away,
256
+ // (c) the group chip visibly carrying confidence. If heuristic precision
257
+ // disappoints in the field, default-expand by seeding expandedGroups with
258
+ // every group key instead of an empty set.
259
+ const FAMILY_AUTO_EXPAND_ON_SEARCH = true
260
+ const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
261
+ const toggleGroup = (key: string) =>
262
+ setExpandedGroups((s) => {
263
+ const n = new Set(s)
264
+ n.has(key) ? n.delete(key) : n.add(key)
265
+ return n
266
+ })
267
+
268
+ const visibleRows = useMemo<FoldedRow<AppEntry>[]>(
269
+ () => foldAppGroups(entries, expandedGroups, FAMILY_AUTO_EXPAND_ON_SEARCH && textFilter.trim() !== ''),
270
+ [entries, expandedGroups, textFilter],
271
+ )
272
+
273
+ // Row keyboard navigation — same contract as the Resources table: j/k or
274
+ // arrows move a highlight, g g / G jump, Enter opens, Escape clears the
275
+ // highlight. The search box hands off via ArrowDown.
276
+ const [highlightedIndex, setHighlightedIndex] = useState(-1)
277
+ const rowsRef = useRef(visibleRows)
278
+ rowsRef.current = visibleRows
279
+ useEffect(() => setHighlightedIndex(-1), [visibleRows])
280
+ const firstOpenableVisibleRow = useCallback(() => {
281
+ const first = rowsRef.current[0]
282
+ if (!first) return null
283
+ return first.kind === 'group' ? first.cells[0]?.firstKey ?? null : first.entry.row.key
284
+ }, [])
285
+ const moveHighlight = (delta: number) =>
286
+ setHighlightedIndex((i) => Math.min(Math.max(i + delta, 0), rowsRef.current.length - 1))
287
+ useRegisterShortcuts([
288
+ { id: 'applications-nav-down', keys: 'j', description: 'Next row', category: 'Table', scope: 'applications', handler: () => moveHighlight(1) },
289
+ { id: 'applications-nav-down-arrow', keys: 'ArrowDown', description: 'Next row', category: 'Table', scope: 'applications', handler: () => moveHighlight(1) },
290
+ { id: 'applications-nav-up', keys: 'k', description: 'Previous row', category: 'Table', scope: 'applications', handler: () => moveHighlight(-1) },
291
+ { id: 'applications-nav-up-arrow', keys: 'ArrowUp', description: 'Previous row', category: 'Table', scope: 'applications', handler: () => moveHighlight(-1) },
292
+ { id: 'applications-nav-top', keys: 'g g', description: 'Jump to first row', category: 'Table', scope: 'applications', handler: () => setHighlightedIndex(rowsRef.current.length > 0 ? 0 : -1) },
293
+ { id: 'applications-nav-bottom', keys: 'G', description: 'Jump to last row', category: 'Table', scope: 'applications', handler: () => setHighlightedIndex(rowsRef.current.length - 1) },
294
+ {
295
+ id: 'applications-open', keys: 'Enter', description: 'Open application', category: 'Table', scope: 'applications',
296
+ handler: () => {
297
+ const r = rowsRef.current[highlightedIndex]
298
+ if (!r) return
299
+ // Enter on a group toggles it; on an instance, opens it.
300
+ if (r.kind === 'group') toggleGroup(r.key)
301
+ else onSelect(r.entry.row.key)
302
+ },
303
+ enabled: highlightedIndex >= 0,
304
+ },
305
+ {
306
+ id: 'applications-escape', keys: 'Escape', description: 'Clear row highlight', category: 'Table', scope: 'applications',
307
+ handler: () => setHighlightedIndex(-1),
308
+ enabled: highlightedIndex >= 0,
309
+ },
310
+ ])
311
+
312
+ const counts = useMemo(() => {
313
+ const health: Record<string, number> = {}
314
+ const env: Record<string, number> = {}
315
+ const source: Record<string, number> = {}
316
+ const workloadClass: Record<string, number> = {}
317
+ const category: Record<string, number> = {}
318
+ for (const e of all) {
319
+ health[e.health] = (health[e.health] ?? 0) + 1
320
+ for (const c of e.classSet) workloadClass[c] = (workloadClass[c] ?? 0) + 1
321
+ source[e.source] = (source[e.source] ?? 0) + 1
322
+ category[e.category] = (category[e.category] ?? 0) + 1
323
+ env[e.env || 'none'] = (env[e.env || 'none'] ?? 0) + 1
324
+ }
325
+ return { health, env, source, workloadClass, category }
326
+ }, [all])
327
+
328
+ const toggle = <T,>(set: Set<T>, setter: (s: Set<T>) => void, v: T) => {
329
+ const next = new Set(set)
330
+ next.has(v) ? next.delete(v) : next.add(v)
331
+ setter(next)
332
+ }
333
+
334
+ // asc → desc → off (null = default health-worst-first sort).
335
+ const onSort = (key: SortKey) => {
336
+ setSort((prev) => {
337
+ if (!prev || prev.key !== key) return { key, dir: 'asc' }
338
+ if (prev.dir === 'asc') return { key, dir: 'desc' }
339
+ return null
340
+ })
341
+ }
342
+
343
+ const total = all.length
344
+ const envOptions = Object.entries(counts.env)
345
+ .sort((a, b) => (envRank(b[0] === 'none' ? undefined : b[0]) ?? -1) - (envRank(a[0] === 'none' ? undefined : a[0]) ?? -1))
346
+ .map(([env, count]) => ({ value: env, label: env === 'none' ? 'unlabeled' : env, count }))
347
+
348
+ return (
349
+ <div className="flex w-full flex-1 flex-col gap-4">
350
+ {/* Health spectrum hero */}
351
+ <div className="flex flex-col gap-1.5 rounded-md border border-theme-border bg-theme-surface px-4 py-3">
352
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
353
+ <span className="font-medium text-theme-text-primary">
354
+ {entries.length < total ? `${entries.length} of ${total} applications` : pluralize(total, 'application')}
355
+ </span>
356
+ {HEALTH_ORDER.map((h) => (counts.health[h] ? <span key={h} className={HEALTH_META[h].text}>{HEALTH_META[h].label} {counts.health[h]}</span> : null))}
357
+ <span className="ml-auto text-theme-text-tertiary">
358
+ {sort ? `Sorted by ${sort.key} ${sort.dir === 'asc' ? '↑' : '↓'}` : 'Sorted by status'}
359
+ </span>
360
+ </div>
361
+ <div className="flex h-2 w-full overflow-hidden rounded-full bg-theme-hover">
362
+ {HEALTH_ORDER.map((h) => (counts.health[h] ? <span key={h} className={HEALTH_META[h].bar} style={{ width: `${(counts.health[h] / total) * 100}%` }} title={`${HEALTH_META[h].label} ${counts.health[h]}`} /> : null))}
363
+ </div>
364
+ </div>
365
+
366
+ <SearchBox
367
+ value={textFilter}
368
+ onChange={setTextFilter}
369
+ scope="applications"
370
+ shortcutId="applications-search"
371
+ className="max-w-md"
372
+ onEnter={() => {
373
+ const key = highlightedIndex >= 0 && rowsRef.current[highlightedIndex]?.kind === 'instance'
374
+ ? (rowsRef.current[highlightedIndex] as Extract<FoldedRow<AppEntry>, { kind: 'instance' }>).entry.row.key
375
+ : firstOpenableVisibleRow()
376
+ if (key) onSelect(key)
377
+ }}
378
+ onArrowDown={() => {
379
+ if (visibleRows.length > 0) setHighlightedIndex(0)
380
+ }}
381
+ />
382
+
383
+ <div className="flex w-full gap-4">
384
+ {/* Facet rail */}
385
+ <aside className="hidden w-[200px] shrink-0 flex-col gap-4 lg:flex">
386
+ <Facet title="Availability" options={HEALTH_ORDER.map((h) => ({ value: h, label: HEALTH_META[h].label, count: counts.health[h] ?? 0, tone: HEALTH_META[h].text }))} selected={fHealth} onToggle={(v) => toggle(fHealth, setFHealth, v)} />
387
+ <Facet 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)} />
388
+ <Facet 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)} />
389
+ <Facet title="Environment" info={<EnvHint />} options={envOptions} selected={fEnv} onToggle={(v) => toggle(fEnv, setFEnv, v)} />
390
+ <Facet 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)} />
391
+ {systemCount > 0 && (
392
+ <label className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-xs text-theme-text-secondary hover:bg-theme-hover">
393
+ <input type="checkbox" checked={showSystem} onChange={(e) => setShowSystem(e.target.checked)} className="accent-skyhook-500" />
394
+ <span>Show system namespaces</span>
395
+ <span className="ml-auto font-mono tabular-nums text-theme-text-tertiary">{systemCount}</span>
396
+ </label>
397
+ )}
398
+ </aside>
399
+
400
+ {/* Table */}
401
+ <div className="min-w-0 flex-1">
402
+ {entries.length === 0 ? (
403
+ <EmptyState tone="filtered" variant="card" headline="No applications match the filters" body="Clear the filters above." />
404
+ ) : (
405
+ <div className="overflow-hidden rounded-md border border-theme-border">
406
+ <table className="w-full text-left text-sm">
407
+ <thead>
408
+ <tr className="border-b border-theme-border bg-theme-base">
409
+ <SortHeader label="Application" sortKey="name" sort={sort} onSort={onSort} className="pl-3 pr-2" />
410
+ <th className="px-2 py-2 text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary">Namespace</th>
411
+ <th className="px-2 py-2 text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary">Env</th>
412
+ <th className="px-2 py-2 text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary">Class</th>
413
+ <SortHeader label="Ready" sortKey="ready" sort={sort} onSort={onSort} />
414
+ <SortHeader label="Version" sortKey="version" sort={sort} onSort={onSort} />
415
+ <th className="px-2 py-2 text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary">Workloads</th>
416
+ <th className="w-8" />
417
+ </tr>
418
+ </thead>
419
+ <tbody>
420
+ {visibleRows.map((r, idx) => r.kind === 'group' ? (
421
+ <tr
422
+ key={`group:${r.key}`}
423
+ ref={idx === highlightedIndex ? (el) => el?.scrollIntoView({ block: 'nearest' }) : undefined}
424
+ aria-expanded={r.expanded}
425
+ className={clsx(
426
+ 'group/row cursor-pointer border-b-subtle',
427
+ idx === highlightedIndex ? 'selection selection-ring' : 'hover:bg-theme-hover',
428
+ )}
429
+ onClick={() => toggleGroup(r.key)}
430
+ >
431
+ <td className="py-2.5 pl-3 pr-2">
432
+ <span className="flex items-center gap-2">
433
+ <ChevronRight className={clsx('h-3.5 w-3.5 shrink-0 text-theme-text-tertiary transition-transform', r.expanded && 'rotate-90')} aria-hidden />
434
+ <Tooltip content={HEALTH_META[r.health].label} delay={150}>
435
+ <span className="inline-flex"><StatusDot tone={mapHealthToTone(r.health)} /></span>
436
+ </Tooltip>
437
+ <span className="truncate font-semibold text-theme-text-primary">{r.label}</span>
438
+ <Tooltip
439
+ 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 }))} />}
440
+ delay={150}
441
+ >
442
+ <span className={`${CHIP} ${r.confidence === 'high' ? CHIP_TONE.emerald : CHIP_TONE.neutral}`}>
443
+ <Layers className="mr-1 h-3 w-3" aria-hidden />{r.cells.length} envs
444
+ </span>
445
+ </Tooltip>
446
+ </span>
447
+ </td>
448
+ <td className="px-2 py-2.5">
449
+ <span className="text-xs text-theme-text-tertiary">{pluralize(r.members.length, 'instance')}</span>
450
+ </td>
451
+ <td className="px-2 py-2.5">
452
+ {/* The ladder: env-ordered cells; click drills into that env's instance. */}
453
+ <span className="flex flex-wrap items-center gap-1">
454
+ {/* Ladder cells scale-capped: a handful inline, the
455
+ rest behind "+N" (expand shows every instance). */}
456
+ {r.cells.slice(0, 4).map((c) => (
457
+ <Tooltip key={c.env} content={`${c.env}${c.version ? ` · ${c.version}` : ''}${c.count > 1 ? ` · ${c.count} instances — expand to choose` : ' — open'}`} delay={150}>
458
+ <button
459
+ type="button"
460
+ onClick={(ev) => {
461
+ ev.stopPropagation()
462
+ if (c.count > 1) toggleGroup(r.key)
463
+ else onSelect(c.firstKey)
464
+ }}
465
+ className={`${CHIP} ${CHIP_TONE.neutral} gap-1 hover:bg-theme-hover`}
466
+ >
467
+ <StatusDot tone={mapHealthToTone(c.health)} />{c.env}
468
+ </button>
469
+ </Tooltip>
470
+ ))}
471
+ {r.cells.length > 4 && (
472
+ <Tooltip content={`${r.cells.length - 4} more environments — expand to see all instances`} delay={150}>
473
+ <button
474
+ type="button"
475
+ onClick={(ev) => { ev.stopPropagation(); toggleGroup(r.key) }}
476
+ className={`${CHIP} ${CHIP_TONE.muted} hover:bg-theme-hover`}
477
+ >
478
+ +{r.cells.length - 4}
479
+ </button>
480
+ </Tooltip>
481
+ )}
482
+ </span>
483
+ </td>
484
+ <td className="px-2 py-2.5"><ClassBadge workloadClass={r.workloadClass} composition={r.classComposition} /></td>
485
+ <td className="px-2 py-2.5"><ReadyBar ready={r.ready} desired={r.desired} /></td>
486
+ <td className="px-2 py-2.5">
487
+ {r.lag ? (
488
+ <Tooltip content={`Promotion lag: ${r.lag} (${r.cells.filter((c) => c.version).map((c) => `${c.env}=${c.version}`).join(', ')})`} delay={150}>
489
+ <span className={`${CHIP} ${CHIP_TONE.amber}`}>{r.lag}</span>
490
+ </Tooltip>
491
+ ) : (
492
+ <span className="text-theme-text-tertiary">—</span>
493
+ )}
494
+ </td>
495
+ <td className="px-2 py-2.5">
496
+ <span className="text-xs text-theme-text-secondary">{Object.entries(r.kinds).map(([k, n]) => pluralize(n, k)).join(' · ')}</span>
497
+ </td>
498
+ <td className="pr-2 text-right" />
499
+ </tr>
500
+ ) : ((e) => (
501
+ <tr
502
+ key={e.row.key}
503
+ ref={idx === highlightedIndex ? (el) => el?.scrollIntoView({ block: 'nearest' }) : undefined}
504
+ className={clsx(
505
+ 'group/row cursor-pointer border-b-subtle',
506
+ idx === highlightedIndex ? 'selection selection-ring' : 'hover:bg-theme-hover',
507
+ )}
508
+ onClick={() => onSelect(e.row.key)}
509
+ >
510
+ <td className={clsx('py-2.5 pr-2', r.child ? 'pl-10' : 'pl-3')}>
511
+ <span className="flex items-center gap-2">
512
+ <Tooltip content={HEALTH_META[e.health].label} delay={150}>
513
+ <span className="inline-flex"><StatusDot tone={mapHealthToTone(e.health)} /></span>
514
+ </Tooltip>
515
+ <span className="truncate font-medium text-theme-text-primary">{e.row.name}</span>
516
+ <ProvenanceBadge tier={e.row.tier} appKey={e.row.key} confidence={e.row.confidence} />
517
+ <CategoryChip category={e.category} addonReason={e.row.addonReason} />
518
+ </span>
519
+ </td>
520
+ <td className="px-2 py-2.5">
521
+ {e.namespace ? (
522
+ <span className="truncate font-mono text-xs text-theme-text-secondary">{e.namespace}</span>
523
+ ) : e.namespaces.length > 1 ? (
524
+ <Tooltip content={e.namespaces.join(', ')} delay={150}>
525
+ <span className="text-xs text-theme-text-secondary">{e.namespaces.length} namespaces</span>
526
+ </Tooltip>
527
+ ) : (
528
+ <span className="text-theme-text-tertiary">—</span>
529
+ )}
530
+ </td>
531
+ <td className="px-2 py-2.5">
532
+ {e.env ? (
533
+ e.envInferred ? (
534
+ <Tooltip content={`Inferred from namespace "${e.namespace || e.env}" — confirm with an environment label.`} delay={150}>
535
+ <span className={`${CHIP} italic ${CHIP_TONE.muted}`}>~{e.env}</span>
536
+ </Tooltip>
537
+ ) : (
538
+ <span className={`${CHIP} ${CHIP_TONE.neutral}`}>{e.env}</span>
539
+ )
540
+ ) : (
541
+ <Tooltip content={<EnvHint unlabeled />} delay={300}>
542
+ <span className="cursor-default text-theme-text-tertiary">—</span>
543
+ </Tooltip>
544
+ )}
545
+ </td>
546
+ <td className="px-2 py-2.5"><ClassBadge workloadClass={e.workloadClass} composition={e.classComposition} /></td>
547
+ <td className="px-2 py-2.5"><ReadyBar ready={e.ready} desired={e.desired} /></td>
548
+ <td className="px-2 py-2.5">
549
+ <VersionInfo app={e.row} variant="cell" />
550
+ </td>
551
+ <td className="px-2 py-2.5">
552
+ {Object.keys(e.kinds).length === 0 ? (
553
+ <span className="text-xs text-theme-text-tertiary">—</span>
554
+ ) : (
555
+ <span className="text-xs text-theme-text-secondary">{Object.entries(e.kinds).map(([k, n]) => pluralize(n, k)).join(' · ')}</span>
556
+ )}
557
+ </td>
558
+ <td className="pr-2 text-right"><ChevronRight className="inline h-4 w-4 text-theme-text-tertiary" /></td>
559
+ </tr>
560
+ ))(r.entry))}
561
+ </tbody>
562
+ </table>
563
+ </div>
564
+ )}
565
+ </div>
566
+ </div>
567
+ </div>
568
+ )
569
+ }
@@ -0,0 +1,22 @@
1
+ import { HEALTH_META } from '../../utils/applications'
2
+
3
+ /** Ready/desired progress bar shared by the Applications list and detail —
4
+ * colors come from HEALTH_META so the bar can't drift from the health system. */
5
+ export function ReadyBar({ ready, desired, width = 'w-12' }: { ready: number; desired: number; width?: string }) {
6
+ if (desired <= 0) {
7
+ return <span className="font-mono text-xs tabular-nums text-theme-text-tertiary">—</span>
8
+ }
9
+ const pct = Math.min(100, Math.round((ready / desired) * 100))
10
+ const ok = ready >= desired
11
+ // Text matches the bar's tier: amber for partial readiness, red only when
12
+ // nothing is ready — partial must not read as fully down.
13
+ const tier = ok ? HEALTH_META.healthy : ready === 0 ? HEALTH_META.unhealthy : HEALTH_META.degraded
14
+ return (
15
+ <span className="inline-flex items-center gap-1.5">
16
+ <span className={`inline-block h-1.5 ${width} rounded-full bg-theme-hover`}>
17
+ <span className={`block h-1.5 rounded-full ${tier.bar}`} style={{ width: `${pct}%` }} />
18
+ </span>
19
+ <span className={`font-mono text-xs tabular-nums ${ok ? 'text-theme-text-secondary' : tier.text}`}>{ready}/{desired || '—'}</span>
20
+ </span>
21
+ )
22
+ }
@@ -0,0 +1,8 @@
1
+ export { ApplicationsList, Facet } from './ApplicationsList'
2
+ export type { ApplicationsListProps } from './ApplicationsList'
3
+ export { ApplicationDetail } from './ApplicationDetail'
4
+ export type { ApplicationDetailProps, SelectedAppWorkload, AppIdentityInstance } from './ApplicationDetail'
5
+ export { CenteredEmpty } from '../ui/CenteredEmpty'
6
+ export { ProvenanceBadge, ClassBadge, CategoryChip, VersionInfo } from './AppChips'
7
+ export { ProvenanceTooltip, CategoryTooltip, VersionTooltip, AppIdentityTooltip } from './AppTooltips'
8
+ export { ReadyBar } from './ReadyBar'
@@ -1,9 +1,10 @@
1
1
  import { useState, useMemo, useRef, useEffect, type Dispatch, type SetStateAction } from 'react'
2
- import { ShieldAlert, AlertTriangle, ChevronRight, CheckCircle2, Search, ExternalLink, MoreHorizontal, EyeOff, Layers } from 'lucide-react'
2
+ import { ShieldAlert, AlertTriangle, ChevronRight, CheckCircle2, ExternalLink, MoreHorizontal, EyeOff, Layers } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import type { AuditFinding } from './AuditAlerts'
5
5
  import { SEVERITY_TEXT, BP_CATEGORY_BADGE, DEFAULT_BADGE_COLOR } from '../../utils/badge-colors'
6
6
  import { EmptyState } from '../ui/EmptyState'
7
+ import { SearchBox } from '../ui/SearchBox'
7
8
  import { FilterPill } from '../ui/FilterPill'
8
9
  import { pluralize } from '../../utils/pluralize'
9
10
 
@@ -62,7 +63,6 @@ export function AuditFindingsTable({ groups, findings, checks, onResourceClick,
62
63
  const [expanded, setExpanded] = useState<Set<string>>(new Set())
63
64
  const [expandedNS, setExpandedNS] = useState<Set<string>>(new Set())
64
65
  const [groupByNS, setGroupByNS] = useState(false)
65
- const searchInputRef = useRef<HTMLInputElement>(null)
66
66
 
67
67
  const toggleInSet = (setter: Dispatch<SetStateAction<Set<string>>>, value: string) => {
68
68
  setter(prev => {
@@ -84,18 +84,6 @@ export function AuditFindingsTable({ groups, findings, checks, onResourceClick,
84
84
  setSearchTerm('')
85
85
  }
86
86
 
87
- // "/" keyboard shortcut to focus search
88
- useEffect(() => {
89
- const handler = (e: KeyboardEvent) => {
90
- if (e.key === '/' && !e.ctrlKey && !e.metaKey && document.activeElement?.tagName !== 'INPUT') {
91
- e.preventDefault()
92
- searchInputRef.current?.focus()
93
- }
94
- }
95
- document.addEventListener('keydown', handler)
96
- return () => document.removeEventListener('keydown', handler)
97
- }, [])
98
-
99
87
  // Compute totals from whichever data source we have
100
88
  const allFindings = useMemo(() => {
101
89
  if (groups) return groups.flatMap(g => g.findings)
@@ -242,17 +230,7 @@ export function AuditFindingsTable({ groups, findings, checks, onResourceClick,
242
230
  <SummaryBadge label="Critical" count={dangerCount} color={SEVERITY_TEXT.error} />
243
231
  <SummaryBadge label="Warning" count={warningCount} color={SEVERITY_TEXT.warning} />
244
232
 
245
- <div className="relative">
246
- <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
247
- <input
248
- ref={searchInputRef}
249
- type="text"
250
- placeholder="Search... (press /)"
251
- value={searchTerm}
252
- onChange={(e) => setSearchTerm(e.target.value)}
253
- className="w-56 pl-10 pr-4 py-1.5 bg-theme-elevated border border-theme-border-light rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-skyhook-500"
254
- />
255
- </div>
233
+ <SearchBox value={searchTerm} onChange={setSearchTerm} scope="audit" shortcutId="audit-search" className="w-64" />
256
234
 
257
235
  <div className="flex-1" />
258
236