@skyhook-io/radar-app 1.8.13 → 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 +7 -7
- package/src/App.tsx +34 -13
- package/src/api/client.ts +21 -6
- package/src/api/config.test.ts +47 -0
- package/src/api/config.ts +15 -0
- package/src/api/diagnose.ts +10 -15
- package/src/api/timelineSource.test.ts +464 -21
- package/src/api/timelineSource.ts +521 -184
- package/src/components/applications/ApplicationsView.tsx +2 -1
- package/src/components/diagnose/AISettings.tsx +21 -7
- package/src/components/diagnose/DiagnoseContext.tsx +82 -53
- package/src/components/diagnose/DiagnoseSurface.tsx +13 -8
- package/src/components/diagnose/parts.test.tsx +125 -0
- package/src/components/diagnose/parts.tsx +166 -75
- package/src/components/home/mcpToolCatalog.ts +1 -1
- package/src/components/resources/ResourcesView.tsx +9 -8
- package/src/components/settings/SettingsDialog.tsx +31 -19
- package/src/components/timeline/RetainedTimelineScrubber.tsx +21 -13
- package/src/components/timeline/TimelineList.tsx +72 -49
- package/src/components/timeline/TimelineView.tsx +83 -19
- package/src/context/DiagnoseCustomization.tsx +1 -1
- package/src/utils/auditBadges.ts +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState, useCallback, useMemo } from 'react'
|
|
2
2
|
import {
|
|
3
|
+
SEVERITY_TEXT,
|
|
3
4
|
TimelineList as TimelineListUI,
|
|
4
5
|
eventsForApplication,
|
|
5
6
|
type ActivityTypeFilter,
|
|
@@ -15,10 +16,11 @@ import { AlertTriangle, RefreshCw } from 'lucide-react'
|
|
|
15
16
|
|
|
16
17
|
export type { ActivityTypeFilter, ActivityFilterKey }
|
|
17
18
|
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
19
|
+
// Cap on the list result — applied client-side in both modes over the loaded
|
|
20
|
+
// window (applyClientFilters slices). Generous so a busy query window isn't
|
|
21
|
+
// silently truncated; the list is already bounded to the selection range, so
|
|
22
|
+
// this only caps pathological bursts. Surfaced as `truncatedAt` so a window
|
|
23
|
+
// that hits it shows an end-of-list note instead of dropping silently.
|
|
22
24
|
const LIST_FETCH_LIMIT = 2000
|
|
23
25
|
const APP_SCOPED_FETCH_LIMIT = 10000
|
|
24
26
|
|
|
@@ -39,14 +41,12 @@ interface TimelineListProps {
|
|
|
39
41
|
kindFilter: string[]
|
|
40
42
|
onKindFilterChange: (kinds: string[]) => void
|
|
41
43
|
// The shared scrubber selection [from,to]. When set (retained mode always;
|
|
42
|
-
// local mode once the scrubber owns the range), it drives the
|
|
44
|
+
// local mode once the scrubber owns the range), it drives the query window and
|
|
43
45
|
// hides the built-in range dropdown so the list can't drift from the
|
|
44
|
-
// swimlane/URL.
|
|
45
|
-
// client-side
|
|
46
|
+
// swimlane/URL. In both modes a window the loaded ring covers slices it
|
|
47
|
+
// client-side with no fetch; a frozen selection older than a truncated ring's
|
|
48
|
+
// oldest row fetches its own server window (see createRingEventsHook).
|
|
46
49
|
selectionWindow?: { fromMs: number; toMs: number }
|
|
47
|
-
// LIVE mode: quantize the base fetch so the sliding window doesn't churn the
|
|
48
|
-
// query key every tick.
|
|
49
|
-
sliding?: boolean
|
|
50
50
|
// Time span of the rows visible in the list's scrollport — the host renders
|
|
51
51
|
// it as the scrubber lens so scrolling the list moves the lens.
|
|
52
52
|
onVisibleWindowChange?: (window: { fromMs: number; toMs: number } | null) => void
|
|
@@ -58,7 +58,7 @@ interface TimelineListProps {
|
|
|
58
58
|
appScopeLoading?: boolean
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
export function TimelineList({ namespaces, onViewChange, currentView, onResourceClick, initialFilter, initialTimeRange, showDeleted, onShowDeletedChange, search, onSearchChange, activityFilter, onActivityFilterChange, kindFilter, onKindFilterChange, selectionWindow,
|
|
61
|
+
export function TimelineList({ namespaces, onViewChange, currentView, onResourceClick, initialFilter, initialTimeRange, showDeleted, onShowDeletedChange, search, onSearchChange, activityFilter, onActivityFilterChange, kindFilter, onKindFilterChange, selectionWindow, onVisibleWindowChange, scrollToMs, focusedAppIndex, appScoped = false, topology, appScopeLoading = false }: TimelineListProps) {
|
|
62
62
|
const hasLimitedAccess = useHasLimitedAccess()
|
|
63
63
|
const timelineSource = useTimelineSource()
|
|
64
64
|
const [queryParams, setQueryParams] = useState<{ timeRange: TimeRange; kinds: string[] }>({
|
|
@@ -71,7 +71,7 @@ export function TimelineList({ namespaces, onViewChange, currentView, onResource
|
|
|
71
71
|
}, [])
|
|
72
72
|
|
|
73
73
|
const fetchLimit = appScoped ? APP_SCOPED_FETCH_LIMIT : LIST_FETCH_LIMIT
|
|
74
|
-
const { data:
|
|
74
|
+
const { data: fetchedEvents, isLoading, isError, error, refetch } = timelineSource.useEvents({
|
|
75
75
|
namespaces,
|
|
76
76
|
kinds: queryParams.kinds,
|
|
77
77
|
timeRange: queryParams.timeRange,
|
|
@@ -81,23 +81,27 @@ export function TimelineList({ namespaces, onViewChange, currentView, onResource
|
|
|
81
81
|
limit: fetchLimit,
|
|
82
82
|
fromMs: selectionWindow?.fromMs,
|
|
83
83
|
toMs: selectionWindow?.toMs,
|
|
84
|
-
sliding,
|
|
85
84
|
})
|
|
86
|
-
const events = useMemo(
|
|
87
|
-
|
|
85
|
+
const events = useMemo(() => {
|
|
86
|
+
const unscoped = fetchedEvents ?? []
|
|
87
|
+
return appScoped
|
|
88
88
|
? focusedAppIndex
|
|
89
|
-
? eventsForApplication(
|
|
89
|
+
? eventsForApplication(unscoped, topology, focusedAppIndex)
|
|
90
90
|
: []
|
|
91
|
-
:
|
|
92
|
-
|
|
93
|
-
)
|
|
94
|
-
const sourceTruncated = unscopedEvents.length >= fetchLimit
|
|
91
|
+
: unscoped
|
|
92
|
+
}, [appScoped, focusedAppIndex, topology, fetchedEvents])
|
|
93
|
+
const sourceTruncated = (fetchedEvents?.length ?? 0) >= fetchLimit
|
|
95
94
|
|
|
96
|
-
|
|
95
|
+
// Full-screen error only when nothing is loaded; a failing background poll
|
|
96
|
+
// with data on screen keeps rendering (data before error).
|
|
97
|
+
if (isError && !fetchedEvents) {
|
|
97
98
|
return (
|
|
98
99
|
<div className="flex flex-col items-center justify-center h-full text-theme-text-tertiary gap-3">
|
|
99
100
|
<AlertTriangle className="w-10 h-10 text-amber-400/70" />
|
|
100
101
|
<p className="text-base">Failed to load timeline data</p>
|
|
102
|
+
{error?.message?.trim() && (
|
|
103
|
+
<p className="max-w-md px-6 text-center text-sm text-theme-text-tertiary">{error.message.trim()}</p>
|
|
104
|
+
)}
|
|
101
105
|
<button
|
|
102
106
|
onClick={() => refetch()}
|
|
103
107
|
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-theme-elevated border border-theme-border-light rounded-lg hover:bg-theme-hover transition-colors"
|
|
@@ -109,34 +113,53 @@ export function TimelineList({ namespaces, onViewChange, currentView, onResource
|
|
|
109
113
|
)
|
|
110
114
|
}
|
|
111
115
|
|
|
116
|
+
// Failing background polls with rows on screen: keep the data, say it may
|
|
117
|
+
// be stale. Only when the list owns its own range — under a scrubber the
|
|
118
|
+
// view-level banner already reports the shared failure, and a second note
|
|
119
|
+
// would double up.
|
|
120
|
+
const staleNote = isError && fetchedEvents && !selectionWindow ? (
|
|
121
|
+
<div className="flex items-center gap-1.5 border-b border-theme-border px-4 py-1.5 text-xs text-theme-text-tertiary">
|
|
122
|
+
<AlertTriangle className={`h-3.5 w-3.5 shrink-0 ${SEVERITY_TEXT.warning}`} />
|
|
123
|
+
Live updates are failing — the list may be stale.
|
|
124
|
+
<button type="button" onClick={() => refetch()} className="underline hover:text-theme-text-primary">
|
|
125
|
+
Retry now
|
|
126
|
+
</button>
|
|
127
|
+
</div>
|
|
128
|
+
) : null
|
|
129
|
+
|
|
112
130
|
return (
|
|
113
|
-
<
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
131
|
+
<div className="flex h-full min-h-0 flex-1 flex-col">
|
|
132
|
+
{staleNote}
|
|
133
|
+
<div className="min-h-0 flex-1">
|
|
134
|
+
<TimelineListUI
|
|
135
|
+
events={events}
|
|
136
|
+
isLoading={isLoading || appScopeLoading}
|
|
137
|
+
onQueryChange={handleQueryChange}
|
|
138
|
+
hasLimitedAccess={hasLimitedAccess}
|
|
139
|
+
namespaces={namespaces}
|
|
140
|
+
onViewChange={onViewChange}
|
|
141
|
+
currentView={currentView}
|
|
142
|
+
onResourceClick={onResourceClick}
|
|
143
|
+
initialFilter={initialFilter}
|
|
144
|
+
initialTimeRange={initialTimeRange}
|
|
145
|
+
hideRangeSelector={!!selectionWindow}
|
|
146
|
+
showDeleted={showDeleted}
|
|
147
|
+
onShowDeletedChange={onShowDeletedChange}
|
|
148
|
+
search={search}
|
|
149
|
+
onSearchChange={onSearchChange}
|
|
150
|
+
activityFilter={activityFilter}
|
|
151
|
+
onActivityFilterChange={onActivityFilterChange}
|
|
152
|
+
kindFilter={kindFilter}
|
|
153
|
+
onKindFilterChange={onKindFilterChange}
|
|
154
|
+
onVisibleWindowChange={onVisibleWindowChange}
|
|
155
|
+
scrollToMs={scrollToMs}
|
|
156
|
+
truncatedAt={fetchLimit}
|
|
157
|
+
isTruncated={sourceTruncated}
|
|
158
|
+
truncationMessage={appScoped && sourceTruncated
|
|
159
|
+
? `Showing application activity found in the newest ${fetchLimit.toLocaleString()} events in this range — narrow the query to see older activity`
|
|
160
|
+
: undefined}
|
|
161
|
+
/>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
141
164
|
)
|
|
142
165
|
}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type TimelineGrouping,
|
|
18
18
|
type TimelineSort,
|
|
19
19
|
type PinnedLaneRef,
|
|
20
|
+
useDebouncedValue,
|
|
20
21
|
} from '@skyhook-io/k8s-ui'
|
|
21
22
|
import { TimelineList } from './TimelineList'
|
|
22
23
|
import type { ActivityFilterKey } from './TimelineList'
|
|
@@ -92,6 +93,10 @@ export type TimelineMode =
|
|
|
92
93
|
// burying the lanes in a day of history; presets/URL widen it deliberately.
|
|
93
94
|
const DEFAULT_LIVE_WIDTH_MS = 60 * 60 * 1000
|
|
94
95
|
const DAY_MS = 24 * 60 * 60 * 1000
|
|
96
|
+
// Presets wider than this land the swimlane on a bounded recent lens instead of
|
|
97
|
+
// the full range, so it never renders every resource lane at once (a 30d domain
|
|
98
|
+
// has thousands). 7d and narrower keep the full-range lens.
|
|
99
|
+
const WIDE_LENS_THRESHOLD_MS = 7 * DAY_MS
|
|
95
100
|
// Fallback cap for a retained hand-entered ?from&to when the source doesn't
|
|
96
101
|
// declare maxRangeDays — mirrors the retained source's own default.
|
|
97
102
|
const DEFAULT_MAX_RANGE_DAYS = 7
|
|
@@ -293,8 +298,8 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
293
298
|
const effectiveInitialMode = scopeRequiresNamespaceFilter ? 'list' : (parseView(searchParams) ?? initialViewMode ?? DEFAULT_VIEW)
|
|
294
299
|
const [viewMode, setViewMode] = useState<TimelineViewMode>(effectiveInitialMode)
|
|
295
300
|
// Shared across list + swimlane so the toggle carries across the view switch,
|
|
296
|
-
// and so the
|
|
297
|
-
//
|
|
301
|
+
// and so the fetch can exclude deletes at the source rather than only hiding
|
|
302
|
+
// them client-side.
|
|
298
303
|
const [showDeleted, setShowDeleted] = useState(() => searchParams.get('deleted') !== '0')
|
|
299
304
|
// ?pinnedOnly=1 is inert without pins: honoring it with no stored pins would
|
|
300
305
|
// arm a filter that hides everything. Gate the read on stored pins so the param
|
|
@@ -304,6 +309,17 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
304
309
|
// Search / activity-type / kind lifted here too, so they survive the view
|
|
305
310
|
// switch and drive both views through one source of truth.
|
|
306
311
|
const [search, setSearch] = useState(() => searchParams.get('q') ?? '')
|
|
312
|
+
// The `q` URL write is debounced so typing doesn't navigate per keystroke
|
|
313
|
+
// (clearing applies immediately — a delayed clear makes the × feel broken).
|
|
314
|
+
// The URL therefore lags the input while typing, so the URL→state read below
|
|
315
|
+
// must not sync a `q` that is merely the echo of our own write — it would
|
|
316
|
+
// revert in-flight keystrokes. Echoes are recognized by value: they carry
|
|
317
|
+
// exactly the debounced search we wrote. Ref instead of a dep: the read
|
|
318
|
+
// effect keys on searchParams alone (see its comment) and only needs the
|
|
319
|
+
// written value at fire time.
|
|
320
|
+
const debouncedSearch = useDebouncedValue(search, 300, (v) => v === '')
|
|
321
|
+
const writtenSearchRef = useRef(debouncedSearch)
|
|
322
|
+
writtenSearchRef.current = debouncedSearch
|
|
307
323
|
// Seed the multi-select from the URL `activity` csv, else the home-page
|
|
308
324
|
// deep-link preset: 'all'/undefined means no chips selected (everything).
|
|
309
325
|
const [activityFilter, setActivityFilter] = useState<ActivityFilterKey[]>(
|
|
@@ -519,11 +535,21 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
519
535
|
// plain fixed widths.
|
|
520
536
|
const all = isLocal && scrubberDomain != null && widthMs >= scrubberDomain.maxSelectionMs
|
|
521
537
|
const now = Date.now()
|
|
538
|
+
const sel = deriveLiveSelection(capped, now)
|
|
522
539
|
setMode({ kind: 'live', widthMs: capped, all: all || undefined })
|
|
523
540
|
setFrozenAsOfMs(null)
|
|
524
541
|
setNowTick(now)
|
|
525
|
-
|
|
526
|
-
|
|
542
|
+
// A full-range lens on a very wide domain (e.g. 30d) renders EVERY resource
|
|
543
|
+
// lane at once — thousands of rows — which freezes the browser for seconds
|
|
544
|
+
// before the view settles. Land directly on a bounded recent lens instead;
|
|
545
|
+
// the density strip still spans the full selected range for context, and the
|
|
546
|
+
// user scrubs it. Narrow presets (<= 7d) keep the full-range lens unchanged.
|
|
547
|
+
if (capped > WIDE_LENS_THRESHOLD_MS) {
|
|
548
|
+
resetLensToRecent(sel)
|
|
549
|
+
} else {
|
|
550
|
+
resetLensToFull(sel)
|
|
551
|
+
}
|
|
552
|
+
}, [isLocal, scrubberDomain, resetLensToFull, resetLensToRecent])
|
|
527
553
|
|
|
528
554
|
// "→ Now" → LIVE, width = current selection width. Pins to now and resets the
|
|
529
555
|
// lens to the live edge.
|
|
@@ -607,7 +633,9 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
607
633
|
const nextPinnedOnly = sp.get('pinnedOnly') === '1' && pinnedLanes.length > 0
|
|
608
634
|
setPinnedOnly((prev) => (prev === nextPinnedOnly ? prev : nextPinnedOnly))
|
|
609
635
|
const nextSearch = sp.get('q') ?? ''
|
|
610
|
-
|
|
636
|
+
if (nextSearch !== writtenSearchRef.current) {
|
|
637
|
+
setSearch((prev) => (prev === nextSearch ? prev : nextSearch))
|
|
638
|
+
}
|
|
611
639
|
const nextActivity = parseActivity(sp) ?? []
|
|
612
640
|
setActivityFilter((prev) => (arraysEqual(prev, nextActivity) ? prev : nextActivity))
|
|
613
641
|
const nextKinds = parseKinds(sp)
|
|
@@ -624,7 +652,7 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
624
652
|
const current = searchParamsRef.current
|
|
625
653
|
const target = writeTimelineParams(
|
|
626
654
|
current,
|
|
627
|
-
{ viewMode, mode, showDeleted, pinnedOnly, search, activityFilter, kindFilter, grouping, sort, selectedEventId },
|
|
655
|
+
{ viewMode, mode, showDeleted, pinnedOnly, search: debouncedSearch, activityFilter, kindFilter, grouping, sort, selectedEventId },
|
|
628
656
|
{ isRetained: isRetained || isLocal, requiresNamespaceFilter: scopeRequiresNamespaceFilter },
|
|
629
657
|
)
|
|
630
658
|
const targetStr = target.toString()
|
|
@@ -638,24 +666,28 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
638
666
|
const replace = !didMountUrlSyncRef.current || onlyHighFreqDiffer(currentStr, targetStr)
|
|
639
667
|
didMountUrlSyncRef.current = true
|
|
640
668
|
setSearchParamsRef.current(target, { replace })
|
|
641
|
-
}, [viewMode, mode, showDeleted, pinnedOnly,
|
|
669
|
+
}, [viewMode, mode, showDeleted, pinnedOnly, debouncedSearch, activityFilter, kindFilter, grouping, sort, selectedEventId, isRetained, isLocal, scopeRequiresNamespaceFilter])
|
|
642
670
|
|
|
643
|
-
// Fetch all activity - zoom controls what's visible in the UI.
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
const { data: activity, isLoading, isError, refetch } = timelineSource.useEvents({
|
|
671
|
+
// Fetch all activity - zoom controls what's visible in the UI. This ring feeds
|
|
672
|
+
// the swimlanes and the local strip's histogram, so it also runs in list mode
|
|
673
|
+
// when that strip is shown; the list itself fetches its own 2000.
|
|
674
|
+
const { data: activity, isLoading, isError, error, refetch, truncated: ringTruncated } = timelineSource.useEvents({
|
|
647
675
|
namespaces: timelineNamespaces,
|
|
648
676
|
timeRange: 'all',
|
|
649
677
|
includeK8sEvents: true,
|
|
650
678
|
includeManaged: true,
|
|
651
679
|
includeDeleted: showDeleted,
|
|
652
|
-
|
|
680
|
+
// Only the local in-memory ring imposes a client-side size cap (it can't
|
|
681
|
+
// hold more than its ring anyway). Retained mode renders every event its
|
|
682
|
+
// ring holds — the hub bounds the ring itself (retention depth + a newest-
|
|
683
|
+
// 50k cap flagged as truncated, never a silent cut), so a busy window
|
|
684
|
+
// never silently drops its oldest events on the client.
|
|
685
|
+
limit: isRetained ? undefined : 10000,
|
|
653
686
|
// The local strip derives its histogram from this ring fetch, so it must
|
|
654
687
|
// run in list mode too whenever the strip is shown.
|
|
655
688
|
enabled: appScopeReady && (showSwimlanes || showLocalScrubber),
|
|
656
689
|
fromMs: isRetained ? selection.fromMs : undefined,
|
|
657
690
|
toMs: isRetained ? selection.toMs : undefined,
|
|
658
|
-
sliding: isRetained && mode.kind === 'live',
|
|
659
691
|
})
|
|
660
692
|
|
|
661
693
|
// Topology powers both swimlane hierarchy and application-scoped attribution.
|
|
@@ -721,7 +753,12 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
721
753
|
)
|
|
722
754
|
const focusedAppLoading = Boolean(focusedAppKey) && appsLoading
|
|
723
755
|
const focusedAppUnavailable = Boolean(focusedAppKey) && (!appScopeReady || (!appsLoading && (appsError || !focusedApp)))
|
|
724
|
-
|
|
756
|
+
// Only local truncates the swimlane fetch at 10k (the in-memory ring cap), so
|
|
757
|
+
// hitting 10k there genuinely means older app activity is hidden. Retained
|
|
758
|
+
// sends no client limit — the full hub ring is on screen (the hub caps it at
|
|
759
|
+
// the newest 50k, surfaced by the truncated flag and its banner, never a
|
|
760
|
+
// silent cut) — so this "showing newest 10k" banner would be false there.
|
|
761
|
+
const focusedAppTimelineLimited = isLocal && Boolean(focusedAppKey) && unscopedEvents.length >= 10_000
|
|
725
762
|
const clearFocusedApp = useCallback(() => {
|
|
726
763
|
const next = new URLSearchParams(searchParamsRef.current)
|
|
727
764
|
next.delete('app')
|
|
@@ -802,6 +839,26 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
802
839
|
return (
|
|
803
840
|
<div className="flex-1 flex flex-col min-h-0">
|
|
804
841
|
{appScopeBar}
|
|
842
|
+
{/* A row-capped ring load means the OLDEST part of the retention
|
|
843
|
+
window is not loaded — say so, or an old selection reads as
|
|
844
|
+
"nothing happened back then". */}
|
|
845
|
+
{ringTruncated && (
|
|
846
|
+
<div className="flex items-center gap-1.5 border-b border-theme-border px-4 py-1.5 text-xs text-theme-text-tertiary">
|
|
847
|
+
<AlertTriangle className={`h-3.5 w-3.5 shrink-0 ${SEVERITY_TEXT.warning}`} />
|
|
848
|
+
History is truncated: showing the newest {timelineSource.capabilities.ringLimit.toLocaleString()} events of the retention window — the oldest activity is not loaded.
|
|
849
|
+
</div>
|
|
850
|
+
)}
|
|
851
|
+
{/* Failing background polls with a loaded ring: keep the data, say
|
|
852
|
+
it's going stale. The full-screen error is reserved for no-data. */}
|
|
853
|
+
{isError && activity && (
|
|
854
|
+
<div className="flex items-center gap-1.5 border-b border-theme-border px-4 py-1.5 text-xs text-theme-text-tertiary">
|
|
855
|
+
<AlertTriangle className={`h-3.5 w-3.5 shrink-0 ${SEVERITY_TEXT.warning}`} />
|
|
856
|
+
Live updates are failing — the timeline may be stale.
|
|
857
|
+
<button type="button" onClick={() => refetch()} className="underline hover:text-theme-text-primary">
|
|
858
|
+
Retry now
|
|
859
|
+
</button>
|
|
860
|
+
</div>
|
|
861
|
+
)}
|
|
805
862
|
{isRetained ? (
|
|
806
863
|
<RetainedTimelineScrubber
|
|
807
864
|
source={timelineSource}
|
|
@@ -887,18 +944,26 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
887
944
|
)
|
|
888
945
|
}
|
|
889
946
|
|
|
890
|
-
// A failed fetch must not render as the swimlane
|
|
891
|
-
// that reads as a quiet cluster rather than
|
|
892
|
-
|
|
947
|
+
// A failed fetch with NOTHING loaded must not render as the swimlane
|
|
948
|
+
// "No events yet" empty state — that reads as a quiet cluster rather than
|
|
949
|
+
// a load failure. With a loaded ring on screen, a failing background poll
|
|
950
|
+
// must NOT blank it (gate on data before error); the stale-data banner
|
|
951
|
+
// below carries the warning instead.
|
|
952
|
+
if (isError && !activity) {
|
|
953
|
+
// Surface the server's own message — a generic "failed to load" would
|
|
954
|
+
// swallow whatever the hub said. "Try again" is a full resync: the
|
|
955
|
+
// retained source drops its delta cursor and reloads the whole ring.
|
|
956
|
+
const detail = error?.message?.trim()
|
|
893
957
|
return wrap(
|
|
894
958
|
<div className="flex-1 flex flex-col">
|
|
895
959
|
<div className="flex items-center justify-between px-4 py-2 border-b border-theme-border">
|
|
896
960
|
<div />
|
|
897
961
|
<ViewModeToggle viewMode={viewMode} onViewModeChange={setViewMode} />
|
|
898
962
|
</div>
|
|
899
|
-
<div className="flex-1 flex flex-col items-center justify-center text-theme-text-tertiary gap-3">
|
|
963
|
+
<div className="flex-1 flex flex-col items-center justify-center text-theme-text-tertiary gap-3 px-6">
|
|
900
964
|
<AlertTriangle className="w-10 h-10 text-amber-400/70" />
|
|
901
965
|
<p className="text-base">Failed to load timeline data</p>
|
|
966
|
+
{detail && <p className="max-w-md text-center text-sm text-theme-text-tertiary">{detail}</p>}
|
|
902
967
|
<button
|
|
903
968
|
onClick={() => refetch()}
|
|
904
969
|
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-theme-elevated border border-theme-border-light rounded-lg hover:bg-theme-hover transition-colors"
|
|
@@ -971,7 +1036,6 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
971
1036
|
// to own the range — passing it scrubber-less would hide the list's own
|
|
972
1037
|
// range dropdown and leave the user with no time control at all.
|
|
973
1038
|
selectionWindow={showScrubber ? selection : undefined}
|
|
974
|
-
sliding={showScrubber && mode.kind === 'live'}
|
|
975
1039
|
onVisibleWindowChange={setListVisibleWindow}
|
|
976
1040
|
// Seeded with the swimlane's window at the switch (see the viewMode
|
|
977
1041
|
// effect); afterwards, dragging the strip band retargets the scroll.
|
|
@@ -41,7 +41,7 @@ export type DiagnoseConsentCopy = {
|
|
|
41
41
|
/** Detail list under the body; each entry is rendered as its own "•" row. */
|
|
42
42
|
bullets?: ReactNode[];
|
|
43
43
|
/** Label for the settings link. `null` hides it — for hosts with one fixed
|
|
44
|
-
* agent and no
|
|
44
|
+
* agent and no execution-profile choice, where it would open an empty dialog. */
|
|
45
45
|
settingsLabel?: string | null;
|
|
46
46
|
approveLabel?: string;
|
|
47
47
|
};
|
package/src/utils/auditBadges.ts
CHANGED
|
@@ -8,7 +8,7 @@ export interface AuditBadgeMessage {
|
|
|
8
8
|
export interface AuditSeverityCounts {
|
|
9
9
|
danger: number
|
|
10
10
|
warning: number
|
|
11
|
-
/** The finding messages behind the counts,
|
|
11
|
+
/** The finding messages behind the counts, High-first, for inline tooltips.
|
|
12
12
|
* Lets a badge say WHAT is wrong on hover instead of just a count. */
|
|
13
13
|
messages: AuditBadgeMessage[]
|
|
14
14
|
}
|