@skyhook-io/k8s-ui 1.8.6 → 1.8.8
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 +3 -3
- package/src/components/applications/ApplicationsList.tsx +5 -2
- package/src/components/applications/ApplicationsView.tsx +6 -1
- package/src/components/audit/AuditAlerts.tsx +4 -0
- package/src/components/audit/AuditBadgeTooltip.test.tsx +30 -0
- package/src/components/audit/AuditBadgeTooltip.tsx +47 -0
- package/src/components/audit/AuditFindingsTable.tsx +4 -0
- package/src/components/audit/index.ts +1 -0
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +27 -9
- package/src/components/gitops/GitOpsDetailLayout.tsx +3 -3
- package/src/components/gitops/GitOpsStatusBadge.tsx +9 -3
- package/src/components/gitops/GitOpsTableView.tsx +49 -46
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +12 -5
- package/src/components/issues/IssuesView.tsx +49 -40
- package/src/components/issues/ResourceIssuesSection.tsx +145 -0
- package/src/components/issues/diagnostic.ts +86 -0
- package/src/components/issues/index.ts +2 -1
- package/src/components/issues/issues.test.ts +21 -0
- package/src/components/issues/severity.ts +10 -9
- package/src/components/issues/types.ts +23 -0
- package/src/components/namespace-switcher/NamespacePicker.tsx +381 -0
- package/src/components/namespace-switcher/index.ts +6 -0
- package/src/components/resources/ResourcesView.tsx +58 -83
- package/src/components/resources/cron-to-human.test.ts +41 -0
- package/src/components/resources/get-pod-problems.test.ts +18 -0
- package/src/components/resources/health-golden.test.ts +66 -0
- package/src/components/resources/renderers/JobRenderer.tsx +6 -2
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -2
- package/src/components/resources/renderers/NodeRenderer.tsx +17 -8
- package/src/components/resources/renderers/PVCRenderer.tsx +7 -7
- package/src/components/resources/renderers/PodRenderer.tsx +28 -9
- package/src/components/resources/renderers/ServiceRenderer.tsx +23 -9
- package/src/components/resources/renderers/WorkloadRenderer.tsx +6 -3
- package/src/components/resources/resource-utils-argo.test.ts +23 -0
- package/src/components/resources/resource-utils-argo.ts +5 -1
- package/src/components/resources/resource-utils-keda.ts +12 -8
- package/src/components/resources/resource-utils.ts +34 -14
- package/src/components/scope-pill/ScopePill.tsx +35 -0
- package/src/components/scope-pill/index.ts +2 -0
- package/src/components/timeline/TimelineList.tsx +27 -1
- package/src/components/timeline/TimelineSwimlanes.tsx +1 -0
- package/src/components/timeline/shared.tsx +15 -4
- package/src/components/topology/K8sResourceNode.tsx +28 -1
- package/src/components/topology/TopologyControls.tsx +90 -14
- package/src/components/topology/layout.ts +11 -5
- package/src/components/ui/FreshnessControl.tsx +153 -0
- package/src/components/ui/PaneLoader.tsx +24 -6
- package/src/components/ui/SortableTh.tsx +16 -10
- package/src/components/ui/Toast.tsx +1 -1
- package/src/components/ui/drawer-components.test.tsx +35 -0
- package/src/components/ui/drawer-components.tsx +13 -1
- package/src/components/ui/index.ts +2 -0
- package/src/components/workload/ResourceDetailDrawer.tsx +215 -32
- package/src/components/workload/WorkloadView.tsx +61 -13
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useKeyboardShortcuts.tsx +23 -2
- package/src/hooks/useRefreshAnimation.ts +15 -2
- package/src/index.ts +8 -0
- package/src/types/core.ts +142 -3
- package/src/types/gitops-insights.ts +4 -0
- package/src/utils/animation.ts +10 -0
- package/src/utils/applications.test.ts +55 -1
- package/src/utils/applications.ts +28 -7
- package/src/utils/badge-colors.ts +7 -0
- package/src/utils/format-freshness.test.ts +34 -0
- package/src/utils/format.ts +32 -0
- package/src/utils/resource-hierarchy.test.ts +51 -0
- package/src/utils/resource-hierarchy.ts +7 -4
|
@@ -99,6 +99,32 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
99
99
|
onQueryChange?.({ timeRange, kind: kindFilter || undefined })
|
|
100
100
|
}, [timeRange, kindFilter, onQueryChange])
|
|
101
101
|
|
|
102
|
+
// Kind filter options: seed with common kinds, then accumulate every kind seen
|
|
103
|
+
// in the data so CRDs the cluster actually emits become filterable. The set only
|
|
104
|
+
// grows — selecting a kind narrows the server query to it, so deriving options
|
|
105
|
+
// from the current events alone would collapse the dropdown to that one kind.
|
|
106
|
+
const [seenKinds, setSeenKinds] = useState<Set<string>>(() => new Set(RESOURCE_KINDS))
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (!events?.length) return
|
|
109
|
+
setSeenKinds((prev) => {
|
|
110
|
+
let next: Set<string> | null = null
|
|
111
|
+
for (const e of events) {
|
|
112
|
+
if (e.kind && !prev.has(e.kind)) {
|
|
113
|
+
if (!next) next = new Set(prev)
|
|
114
|
+
next.add(e.kind)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return next ?? prev
|
|
118
|
+
})
|
|
119
|
+
}, [events])
|
|
120
|
+
// Common kinds keep their curated order (most-used first); kinds discovered in
|
|
121
|
+
// the data that aren't in the seed (CRDs) are appended alphabetically.
|
|
122
|
+
const kindOptions = useMemo(() => {
|
|
123
|
+
const seeded = new Set<string>(RESOURCE_KINDS)
|
|
124
|
+
const extra = [...seenKinds].filter((k) => !seeded.has(k)).sort()
|
|
125
|
+
return [...RESOURCE_KINDS, ...extra]
|
|
126
|
+
}, [seenKinds])
|
|
127
|
+
|
|
102
128
|
|
|
103
129
|
const [handleRefresh, isRefreshAnimating] = useRefreshAnimation(onRefresh ?? (() => {}))
|
|
104
130
|
|
|
@@ -341,7 +367,7 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
341
367
|
className="appearance-none bg-theme-elevated text-theme-text-primary text-sm rounded-lg px-3 py-2 border border-theme-border-light focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
342
368
|
>
|
|
343
369
|
<option value="">All Kinds</option>
|
|
344
|
-
{
|
|
370
|
+
{kindOptions.map((kind) => (
|
|
345
371
|
<option key={kind} value={kind}>
|
|
346
372
|
{kind}
|
|
347
373
|
</option>
|
|
@@ -573,6 +573,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
573
573
|
<HealthBarLegendItem color="bg-blue-500/60 dark:bg-blue-500/60" label="rolling" description="Expected degradation during deployment rollout" />
|
|
574
574
|
<HealthBarLegendItem color="bg-amber-500/60 dark:bg-[#b8861e]" label="degraded" description="Unexpected partial availability" />
|
|
575
575
|
<HealthBarLegendItem color="bg-red-500/60 dark:bg-red-500/60" label="unhealthy" description="Resource is failing or not ready" />
|
|
576
|
+
<HealthBarLegendItem color="bg-sky-500/60 dark:bg-sky-500/60" label="idle" description="Intentionally off/resting (completed, suspended, scaled to zero)" />
|
|
576
577
|
</div>
|
|
577
578
|
</div>
|
|
578
579
|
|
|
@@ -138,6 +138,10 @@ export function HealthSpanLegend() {
|
|
|
138
138
|
<span className="w-4 h-2 rounded-sm bg-red-500/60 dark:bg-red-500/60" />
|
|
139
139
|
<span>Unhealthy</span>
|
|
140
140
|
</span>
|
|
141
|
+
<span className="flex items-center gap-1">
|
|
142
|
+
<span className="w-4 h-2 rounded-sm bg-sky-500/60 dark:bg-sky-500/60" />
|
|
143
|
+
<span>Idle</span>
|
|
144
|
+
</span>
|
|
141
145
|
</div>
|
|
142
146
|
)
|
|
143
147
|
}
|
|
@@ -387,7 +391,7 @@ export function TimeAxis({ startTime, endTime, tickCount = 8, labelColumnClass =
|
|
|
387
391
|
* Health span bar showing a health state over a time range.
|
|
388
392
|
*/
|
|
389
393
|
interface HealthSpanProps {
|
|
390
|
-
health: 'healthy' | 'degraded' | 'unhealthy' | string
|
|
394
|
+
health: 'healthy' | 'rolling' | 'degraded' | 'unhealthy' | 'neutral' | 'unknown' | string
|
|
391
395
|
left: number // percentage
|
|
392
396
|
width: number // percentage
|
|
393
397
|
title?: string
|
|
@@ -408,6 +412,9 @@ export function HealthSpan({ health, left, width, title, createdBefore }: Health
|
|
|
408
412
|
return 'bg-amber-500/60 dark:bg-[#b8861e]'
|
|
409
413
|
case 'unhealthy':
|
|
410
414
|
return 'bg-red-500/60 dark:bg-red-500/60'
|
|
415
|
+
case 'neutral':
|
|
416
|
+
// intentional/idle (suspended, scaled-to-0) — sky, calm
|
|
417
|
+
return 'bg-sky-500/60 dark:bg-sky-500/60'
|
|
411
418
|
default:
|
|
412
419
|
// Unknown or other states
|
|
413
420
|
return 'bg-gray-400/40'
|
|
@@ -569,7 +576,11 @@ export function buildHealthSpans(
|
|
|
569
576
|
const existsUntil = deleteEvent ? new Date(deleteEvent.timestamp).getTime() : now
|
|
570
577
|
|
|
571
578
|
const spans: { start: number; end: number; health: string }[] = []
|
|
572
|
-
|
|
579
|
+
// `null` is the "no health observed yet" sentinel — kept distinct from the
|
|
580
|
+
// real 'unknown' health value (emitted for node-lost pods). Overloading
|
|
581
|
+
// 'unknown' as both swallowed genuine-unknown spans and then false-greened
|
|
582
|
+
// them via the empty-spans fallback below.
|
|
583
|
+
let currentHealth: string | null = null
|
|
573
584
|
let spanStart = Math.max(existsFrom, startTime)
|
|
574
585
|
|
|
575
586
|
for (const evt of sorted) {
|
|
@@ -586,7 +597,7 @@ export function buildHealthSpans(
|
|
|
586
597
|
continue
|
|
587
598
|
}
|
|
588
599
|
|
|
589
|
-
if (newHealth !== currentHealth && currentHealth !==
|
|
600
|
+
if (newHealth !== currentHealth && currentHealth !== null) {
|
|
590
601
|
spans.push({ start: spanStart, end: ts, health: currentHealth })
|
|
591
602
|
spanStart = ts
|
|
592
603
|
}
|
|
@@ -594,7 +605,7 @@ export function buildHealthSpans(
|
|
|
594
605
|
}
|
|
595
606
|
|
|
596
607
|
// Close final span (only up to when resource existed)
|
|
597
|
-
if (currentHealth !==
|
|
608
|
+
if (currentHealth !== null) {
|
|
598
609
|
spans.push({ start: spanStart, end: Math.min(existsUntil, now), health: currentHealth })
|
|
599
610
|
}
|
|
600
611
|
|
|
@@ -3,15 +3,17 @@ import { Handle, Position } from '@xyflow/react'
|
|
|
3
3
|
import {
|
|
4
4
|
ChevronDown,
|
|
5
5
|
ChevronUp,
|
|
6
|
+
TriangleAlert,
|
|
6
7
|
} from 'lucide-react'
|
|
7
8
|
import { clsx } from 'clsx'
|
|
8
9
|
import type { NodeKind, HealthStatus, PodSummary } from '../../types'
|
|
9
10
|
import { displayKind } from '../../types'
|
|
10
|
-
import { healthToSeverity, SEVERITY_DOT } from '../../utils/badge-colors'
|
|
11
|
+
import { healthToSeverity, SEVERITY_DOT, SEVERITY_TEXT } from '../../utils/badge-colors'
|
|
11
12
|
import { workloadHue } from '../../utils/workload-colors'
|
|
12
13
|
import { ownershipOf } from '../../utils/topology-neighborhood'
|
|
13
14
|
import { midTruncate } from '../../utils/format'
|
|
14
15
|
import { Tooltip } from '../ui/Tooltip'
|
|
16
|
+
import { AuditBadgeTooltip, type AuditBadgeMessage } from '../audit/AuditBadgeTooltip'
|
|
15
17
|
|
|
16
18
|
// Get actionable tooltip content for health issues
|
|
17
19
|
function getIssueTooltip(issue: string | undefined): React.ReactNode {
|
|
@@ -215,6 +217,12 @@ const STATUS_STYLES: Record<HealthStatus, React.CSSProperties> = {
|
|
|
215
217
|
border: '2px solid rgb(239 68 68 / 0.7)',
|
|
216
218
|
backgroundColor: 'rgb(248 113 113 / 0.15)',
|
|
217
219
|
},
|
|
220
|
+
// neutral = intentional/idle (suspended, scaled-to-0) — sky outline, calm.
|
|
221
|
+
// Distinct from `unknown`/`healthy` which carry no outline.
|
|
222
|
+
neutral: {
|
|
223
|
+
border: '2px solid rgb(56 189 248 / 0.55)',
|
|
224
|
+
backgroundColor: 'rgb(56 189 248 / 0.1)',
|
|
225
|
+
},
|
|
218
226
|
healthy: {},
|
|
219
227
|
unknown: {},
|
|
220
228
|
}
|
|
@@ -376,6 +384,15 @@ export const K8sResourceNode = memo(function K8sResourceNode({
|
|
|
376
384
|
id,
|
|
377
385
|
}: K8sResourceNodeProps) {
|
|
378
386
|
const { kind, name, status, nodeData, selected, dimmed, onExpand, onCollapse, isExpanded } = data
|
|
387
|
+
// Cluster Audit findings joined onto this node by the host (web/ enriches each
|
|
388
|
+
// node's data by auditKey). The host only counts "badge-worthy" findings —
|
|
389
|
+
// reference-integrity / lifecycle, "this resource is actually broken" — not the
|
|
390
|
+
// posture/best-practice nags that fire near-universally, so the indicator stays
|
|
391
|
+
// a signal. Colored by worst severity (danger red, else warning amber).
|
|
392
|
+
const auditDanger = typeof nodeData.auditDanger === 'number' ? nodeData.auditDanger : 0
|
|
393
|
+
const auditWarning = typeof nodeData.auditWarning === 'number' ? nodeData.auditWarning : 0
|
|
394
|
+
const auditTotal = auditDanger + auditWarning
|
|
395
|
+
const auditMessages = Array.isArray(nodeData.auditMessages) ? (nodeData.auditMessages as AuditBadgeMessage[]) : []
|
|
379
396
|
// Workload tint (application graph): a node owned by exactly one workload
|
|
380
397
|
// carries that workload's hue. Only on healthy/unknown cards — degraded/
|
|
381
398
|
// unhealthy already own the card background for health, which must win.
|
|
@@ -506,6 +523,16 @@ export const K8sResourceNode = memo(function K8sResourceNode({
|
|
|
506
523
|
<ChevronUp className="w-3.5 h-3.5 text-theme-text-secondary" />
|
|
507
524
|
</button>
|
|
508
525
|
)}
|
|
526
|
+
{auditTotal > 0 && (
|
|
527
|
+
<Tooltip
|
|
528
|
+
content={auditMessages.length > 0
|
|
529
|
+
? <AuditBadgeTooltip messages={auditMessages} clickHint={false} />
|
|
530
|
+
: `${auditTotal} audit ${auditTotal === 1 ? 'finding' : 'findings'}${auditDanger > 0 ? ` · ${auditDanger} danger` : ''}`}
|
|
531
|
+
position="right"
|
|
532
|
+
>
|
|
533
|
+
<TriangleAlert className={clsx('w-3 h-3 cursor-help', auditDanger > 0 ? SEVERITY_TEXT.error : SEVERITY_TEXT.warning)} />
|
|
534
|
+
</Tooltip>
|
|
535
|
+
)}
|
|
509
536
|
{issueTooltip ? (
|
|
510
537
|
<Tooltip content={issueTooltip} position="right">
|
|
511
538
|
<span className={clsx('w-1.5 h-1.5 rounded-full cursor-help', getStatusDotColor(status))} />
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
|
|
2
|
+
import { FolderTree, ShieldCheck, ChevronDown, Check } from 'lucide-react'
|
|
3
|
+
import { clsx } from 'clsx'
|
|
2
4
|
import type { TopologyMode, GroupingMode } from '../../types/core'
|
|
3
5
|
import { Tooltip } from '../ui/Tooltip'
|
|
4
6
|
|
|
@@ -18,6 +20,8 @@ interface TopologyControlsProps {
|
|
|
18
20
|
* here from the live, observed Traffic view. Omitted by hosts without one.
|
|
19
21
|
*/
|
|
20
22
|
onNavigateToTraffic?: () => void
|
|
23
|
+
/** Optional leading element (e.g. a freshness/liveness indicator). */
|
|
24
|
+
leadingSlot?: ReactNode
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
export function TopologyControls({
|
|
@@ -30,9 +34,63 @@ export function TopologyControls({
|
|
|
30
34
|
onShowPolicyEffectChange,
|
|
31
35
|
showFleetMode = false,
|
|
32
36
|
onNavigateToTraffic,
|
|
37
|
+
leadingSlot,
|
|
33
38
|
}: TopologyControlsProps) {
|
|
39
|
+
const [groupOpen, setGroupOpen] = useState(false)
|
|
40
|
+
const groupRef = useRef<HTMLDivElement>(null)
|
|
41
|
+
const groupTriggerRef = useRef<HTMLButtonElement>(null)
|
|
42
|
+
const groupItemRefs = useRef<(HTMLButtonElement | null)[]>([])
|
|
43
|
+
|
|
44
|
+
const groupOptions: { value: GroupingMode; label: string }[] = [
|
|
45
|
+
...(showNoGrouping ? [{ value: 'none' as GroupingMode, label: 'No Grouping' }] : []),
|
|
46
|
+
{ value: 'namespace', label: 'By Namespace' },
|
|
47
|
+
{ value: 'app', label: 'By App Label' },
|
|
48
|
+
]
|
|
49
|
+
const currentGroupLabel = groupOptions.find((o) => o.value === groupingMode)?.label ?? 'Grouping'
|
|
50
|
+
|
|
51
|
+
const closeGroup = useCallback((restoreFocus = false) => {
|
|
52
|
+
setGroupOpen(false)
|
|
53
|
+
if (restoreFocus) groupTriggerRef.current?.focus()
|
|
54
|
+
}, [])
|
|
55
|
+
|
|
56
|
+
// On open, move focus onto the active option so the menu is keyboard-navigable
|
|
57
|
+
// (parity with the native <select> this replaced). Click-outside closes it.
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!groupOpen) return
|
|
60
|
+
const active = Math.max(0, groupOptions.findIndex((o) => o.value === groupingMode))
|
|
61
|
+
groupItemRefs.current[active]?.focus()
|
|
62
|
+
const onDown = (e: MouseEvent) => {
|
|
63
|
+
if (groupRef.current && !groupRef.current.contains(e.target as Node)) setGroupOpen(false)
|
|
64
|
+
}
|
|
65
|
+
document.addEventListener('mousedown', onDown)
|
|
66
|
+
return () => document.removeEventListener('mousedown', onDown)
|
|
67
|
+
// groupOptions/groupingMode are read once at open; re-running on their
|
|
68
|
+
// identity change would steal focus mid-interaction.
|
|
69
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
70
|
+
}, [groupOpen])
|
|
71
|
+
|
|
72
|
+
const onGroupMenuKey = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
73
|
+
const items = groupItemRefs.current.filter(Boolean) as HTMLButtonElement[]
|
|
74
|
+
const i = items.indexOf(document.activeElement as HTMLButtonElement)
|
|
75
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); items[(i + 1) % items.length]?.focus() }
|
|
76
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); items[(i - 1 + items.length) % items.length]?.focus() }
|
|
77
|
+
else if (e.key === 'Home') { e.preventDefault(); items[0]?.focus() }
|
|
78
|
+
else if (e.key === 'End') { e.preventDefault(); items[items.length - 1]?.focus() }
|
|
79
|
+
else if (e.key === 'Escape') { e.preventDefault(); closeGroup(true) }
|
|
80
|
+
}
|
|
81
|
+
|
|
34
82
|
return (
|
|
35
83
|
<div className="absolute top-4 right-4 z-10 flex items-center gap-2">
|
|
84
|
+
{/* Freshness/liveness status — backed for legibility over the canvas but
|
|
85
|
+
borderless + divided off, so it reads as a status, not another control. */}
|
|
86
|
+
{leadingSlot && (
|
|
87
|
+
<>
|
|
88
|
+
<div className="flex items-center rounded-lg bg-theme-surface/80 px-2.5 py-1.5 backdrop-blur">
|
|
89
|
+
{leadingSlot}
|
|
90
|
+
</div>
|
|
91
|
+
<div className="h-5 w-px bg-theme-border/70" />
|
|
92
|
+
</>
|
|
93
|
+
)}
|
|
36
94
|
{/* Policy effect toggle */}
|
|
37
95
|
{onShowPolicyEffectChange && (
|
|
38
96
|
<button
|
|
@@ -49,20 +107,38 @@ export function TopologyControls({
|
|
|
49
107
|
</button>
|
|
50
108
|
)}
|
|
51
109
|
|
|
52
|
-
{/* Grouping selector */}
|
|
53
|
-
<div className="
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
110
|
+
{/* Grouping selector — themed dropdown (not a native <select>). */}
|
|
111
|
+
<div ref={groupRef} className="relative">
|
|
112
|
+
<button
|
|
113
|
+
ref={groupTriggerRef}
|
|
114
|
+
type="button"
|
|
115
|
+
onClick={() => setGroupOpen((v) => !v)}
|
|
116
|
+
aria-haspopup="menu"
|
|
117
|
+
aria-expanded={groupOpen}
|
|
118
|
+
className="flex items-center gap-1.5 px-2 py-1.5 bg-theme-surface/90 backdrop-blur border border-theme-border rounded-lg text-xs text-theme-text-primary hover:bg-theme-elevated transition-colors"
|
|
59
119
|
>
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
120
|
+
<FolderTree className="w-3.5 h-3.5 text-theme-text-secondary" />
|
|
121
|
+
{currentGroupLabel}
|
|
122
|
+
<ChevronDown className="w-3 h-3 text-theme-text-tertiary" />
|
|
123
|
+
</button>
|
|
124
|
+
{groupOpen && (
|
|
125
|
+
<div role="menu" onKeyDown={onGroupMenuKey} className="absolute right-0 top-full mt-1 z-50 min-w-[160px] rounded-lg border border-theme-border bg-theme-surface py-1 shadow-xl">
|
|
126
|
+
{groupOptions.map((o, idx) => (
|
|
127
|
+
<button
|
|
128
|
+
key={o.value}
|
|
129
|
+
ref={(el) => { groupItemRefs.current[idx] = el }}
|
|
130
|
+
type="button"
|
|
131
|
+
role="menuitemradio"
|
|
132
|
+
aria-checked={groupingMode === o.value}
|
|
133
|
+
onClick={() => { onGroupingModeChange(o.value); closeGroup(true) }}
|
|
134
|
+
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary focus:bg-theme-hover focus:text-theme-text-primary focus:outline-none transition-colors"
|
|
135
|
+
>
|
|
136
|
+
<Check className={clsx('w-3.5 h-3.5 shrink-0', groupingMode === o.value ? 'opacity-100 text-skyhook-500' : 'opacity-0')} />
|
|
137
|
+
<span className="truncate">{o.label}</span>
|
|
138
|
+
</button>
|
|
139
|
+
))}
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
66
142
|
</div>
|
|
67
143
|
|
|
68
144
|
{/* View mode toggle */}
|
|
@@ -731,11 +731,14 @@ export function buildHierarchicalElkGraph(
|
|
|
731
731
|
}
|
|
732
732
|
}
|
|
733
733
|
|
|
734
|
-
// Lower number = higher severity
|
|
735
|
-
|
|
734
|
+
// Lower number = higher severity. `neutral` (intentional/idle) is the
|
|
735
|
+
// most-benign tier, so a group whose every member is idle rolls up to neutral,
|
|
736
|
+
// while a mixed healthy+idle group still reads healthy (healthy out-ranks
|
|
737
|
+
// neutral). An empty/unresolved group defaults to healthy.
|
|
738
|
+
const HEALTH_PRIORITY: Record<HealthStatus, number> = { unhealthy: 0, degraded: 1, unknown: 2, healthy: 3, neutral: 4 }
|
|
736
739
|
|
|
737
740
|
function computeGroupHealth(memberIds: string[], nodeMap: Map<string, TopologyNode>): { worstStatus: HealthStatus; unhealthyCount: number } {
|
|
738
|
-
let worstPriority =
|
|
741
|
+
let worstPriority = Infinity
|
|
739
742
|
let worstStatus: HealthStatus = 'healthy'
|
|
740
743
|
let unhealthyCount = 0
|
|
741
744
|
for (const id of memberIds) {
|
|
@@ -804,8 +807,11 @@ function computeWorkloadCards(
|
|
|
804
807
|
})
|
|
805
808
|
const primary = sorted[0]
|
|
806
809
|
|
|
807
|
-
// Compute worst health
|
|
808
|
-
|
|
810
|
+
// Compute worst health. Seed with Infinity (not 3) so an all-neutral
|
|
811
|
+
// component can surface neutral — `neutral` is priority 4 (most benign), so a
|
|
812
|
+
// `< 3` seed would never accept it and the card would stay green. Matches
|
|
813
|
+
// computeGroupHealth.
|
|
814
|
+
let worstPriority = Infinity
|
|
809
815
|
let worstStatus: HealthStatus = 'healthy'
|
|
810
816
|
for (const node of comp) {
|
|
811
817
|
const p = HEALTH_PRIORITY[node.status] ?? 2
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
import { RefreshCw, Check } from 'lucide-react'
|
|
4
|
+
import { Tooltip } from './Tooltip'
|
|
5
|
+
import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
|
|
6
|
+
import { formatUpdatedAgo, msToNextBucket } from '../../utils/format'
|
|
7
|
+
|
|
8
|
+
export type FreshnessMode = 'auto' | 'snapshot'
|
|
9
|
+
export type FreshnessConnection = 'connected' | 'disconnected' | 'connecting'
|
|
10
|
+
|
|
11
|
+
interface FreshnessControlProps {
|
|
12
|
+
// 'auto' — the view keeps itself current (polls or streams). Reads
|
|
13
|
+
// "Auto-updating". Pass onRefresh on slow polled views for a
|
|
14
|
+
// "check now" hatch; omit it on instant stream-backed views.
|
|
15
|
+
// 'snapshot' — a one-shot load that only changes on demand. Reads "Updated N
|
|
16
|
+
// ago" with a manual refresh button.
|
|
17
|
+
mode: FreshnessMode
|
|
18
|
+
// Epoch ms of the last successful load (React Query dataUpdatedAt). Optional
|
|
19
|
+
// for 'auto' streams that have no per-fetch timestamp (e.g. topology).
|
|
20
|
+
dataUpdatedAt?: number
|
|
21
|
+
// Spins the refresh icon during background refetches (for views with a button).
|
|
22
|
+
isFetching?: boolean
|
|
23
|
+
// Manual refresh. Provide it on polled + snapshot views where forcing a fetch
|
|
24
|
+
// is useful; omit on stream-backed 'auto' views (instant, so a refresh button
|
|
25
|
+
// would just undercut "Auto-updating"). May return a promise — the animation
|
|
26
|
+
// waits for it before showing success.
|
|
27
|
+
onRefresh?: () => void | Promise<unknown>
|
|
28
|
+
// Cluster/SSE connection health. When not connected, freshness must not claim
|
|
29
|
+
// currency — it degrades to "Reconnecting…" instead of a stale age/Auto-updating.
|
|
30
|
+
connectionState?: FreshnessConnection
|
|
31
|
+
// 'auto' streams only: paused (e.g. topology pause toggle). "Auto-updating"
|
|
32
|
+
// while paused would be a lie, so it degrades to "Paused".
|
|
33
|
+
paused?: boolean
|
|
34
|
+
className?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The canonical freshness/liveness signal. It answers "does this view stay
|
|
38
|
+
// current on its own?" — "Auto-updating" for anything self-refreshing (poll or
|
|
39
|
+
// stream, mechanism deliberately not exposed), "Updated N ago" + refresh for a
|
|
40
|
+
// manual snapshot. Place it at the right end of a view's header — never a band.
|
|
41
|
+
export function FreshnessControl({
|
|
42
|
+
mode,
|
|
43
|
+
dataUpdatedAt,
|
|
44
|
+
isFetching,
|
|
45
|
+
onRefresh,
|
|
46
|
+
connectionState = 'connected',
|
|
47
|
+
paused = false,
|
|
48
|
+
className,
|
|
49
|
+
}: FreshnessControlProps) {
|
|
50
|
+
const [, force] = useState(0)
|
|
51
|
+
const showAge = typeof dataUpdatedAt === 'number' && dataUpdatedAt > 0
|
|
52
|
+
|
|
53
|
+
// Re-render exactly when the displayed age bucket flips (not every second).
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!showAge) return
|
|
56
|
+
let id: ReturnType<typeof setTimeout>
|
|
57
|
+
function schedule() {
|
|
58
|
+
const delay = Math.max(1000, msToNextBucket(Date.now() - (dataUpdatedAt as number)))
|
|
59
|
+
id = setTimeout(() => {
|
|
60
|
+
force((t) => t + 1)
|
|
61
|
+
schedule()
|
|
62
|
+
}, delay)
|
|
63
|
+
}
|
|
64
|
+
schedule()
|
|
65
|
+
return () => clearTimeout(id)
|
|
66
|
+
}, [showAge, dataUpdatedAt])
|
|
67
|
+
|
|
68
|
+
const [refresh, , phase] = useRefreshAnimation(() => onRefresh?.())
|
|
69
|
+
const spinning = phase === 'spinning' || !!isFetching
|
|
70
|
+
|
|
71
|
+
// Any non-connected state (disconnected OR mid-reconnect) must not claim
|
|
72
|
+
// currency — the signal degrades rather than showing a stale age/Auto-updating.
|
|
73
|
+
const degraded = connectionState !== 'connected'
|
|
74
|
+
|
|
75
|
+
// Show the refresh button whenever the host wired one. Stream-backed 'auto'
|
|
76
|
+
// views (Resources, Topology) deliberately omit onRefresh — they update
|
|
77
|
+
// instantly, so a manual refresh would only undercut "Auto-updating". Slow
|
|
78
|
+
// polled views keep it as a "check now" escape hatch.
|
|
79
|
+
const showRefresh = !!onRefresh
|
|
80
|
+
|
|
81
|
+
const exact = showAge ? `Last updated ${new Date(dataUpdatedAt as number).toLocaleTimeString()}` : null
|
|
82
|
+
const age = showAge ? formatUpdatedAgo(Date.now() - (dataUpdatedAt as number)) : null
|
|
83
|
+
|
|
84
|
+
// Tooltip is only rendered when it ADDS information beyond the visible label.
|
|
85
|
+
let label: string | null
|
|
86
|
+
let tooltip: string | null
|
|
87
|
+
let live = false
|
|
88
|
+
if (degraded) {
|
|
89
|
+
label = 'Reconnecting…'
|
|
90
|
+
tooltip = 'Not connected to the cluster — data may be stale until the connection is restored.'
|
|
91
|
+
} else if (mode === 'auto' && paused) {
|
|
92
|
+
label = 'Paused'
|
|
93
|
+
tooltip = 'Live updates are paused — resume to keep this view current.'
|
|
94
|
+
} else if (mode === 'auto') {
|
|
95
|
+
label = 'Auto-updating'
|
|
96
|
+
tooltip = exact ?? 'Updates automatically as the data changes.'
|
|
97
|
+
live = true
|
|
98
|
+
} else if (age) {
|
|
99
|
+
label = `Updated ${age}`
|
|
100
|
+
tooltip = exact
|
|
101
|
+
} else {
|
|
102
|
+
// Snapshot before its first load — render just the button, no stale text.
|
|
103
|
+
label = null
|
|
104
|
+
tooltip = null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The relative age is the dynamic trust detail. It rides alongside every 'auto'
|
|
108
|
+
// label, and alongside a degraded label in either mode — "Reconnecting… ·
|
|
109
|
+
// updated 8m ago" is exactly when staleness matters most. (In connected
|
|
110
|
+
// 'snapshot' mode the age IS the label, so no suffix.)
|
|
111
|
+
const ageSuffix = age && (mode === 'auto' || degraded) ? age : null
|
|
112
|
+
|
|
113
|
+
const labelNode = label ? (
|
|
114
|
+
<span className="flex items-center gap-1 text-xs text-theme-text-tertiary">
|
|
115
|
+
{(live || (mode === 'auto' && paused)) && !degraded && (
|
|
116
|
+
<span
|
|
117
|
+
className={clsx('w-1.5 h-1.5 rounded-full', paused ? 'bg-amber-400' : 'bg-green-500 animate-pulse')}
|
|
118
|
+
aria-hidden
|
|
119
|
+
/>
|
|
120
|
+
)}
|
|
121
|
+
<span className="tabular-nums">{label}</span>
|
|
122
|
+
{ageSuffix && <span className="tabular-nums text-theme-text-quaternary">· updated {ageSuffix}</span>}
|
|
123
|
+
</span>
|
|
124
|
+
) : null
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div className={clsx('flex items-center gap-1.5 whitespace-nowrap', className)}>
|
|
128
|
+
{/* Only wrap in a tooltip when it adds information beyond the label. */}
|
|
129
|
+
{labelNode && (tooltip
|
|
130
|
+
? <Tooltip content={tooltip} delay={100} position="bottom">{labelNode}</Tooltip>
|
|
131
|
+
: labelNode)}
|
|
132
|
+
{showRefresh && (
|
|
133
|
+
<Tooltip content="Refresh now" delay={100} position="bottom">
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
onClick={refresh}
|
|
137
|
+
// Only disable during the button's own refresh animation (prevents
|
|
138
|
+
// double-trigger); stay clickable during background refetches.
|
|
139
|
+
disabled={phase === 'spinning'}
|
|
140
|
+
aria-label="Refresh now"
|
|
141
|
+
className="p-1.5 rounded-lg text-theme-text-tertiary hover:text-theme-text-secondary hover:bg-theme-hover transition-colors disabled:opacity-50"
|
|
142
|
+
>
|
|
143
|
+
{phase === 'success' ? (
|
|
144
|
+
<Check className="w-3.5 h-3.5 text-emerald-500" />
|
|
145
|
+
) : (
|
|
146
|
+
<RefreshCw className={clsx('w-3.5 h-3.5', spinning && 'animate-spin')} />
|
|
147
|
+
)}
|
|
148
|
+
</button>
|
|
149
|
+
</Tooltip>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
@@ -5,9 +5,14 @@ import radarLoadingIconAsset from '../../assets/radar/radar-icon-loading.svg'
|
|
|
5
5
|
// StaticImageData under webpack/Next) to a URL string usable in `<img src>`.
|
|
6
6
|
const radarLoadingIcon = assetUrl(radarLoadingIconAsset)
|
|
7
7
|
|
|
8
|
-
// PaneLoader — center-of-pane loading state.
|
|
9
|
-
//
|
|
10
|
-
//
|
|
8
|
+
// PaneLoader — center-of-pane loading state. The animated radar icon is
|
|
9
|
+
// pinned to the pane's exact center; the label hangs at a fixed offset
|
|
10
|
+
// BELOW it, absolutely positioned (not a flex sibling), so the label never
|
|
11
|
+
// affects where the icon sits. The icon therefore holds a single position
|
|
12
|
+
// while only the text under it appears/changes — and it lands at the same
|
|
13
|
+
// point as the host/connect splash surfaces (which center the icon at 50%
|
|
14
|
+
// with the label decoupled below), so a splash → PaneLoader hand-off no
|
|
15
|
+
// longer makes the logo jump. Pin to the parent's fill via `className`
|
|
11
16
|
// (`flex-1`, `h-full`, `h-32`, `absolute inset-0`, etc.). The SVG self-
|
|
12
17
|
// animates (sweep arm + blips, `prefers-reduced-motion` honored).
|
|
13
18
|
export function PaneLoader({
|
|
@@ -17,10 +22,23 @@ export function PaneLoader({
|
|
|
17
22
|
label?: string
|
|
18
23
|
className?: string
|
|
19
24
|
}) {
|
|
25
|
+
// No `relative` on the root: the label anchors to the inner `relative` span
|
|
26
|
+
// below, and callers may pass a positioning class (e.g. `absolute inset-0`,
|
|
27
|
+
// for topology panes) — a root `relative` would conflict with it.
|
|
20
28
|
return (
|
|
21
|
-
<div className={`flex
|
|
22
|
-
|
|
23
|
-
|
|
29
|
+
<div className={`flex items-center justify-center ${className}`} aria-live="polite">
|
|
30
|
+
{/* The icon is the only in-flow child, so it centers in the pane. The
|
|
31
|
+
label is absolutely positioned below the icon and so never shifts it. */}
|
|
32
|
+
<span className="relative">
|
|
33
|
+
<img src={radarLoadingIcon} alt="" aria-hidden className="w-11 h-11" />
|
|
34
|
+
{/* Label style matches the splash surfaces (17px semibold tracking-tight,
|
|
35
|
+
primary) so the whole loading family — boot splash, connect splash,
|
|
36
|
+
PaneLoader — reads as one continuous state, not a font change at the
|
|
37
|
+
hand-off. */}
|
|
38
|
+
<span className="absolute left-1/2 top-full mt-3 -translate-x-1/2 whitespace-nowrap text-[17px] font-semibold tracking-tight text-theme-text-primary">
|
|
39
|
+
{label}
|
|
40
|
+
</span>
|
|
41
|
+
</span>
|
|
24
42
|
</div>
|
|
25
43
|
)
|
|
26
44
|
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import type { ReactNode } from 'react'
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
|
-
import { ChevronUp, ChevronDown } from 'lucide-react'
|
|
3
|
+
import { ChevronUp, ChevronDown, ArrowUpDown } from 'lucide-react'
|
|
4
4
|
|
|
5
5
|
export type SortDir = 'asc' | 'desc'
|
|
6
6
|
|
|
7
|
-
// Canonical dense-table header-cell styling, shared so the Applications
|
|
8
|
-
// GitOps tables
|
|
7
|
+
// Canonical dense-table header-cell styling, shared so the Applications,
|
|
8
|
+
// GitOps, and Helm tables read as one family with the Resources table.
|
|
9
9
|
export const TH_CLASS =
|
|
10
|
-
'border-b border-theme-border px-
|
|
10
|
+
'border-b border-theme-border px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-theme-text-secondary'
|
|
11
11
|
|
|
12
12
|
// A clickable, sortable column header. Clicking fires onSort(sortKey); the
|
|
13
13
|
// consumer owns the cycle (toggle dir, or asc→desc→off). One chevron marks the
|
|
@@ -42,16 +42,22 @@ export function SortableTh<K extends string>({
|
|
|
42
42
|
type="button"
|
|
43
43
|
onClick={() => onSort(sortKey)}
|
|
44
44
|
className={clsx(
|
|
45
|
-
|
|
45
|
+
// `uppercase` is repeated here on purpose: Tailwind Preflight resets
|
|
46
|
+
// `button { text-transform: none }`, which would otherwise cancel the
|
|
47
|
+
// inherited uppercase from TH_CLASS and render sortable headers in
|
|
48
|
+
// title case while non-sortable <th> cells stay uppercase.
|
|
49
|
+
'inline-flex items-center gap-1 select-none uppercase hover:text-theme-text-primary focus-visible:outline-none focus-visible:text-theme-text-primary',
|
|
46
50
|
align === 'right' && 'w-full justify-end',
|
|
47
51
|
)}
|
|
48
52
|
>
|
|
49
53
|
{label}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
<span className="shrink-0 text-theme-text-tertiary">
|
|
55
|
+
{active ? (
|
|
56
|
+
direction === 'asc' ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />
|
|
57
|
+
) : (
|
|
58
|
+
<ArrowUpDown className="h-3 w-3 opacity-50" />
|
|
59
|
+
)}
|
|
60
|
+
</span>
|
|
55
61
|
</button>
|
|
56
62
|
</th>
|
|
57
63
|
)
|
|
@@ -227,7 +227,7 @@ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }
|
|
|
227
227
|
{toast.detail}
|
|
228
228
|
</button>
|
|
229
229
|
) : (
|
|
230
|
-
<p className={clsx('mt-1 text-xs break-
|
|
230
|
+
<p className={clsx('mt-1 text-xs break-words', isError ? 'text-red-300/80' : isSuccess ? 'text-emerald-300/80' : 'text-theme-text-secondary')}>
|
|
231
231
|
{toast.detail}
|
|
232
232
|
</p>
|
|
233
233
|
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { ProblemAlerts, OperationalIssuesShownContext } from './drawer-components'
|
|
4
|
+
|
|
5
|
+
const problems = [
|
|
6
|
+
{ color: 'red' as const, message: 'Application is Degraded' },
|
|
7
|
+
{ color: 'yellow' as const, message: 'Application is OutOfSync' },
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
describe('ProblemAlerts', () => {
|
|
11
|
+
it('renders every problem', () => {
|
|
12
|
+
const html = renderToString(<ProblemAlerts problems={problems} />)
|
|
13
|
+
expect(html).toContain('Application is Degraded')
|
|
14
|
+
expect(html).toContain('Application is OutOfSync')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('renders nothing when there are no problems', () => {
|
|
18
|
+
expect(renderToString(<ProblemAlerts problems={[]} />)).toBe('')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
// Regression guard: ProblemAlerts is used only by GitOps renderers, whose
|
|
22
|
+
// problems the live-Issues pipeline does not comprehensively emit. It must NOT
|
|
23
|
+
// suppress itself under the Operational-Issues context — doing so hid real
|
|
24
|
+
// GitOps warnings (e.g. a manual Argo app's OutOfSync). Pod/Workload renderers
|
|
25
|
+
// self-gate their own arrays; this component never should.
|
|
26
|
+
it('still renders under OperationalIssuesShownContext (does not self-suppress)', () => {
|
|
27
|
+
const html = renderToString(
|
|
28
|
+
<OperationalIssuesShownContext.Provider value={true}>
|
|
29
|
+
<ProblemAlerts problems={problems} />
|
|
30
|
+
</OperationalIssuesShownContext.Provider>
|
|
31
|
+
)
|
|
32
|
+
expect(html).toContain('Application is Degraded')
|
|
33
|
+
expect(html).toContain('Application is OutOfSync')
|
|
34
|
+
})
|
|
35
|
+
})
|