@skyhook-io/radar-app 1.8.13 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/App.tsx +7 -4
- 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/home/mcpToolCatalog.ts +1 -1
- package/src/components/timeline/RetainedTimelineScrubber.tsx +21 -13
- package/src/components/timeline/TimelineList.tsx +72 -49
- package/src/components/timeline/TimelineView.tsx +66 -16
- package/src/utils/auditBadges.ts +1 -1
|
@@ -639,13 +639,14 @@ function AppDetailRoute({
|
|
|
639
639
|
history={historyQuery.data}
|
|
640
640
|
historyLoading={historyQuery.isLoading}
|
|
641
641
|
historyItems={historyItems}
|
|
642
|
-
historyRuntimeLoading={historyTimelineQuery.
|
|
642
|
+
historyRuntimeLoading={historyTimelineQuery.isLoading}
|
|
643
643
|
historyRuntimeError={historyTimelineQuery.isError}
|
|
644
644
|
historyMode={timelineSource.capabilities.mode}
|
|
645
645
|
historyRange={historyRange}
|
|
646
646
|
historyRangeOptions={historyRangeOptions}
|
|
647
647
|
historyCoverageRecordCount={historyTimelineQuery.coverage?.length ?? 0}
|
|
648
648
|
historyRuntimeLimited={historyRuntimeLimited}
|
|
649
|
+
historyRingTruncated={historyTimelineQuery.truncated ?? false}
|
|
649
650
|
onHistoryRangeChange={setRetainedHistoryRange}
|
|
650
651
|
onOpenTimeline={openApplicationTimeline}
|
|
651
652
|
onOpenSource={openSource}
|
|
@@ -141,7 +141,7 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
|
|
|
141
141
|
params: [
|
|
142
142
|
{ arg: 'namespace', desc: 'filter to a specific namespace' },
|
|
143
143
|
{ arg: 'category', desc: 'Security, Reliability, or Efficiency' },
|
|
144
|
-
{ arg: 'severity', desc: '
|
|
144
|
+
{ arg: 'severity', desc: 'posture priority: critical, high, medium, or low (built-ins use high or medium)' },
|
|
145
145
|
{ arg: 'limit', desc: 'max findings (default 30, max 100)' },
|
|
146
146
|
],
|
|
147
147
|
},
|
|
@@ -13,10 +13,11 @@ import {
|
|
|
13
13
|
type ScrubberRange,
|
|
14
14
|
type TimelineLiveState,
|
|
15
15
|
} from '@skyhook-io/k8s-ui'
|
|
16
|
-
import
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
import {
|
|
17
|
+
RETAINED_CLOCK_SKEW_SLACK_MS,
|
|
18
|
+
type TimelineSource,
|
|
19
|
+
type TimelineOverviewBucket,
|
|
20
|
+
type TimelineOverviewResult,
|
|
20
21
|
} from '../../api/timelineSource'
|
|
21
22
|
import { getApiBase } from '../../api/config'
|
|
22
23
|
|
|
@@ -25,8 +26,11 @@ const DAY_MS = 24 * HOUR_MS
|
|
|
25
26
|
const EMPTY_BUCKETS: TimelineOverviewBucket[] = []
|
|
26
27
|
const MAX_STRIP_BARS = 512
|
|
27
28
|
|
|
28
|
-
//
|
|
29
|
-
|
|
29
|
+
// Absolute per-request ceiling on the retained events endpoint, matching the
|
|
30
|
+
// hub's own timelineEventsMaxRange. The effective cap is the smaller of this
|
|
31
|
+
// and the embedder's declared retention depth (maxRangeDays), so a host that
|
|
32
|
+
// advertises 30d of retention can load all 30d in one view.
|
|
33
|
+
const MAX_SELECTION_MS = 31 * DAY_MS
|
|
30
34
|
|
|
31
35
|
// Group the server's hour buckets into fixed display buckets aligned to the
|
|
32
36
|
// display size, summing counts. The host owns this so the pure scrubber only
|
|
@@ -83,8 +87,8 @@ export function buildPresets(maxRangeDays: number): ScrubberPreset[] {
|
|
|
83
87
|
{ label: '24h', ms: DAY_MS },
|
|
84
88
|
{ label: '7d', ms: 7 * DAY_MS },
|
|
85
89
|
]
|
|
86
|
-
// 30d
|
|
87
|
-
//
|
|
90
|
+
// 30d loads the full retained window in one request (bounded by the hub's
|
|
91
|
+
// per-request cap); shown only when the retention depth reaches it.
|
|
88
92
|
if (maxRangeDays >= 30) presets.push({ label: '30d', ms: 30 * DAY_MS })
|
|
89
93
|
return presets
|
|
90
94
|
}
|
|
@@ -176,17 +180,21 @@ export function RetainedTimelineScrubber({ source, selection, onSelectionChange,
|
|
|
176
180
|
const availableFromMs = overview.data?.availableFromMs
|
|
177
181
|
|
|
178
182
|
const domain = useMemo<ScrubberRange>(() => {
|
|
179
|
-
// Clamp the domain floor to the
|
|
183
|
+
// Clamp the domain floor to the LOADED window. availableFromMs can point
|
|
180
184
|
// at ancient synthesized-historical event times (resource creation dates on
|
|
181
|
-
// long-lived clusters),
|
|
182
|
-
//
|
|
183
|
-
|
|
185
|
+
// long-lived clusters), and a host may declare maxRangeDays deeper than the
|
|
186
|
+
// ring the client actually loads (MAX_SELECTION_MS) — either would stretch
|
|
187
|
+
// the strip over regions that render empty despite overview density. The
|
|
188
|
+
// ring's window slides forward by the clock-skew slack, so the floor does
|
|
189
|
+
// too — without it the oldest slack-width sliver is brushable but never
|
|
190
|
+
// loadable.
|
|
191
|
+
const floor = now - Math.min(maxRangeDays * DAY_MS, MAX_SELECTION_MS) + RETAINED_CLOCK_SKEW_SLACK_MS
|
|
184
192
|
const fromMs = availableFromMs != null ? Math.max(availableFromMs, floor) : floor
|
|
185
193
|
return { fromMs: Math.min(fromMs, now - HOUR_MS), toMs: now }
|
|
186
194
|
}, [availableFromMs, now, maxRangeDays])
|
|
187
195
|
|
|
188
196
|
const domainWidth = domain.toMs - domain.fromMs
|
|
189
|
-
const maxSelectionMs = Math.min(MAX_SELECTION_MS, domainWidth)
|
|
197
|
+
const maxSelectionMs = Math.min(maxRangeDays * DAY_MS, MAX_SELECTION_MS, domainWidth)
|
|
190
198
|
|
|
191
199
|
// The histogram spans the QUERY RANGE (selection) directly —
|
|
192
200
|
// no ×8 framing, no minimap. The query is the view, so a narrow window is never
|
|
@@ -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
|
}
|
|
@@ -92,6 +92,10 @@ export type TimelineMode =
|
|
|
92
92
|
// burying the lanes in a day of history; presets/URL widen it deliberately.
|
|
93
93
|
const DEFAULT_LIVE_WIDTH_MS = 60 * 60 * 1000
|
|
94
94
|
const DAY_MS = 24 * 60 * 60 * 1000
|
|
95
|
+
// Presets wider than this land the swimlane on a bounded recent lens instead of
|
|
96
|
+
// the full range, so it never renders every resource lane at once (a 30d domain
|
|
97
|
+
// has thousands). 7d and narrower keep the full-range lens.
|
|
98
|
+
const WIDE_LENS_THRESHOLD_MS = 7 * DAY_MS
|
|
95
99
|
// Fallback cap for a retained hand-entered ?from&to when the source doesn't
|
|
96
100
|
// declare maxRangeDays — mirrors the retained source's own default.
|
|
97
101
|
const DEFAULT_MAX_RANGE_DAYS = 7
|
|
@@ -293,8 +297,8 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
293
297
|
const effectiveInitialMode = scopeRequiresNamespaceFilter ? 'list' : (parseView(searchParams) ?? initialViewMode ?? DEFAULT_VIEW)
|
|
294
298
|
const [viewMode, setViewMode] = useState<TimelineViewMode>(effectiveInitialMode)
|
|
295
299
|
// Shared across list + swimlane so the toggle carries across the view switch,
|
|
296
|
-
// and so the
|
|
297
|
-
//
|
|
300
|
+
// and so the fetch can exclude deletes at the source rather than only hiding
|
|
301
|
+
// them client-side.
|
|
298
302
|
const [showDeleted, setShowDeleted] = useState(() => searchParams.get('deleted') !== '0')
|
|
299
303
|
// ?pinnedOnly=1 is inert without pins: honoring it with no stored pins would
|
|
300
304
|
// arm a filter that hides everything. Gate the read on stored pins so the param
|
|
@@ -519,11 +523,21 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
519
523
|
// plain fixed widths.
|
|
520
524
|
const all = isLocal && scrubberDomain != null && widthMs >= scrubberDomain.maxSelectionMs
|
|
521
525
|
const now = Date.now()
|
|
526
|
+
const sel = deriveLiveSelection(capped, now)
|
|
522
527
|
setMode({ kind: 'live', widthMs: capped, all: all || undefined })
|
|
523
528
|
setFrozenAsOfMs(null)
|
|
524
529
|
setNowTick(now)
|
|
525
|
-
|
|
526
|
-
|
|
530
|
+
// A full-range lens on a very wide domain (e.g. 30d) renders EVERY resource
|
|
531
|
+
// lane at once — thousands of rows — which freezes the browser for seconds
|
|
532
|
+
// before the view settles. Land directly on a bounded recent lens instead;
|
|
533
|
+
// the density strip still spans the full selected range for context, and the
|
|
534
|
+
// user scrubs it. Narrow presets (<= 7d) keep the full-range lens unchanged.
|
|
535
|
+
if (capped > WIDE_LENS_THRESHOLD_MS) {
|
|
536
|
+
resetLensToRecent(sel)
|
|
537
|
+
} else {
|
|
538
|
+
resetLensToFull(sel)
|
|
539
|
+
}
|
|
540
|
+
}, [isLocal, scrubberDomain, resetLensToFull, resetLensToRecent])
|
|
527
541
|
|
|
528
542
|
// "→ Now" → LIVE, width = current selection width. Pins to now and resets the
|
|
529
543
|
// lens to the live edge.
|
|
@@ -640,22 +654,26 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
640
654
|
setSearchParamsRef.current(target, { replace })
|
|
641
655
|
}, [viewMode, mode, showDeleted, pinnedOnly, search, activityFilter, kindFilter, grouping, sort, selectedEventId, isRetained, isLocal, scopeRequiresNamespaceFilter])
|
|
642
656
|
|
|
643
|
-
// Fetch all activity - zoom controls what's visible in the UI.
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
const { data: activity, isLoading, isError, refetch } = timelineSource.useEvents({
|
|
657
|
+
// Fetch all activity - zoom controls what's visible in the UI. This ring feeds
|
|
658
|
+
// the swimlanes and the local strip's histogram, so it also runs in list mode
|
|
659
|
+
// when that strip is shown; the list itself fetches its own 2000.
|
|
660
|
+
const { data: activity, isLoading, isError, error, refetch, truncated: ringTruncated } = timelineSource.useEvents({
|
|
647
661
|
namespaces: timelineNamespaces,
|
|
648
662
|
timeRange: 'all',
|
|
649
663
|
includeK8sEvents: true,
|
|
650
664
|
includeManaged: true,
|
|
651
665
|
includeDeleted: showDeleted,
|
|
652
|
-
|
|
666
|
+
// Only the local in-memory ring imposes a client-side size cap (it can't
|
|
667
|
+
// hold more than its ring anyway). Retained mode renders every event its
|
|
668
|
+
// ring holds — the hub bounds the ring itself (retention depth + a newest-
|
|
669
|
+
// 50k cap flagged as truncated, never a silent cut), so a busy window
|
|
670
|
+
// never silently drops its oldest events on the client.
|
|
671
|
+
limit: isRetained ? undefined : 10000,
|
|
653
672
|
// The local strip derives its histogram from this ring fetch, so it must
|
|
654
673
|
// run in list mode too whenever the strip is shown.
|
|
655
674
|
enabled: appScopeReady && (showSwimlanes || showLocalScrubber),
|
|
656
675
|
fromMs: isRetained ? selection.fromMs : undefined,
|
|
657
676
|
toMs: isRetained ? selection.toMs : undefined,
|
|
658
|
-
sliding: isRetained && mode.kind === 'live',
|
|
659
677
|
})
|
|
660
678
|
|
|
661
679
|
// Topology powers both swimlane hierarchy and application-scoped attribution.
|
|
@@ -721,7 +739,12 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
721
739
|
)
|
|
722
740
|
const focusedAppLoading = Boolean(focusedAppKey) && appsLoading
|
|
723
741
|
const focusedAppUnavailable = Boolean(focusedAppKey) && (!appScopeReady || (!appsLoading && (appsError || !focusedApp)))
|
|
724
|
-
|
|
742
|
+
// Only local truncates the swimlane fetch at 10k (the in-memory ring cap), so
|
|
743
|
+
// hitting 10k there genuinely means older app activity is hidden. Retained
|
|
744
|
+
// sends no client limit — the full hub ring is on screen (the hub caps it at
|
|
745
|
+
// the newest 50k, surfaced by the truncated flag and its banner, never a
|
|
746
|
+
// silent cut) — so this "showing newest 10k" banner would be false there.
|
|
747
|
+
const focusedAppTimelineLimited = isLocal && Boolean(focusedAppKey) && unscopedEvents.length >= 10_000
|
|
725
748
|
const clearFocusedApp = useCallback(() => {
|
|
726
749
|
const next = new URLSearchParams(searchParamsRef.current)
|
|
727
750
|
next.delete('app')
|
|
@@ -802,6 +825,26 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
802
825
|
return (
|
|
803
826
|
<div className="flex-1 flex flex-col min-h-0">
|
|
804
827
|
{appScopeBar}
|
|
828
|
+
{/* A row-capped ring load means the OLDEST part of the retention
|
|
829
|
+
window is not loaded — say so, or an old selection reads as
|
|
830
|
+
"nothing happened back then". */}
|
|
831
|
+
{ringTruncated && (
|
|
832
|
+
<div className="flex items-center gap-1.5 border-b border-theme-border px-4 py-1.5 text-xs text-theme-text-tertiary">
|
|
833
|
+
<AlertTriangle className={`h-3.5 w-3.5 shrink-0 ${SEVERITY_TEXT.warning}`} />
|
|
834
|
+
History is truncated: showing the newest {timelineSource.capabilities.ringLimit.toLocaleString()} events of the retention window — the oldest activity is not loaded.
|
|
835
|
+
</div>
|
|
836
|
+
)}
|
|
837
|
+
{/* Failing background polls with a loaded ring: keep the data, say
|
|
838
|
+
it's going stale. The full-screen error is reserved for no-data. */}
|
|
839
|
+
{isError && activity && (
|
|
840
|
+
<div className="flex items-center gap-1.5 border-b border-theme-border px-4 py-1.5 text-xs text-theme-text-tertiary">
|
|
841
|
+
<AlertTriangle className={`h-3.5 w-3.5 shrink-0 ${SEVERITY_TEXT.warning}`} />
|
|
842
|
+
Live updates are failing — the timeline may be stale.
|
|
843
|
+
<button type="button" onClick={() => refetch()} className="underline hover:text-theme-text-primary">
|
|
844
|
+
Retry now
|
|
845
|
+
</button>
|
|
846
|
+
</div>
|
|
847
|
+
)}
|
|
805
848
|
{isRetained ? (
|
|
806
849
|
<RetainedTimelineScrubber
|
|
807
850
|
source={timelineSource}
|
|
@@ -887,18 +930,26 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
887
930
|
)
|
|
888
931
|
}
|
|
889
932
|
|
|
890
|
-
// A failed fetch must not render as the swimlane
|
|
891
|
-
// that reads as a quiet cluster rather than
|
|
892
|
-
|
|
933
|
+
// A failed fetch with NOTHING loaded must not render as the swimlane
|
|
934
|
+
// "No events yet" empty state — that reads as a quiet cluster rather than
|
|
935
|
+
// a load failure. With a loaded ring on screen, a failing background poll
|
|
936
|
+
// must NOT blank it (gate on data before error); the stale-data banner
|
|
937
|
+
// below carries the warning instead.
|
|
938
|
+
if (isError && !activity) {
|
|
939
|
+
// Surface the server's own message — a generic "failed to load" would
|
|
940
|
+
// swallow whatever the hub said. "Try again" is a full resync: the
|
|
941
|
+
// retained source drops its delta cursor and reloads the whole ring.
|
|
942
|
+
const detail = error?.message?.trim()
|
|
893
943
|
return wrap(
|
|
894
944
|
<div className="flex-1 flex flex-col">
|
|
895
945
|
<div className="flex items-center justify-between px-4 py-2 border-b border-theme-border">
|
|
896
946
|
<div />
|
|
897
947
|
<ViewModeToggle viewMode={viewMode} onViewModeChange={setViewMode} />
|
|
898
948
|
</div>
|
|
899
|
-
<div className="flex-1 flex flex-col items-center justify-center text-theme-text-tertiary gap-3">
|
|
949
|
+
<div className="flex-1 flex flex-col items-center justify-center text-theme-text-tertiary gap-3 px-6">
|
|
900
950
|
<AlertTriangle className="w-10 h-10 text-amber-400/70" />
|
|
901
951
|
<p className="text-base">Failed to load timeline data</p>
|
|
952
|
+
{detail && <p className="max-w-md text-center text-sm text-theme-text-tertiary">{detail}</p>}
|
|
902
953
|
<button
|
|
903
954
|
onClick={() => refetch()}
|
|
904
955
|
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 +1022,6 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
971
1022
|
// to own the range — passing it scrubber-less would hide the list's own
|
|
972
1023
|
// range dropdown and leave the user with no time control at all.
|
|
973
1024
|
selectionWindow={showScrubber ? selection : undefined}
|
|
974
|
-
sliding={showScrubber && mode.kind === 'live'}
|
|
975
1025
|
onVisibleWindowChange={setListVisibleWindow}
|
|
976
1026
|
// Seeded with the swimlane's window at the switch (see the viewMode
|
|
977
1027
|
// effect); afterwards, dragging the strip band retargets the scroll.
|
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
|
}
|