@skyhook-io/k8s-ui 1.8.7 → 1.8.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/package.json +3 -3
  2. package/src/components/applications/ApplicationsList.tsx +5 -2
  3. package/src/components/applications/ApplicationsView.tsx +37 -30
  4. package/src/components/checks/ChecksView.tsx +25 -14
  5. package/src/components/cluster-switcher/ClusterSwitcher.tsx +27 -9
  6. package/src/components/gitops/GitOpsTableView.tsx +163 -88
  7. package/src/components/gitops/insights/GitOpsInsightViews.tsx +12 -5
  8. package/src/components/issues/IssuesView.tsx +41 -5
  9. package/src/components/issues/ResourceIssuesSection.tsx +3 -0
  10. package/src/components/issues/diagnostic.ts +22 -0
  11. package/src/components/issues/index.ts +1 -1
  12. package/src/components/issues/issues.test.ts +21 -0
  13. package/src/components/issues/types.ts +18 -0
  14. package/src/components/namespace-switcher/NamespacePicker.tsx +381 -0
  15. package/src/components/namespace-switcher/index.ts +6 -0
  16. package/src/components/resources/ResourcesView.tsx +20 -81
  17. package/src/components/scope-pill/ScopePill.tsx +35 -0
  18. package/src/components/scope-pill/index.ts +2 -0
  19. package/src/components/timeline/TimelineList.tsx +27 -1
  20. package/src/components/topology/TopologyControls.tsx +90 -14
  21. package/src/components/ui/FreshnessControl.tsx +153 -0
  22. package/src/components/ui/SortableTh.tsx +16 -10
  23. package/src/components/ui/SummaryTile.tsx +9 -1
  24. package/src/components/ui/Toast.tsx +1 -1
  25. package/src/components/ui/index.ts +2 -0
  26. package/src/components/workload/ResourceDetailDrawer.tsx +215 -32
  27. package/src/components/workload/WorkloadView.tsx +26 -8
  28. package/src/filter-state/filter-state-core.test.ts +98 -0
  29. package/src/filter-state/filter-state-core.ts +138 -0
  30. package/src/filter-state/filter-state.tsx +127 -0
  31. package/src/filter-state/index.ts +16 -0
  32. package/src/hooks/index.ts +1 -0
  33. package/src/hooks/useKeyboardShortcuts.tsx +23 -2
  34. package/src/hooks/useRefreshAnimation.ts +15 -2
  35. package/src/index.ts +12 -0
  36. package/src/types/core.ts +42 -0
  37. package/src/types/gitops-insights.ts +4 -0
  38. package/src/utils/animation.ts +10 -0
  39. package/src/utils/format-freshness.test.ts +34 -0
  40. package/src/utils/format.ts +32 -0
  41. package/src/utils/resource-hierarchy.test.ts +51 -0
  42. package/src/utils/resource-hierarchy.ts +7 -4
