@skyhook-io/k8s-ui 1.8.15 → 1.9.1
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 +1 -1
- package/src/components/applications/ApplicationDetail.test.tsx +28 -0
- package/src/components/applications/ApplicationDetail.tsx +17 -0
- package/src/components/audit/AuditAlerts.tsx +13 -11
- package/src/components/audit/AuditBadgeTooltip.test.tsx +6 -0
- package/src/components/audit/AuditBadgeTooltip.tsx +9 -6
- package/src/components/audit/AuditCard.tsx +15 -13
- package/src/components/audit/AuditFindingsTable.tsx +33 -31
- package/src/components/audit/AuditSeverityPresentation.test.tsx +64 -0
- package/src/components/checks/types.ts +4 -4
- package/src/components/issues/types.ts +4 -0
- package/src/components/logs/useLogSearch.ts +12 -7
- package/src/components/resources/ResourcesView.per-container.test.ts +111 -0
- package/src/components/resources/ResourcesView.tsx +310 -99
- package/src/components/resources/renderers/PodRenderer.tsx +4 -1
- package/src/components/timeline/TimelineList.tsx +7 -2
- package/src/components/timeline/TimelineSwimlanes.tsx +37 -19
- package/src/components/timeline/index.ts +0 -2
- package/src/components/timeline/timeline-live.test.ts +0 -37
- package/src/components/timeline/timeline-live.ts +2 -24
- package/src/components/topology/K8sResourceNode.tsx +11 -9
- package/src/components/ui/FilterPill.tsx +3 -1
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useDebouncedValue.ts +29 -0
- package/src/types/core.ts +17 -4
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Fragment, useState, useMemo, useRef, useCallback, useEffect } from 'react'
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
|
+
import { useDebouncedValue } from '../../hooks/useDebouncedValue'
|
|
3
4
|
import {
|
|
4
5
|
AlertCircle,
|
|
5
6
|
AlertTriangle,
|
|
@@ -795,6 +796,10 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
795
796
|
const [searchInternal, setSearchInternal] = useState('')
|
|
796
797
|
const searchTerm = searchProp ?? searchInternal
|
|
797
798
|
const setSearchTerm = onSearchChange ?? setSearchInternal
|
|
799
|
+
// Filtering and re-ranking rebuild the whole lane board (tens of thousands
|
|
800
|
+
// of DOM mutations on a large timeline) — coalesce a typing burst into one
|
|
801
|
+
// rebuild instead of one per keystroke. Clearing flushes immediately.
|
|
802
|
+
const debouncedSearchTerm = useDebouncedValue(searchTerm, 300, (v) => v === '')
|
|
798
803
|
const [activityFilterInternal, setActivityFilterInternal] = useState<ActivityFilterKey[]>([])
|
|
799
804
|
const activityFilter = activityFilterProp ?? activityFilterInternal
|
|
800
805
|
const setActivityFilter = onActivityFilterChange ?? setActivityFilterInternal
|
|
@@ -939,10 +944,10 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
939
944
|
if (!showDeleted && e.eventType === 'delete') return false
|
|
940
945
|
if (!matchesActivityFilter(e, activityFilter)) return false
|
|
941
946
|
if (kindFilter.length > 0 && !kindFilter.includes(e.kind)) return false
|
|
942
|
-
if (!matchesTimelineSearch(e,
|
|
947
|
+
if (!matchesTimelineSearch(e, debouncedSearchTerm)) return false
|
|
943
948
|
return true
|
|
944
949
|
})
|
|
945
|
-
}, [events,
|
|
950
|
+
}, [events, debouncedSearchTerm, showDeleted, activityFilter, kindFilter])
|
|
946
951
|
|
|
947
952
|
// Kind dropdown options: seed set + every kind present in the (unfiltered)
|
|
948
953
|
// events. The swimlane fetches all kinds, so deriving from events is stable.
|
|
@@ -1077,16 +1082,29 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
1077
1082
|
return { start, end, windowMs, now: effectiveNow }
|
|
1078
1083
|
}, [zoom, panOffset, effectiveNow, viewWindow])
|
|
1079
1084
|
|
|
1085
|
+
// The visible window WITHOUT the live-`now` field. Keyed on the primitive
|
|
1086
|
+
// bounds so its identity changes only when the window actually moves — a live
|
|
1087
|
+
// `now` tick (30s) advances `visibleTimeRange.now` for the now-line but must
|
|
1088
|
+
// NOT invalidate the lane-ordering / window-clip memos below, which only care
|
|
1089
|
+
// about [start,end]. This stops the heavy build from re-running on each tick
|
|
1090
|
+
// while the window is static — the controlled retained lens or a paused view.
|
|
1091
|
+
// (In uncontrolled live mode start/end track `effectiveNow`, so they advance
|
|
1092
|
+
// every tick regardless; the win is for the static-window cases this targets.)
|
|
1093
|
+
const visibleWindow = useMemo(
|
|
1094
|
+
() => ({ start: visibleTimeRange.start, end: visibleTimeRange.end, windowMs: visibleTimeRange.windowMs }),
|
|
1095
|
+
[visibleTimeRange.start, visibleTimeRange.end, visibleTimeRange.windowMs],
|
|
1096
|
+
)
|
|
1097
|
+
|
|
1080
1098
|
// Apply the chosen ordering to the top-level lanes. 'recent' needs the visible
|
|
1081
1099
|
// window (newest-in-view first), so this lives after visibleTimeRange. The
|
|
1082
1100
|
// pinned section is ordered separately (strict pin order) and never flows here.
|
|
1083
1101
|
const orderedLanes = useMemo(
|
|
1084
1102
|
() => sortTimelineLanes(lanes, sort, {
|
|
1085
|
-
windowStart:
|
|
1086
|
-
windowEnd:
|
|
1103
|
+
windowStart: visibleWindow.start,
|
|
1104
|
+
windowEnd: visibleWindow.end,
|
|
1087
1105
|
scoreOf: (lane) => lane.scoreBreakdown?.total ?? calculateInterestingness(lane),
|
|
1088
1106
|
}),
|
|
1089
|
-
[lanes, sort,
|
|
1107
|
+
[lanes, sort, visibleWindow],
|
|
1090
1108
|
)
|
|
1091
1109
|
|
|
1092
1110
|
// Live-mode order hysteresis. The importance rank recomputes every poll (recency
|
|
@@ -1098,8 +1116,8 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
1098
1116
|
// calm across it is the whole point. Inert when !isLive (local / WorkloadView).
|
|
1099
1117
|
const laneOrderRef = useRef<string[] | null>(null)
|
|
1100
1118
|
const rankResetKey = useMemo(
|
|
1101
|
-
() => JSON.stringify([sort, grouping,
|
|
1102
|
-
[sort, grouping,
|
|
1119
|
+
() => JSON.stringify([sort, grouping, debouncedSearchTerm, showDeleted, activityFilter, kindFilter]),
|
|
1120
|
+
[sort, grouping, debouncedSearchTerm, showDeleted, activityFilter, kindFilter],
|
|
1103
1121
|
)
|
|
1104
1122
|
const rankResetKeyRef = useRef(rankResetKey)
|
|
1105
1123
|
const wasLiveRef = useRef(false)
|
|
@@ -1147,17 +1165,17 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
1147
1165
|
// "events" count so it's view-scoped like "resources" — a lens sitting in a
|
|
1148
1166
|
// recording gap reads "0 resources · 0 events", not "0 resources · N events".
|
|
1149
1167
|
const eventsInWindow = useMemo(() => {
|
|
1150
|
-
const { start, end } =
|
|
1168
|
+
const { start, end } = visibleWindow
|
|
1151
1169
|
return filteredEvents.filter((e) => {
|
|
1152
1170
|
const t = new Date(e.timestamp).getTime()
|
|
1153
1171
|
return t >= start && t <= end
|
|
1154
1172
|
})
|
|
1155
|
-
}, [filteredEvents,
|
|
1173
|
+
}, [filteredEvents, visibleWindow])
|
|
1156
1174
|
|
|
1157
1175
|
// Recording gaps clipped to the visible window, for the hatched lane bands.
|
|
1158
1176
|
const visibleGaps = useMemo(() => {
|
|
1159
1177
|
if (!gaps || gaps.length === 0) return []
|
|
1160
|
-
const { start, end } =
|
|
1178
|
+
const { start, end } = visibleWindow
|
|
1161
1179
|
const out: TimeWindow[] = []
|
|
1162
1180
|
for (const g of gaps) {
|
|
1163
1181
|
const fromMs = Math.max(g.fromMs, start)
|
|
@@ -1165,7 +1183,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
1165
1183
|
if (toMs > fromMs) out.push({ fromMs, toMs })
|
|
1166
1184
|
}
|
|
1167
1185
|
return out
|
|
1168
|
-
}, [gaps,
|
|
1186
|
+
}, [gaps, visibleWindow])
|
|
1169
1187
|
|
|
1170
1188
|
// Pin MOVES a row: pinned lanes (and pinned children inside groups) leave
|
|
1171
1189
|
// the regular list entirely — the pinned section is their only home.
|
|
@@ -1181,7 +1199,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
1181
1199
|
|
|
1182
1200
|
// Filter out lanes with no events in the visible time window
|
|
1183
1201
|
const visibleLanes = useMemo(() => {
|
|
1184
|
-
const { start, end } =
|
|
1202
|
+
const { start, end } = visibleWindow
|
|
1185
1203
|
return unpinnedLanes.filter(lane => {
|
|
1186
1204
|
const allLaneEvents = lane.allEventsSorted || []
|
|
1187
1205
|
return allLaneEvents.some(e => {
|
|
@@ -1189,7 +1207,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
1189
1207
|
return t >= start && t <= end
|
|
1190
1208
|
})
|
|
1191
1209
|
})
|
|
1192
|
-
}, [unpinnedLanes,
|
|
1210
|
+
}, [unpinnedLanes, visibleWindow])
|
|
1193
1211
|
|
|
1194
1212
|
// Pinned rows: resolved from the FULL lane list (pre-window-filter, any
|
|
1195
1213
|
// grouping) so they stay put while the lens moves and even when they have no
|
|
@@ -1203,7 +1221,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
1203
1221
|
// Honest counts while filtered to pins: only pinned rows' in-window events.
|
|
1204
1222
|
const pinnedEventsInWindow = useMemo(() => {
|
|
1205
1223
|
if (pinnedLaneRows.length === 0) return 0
|
|
1206
|
-
const { start, end } =
|
|
1224
|
+
const { start, end } = visibleWindow
|
|
1207
1225
|
let n = 0
|
|
1208
1226
|
for (const lane of pinnedLaneRows) {
|
|
1209
1227
|
for (const e of lane.allEventsSorted || []) {
|
|
@@ -1212,7 +1230,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
1212
1230
|
}
|
|
1213
1231
|
}
|
|
1214
1232
|
return n
|
|
1215
|
-
}, [pinnedLaneRows,
|
|
1233
|
+
}, [pinnedLaneRows, visibleWindow])
|
|
1216
1234
|
|
|
1217
1235
|
const pinnedIdSet = useMemo(() => new Set((pinnedLanes ?? []).map((p) => p.id)), [pinnedLanes])
|
|
1218
1236
|
// A pin button for a lane, or null when the host wired no pin handler. A pinned
|
|
@@ -1236,7 +1254,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
1236
1254
|
|
|
1237
1255
|
// Generate time axis ticks
|
|
1238
1256
|
const axisTicks = useMemo(() => {
|
|
1239
|
-
const { start, end } =
|
|
1257
|
+
const { start, end } = visibleWindow
|
|
1240
1258
|
const ticks: { time: number; label: string }[] = []
|
|
1241
1259
|
const span = end - start
|
|
1242
1260
|
if (span <= 0) return ticks
|
|
@@ -1262,18 +1280,18 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
1262
1280
|
}
|
|
1263
1281
|
|
|
1264
1282
|
return ticks
|
|
1265
|
-
}, [
|
|
1283
|
+
}, [visibleWindow])
|
|
1266
1284
|
|
|
1267
1285
|
// Convert timestamp to X position (0-100%)
|
|
1268
1286
|
const timeToX = useCallback(
|
|
1269
1287
|
(timestamp: number): number => {
|
|
1270
|
-
const { start, windowMs } =
|
|
1288
|
+
const { start, windowMs } = visibleWindow
|
|
1271
1289
|
// A zero-width window (host bounds with fromMs === toMs) would divide by
|
|
1272
1290
|
// zero and emit NaN into `left:` CSS — pin everything to the left edge.
|
|
1273
1291
|
if (windowMs <= 0) return 0
|
|
1274
1292
|
return ((timestamp - start) / windowMs) * 100
|
|
1275
1293
|
},
|
|
1276
|
-
[
|
|
1294
|
+
[visibleWindow]
|
|
1277
1295
|
)
|
|
1278
1296
|
|
|
1279
1297
|
// Vertical grid line positions (x-percent) shared by every lane backdrop.
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import {
|
|
3
|
-
BASE_QUANTIZE_STEP_MS,
|
|
4
3
|
LENS_LATCH_EPSILON_MS,
|
|
5
4
|
STALE_AMBER_AFTER_MS,
|
|
6
5
|
advanceLatchedLens,
|
|
7
6
|
deriveLiveSelection,
|
|
8
7
|
isLensLatched,
|
|
9
|
-
quantizeBaseWindow,
|
|
10
8
|
} from './timeline-live'
|
|
11
9
|
import { clampSelection, type ScrubberRange } from './scrubber-math'
|
|
12
10
|
|
|
@@ -20,41 +18,6 @@ describe('deriveLiveSelection', () => {
|
|
|
20
18
|
})
|
|
21
19
|
})
|
|
22
20
|
|
|
23
|
-
describe('quantizeBaseWindow', () => {
|
|
24
|
-
it('floors both edges to the step', () => {
|
|
25
|
-
const step = BASE_QUANTIZE_STEP_MS
|
|
26
|
-
const q = quantizeBaseWindow(step * 3 + 12_345, step * 10 + 4_000, step)
|
|
27
|
-
expect(q).toEqual({ fromMs: step * 3, toMs: step * 10 })
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
it('is stable across two ticks inside the same step', () => {
|
|
31
|
-
const step = BASE_QUANTIZE_STEP_MS
|
|
32
|
-
const base = step * 100
|
|
33
|
-
// Two "now" values 30s apart but inside the same 5-minute step.
|
|
34
|
-
const a = quantizeBaseWindow(base - HOUR + 10_000, base + 10_000, step)
|
|
35
|
-
const b = quantizeBaseWindow(base - HOUR + 40_000, base + 40_000, step)
|
|
36
|
-
expect(a).toEqual(b)
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
it('advances once the edge crosses a step boundary', () => {
|
|
40
|
-
const step = BASE_QUANTIZE_STEP_MS
|
|
41
|
-
const before = quantizeBaseWindow(0, step - 1, step)
|
|
42
|
-
const after = quantizeBaseWindow(0, step + 1, step)
|
|
43
|
-
expect(before.toMs).toBe(0)
|
|
44
|
-
expect(after.toMs).toBe(step)
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
it('trailing seam never exceeds one step (covered by the 10-min live poll)', () => {
|
|
48
|
-
const step = BASE_QUANTIZE_STEP_MS
|
|
49
|
-
const now = step * 42 + 137_000
|
|
50
|
-
const q = quantizeBaseWindow(now - HOUR, now, step)
|
|
51
|
-
// Gap between the quantized right edge and now is < one step, and one step
|
|
52
|
-
// (5min) is well inside the 10-min live poll window ⇒ no data hole.
|
|
53
|
-
expect(now - q.toMs).toBeLessThan(step)
|
|
54
|
-
expect(step).toBeLessThan(10 * MIN)
|
|
55
|
-
})
|
|
56
|
-
})
|
|
57
|
-
|
|
58
21
|
describe('isLensLatched', () => {
|
|
59
22
|
const sel: ScrubberRange = { fromMs: 0, toMs: 100 * HOUR }
|
|
60
23
|
|
|
@@ -18,8 +18,8 @@ export type TimelineLiveState =
|
|
|
18
18
|
| { kind: 'frozen'; asOfMs: number; newEventCount?: number }
|
|
19
19
|
|
|
20
20
|
// Cadence the host slides a live selection at. Coarse on purpose: the precise
|
|
21
|
-
// [from,to] is re-derived every tick
|
|
22
|
-
//
|
|
21
|
+
// [from,to] is re-derived every tick without refetching — data arrives via the
|
|
22
|
+
// source's own delta polling, so the tick only moves the visible window.
|
|
23
23
|
export const LIVE_TICK_MS = 30_000
|
|
24
24
|
|
|
25
25
|
// A frozen selection older than this reads as stale — the chip turns amber.
|
|
@@ -30,33 +30,11 @@ export const STALE_AMBER_AFTER_MS = 15 * 60_000
|
|
|
30
30
|
// LIVE_TICK_MS so a lens that just slid with the tick still reads as latched.
|
|
31
31
|
export const LENS_LATCH_EPSILON_MS = 60_000
|
|
32
32
|
|
|
33
|
-
// Base-fetch quantization step. The react-query key is keyed on the quantized
|
|
34
|
-
// window, so it only changes when the live edge crosses a 5-minute boundary —
|
|
35
|
-
// the seam between the quantized edge and now is covered by the 10-minute live
|
|
36
|
-
// poll (5min quantization lag < 10min poll window ⇒ no hole).
|
|
37
|
-
export const BASE_QUANTIZE_STEP_MS = 5 * 60_000
|
|
38
|
-
|
|
39
33
|
/** LIVE selection: a width pinned to now. */
|
|
40
34
|
export function deriveLiveSelection(widthMs: number, nowMs: number): ScrubberRange {
|
|
41
35
|
return { fromMs: nowMs - widthMs, toMs: nowMs }
|
|
42
36
|
}
|
|
43
37
|
|
|
44
|
-
/**
|
|
45
|
-
* Quantize a sliding [from,to] down to fixed steps so the value is stable across
|
|
46
|
-
* ticks within one step (identical output ⇒ stable react-query key). Both edges
|
|
47
|
-
* floor to the step; the trailing seam to `now` is filled by the live poll.
|
|
48
|
-
*/
|
|
49
|
-
export function quantizeBaseWindow(
|
|
50
|
-
fromMs: number,
|
|
51
|
-
toMs: number,
|
|
52
|
-
stepMs: number = BASE_QUANTIZE_STEP_MS,
|
|
53
|
-
): ScrubberRange {
|
|
54
|
-
return {
|
|
55
|
-
fromMs: Math.floor(fromMs / stepMs) * stepMs,
|
|
56
|
-
toMs: Math.floor(toMs / stepMs) * stepMs,
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
38
|
/**
|
|
61
39
|
* True when the lens rides the selection's live edge (so it should slide with
|
|
62
40
|
* the tick). A lens the user dragged into the past sits further than epsilon
|
|
@@ -10,13 +10,14 @@ import {
|
|
|
10
10
|
import { clsx } from 'clsx'
|
|
11
11
|
import type { NodeKind, HealthStatus, PodSummary } from '../../types'
|
|
12
12
|
import { displayKind } from '../../types'
|
|
13
|
-
import { healthToSeverity, SEVERITY_DOT
|
|
13
|
+
import { healthToSeverity, SEVERITY_DOT } from '../../utils/badge-colors'
|
|
14
14
|
import { workloadHue } from '../../utils/workload-colors'
|
|
15
15
|
import { ownershipOf } from '../../utils/topology-neighborhood'
|
|
16
16
|
import { midTruncate } from '../../utils/format'
|
|
17
17
|
import { getTopologyIcon } from '../../utils/resource-icons'
|
|
18
18
|
import { Tooltip } from '../ui/Tooltip'
|
|
19
19
|
import { AuditBadgeTooltip, type AuditBadgeMessage } from '../audit/AuditBadgeTooltip'
|
|
20
|
+
import { SEVERITY_TEXT_CLASS } from '../checks/severity'
|
|
20
21
|
import argoCdLogo from '../../assets/gitops/argocd.png'
|
|
21
22
|
import fluxLogo from '../../assets/gitops/flux.svg'
|
|
22
23
|
|
|
@@ -464,12 +465,13 @@ export const K8sResourceNode = memo(function K8sResourceNode({
|
|
|
464
465
|
// node's data by auditKey). The host only counts "badge-worthy" findings —
|
|
465
466
|
// reference-integrity / lifecycle, "this resource is actually broken" — not the
|
|
466
467
|
// posture/best-practice nags that fire near-universally, so the indicator stays
|
|
467
|
-
// a signal.
|
|
468
|
-
|
|
468
|
+
// a signal. The counters retain the raw transport names, but presentation
|
|
469
|
+
// follows the canonical Checks severity ladder (High, then Medium).
|
|
470
|
+
const auditHigh =
|
|
469
471
|
typeof nodeData.auditDanger === "number" ? nodeData.auditDanger : 0;
|
|
470
|
-
const
|
|
472
|
+
const auditMedium =
|
|
471
473
|
typeof nodeData.auditWarning === "number" ? nodeData.auditWarning : 0;
|
|
472
|
-
const auditTotal =
|
|
474
|
+
const auditTotal = auditHigh + auditMedium;
|
|
473
475
|
const auditMessages = Array.isArray(nodeData.auditMessages)
|
|
474
476
|
? (nodeData.auditMessages as AuditBadgeMessage[])
|
|
475
477
|
: [];
|
|
@@ -652,7 +654,7 @@ export const K8sResourceNode = memo(function K8sResourceNode({
|
|
|
652
654
|
clickHint={false}
|
|
653
655
|
/>
|
|
654
656
|
) : (
|
|
655
|
-
`${auditTotal} audit ${auditTotal === 1 ? "finding" : "findings"}${
|
|
657
|
+
`${auditTotal} audit ${auditTotal === 1 ? "finding" : "findings"}${auditHigh > 0 ? ` · ${auditHigh} high` : ""}${auditMedium > 0 ? ` · ${auditMedium} medium` : ""}`
|
|
656
658
|
)
|
|
657
659
|
}
|
|
658
660
|
position="right"
|
|
@@ -660,9 +662,9 @@ export const K8sResourceNode = memo(function K8sResourceNode({
|
|
|
660
662
|
<TriangleAlert
|
|
661
663
|
className={clsx(
|
|
662
664
|
"w-3 h-3 cursor-help",
|
|
663
|
-
|
|
664
|
-
?
|
|
665
|
-
:
|
|
665
|
+
auditHigh > 0
|
|
666
|
+
? SEVERITY_TEXT_CLASS.high
|
|
667
|
+
: SEVERITY_TEXT_CLASS.medium,
|
|
666
668
|
)}
|
|
667
669
|
/>
|
|
668
670
|
</Tooltip>
|
|
@@ -22,7 +22,7 @@ import { Tooltip } from './Tooltip'
|
|
|
22
22
|
// screen readers announce pressed/unpressed correctly. Optional tooltip
|
|
23
23
|
// describes the toggle action ("Click to stop filtering by danger").
|
|
24
24
|
|
|
25
|
-
export type FilterPillTone = 'neutral' | 'danger' | 'warn' | 'ok' | 'brand'
|
|
25
|
+
export type FilterPillTone = 'neutral' | 'danger' | 'warn' | 'high' | 'medium' | 'ok' | 'brand'
|
|
26
26
|
|
|
27
27
|
interface Props {
|
|
28
28
|
label: ReactNode
|
|
@@ -49,6 +49,8 @@ const TONE_ACTIVE: Record<FilterPillTone, string> = {
|
|
|
49
49
|
neutral: 'bg-theme-text-primary/10 border-theme-text-primary/25 text-theme-text-primary',
|
|
50
50
|
danger: 'bg-red-500/15 border-red-500/40 text-red-700 dark:text-red-300',
|
|
51
51
|
warn: 'bg-amber-500/15 border-amber-500/40 text-amber-800 dark:text-amber-300',
|
|
52
|
+
high: 'bg-orange-500/15 border-orange-500/40 text-orange-800 dark:text-orange-300',
|
|
53
|
+
medium: 'bg-yellow-500/15 border-yellow-500/40 text-yellow-800 dark:text-yellow-300',
|
|
52
54
|
ok: 'bg-emerald-500/15 border-emerald-500/40 text-emerald-700 dark:text-emerald-300',
|
|
53
55
|
brand: 'bg-[var(--color-brand-50)] border-[var(--color-radar-accent)] text-theme-text-primary dark:bg-[var(--color-brand-950)]',
|
|
54
56
|
}
|
package/src/hooks/index.ts
CHANGED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/** Trailing-edge debounce of a value. Unlike useDeferredValue (which yields to
|
|
4
|
+
* React's scheduler but still emits once per input change), this coalesces a
|
|
5
|
+
* burst of changes into one emission after `delayMs` of quiet — the right tool
|
|
6
|
+
* when each downstream emission has a fixed cost regardless of render pressure
|
|
7
|
+
* (router URL writes, live-order re-ranks).
|
|
8
|
+
*
|
|
9
|
+
* `flushWhen` values bypass the delay (e.g. an emptied search box: a delayed
|
|
10
|
+
* clear makes the × button feel broken). The flush must reset the pending
|
|
11
|
+
* timer state INSIDE the hook — masking it at the call site
|
|
12
|
+
* (`v === '' ? '' : debounced`) leaves the stale value armed, and it
|
|
13
|
+
* resurfaces if the user types again before the timer fires. */
|
|
14
|
+
export function useDebouncedValue<T>(value: T, delayMs: number, flushWhen?: (value: T) => boolean): T {
|
|
15
|
+
const [debounced, setDebounced] = useState(value)
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (Object.is(debounced, value)) return
|
|
19
|
+
if (flushWhen?.(value)) {
|
|
20
|
+
setDebounced(value)
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
const timer = setTimeout(() => setDebounced(value), delayMs)
|
|
24
|
+
return () => clearTimeout(timer)
|
|
25
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- re-arm only when the input changes; `debounced` catching up (or an inline `flushWhen` identity) must not restart the timer
|
|
26
|
+
}, [value, delayMs])
|
|
27
|
+
|
|
28
|
+
return debounced
|
|
29
|
+
}
|
package/src/types/core.ts
CHANGED
|
@@ -1032,15 +1032,28 @@ export type ChartSource = 'local' | 'artifacthub'
|
|
|
1032
1032
|
// ============================================================================
|
|
1033
1033
|
|
|
1034
1034
|
// Top metrics types (bulk, for resource table view)
|
|
1035
|
+
export interface ContainerResourceMetrics {
|
|
1036
|
+
name: string
|
|
1037
|
+
cpu: number // nanocores (usage)
|
|
1038
|
+
cpuRequest: number // nanocores
|
|
1039
|
+
cpuLimit: number // nanocores
|
|
1040
|
+
memory: number // bytes (usage)
|
|
1041
|
+
memoryRequest: number // bytes
|
|
1042
|
+
memoryLimit: number // bytes
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1035
1045
|
export interface TopPodMetrics {
|
|
1036
1046
|
namespace: string
|
|
1037
1047
|
name: string
|
|
1038
1048
|
cpu: number // nanocores (usage)
|
|
1039
1049
|
memory: number // bytes (usage)
|
|
1040
|
-
cpuRequest: number // nanocores (sum across containers)
|
|
1041
|
-
cpuLimit: number // nanocores (sum across containers)
|
|
1042
|
-
memoryRequest: number // bytes (sum across containers)
|
|
1043
|
-
memoryLimit: number // bytes (sum across containers)
|
|
1050
|
+
cpuRequest: number // nanocores (sum across running containers)
|
|
1051
|
+
cpuLimit: number // nanocores (sum across running containers)
|
|
1052
|
+
memoryRequest: number // bytes (sum across running containers)
|
|
1053
|
+
memoryLimit: number // bytes (sum across running containers)
|
|
1054
|
+
// Per-container breakdown; present only for pods with more than one running
|
|
1055
|
+
// container (regular + native sidecars). Absent for single-container pods.
|
|
1056
|
+
containers?: ContainerResourceMetrics[]
|
|
1044
1057
|
}
|
|
1045
1058
|
|
|
1046
1059
|
export interface TopNodeMetrics {
|