@skyhook-io/k8s-ui 1.7.15 → 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.
- package/package.json +2 -1
- package/src/components/applications/ApplicationsList.tsx +131 -115
- package/src/components/applications/index.ts +3 -1
- package/src/components/audit/AuditCard.tsx +1 -1
- package/src/components/charts/AreaChart.tsx +52 -18
- package/src/components/charts/MetricsSummary.tsx +12 -3
- package/src/components/charts/saturation.test.ts +34 -0
- package/src/components/charts/saturation.ts +1 -0
- package/src/components/charts/types.ts +5 -4
- package/src/components/checks/ChecksView.tsx +12 -23
- package/src/components/checks/severity.ts +11 -11
- package/src/components/gitops/GitOpsTableView.tsx +298 -274
- package/src/components/issues/severity.ts +19 -11
- package/src/components/resources/ResourcesSidebar.tsx +3 -1
- package/src/components/resources/ResourcesView.tsx +13 -0
- package/src/components/resources/index.ts +1 -0
- package/src/components/resources/renderers/HPARenderer.test.tsx +91 -0
- package/src/components/resources/renderers/HPARenderer.tsx +93 -86
- package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +73 -4
- package/src/components/resources/resource-utils-hpa.test.ts +30 -0
- package/src/components/resources/resource-utils-hpa.ts +106 -0
- package/src/components/resources/resource-utils.ts +2 -10
- package/src/components/shared/ResourceRendererDispatch.tsx +10 -2
- package/src/components/ui/DistributionBar.tsx +88 -0
- package/src/components/ui/Facet.tsx +130 -0
- package/src/components/ui/PageHeader.tsx +43 -0
- package/src/components/ui/SearchPillInput.tsx +237 -0
- package/src/components/ui/SortableTh.tsx +58 -0
- package/src/components/ui/SummaryTile.tsx +60 -0
- package/src/components/ui/drawer-components.tsx +56 -17
- package/src/components/ui/index.ts +12 -0
- package/src/components/workload/WorkloadView.tsx +18 -1
- package/src/theme/tailwind-theme.css +1 -0
- package/src/theme/variables.css +7 -3
- package/src/types/core.ts +54 -2
- package/src/utils/rbac-blast-radius.test.ts +48 -3
- package/src/utils/rbac-blast-radius.ts +14 -4
- 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.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/skyhook-io/radar",
|
|
@@ -85,6 +85,7 @@
|
|
|
85
85
|
"yaml": ">=2.0.0"
|
|
86
86
|
},
|
|
87
87
|
"devDependencies": {
|
|
88
|
+
"@types/node": "^25.9.3",
|
|
88
89
|
"@types/react": "^19.2.17",
|
|
89
90
|
"@types/react-dom": "^19.2.3",
|
|
90
91
|
"@xterm/addon-fit": "^0.11.0",
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { useMemo, useState, useEffect, useRef, useCallback } from 'react'
|
|
2
|
-
import { ChevronRight,
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
350
|
-
{/*
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
className="max-w-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
{/*
|
|
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
|
-
<
|
|
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
|
|
409
|
-
<
|
|
410
|
-
<th className=
|
|
411
|
-
<th className=
|
|
412
|
-
<th className=
|
|
413
|
-
<
|
|
414
|
-
<
|
|
415
|
-
<th className=
|
|
416
|
-
<th className=
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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
|
|
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
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
const segments: {
|
|
100
|
+
linePath: string
|
|
101
|
+
areaPath: string
|
|
102
|
+
strokeColor: string
|
|
103
|
+
areaFillColor: string
|
|
104
|
+
key: string
|
|
105
|
+
}[] = []
|
|
102
106
|
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
})
|
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
export interface TimeSeriesPoint {
|
|
6
6
|
timestamp: number
|
|
7
|
-
value
|
|
7
|
+
value?: number | null
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
export interface TimeSeries {
|
|
11
11
|
labels: Record<string, string>
|
|
12
|
-
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
3
|
import { ChevronDown, ChevronRight, ExternalLink, EyeOff, MoreHorizontal, Search, ShieldCheck, Wrench, X } from 'lucide-react'
|
|
4
|
-
import { ClusterName, EmptyState, FilterPill } from '../ui'
|
|
4
|
+
import { ClusterName, EmptyState, FilterPill, DistributionBar, DistributionLegendChip } from '../ui'
|
|
5
5
|
import type { CheckMeta, CheckReference } from '../audit'
|
|
6
6
|
import { CHECK_SEVERITIES, CHECK_SEVERITY_RANK, type Check, type CheckSeverity, type EffectiveCheckFinding, type CheckResourceRef } from './types'
|
|
7
7
|
import {
|
|
@@ -367,35 +367,24 @@ export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceC
|
|
|
367
367
|
}
|
|
368
368
|
|
|
369
369
|
export function CheckSeverityBar({ totals }: { totals: Record<CheckSeverity, number> }) {
|
|
370
|
-
const sum = CHECK_SEVERITIES.reduce((n, s) => n + totals[s], 0)
|
|
371
370
|
return (
|
|
372
|
-
<
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
totals[s] > 0 ? (
|
|
377
|
-
<div key={s} className={`${SEVERITY_FILL_CLASS[s]} transition-[width] duration-500 ease-out`} style={{ width: `${(totals[s] / sum) * 100}%` }} />
|
|
378
|
-
) : null,
|
|
379
|
-
)}
|
|
380
|
-
</div>
|
|
371
|
+
<DistributionBar
|
|
372
|
+
ariaLabel="Severity distribution"
|
|
373
|
+
segments={CHECK_SEVERITIES.map((s) => ({ key: s, count: totals[s], fillClass: SEVERITY_FILL_CLASS[s] }))}
|
|
374
|
+
/>
|
|
381
375
|
)
|
|
382
376
|
}
|
|
383
377
|
|
|
384
378
|
export function CheckSeverityChip({ severity, count, active, onClick }: { severity: CheckSeverity; count: number; active: boolean; onClick: () => void }) {
|
|
385
379
|
return (
|
|
386
|
-
<
|
|
387
|
-
|
|
380
|
+
<DistributionLegendChip
|
|
381
|
+
label={SEVERITY_LABEL[severity]}
|
|
382
|
+
count={count}
|
|
383
|
+
fillClass={SEVERITY_FILL_CLASS[severity]}
|
|
384
|
+
textClass={SEVERITY_TEXT_CLASS[severity]}
|
|
385
|
+
active={active}
|
|
388
386
|
onClick={onClick}
|
|
389
|
-
|
|
390
|
-
className={[
|
|
391
|
-
'group inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors',
|
|
392
|
-
active ? 'border-theme-border bg-theme-elevated text-theme-text-primary' : 'border-transparent text-theme-text-secondary hover:bg-theme-hover/60',
|
|
393
|
-
].join(' ')}
|
|
394
|
-
>
|
|
395
|
-
<span className={`h-2 w-2 rounded-full ${SEVERITY_FILL_CLASS[severity]} ${count === 0 ? 'opacity-30' : ''}`} />
|
|
396
|
-
<span className={`font-semibold tabular-nums ${count > 0 ? SEVERITY_TEXT_CLASS[severity] : 'text-theme-text-tertiary'}`}>{count}</span>
|
|
397
|
-
<span>{SEVERITY_LABEL[severity]}</span>
|
|
398
|
-
</button>
|
|
387
|
+
/>
|
|
399
388
|
)
|
|
400
389
|
}
|
|
401
390
|
|