@@ -0,0 +1,127 @@
1
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
2
+ import {
3
+ decodeFilters,
4
+ isFilterActive,
5
+ withCleared,
6
+ withField,
7
+ withToggle,
8
+ emptyValue,
9
+ type FieldKeysOfType,
10
+ type FilterSchema,
11
+ type FilterValues,
12
+ } from './filter-state-core'
13
+
14
+ // Shared filter-state contract for Radar's list views (OSS + Hub).
15
+ //
16
+ // The consolidation center is BEHAVIOR, not components: one place owns how a
17
+ // view's filters serialize to the URL, what "cleared" means, and how defaults
18
+ // encode — so every list view is shareable/bookmarkable and behaves the same,
19
+ // whether it renders as a facet sidebar or a compact bar.
20
+ //
21
+ // URL is the single source of truth (no localStorage of filter values — that
22
+ // goes stale across clusters/orgs). Router-agnostic + app-agnostic: this never
23
+ // imports react-router or reads window; the host injects a FilterLocation
24
+ // adapter over its own router. All transforms live in ./filter-state-core (pure,
25
+ // exhaustively tested); this file is only the React glue.
26
+
27
+ /** A reactive view of the URL query string, injected by the host app. */
28
+ export interface FilterLocation {
29
+ /** Current query params — reactive (sourced from the app's router hook). */
30
+ searchParams: URLSearchParams
31
+ /**
32
+ * Apply an update. The updater receives the LATEST params (not a captured
33
+ * snapshot), so rapid successive toggles compose instead of clobbering.
34
+ * `replace` avoids a history entry per keystroke (used for text fields).
35
+ */
36
+ update: (updater: (prev: URLSearchParams) => URLSearchParams, opts?: { replace?: boolean }) => void
37
+ }
38
+
39
+ const FilterLocationContext = createContext<FilterLocation | null>(null)
40
+
41
+ /**
42
+ * Wrap the app subtree once with the host's router adapter, e.g.
43
+ * const [searchParams, setSearchParams] = useSearchParams()
44
+ * <FilterLocationProvider value={{ searchParams, update: setSearchParams }}>
45
+ */
46
+ export function FilterLocationProvider({ value, children }: { value: FilterLocation; children: ReactNode }) {
47
+ return <FilterLocationContext.Provider value={value}>{children}</FilterLocationContext.Provider>
48
+ }
49
+
50
+ export function useFilterLocation(): FilterLocation {
51
+ const ctx = useContext(FilterLocationContext)
52
+ if (!ctx) throw new Error('useFilterState requires a <FilterLocationProvider> ancestor')
53
+ return ctx
54
+ }
55
+
56
+ export interface FilterState<S extends FilterSchema> {
57
+ values: FilterValues<S>
58
+ /** Any field diverges from its default (⇒ show a "Clear all" affordance). */
59
+ isActive: boolean
60
+ /** Add/remove one value in a 'set' field. */
61
+ toggle: (key: FieldKeysOfType<S, 'set'>, value: string) => void
62
+ /** Replace a 'set' field wholesale. */
63
+ setSet: (key: FieldKeysOfType<S, 'set'>, values: string[]) => void
64
+ /** Set a 'text' or 'single' field. 'text' replaces history; 'single' pushes it. */
65
+ setString: (key: FieldKeysOfType<S, 'text' | 'single'>, value: string) => void
66
+ setBoolean: (key: FieldKeysOfType<S, 'boolean'>, value: boolean) => void
67
+ /** Reset one field to its default. */
68
+ clear: (key: keyof S) => void
69
+ /** Reset every field in the schema (leaves unrelated params untouched). */
70
+ clearAll: () => void
71
+ }
72
+
73
+ export function useFilterState<S extends FilterSchema>(schema: S): FilterState<S> {
74
+ const ctx = useContext(FilterLocationContext)
75
+
76
+ // Graceful fallback: without a provider, keep filter state locally (still
77
+ // works, just not URL-synced). This lets a shared view adopt the contract
78
+ // before every host has wired a FilterLocationBridge — a host that hasn't
79
+ // (e.g. Radar Hub, until it picks up a published build) keeps its prior local
80
+ // behavior instead of crashing. A one-time warning flags the missing wiring.
81
+ const [localParams, setLocalParams] = useState(() => new URLSearchParams())
82
+ const warned = useRef(false)
83
+ useEffect(() => {
84
+ if (!ctx && !warned.current) {
85
+ warned.current = true
86
+ console.warn('[k8s-ui] useFilterState: no <FilterLocationProvider> ancestor — filters are local, not URL-synced.')
87
+ }
88
+ }, [ctx])
89
+ const fallback = useMemo<FilterLocation>(
90
+ () => ({ searchParams: localParams, update: (u) => setLocalParams((prev) => u(new URLSearchParams(prev))) }),
91
+ [localParams],
92
+ )
93
+ const { searchParams, update } = ctx ?? fallback
94
+
95
+ // Depend on the serialized string, not the URLSearchParams object identity, so
96
+ // a router adapter that returns a mutated/unstable instance can't stale the memo.
97
+ const paramsKey = searchParams.toString()
98
+ const values = useMemo(() => decodeFilters(schema, new URLSearchParams(paramsKey)), [schema, paramsKey])
99
+ const isActive = useMemo(() => isFilterActive(schema, values), [schema, values])
100
+
101
+ const toggle = useCallback(
102
+ (key: FieldKeysOfType<S, 'set'>, value: string) => update((prev) => withToggle(schema, prev, key, value)),
103
+ [schema, update],
104
+ )
105
+ const setSet = useCallback(
106
+ (key: FieldKeysOfType<S, 'set'>, vals: string[]) => update((prev) => withField(schema, prev, key, new Set(vals))),
107
+ [schema, update],
108
+ )
109
+ const setString = useCallback(
110
+ (key: FieldKeysOfType<S, 'text' | 'single'>, v: string) =>
111
+ // Typing into a search box shouldn't spam history; picking an enum should be
112
+ // a real back-step. 'text' replaces, 'single' pushes.
113
+ update((prev) => withField(schema, prev, key, v), { replace: schema[key].type === 'text' }),
114
+ [schema, update],
115
+ )
116
+ const setBoolean = useCallback(
117
+ (key: FieldKeysOfType<S, 'boolean'>, v: boolean) => update((prev) => withField(schema, prev, key, v)),
118
+ [schema, update],
119
+ )
120
+ const clear = useCallback(
121
+ (key: keyof S) => update((prev) => withField(schema, prev, key, emptyValue(schema[key]))),
122
+ [schema, update],
123
+ )
124
+ const clearAll = useCallback(() => update((prev) => withCleared(schema, prev)), [schema, update])
125
+
126
+ return { values, isActive, toggle, setSet, setString, setBoolean, clear, clearAll }
127
+ }
@@ -0,0 +1,16 @@
1
+ export {
2
+ FilterLocationProvider,
3
+ useFilterLocation,
4
+ useFilterState,
5
+ } from './filter-state'
6
+ export type { FilterLocation, FilterState } from './filter-state'
7
+ export {
8
+ defineFilterSchema,
9
+ decodeFilters,
10
+ withField,
11
+ withToggle,
12
+ withCleared,
13
+ emptyValue,
14
+ isFilterActive,
15
+ } from './filter-state-core'
16
+ export type { FilterFieldDef, FilterSchema, FilterValues, FieldKeysOfType } from './filter-state-core'
@@ -5,5 +5,6 @@ export {
5
5
  useRegisterShortcut,
6
6
  useRegisterShortcuts,
7
7
  useActiveShortcuts,
8
+ useSuppressBaseShortcuts,
8
9
  } from './useKeyboardShortcuts'
