@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.
@@ -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, searchTerm)) return false
947
+ if (!matchesTimelineSearch(e, debouncedSearchTerm)) return false
943
948
  return true
944
949
  })
945
- }, [events, searchTerm, showDeleted, activityFilter, kindFilter])
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: visibleTimeRange.start,
1086
- windowEnd: visibleTimeRange.end,
1103
+ windowStart: visibleWindow.start,
1104
+ windowEnd: visibleWindow.end,
1087
1105
  scoreOf: (lane) => lane.scoreBreakdown?.total ?? calculateInterestingness(lane),
1088
1106
  }),
1089
- [lanes, sort, visibleTimeRange],
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, searchTerm, showDeleted, activityFilter, kindFilter]),
1102
- [sort, grouping, searchTerm, showDeleted, activityFilter, kindFilter],
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 } = visibleTimeRange
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, visibleTimeRange])
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 } = visibleTimeRange
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, visibleTimeRange])
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 } = visibleTimeRange
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, visibleTimeRange])
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 } = visibleTimeRange
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, visibleTimeRange])
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 } = visibleTimeRange
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
- }, [visibleTimeRange])
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 } = visibleTimeRange
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
- [visibleTimeRange]
1294
+ [visibleWindow]
1277
1295
  )
1278
1296
 
1279
1297
  // Vertical grid line positions (x-percent) shared by every lane backdrop.
@@ -20,8 +20,6 @@ export {
20
20
  advanceLatchedLens,
21
21
  deriveLiveSelection,
22
22
  isLensLatched,
23
- quantizeBaseWindow,
24
23
  LIVE_TICK_MS,
25
- BASE_QUANTIZE_STEP_MS,
26
24
  type TimelineLiveState,
27
25
  } from './timeline-live'
@@ -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, but the base fetch is quantized so the
22
- // query key only churns every QUANTIZE step (see quantizeBaseWindow).
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, SEVERITY_TEXT } from '../../utils/badge-colors'
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. Colored by worst severity (danger red, else warning amber).
468
- const auditDanger =
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 auditWarning =
472
+ const auditMedium =
471
473
  typeof nodeData.auditWarning === "number" ? nodeData.auditWarning : 0;
472
- const auditTotal = auditDanger + auditWarning;
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"}${auditDanger > 0 ? ` · ${auditDanger} danger` : ""}`
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
- auditDanger > 0
664
- ? SEVERITY_TEXT.error
665
- : SEVERITY_TEXT.warning,
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
  }
@@ -1,4 +1,5 @@
1
1
  export { useAnimatedUnmount } from './useAnimatedUnmount'
2
+ export { useDebouncedValue } from './useDebouncedValue'
2
3
  export { useRefreshAnimation } from './useRefreshAnimation'
3
4
  export {
4
5
  KeyboardShortcutProvider,
@@ -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 {