@skyhook-io/k8s-ui 1.7.14 → 1.8.0

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 (48) hide show
  1. package/package.json +7 -7
  2. package/src/components/applications/ApplicationsList.tsx +131 -115
  3. package/src/components/applications/index.ts +3 -1
  4. package/src/components/audit/AuditCard.tsx +1 -1
  5. package/src/components/charts/AreaChart.tsx +52 -18
  6. package/src/components/charts/MetricsSummary.tsx +12 -3
  7. package/src/components/charts/saturation.test.ts +34 -0
  8. package/src/components/charts/saturation.ts +1 -0
  9. package/src/components/charts/types.ts +5 -4
  10. package/src/components/checks/ChecksView.tsx +12 -23
  11. package/src/components/checks/severity.ts +11 -11
  12. package/src/components/gitops/GitOpsTableView.tsx +298 -274
  13. package/src/components/issues/IssuesView.tsx +4 -6
  14. package/src/components/issues/severity.ts +19 -11
  15. package/src/components/issues/types.ts +22 -2
  16. package/src/components/logs/WorkloadLogsViewer.tsx +16 -7
  17. package/src/components/resources/ResourcesSidebar.tsx +3 -1
  18. package/src/components/resources/ResourcesView.tsx +180 -21
  19. package/src/components/resources/get-pod-problems.test.ts +127 -0
  20. package/src/components/resources/index.ts +1 -0
  21. package/src/components/resources/renderers/HPARenderer.test.tsx +91 -0
  22. package/src/components/resources/renderers/HPARenderer.tsx +93 -86
  23. package/src/components/resources/renderers/PodRenderer.test.tsx +56 -0
  24. package/src/components/resources/renderers/PodRenderer.tsx +1 -1
  25. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
  26. package/src/components/resources/renderers/WorkloadRenderer.tsx +73 -4
  27. package/src/components/resources/resource-utils-hpa.test.ts +30 -0
  28. package/src/components/resources/resource-utils-hpa.ts +106 -0
  29. package/src/components/resources/resource-utils.ts +41 -28
  30. package/src/components/shared/ResourceRendererDispatch.tsx +10 -2
  31. package/src/components/ui/DistributionBar.tsx +88 -0
  32. package/src/components/ui/Facet.tsx +130 -0
  33. package/src/components/ui/PageHeader.tsx +43 -0
  34. package/src/components/ui/SearchPillInput.tsx +237 -0
  35. package/src/components/ui/SortableTh.tsx +58 -0
  36. package/src/components/ui/SummaryTile.tsx +60 -0
  37. package/src/components/ui/drawer-components.tsx +56 -17
  38. package/src/components/ui/index.ts +12 -0
  39. package/src/components/workload/WorkloadView.tsx +18 -1
  40. package/src/theme/tailwind-theme.css +1 -0
  41. package/src/theme/variables.css +7 -3
  42. package/src/types/core.ts +64 -2
  43. package/src/utils/bulk-workload-actions.test.ts +78 -0
  44. package/src/utils/bulk-workload-actions.ts +50 -0
  45. package/src/utils/index.ts +1 -0
  46. package/src/utils/rbac-blast-radius.test.ts +48 -3
  47. package/src/utils/rbac-blast-radius.ts +14 -4
  48. package/tsconfig.json +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.7.14",
3
+ "version": "1.8.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -69,7 +69,7 @@
69
69
  "@monaco-editor/react": "^4.7.0",
70
70
  "html-to-image": "^1.11.0",
71
71
  "react-virtuoso": "^4.18.7",
72
- "shiki": "^4.0.0"
72
+ "shiki": "^4.2.0"
73
73
  },
74
74
  "peerDependencies": {
75
75
  "@xterm/addon-fit": ">=0.10.0",
@@ -85,8 +85,8 @@
85
85
  "yaml": ">=2.0.0"
86
86
  },