9
10
  export type { KeyboardShortcut, ShortcutCategory } from './useKeyboardShortcuts'
@@ -46,6 +46,10 @@ interface RegisteredShortcut extends KeyboardShortcut {
46
46
  interface KeyboardShortcutContextType {
47
47
  registerShortcut: (shortcut: KeyboardShortcut) => () => void
48
48
  activeShortcuts: KeyboardShortcut[]
49
+ /** When true, view-level shortcuts (the per-page scopes) are suppressed — used
50
+ * while a fullscreen detail overlay covers the page so the hidden view's keys
51
+ * don't fire. `global` (⌘K, the overlay's Escape) and `drawer` still fire. */
52
+ setBaseShortcutsSuppressed: (suppressed: boolean) => void
49
53
  }
50
54
 
51
55
  const KeyboardShortcutContext = createContext<KeyboardShortcutContextType | null>(null)
@@ -142,6 +146,9 @@ export function KeyboardShortcutProvider({ children }: { children: ReactNode })
142
146
  const [version, setVersion] = useState(0)
143
147
  const sequenceKeyRef = useRef<string | null>(null)
144
148
  const sequenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
149
+ // Read in the keydown handler (a ref, so no re-subscribe needed on change).
150
+ const baseSuppressedRef = useRef(false)
151
+ const setBaseShortcutsSuppressed = useCallback((s: boolean) => { baseSuppressedRef.current = s }, [])
145
152
 
146
153
  const registerShortcut = useCallback((shortcut: KeyboardShortcut) => {
147
154
  const id = nextRegistrationId++
@@ -161,9 +168,13 @@ export function KeyboardShortcutProvider({ children }: { children: ReactNode })
161
168
  const suppression = getSuppressionLevel(e)
162
169
  const suppressed = suppression !== 'none'
163
170
 
164
- // Get all enabled shortcuts, sorted by scope priority (highest first)
171
+ // Get all enabled shortcuts, sorted by scope priority (highest first).
172
+ // While a fullscreen overlay covers the page, drop the view-level scopes
173
+ // (the hidden page's keys) but keep `global` (⌘K, the overlay's Escape) and
174
+ // `drawer` so the overlay still works.
165
175
  const shortcuts = Array.from(shortcutsRef.current.values())
166
176
  .filter(s => s.enabled !== false)
177
+ .filter(s => !baseSuppressedRef.current || s.scope === 'global' || s.scope === 'drawer')
167
178
  .sort((a, b) => SCOPE_PRIORITY[b.scope] - SCOPE_PRIORITY[a.scope])
168
179
 
169
180
  // Check for multi-key sequence completion
@@ -258,12 +269,22 @@ export function KeyboardShortcutProvider({ children }: { children: ReactNode })
258
269
  , [version])
259
270
 
260
271
  return (
261
- <KeyboardShortcutContext.Provider value={{ registerShortcut, activeShortcuts }}>
272
+ <KeyboardShortcutContext.Provider value={{ registerShortcut, activeShortcuts, setBaseShortcutsSuppressed }}>
262
273
  {children}
263
274
  </KeyboardShortcutContext.Provider>
264
275
  )
265
276
  }
266
277
 
278
+ /** Suppress view-level shortcuts while `active` (a fullscreen detail overlay is up).
279
+ * `global` and `drawer` scopes keep firing so ⌘K and the overlay's own keys work. */
280
+ export function useSuppressBaseShortcuts(active: boolean) {
281
+ const ctx = useContext(KeyboardShortcutContext)
282
+ useEffect(() => {
283
+ ctx?.setBaseShortcutsSuppressed(active)
284
+ return () => ctx?.setBaseShortcutsSuppressed(false)
285
+ }, [ctx, active])
286
+ }
287
+
267
288
  /** Register a keyboard shortcut. Automatically deregisters on unmount or when key config changes. */
268
289
  export function useRegisterShortcut(shortcut: KeyboardShortcut) {
269
290
  const ctx = useContext(KeyboardShortcutContext)
@@ -1,4 +1,4 @@
1
- import { useState, useCallback, useRef } from 'react'
1
+ import { useState, useCallback, useRef, useEffect } from 'react'
2
2
 
3
3
  const MIN_SPIN_DURATION = 400 // ms
4
4
  const SUCCESS_DISPLAY_DURATION = 1200 // ms
@@ -15,6 +15,18 @@ type RefreshPhase = 'idle' | 'spinning' | 'success'
15
15
  export function useRefreshAnimation(refetchFn: () => void | Promise<unknown>): [() => void, boolean, RefreshPhase] {
16
16
  const [phase, setPhase] = useState<RefreshPhase>('idle')
17
17
  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
18
+ // Guards the async `finally`/timer callbacks from setting state after the
19
+ // host unmounts — this hook now lives on routed views that mount/unmount.
20
+ const mountedRef = useRef(true)
21
+ useEffect(() => {
22
+ // Re-arm on (re)mount — Strict Mode's mount→cleanup→remount would otherwise
23
+ // leave this stuck false and freeze the spinner on the next refresh.
24
+ mountedRef.current = true
25
+ return () => {
26
+ mountedRef.current = false
27
+ if (timeoutRef.current) clearTimeout(timeoutRef.current)
28
+ }
29
+ }, [])
18
30
 
19
31
  const wrappedRefetch = useCallback(() => {
20
32
  if (timeoutRef.current) {
@@ -31,9 +43,10 @@ export function useRefreshAnimation(refetchFn: () => void | Promise<unknown>): [
31
43
  const remaining = MIN_SPIN_DURATION - elapsed
32
44
 
33
45
  const transitionToSuccess = () => {
46
+ if (!mountedRef.current) return
34
47
  setPhase('success')
35
48
  timeoutRef.current = setTimeout(() => {
36
- setPhase('idle')
49
+ if (mountedRef.current) setPhase('idle')
37
50
  }, SUCCESS_DISPLAY_DURATION)
38
51
  }
39
52
 
package/src/index.ts CHANGED
@@ -53,6 +53,18 @@ export * from './components/issues'
53
53
  // Cluster switcher (shared trigger+dropdown for OSS Radar and Radar Hub)
54
54
  export * from './components/cluster-switcher'
55
55
 
56
+ // Namespace picker (shared scope-filter trigger+dropdown for OSS Radar and
57
+ // Radar Hub — pure presentation, data injected via props)
58
+ export * from './components/namespace-switcher'
59
+
60
+ // Scope pill — the shared bordered shell that groups the cluster + namespace
61
+ // segments into one unit (OSS header + Radar Hub cluster top bar)
62
+ export * from './components/scope-pill'
63
+
64
+ // Filter-state contract — shared URL-synced filter state for list views (OSS +
65
+ // Hub), router-agnostic via an injected FilterLocation adapter
66
+ export * from './filter-state'
67
+
56
68
  // Applications (shared host-agnostic list + detail shell for the deployable-
57
69
  // software surface; OSS renders single-cluster, Cloud adds the fleet layer)
58
70
  export * from './components/applications'
package/src/types/core.ts CHANGED
@@ -587,6 +587,7 @@ export interface HelmOperation {
587
587
  source: HelmOperationSource
588
588
  confidence: HelmOperationConfidence
589
589
  message: string
590
+ rawMessage?: string
590
591
  evidence?: string
591
592
  failureDescription?: string
592
593
  revision?: number
@@ -597,6 +598,22 @@ export interface HelmOperation {
597
598
  updated?: string
598
599
  }
599
600
 
601
+ export type HelmOperationInsightState = 'active' | 'recovered'
602
+
603
+ export interface HelmSuggestedCompare {
604
+ revision1: number
605
+ revision2: number
606
+ reason?: string
607
+ }
608
+
609
+ export interface HelmOperationInsight {
610
+ state: HelmOperationInsightState
611
+ primaryResource?: HelmOwnedResource
612
+ relatedResources?: HelmOwnedResource[]
613
+ signalCount?: number
614
+ suggestedCompare?: HelmSuggestedCompare
615
+ }
616
+
600
617
  export interface HelmReleaseDetail {
601
618
  name: string
602
619
  namespace: string
@@ -621,6 +638,7 @@ export interface HelmReleaseDetail {
621
638
  dependencies?: ChartDependency[]
622
639
  lastOperation?: HelmOperation
623
640
  operations?: HelmOperation[]
641
+ operationInsight?: HelmOperationInsight
624
642
  // When set, this release was installed by Flux's helm-controller — see
625
643
  // HelmRelease.managedByFluxHelmRelease for context. Format: "namespace/name".
626
644
  managedByFluxHelmRelease?: string
@@ -631,6 +649,7 @@ export interface HelmHook {
631
649
  namespace?: string
632
650
  kind: string
633
651
  path?: string
652
+ manifestChanged?: boolean
634
653
  events: string[]
635
654
  weight: number
636
655
  status?: string
@@ -746,6 +765,15 @@ export interface NotesDiff {
746
765
  diff: string
747
766
  }
748
767
 
768
+ export interface HooksDiff {
769
+ revision1: number
770
+ revision2: number
771
+ added: HelmHook[]
772
+ removed: HelmHook[]
773
+ modified: HelmHook[]
774
+ unchanged: HelmHook[]
775
+ }
776
+
749
777
  export interface HelmResourceRef {
750
778
  kind: string
751
779
  apiVersion?: string
@@ -753,12 +781,26 @@ export interface HelmResourceRef {
753
781
  namespace: string
754
782
  }
755
783
 
784
+ export interface HelmResourceFieldChange {
785
+ path: string
786
+ oldValue: unknown
787
+ newValue: unknown
788
+ }
789
+
790
+ export interface HelmResourceChange extends HelmResourceRef {
791
+ summary?: string
792
+ fieldCount: number
793
+ fields: HelmResourceFieldChange[]
794
+ }
795
+
756
796
  export interface ResourceDiff {
757
797
  revision1: number
758
798
  revision2: number
759
799
  added: HelmResourceRef[]
760
800
  removed: HelmResourceRef[]
801
+ modified: HelmResourceChange[]
761
802
  unchanged: HelmResourceRef[]
803
+ parseErrorCount?: number
762
804
  }
763
805
 
764
806
  // Selected Helm release (for drawer state)
@@ -45,6 +45,7 @@ export interface GitOpsInsightSummary {
45
45
  // Latest operation status message — surfaced inline in the status strip
46
46
  // when an operation is in flight or just failed.
47
47
  operationMessage?: string
48
+ rawOperationMessage?: string
48
49
  source?: string
49
50
  targetRevision?: string
50
51
  lastRevision?: string
@@ -79,6 +80,7 @@ export interface GitOpsIssue {
79
80
  scope: GitOpsScope
80
81
  reason: string
81
82
  message: string
83
+ rawMessage?: string
82
84
  refs?: GitOpsInsightRef[]
83
85
  action?: string
84
86
  // Plain-English root cause when the message matched a recognized error
@@ -112,6 +114,7 @@ export interface GitOpsChange {
112
114
  // Per-resource sync failure message (Argo's status.resources[].syncResult).
113
115
  // Distinct from `message` (live health). Empty when sync succeeded.
114
116
  syncError?: string
117
+ rawSyncError?: string
115
118
  // Sync hook phase: PreSync / PostSync / SyncFail / PostDelete. Empty
116
119
  // for non-hook resources.
117
120
  hookPhase?: string
@@ -175,6 +178,7 @@ export interface GitOpsHistoryItem {
175
178
  deployedAt?: string
176
179
  phase?: string
177
180
  message?: string
181
+ rawMessage?: string
178
182
  source?: string
179
183
  initiatedBy?: string
180
184
  }
@@ -22,6 +22,16 @@ export const DURATION_DOCK = 150
22
22
  /** Toast exit animation */
23
23
  export const DURATION_TOAST_EXIT = 200
24
24
 
25
+ /** Drawer ↔ fullscreen expand/collapse morph. Longer than DURATION_NORMAL so a
26
+ * large width change advances in small per-frame steps (reads as fluid, not
27
+ * abrupt). Drives the frame width, the content crossfade, and the JS window
28
+ * together — keep them in lockstep. */
29
+ export const DURATION_DRAWER_MORPH = 260
30
+ /** Decelerate curve — fast start, gentle settle. Reads as snappy/responsive (the
31
+ * earlier ease-in-out's slow start felt sluggish). Safe now that the pinned-layer
32
+ * crossfade fixed the reflow that originally needed the gentle start. */
33
+ export const EASE_DRAWER_MORPH = 'cubic-bezier(0.2, 0.8, 0.2, 1)'
34
+
25
35
  // -- Tailwind class presets ---------------------------------------------------
26
36
  // Reusable class fragments — import and spread into clsx() calls.
27
37
 
@@ -0,0 +1,34 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { formatUpdatedAgo, formatLastUpdatedBucket, msToNextBucket } from './format'
3
+
4
+ describe('formatUpdatedAgo', () => {
5
+ it('collapses the first minute to "just now"', () => {
6
+ expect(formatUpdatedAgo(0)).toBe('just now')
7
+ expect(formatUpdatedAgo(59_000)).toBe('just now')
8
+ })
9
+ it('renders minutes and hours with an "ago" suffix', () => {
10
+ expect(formatUpdatedAgo(60_000)).toBe('1m ago')
11
+ expect(formatUpdatedAgo(3_600_000)).toBe('1h ago')
12
+ })
13
+ it('collapses anything past a day to "over a day ago"', () => {
14
+ expect(formatUpdatedAgo(25 * 3_600_000)).toBe('over a day ago')
15
+ })
16
+ })
17
+
18
+ describe('formatLastUpdatedBucket', () => {
19
+ it('buckets by the coarsest unit', () => {
20
+ expect(formatLastUpdatedBucket(0)).toBe('just now')
21
+ expect(formatLastUpdatedBucket(60_000)).toBe('1m')
22
+ expect(formatLastUpdatedBucket(3_600_000)).toBe('1h')
23
+ expect(formatLastUpdatedBucket(86_400_000)).toBe('1d')
24
+ })
25
+ })
26
+
27
+ describe('msToNextBucket', () => {
28
+ it('schedules the next re-render exactly on the bucket boundary', () => {
29
+ expect(msToNextBucket(0)).toBe(60_000)
30
+ expect(msToNextBucket(30_000)).toBe(30_000)
31
+ // 90s elapsed → 30s until the "2m" bucket flips.
32
+ expect(msToNextBucket(90_000)).toBe(30_000)
33
+ })
34
+ })
@@ -192,6 +192,38 @@ export function formatCompactAge(value?: string): string {
192
192
  return `${Math.floor(hours / 24)}d`
193
193
  }
194
194
 
195
+ // Coarse "just now / Xm / Xh / Xd" buckets for freshness labels — finer-grained
196
+ // updates add motion in the periphery without aiding any user decision.
197
+ export function formatLastUpdatedBucket(elapsedMs: number): string {
198
+ const elapsedSec = Math.max(0, Math.floor(elapsedMs / 1000))
199
+ if (elapsedSec < 60) return 'just now'
200
+ const minutes = Math.floor(elapsedSec / 60)
201
+ if (minutes < 60) return `${minutes}m`
202
+ const hours = Math.floor(minutes / 60)
203
+ if (hours < 24) return `${hours}h`
204
+ return `${Math.floor(hours / 24)}d`
205
+ }
206
+
207
+ // ms until the bucket produced by formatLastUpdatedBucket would change — lets a
208
+ // ticker re-render exactly on the boundary instead of polling every second.
209
+ export function msToNextBucket(elapsedMs: number): number {
210
+ const elapsed = Math.max(0, elapsedMs)
211
+ if (elapsed < 60_000) return 60_000 - elapsed
212
+ if (elapsed < 3_600_000) return 60_000 - (elapsed % 60_000)
213
+ if (elapsed < 86_400_000) return 3_600_000 - (elapsed % 3_600_000)
214
+ return 86_400_000 - (elapsed % 86_400_000)
215
+ }
216
+
217
+ // Freshness phrasing for "Updated X" indicators: just now / Xm ago / Xh ago /
218
+ // over a day ago. An exact day count is noise for an auto-refresh signal, so
219
+ // anything past 24h collapses to "over a day ago".
220
+ export function formatUpdatedAgo(elapsedMs: number): string {
221
+ const bucket = formatLastUpdatedBucket(elapsedMs)
222
+ if (bucket === 'just now') return 'just now'
223
+ if (bucket.endsWith('d')) return 'over a day ago'
224
+ return `${bucket} ago`
225
+ }
226
+
195
227
  export function formatRelativeAgeTime(value?: string, fallback = '-'): string {
196
228
  if (!value) return fallback
197
229
  const time = Date.parse(value)
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect } from 'vitest'
2
+
3
+ import type { TimelineEvent, Topology } from '../types/core'
4
+
5
+ import { buildResourceHierarchy } from './resource-hierarchy'
6
+
7
+ function svcEvent(namespace: string, name: string): TimelineEvent {
8
+ return {
9
+ id: `${namespace}/${name}`,
10
+ timestamp: '2024-01-01T00:00:00.000Z',
11
+ source: 'informer',
12
+ kind: 'Service',
13
+ namespace,
14
+ name,
15
+ eventType: 'update',
16
+ }
17
+ }
18
+
19
+ function svcNode(namespace: string, name: string, app: string) {
20
+ return {
21
+ id: `service/${namespace}/${name}`,
22
+ data: { apiVersion: 'v1', labels: { 'app.kubernetes.io/name': app } },
23
+ }
24
+ }
25
+
26
+ function topo(nodes: ReturnType<typeof svcNode>[]): Topology {
27
+ return { nodes, edges: [] } as unknown as Topology
28
+ }
29
+
30
+ describe('buildResourceHierarchy app-label grouping', () => {
31
+ it('does not merge the same app label across namespaces', () => {
32
+ const events = [svcEvent('team-a', 'web'), svcEvent('team-b', 'web')]
33
+ const topology = topo([svcNode('team-a', 'web', 'web'), svcNode('team-b', 'web', 'web')])
34
+
35
+ const lanes = buildResourceHierarchy({ events, topology, groupByApp: true })
36
+
37
+ expect(lanes).toHaveLength(2)
38
+ expect(lanes.every((l) => (l.children ?? []).length === 0)).toBe(true)
39
+ expect(new Set(lanes.map((l) => l.namespace))).toEqual(new Set(['team-a', 'team-b']))
40
+ })
41
+
42
+ it('groups distinct resources sharing an app label within one namespace', () => {
43
+ const events = [svcEvent('team-a', 'web'), svcEvent('team-a', 'web-edge')]
44
+ const topology = topo([svcNode('team-a', 'web', 'web'), svcNode('team-a', 'web-edge', 'web')])
45
+
46
+ const lanes = buildResourceHierarchy({ events, topology, groupByApp: true })
47
+
48
+ expect(lanes).toHaveLength(1)
49
+ expect(lanes[0].children).toHaveLength(1)
50
+ })
51
+ })
@@ -465,17 +465,20 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
465
465
  'HTTPProxy', // Contour
466
466
  ])
467
467
 
468
- // Group lanes by app label
468
+ // Group lanes by app label, scoped to namespace: the same app label in two
469
+ // namespaces (e.g. the same workload deployed to dev and staging) is two
470
+ // distinct apps and must not collapse into one lane.
469
471
  const appGroups = new Map<string, string[]>()
470
472
  for (const [laneId, lane] of laneMap) {
471
473
  if (laneParent.has(laneId)) continue
472
474
  if (!appLabelEligibleKinds.has(lane.kind)) continue
473
475
  const appLabel = laneAppLabels.get(laneId)
474
476
  if (!appLabel) continue
475
- if (!appGroups.has(appLabel)) {
476
- appGroups.set(appLabel, [])
477
+ const groupKey = `${lane.namespace}/${appLabel}`
478
+ if (!appGroups.has(groupKey)) {
479
+ appGroups.set(groupKey, [])
477
480
  }
478
- appGroups.get(appLabel)!.push(laneId)
481
+ appGroups.get(groupKey)!.push(laneId)
479
482
  }
480
483
 
481
484
  // For each app group with multiple members, pick the best parent