@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.
- package/package.json +3 -3
- package/src/components/applications/ApplicationsList.tsx +5 -2
- package/src/components/applications/ApplicationsView.tsx +37 -30
- package/src/components/checks/ChecksView.tsx +25 -14
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +27 -9
- package/src/components/gitops/GitOpsTableView.tsx +163 -88
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +12 -5
- package/src/components/issues/IssuesView.tsx +41 -5
- package/src/components/issues/ResourceIssuesSection.tsx +3 -0
- package/src/components/issues/diagnostic.ts +22 -0
- package/src/components/issues/index.ts +1 -1
- package/src/components/issues/issues.test.ts +21 -0
- package/src/components/issues/types.ts +18 -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 +20 -81
- 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/topology/TopologyControls.tsx +90 -14
- package/src/components/ui/FreshnessControl.tsx +153 -0
- package/src/components/ui/SortableTh.tsx +16 -10
- package/src/components/ui/SummaryTile.tsx +9 -1
- package/src/components/ui/Toast.tsx +1 -1
- package/src/components/ui/index.ts +2 -0
- package/src/components/workload/ResourceDetailDrawer.tsx +215 -32
- package/src/components/workload/WorkloadView.tsx +26 -8
- package/src/filter-state/filter-state-core.test.ts +98 -0
- package/src/filter-state/filter-state-core.ts +138 -0
- package/src/filter-state/filter-state.tsx +127 -0
- package/src/filter-state/index.ts +16 -0
- 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 +12 -0
- package/src/types/core.ts +42 -0
- package/src/types/gitops-insights.ts +4 -0
- package/src/utils/animation.ts +10 -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
|
@@ -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'
|
package/src/hooks/index.ts
CHANGED
|
@@ -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
|
}
|
package/src/utils/animation.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/utils/format.ts
CHANGED
|
@@ -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
|
-
|
|
476
|
-
|
|
477
|
+
const groupKey = `${lane.namespace}/${appLabel}`
|
|
478
|
+
if (!appGroups.has(groupKey)) {
|
|
479
|
+
appGroups.set(groupKey, [])
|
|
477
480
|
}
|
|
478
|
-
appGroups.get(
|
|
481
|
+
appGroups.get(groupKey)!.push(laneId)
|
|
479
482
|
}
|
|
480
483
|
|
|
481
484
|
// For each app group with multiple members, pick the best parent
|