87
87
  "devDependencies": {
88
- "@types/diff": "^7.0.2",
89
- "@types/react": "^19.2.14",
88
+ "@types/node": "^25.9.3",
89
+ "@types/react": "^19.2.17",
90
90
  "@types/react-dom": "^19.2.3",
91
91
  "@xterm/addon-fit": "^0.11.0",
92
92
  "@xterm/addon-web-links": "^0.12.0",
@@ -96,10 +96,10 @@
96
96
  "diff": "^9.0.0",
97
97
  "elkjs": "^0.11.1",
98
98
  "lucide-react": "^1.16.0",
99
- "react": "^19.2.6",
100
- "react-dom": "^19.2.6",
99
+ "react": "^19.2.7",
100
+ "react-dom": "^19.2.7",
101
101
  "typescript": "^6.0.2",
102
- "vitest": "^4.1.5",
102
+ "vitest": "^4.1.8",
103
103
  "yaml": "^2.9.0"
104
104
  }
105
105
  }
@@ -1,10 +1,15 @@
1
1
  import { useMemo, useState, useEffect, useRef, useCallback } from 'react'
2
- import { ChevronRight, ChevronUp, ChevronDown, Layers, Info } from 'lucide-react'
2
+ import { ChevronRight, Layers, Boxes, HeartPulse, Shapes, Globe, Tag } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { StatusDot, mapHealthToTone } from '../ui/status-tone'
5
5
  import { Tooltip } from '../ui/Tooltip'
6
6
  import { EmptyState } from '../ui/EmptyState'
7
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'
8
13
  import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
9
14
  import { pluralize } from '../../utils/pluralize'
10
15
  import {
@@ -128,42 +133,13 @@ function searchTextForEntry(e: AppEntry): string {
128
133
  .toLowerCase()
129
134
  }
130
135
 
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
- )
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',
167
143
  }
168
144
 
169
145
  // Sortable columns. `health` is the implicit default (worst-first then name);
@@ -187,19 +163,6 @@ function compareEntries(a: AppEntry, b: AppEntry, key: SortKey): number {
187
163
  }
188
164
  }
189
165
 
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
166
  export interface ApplicationsListProps {
204
167
  apps: AppRow[]
205
168
  onSelect: (key: string) => void
@@ -345,75 +308,126 @@ export function ApplicationsList({ apps, onSelect }: ApplicationsListProps) {
345
308
  .sort((a, b) => (envRank(b[0] === 'none' ? undefined : b[0]) ?? -1) - (envRank(a[0] === 'none' ? undefined : a[0]) ?? -1))
346
309
  .map(([env, count]) => ({ value: env, label: env === 'none' ? 'unlabeled' : env, count }))
347
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
+ }
329
+
348
330
  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>
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
+ />
364
356
  </div>
365
357
 
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
- )}
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>
398
384
  </aside>
399
385
 
400
- {/* Table */}
401
- <div className="min-w-0 flex-1">
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">
402
410
  {entries.length === 0 ? (
403
- <EmptyState tone="filtered" variant="card" headline="No applications match the filters" body="Clear the filters above." />
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>
404
419
  ) : (
405
- <div className="overflow-hidden rounded-md border border-theme-border">
406
420
  <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" />
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')} />
417
431
  </tr>
418
432
  </thead>
419
433
  <tbody>
@@ -430,10 +444,12 @@ export function ApplicationsList({ apps, onSelect }: ApplicationsListProps) {
430
444
  >
431
445
  <td className="py-2.5 pl-3 pr-2">
432
446
  <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 />
447
+ {/* Left status stripe (worst-child health) the row status
448
+ gutter shared with the GitOps table. */}
434
449
  <Tooltip content={HEALTH_META[r.health].label} delay={150}>
435
- <span className="inline-flex"><StatusDot tone={mapHealthToTone(r.health)} /></span>
450
+ <span className={clsx('h-8 w-1 shrink-0 rounded-full', HEALTH_META[r.health].bar)} />
436
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 />
437
453
  <span className="truncate font-semibold text-theme-text-primary">{r.label}</span>
438
454
  <Tooltip
439
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 }))} />}
@@ -510,7 +526,7 @@ export function ApplicationsList({ apps, onSelect }: ApplicationsListProps) {
510
526
  <td className={clsx('py-2.5 pr-2', r.child ? 'pl-10' : 'pl-3')}>
511
527
  <span className="flex items-center gap-2">
512
528
  <Tooltip content={HEALTH_META[e.health].label} delay={150}>
513
- <span className="inline-flex"><StatusDot tone={mapHealthToTone(e.health)} /></span>
529
+ <span className={clsx('h-8 w-1 shrink-0 rounded-full', HEALTH_META[e.health].bar)} />
514
530
  </Tooltip>
515
531
  <span className="truncate font-medium text-theme-text-primary">{e.row.name}</span>
516
532
  <ProvenanceBadge tier={e.row.tier} appKey={e.row.key} confidence={e.row.confidence} />
@@ -560,8 +576,8 @@ export function ApplicationsList({ apps, onSelect }: ApplicationsListProps) {
560
576
  ))(r.entry))}
561
577
  </tbody>
562
578
  </table>
563
- </div>
564
579
  )}
580
+ </div>
565
581
  </div>
566
582
  </div>
567
583
  </div>
@@ -1,5 +1,7 @@
1
- export { ApplicationsList, Facet } from './ApplicationsList'
1
+ export { ApplicationsList } from './ApplicationsList'
2
2
  export type { ApplicationsListProps } from './ApplicationsList'
3
+ // Facet moved to the shared ui/ primitives; re-exported here for compatibility.
4
+ export { Facet } from '../ui/Facet'
3
5
  export { ApplicationDetail } from './ApplicationDetail'
4
6
  export type { ApplicationDetailProps, SelectedAppWorkload, AppIdentityInstance } from './ApplicationDetail'
5
7
  export { CenteredEmpty } from '../ui/CenteredEmpty'
@@ -47,7 +47,7 @@ export function AuditCard({ data, onNavigate }: AuditCardProps) {
47
47
  <div className="flex items-center justify-between px-5 py-3 border-b border-theme-border/50">
48
48
  <div className="flex items-center gap-2">
49
49
  <ClipboardCheck className={clsx('w-4 h-4', accentColor)} />
50
- <span className={clsx('text-xs font-semibold uppercase tracking-wider', accentColor)}>Cluster Audit</span>
50
+ <span className={clsx('text-xs font-semibold uppercase tracking-wider', accentColor)}>Cluster Checks</span>
51
51
  {issueCount > 0 ? (
52
52
  <span className={clsx('badge-sm', accentBg, accentColor)}>
53
53
  {issueCount}
@@ -26,7 +26,7 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
26
26
  for (const dp of s.dataPoints) {
27
27
  if (dp.timestamp < minTs) minTs = dp.timestamp
28
28
  if (dp.timestamp > maxTs) maxTs = dp.timestamp
29
- if (dp.value > maxVal) maxVal = dp.value
29
+ if (dp.value != null && dp.value > maxVal) maxVal = dp.value
30
30
  }
31
31
  }
32
32
 
@@ -96,23 +96,53 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
96
96
 
97
97
  const paths = useMemo(() => {
98
98
  if (!chartData) return []
99
- return chartData.series.map((s, seriesIdx) => {
100
- if (s.dataPoints.length < 2) return null
101
- const points = s.dataPoints.map(dp => ({ x: toX(dp.timestamp), y: toY(dp.value) }))
99
+ const segments: {
100
+ linePath: string
101
+ areaPath: string
102
+ strokeColor: string
103
+ areaFillColor: string
104
+ key: string
105
+ }[] = []
102
106
 
103
- const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
104
- const areaPath = linePath +
105
- ` L${points[points.length - 1].x},${marginTop + plotHeight}` +
106
- ` L${points[0].x},${marginTop + plotHeight} Z`
107
+ chartData.series.forEach((s, seriesIdx) => {
108
+ const strokeColor = multiSeries ? seriesColor(seriesIdx, color) : color
109
+ const areaFillColor = multiSeries ? seriesFill(seriesIdx, fillColor) : fillColor
107
110
 
108
- return {
109
- linePath,
110
- areaPath,
111
- strokeColor: multiSeries ? seriesColor(seriesIdx, color) : color,
112
- areaFillColor: multiSeries ? seriesFill(seriesIdx, fillColor) : fillColor,
113
- key: seriesIdx,
111
+ // Split the series into contiguous runs of finite points. A gap
112
+ // (value === undefined) breaks the path so the line/area visibly stops
113
+ // rather than bridging across missing data or dropping to y=0. Each run
114
+ // becomes its own path segment; a run needs >=2 points to render a line.
115
+ let run: { x: number; y: number }[] = []
116
+ let runIdx = 0
117
+ const flush = () => {
118
+ if (run.length >= 2) {
119
+ const linePath = run.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
120
+ const areaPath = linePath +
121
+ ` L${run[run.length - 1].x},${marginTop + plotHeight}` +
122
+ ` L${run[0].x},${marginTop + plotHeight} Z`
123
+ segments.push({
124
+ linePath,
125
+ areaPath,
126
+ strokeColor,
127
+ areaFillColor,
128
+ key: `${seriesIdx}-${runIdx}`,
129
+ })
130
+ }
131
+ run = []
132
+ runIdx++
133
+ }
134
+
135
+ for (const dp of s.dataPoints) {
136
+ if (dp.value == null) {
137
+ flush()
138
+ continue
139
+ }
140
+ run.push({ x: toX(dp.timestamp), y: toY(dp.value) })
114
141
  }
115
- }).filter(Boolean)
142
+ flush()
143
+ })
144
+
145
+ return segments
116
146
  }, [chartData])
117
147
 
118
148
  // Hover: only emit a tooltip row when the hovered timestamp lies within
@@ -127,7 +157,7 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
127
157
 
128
158
  const validSeries = chartData.series
129
159
  .map((s, i) => ({ s, i }))
130
- .filter(({ s }) => s.dataPoints.length >= 2)
160
+ .filter(({ s }) => s.dataPoints.filter(dp => dp.value != null).length >= 2)
131
161
 
132
162
  const fullLabels = validSeries.map(({ s, i }) =>
133
163
  s.labels.pod || s.labels.instance || s.labels.node || `series-${i}`
@@ -145,15 +175,19 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines }: {
145
175
  if (ts < seriesMin - tolerance || ts > seriesMax + tolerance) {
146
176
  return null
147
177
  }
148
- let closest = dps[0]
178
+ // Only snap to finite points — a gap (undefined value) has no value to
179
+ // show and would render NaN in the tooltip / a dot at y=NaN.
180
+ let closest: { timestamp: number; value: number } | null = null
149
181
  let closestDist = Infinity
150
182
  for (const dp of dps) {
183
+ if (dp.value == null) continue
151
184
  const dist = Math.abs(dp.timestamp - ts)
152
185
  if (dist < closestDist) {
153
186
  closestDist = dist
154
- closest = dp
187
+ closest = { timestamp: dp.timestamp, value: dp.value }
155
188
  }
156
189
  }
190
+ if (!closest) return null
157
191
  return {
158
192
  label: shortLabels[vi],
159
193
  fullLabel: fullLabels[vi],
@@ -13,14 +13,23 @@ export function MetricsSummary({ series, unit, currentColorClass }: {
13
13
  const allValues: number[] = []
14
14
  for (const s of series) {
15
15
  for (const dp of s.dataPoints) {
16
+ if (dp.value == null) continue
16
17
  allValues.push(dp.value)
17
18
  }
18
19
  }
19
20
  if (allValues.length === 0) return null
20
21
 
21
- // Current = sum of each series' most recent data point (matches
22
- // operator mental model of "total across pods right now").
23
- const lastValues = series.map(s => s.dataPoints[s.dataPoints.length - 1]?.value ?? 0)
22
+ // Current = sum of each series' most recent finite data point (matches
23
+ // operator mental model of "total across pods right now"). A trailing gap
24
+ // is skipped we walk back to the last real sample rather than treating
25
+ // the gap as 0, which would understate the current total.
26
+ const lastValues = series.map(s => {
27
+ for (let i = s.dataPoints.length - 1; i >= 0; i--) {
28
+ const v = s.dataPoints[i].value
29
+ if (v != null) return v
30
+ }
31
+ return 0
32
+ })
24
33
  const current = lastValues.reduce((a, b) => a + b, 0)
25
34
  const max = Math.max(...allValues)
26
35
  const avg = allValues.reduce((a, b) => a + b, 0) / allValues.length
@@ -7,6 +7,16 @@ const series = (values: number[]): TimeSeries => ({
7
7
  dataPoints: values.map((value, i) => ({ timestamp: i, value })),
8
8
  })
9
9
 
10
+ // Builds a series where `undefined` entries are GAPS — the wire shape omits
11
+ // the `value` key entirely, so we construct points without it rather than
12
+ // setting `value: undefined`.
13
+ const gappedSeries = (values: (number | undefined)[]): TimeSeries => ({
14
+ labels: { pod: 'p' },
15
+ dataPoints: values.map((value, i) =>
16
+ value === undefined ? { timestamp: i } : { timestamp: i, value },
17
+ ),
18
+ })
19
+
10
20
  const refRequest: ReferenceLine = { value: 100, label: 'request 100', kind: 'request' }
11
21
  const refLimit: ReferenceLine = { value: 200, label: 'limit 200', kind: 'limit' }
12
22
 
@@ -57,4 +67,28 @@ describe('computeSaturation', () => {
57
67
  const result = computeSaturation([series([0, 0, 50, 0])], [refLimit])
58
68
  expect(result?.ratio).toBeCloseTo(0.25)
59
69
  })
70
+
71
+ it('skips undefined (gap) samples in the peak scan', () => {
72
+ // The undefined sample (300) is a gap, not the peak — it must not count.
73
+ const result = computeSaturation(
74
+ [gappedSeries([10, undefined, 50, undefined])],
75
+ [refLimit],
76
+ )
77
+ expect(result?.ratio).toBeCloseTo(0.25)
78
+ })
79
+
80
+ it('returns undefined when every sample is a gap', () => {
81
+ expect(
82
+ computeSaturation([gappedSeries([undefined, undefined, undefined])], [refLimit]),
83
+ ).toBeUndefined()
84
+ })
85
+
86
+ it('returns undefined when gaps span multiple series with no finite sample', () => {
87
+ expect(
88
+ computeSaturation(
89
+ [gappedSeries([undefined]), gappedSeries([undefined, undefined])],
90
+ [refLimit],
91
+ ),
92
+ ).toBeUndefined()
93
+ })
60
94
  })
@@ -20,6 +20,7 @@ export function computeSaturation(
20
20
  let peak = 0
21
21
  for (const s of series) {
22
22
  for (const dp of s.dataPoints) {
23
+ if (dp.value == null) continue
23
24
  if (dp.value > peak) peak = dp.value
24
25
  }
25
26
  }
@@ -4,14 +4,15 @@
4
4
 
5
5
  export interface TimeSeriesPoint {
6
6
  timestamp: number
7
- value: number
7
+ value?: number | null
8
8
  }
9
9
 
10
10
  export interface TimeSeries {
11
11
  labels: Record<string, string>
12
- /** Expected to be sorted ascending by timestamp and finite-valued. The
13
- * chart's path math and saturation peak scans assume this; passing
14
- * unsorted or NaN samples will produce garbage rendering, not crashes. */
12
+ /** Sorted ascending by timestamp. Each point's `value` is either finite or
13
+ * null/absent (a gap). Consumers must skip gap points in arithmetic and
14
+ * break the line/area path across them rather than bridging or dropping to
15
+ * 0. */
15
16
  dataPoints: TimeSeriesPoint[]
16
17
  }
17
18