@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.
@@ -639,13 +639,14 @@ function AppDetailRoute({
639
639
  history={historyQuery.data}
640
640
  historyLoading={historyQuery.isLoading}
641
641
  historyItems={historyItems}
642
- historyRuntimeLoading={historyTimelineQuery.isFetching}
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: 'danger or warning' },
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 type {
17
- TimelineSource,
18
- TimelineOverviewBucket,
19
- TimelineOverviewResult,
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
- // Per-request guard on the retained events endpoint: never brush wider than 7d.
29
- const MAX_SELECTION_MS = 7 * DAY_MS
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 is a domain-context preset it clamps to the 7d per-request cap, but
87
- // signals the deeper retained window is available.
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 queryable window. availableFromMs can point
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), which would stretch the strip over years of
182
- // unreachable nothing the UI can never brush past maxRangeDays anyway.
183
- const floor = now - maxRangeDays * DAY_MS
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
- // Server-side cap on the list fetch. Generous so a busy query window isn't
19
- // silently truncated the list is already bounded to the selection range, so
20
- // this only caps pathological bursts. Surfaced to the list as `truncatedAt` so a
21
- // window that does hit it shows an end-of-list note instead of dropping silently.
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 fetch window and
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. Retained scopes server-side; local loads the ring and bounds it
45
- // client-side (see useLocalEvents).
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, sliding, onVisibleWindowChange, scrollToMs, focusedAppIndex, appScoped = false, topology, appScopeLoading = false }: TimelineListProps) {
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: unscopedEvents = [], isLoading, isError, refetch } = timelineSource.useEvents({
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
- () => appScoped
85
+ const events = useMemo(() => {
86
+ const unscoped = fetchedEvents ?? []
87
+ return appScoped
88
88
  ? focusedAppIndex
89
- ? eventsForApplication(unscopedEvents, topology, focusedAppIndex)
89
+ ? eventsForApplication(unscoped, topology, focusedAppIndex)
90
90
  : []
91
- : unscopedEvents,
92
- [appScoped, focusedAppIndex, topology, unscopedEvents],
93
- )
94
- const sourceTruncated = unscopedEvents.length >= fetchLimit
91
+ : unscoped
92
+ }, [appScoped, focusedAppIndex, topology, fetchedEvents])
93
+ const sourceTruncated = (fetchedEvents?.length ?? 0) >= fetchLimit
95
94
 
96
- if (isError) {
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
- <TimelineListUI
114
- events={events}
115
- isLoading={isLoading || appScopeLoading}
116
- onQueryChange={handleQueryChange}
117
- hasLimitedAccess={hasLimitedAccess}
118
- namespaces={namespaces}
119
- onViewChange={onViewChange}
120
- currentView={currentView}
121
- onResourceClick={onResourceClick}
122
- initialFilter={initialFilter}
123
- initialTimeRange={initialTimeRange}
124
- hideRangeSelector={!!selectionWindow}
125
- showDeleted={showDeleted}
126
- onShowDeletedChange={onShowDeletedChange}
127
- search={search}
128
- onSearchChange={onSearchChange}
129
- activityFilter={activityFilter}
130
- onActivityFilterChange={onActivityFilterChange}
131
- kindFilter={kindFilter}
132
- onKindFilterChange={onKindFilterChange}
133
- onVisibleWindowChange={onVisibleWindowChange}
134
- scrollToMs={scrollToMs}
135
- truncatedAt={fetchLimit}
136
- isTruncated={sourceTruncated}
137
- truncationMessage={appScoped && sourceTruncated
138
- ? `Showing application activity found in the newest ${fetchLimit.toLocaleString()} events in this range — narrow the query to see older activity`
139
- : undefined}
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 swimlane fetch can exclude deletes server-side (before LIMIT)
297
- // rather than only hiding them client-side after the 10k cap.
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
- resetLensToFull(deriveLiveSelection(capped, now))
526
- }, [isLocal, scrubberDomain, resetLensToFull])
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. The heavy 10k
644
- // ring feeds the swimlanes and the local strip's histogram, so it also runs in
645
- // list mode when that strip is shown; the list itself fetches its own 2000.
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
- limit: 10000,
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
- const focusedAppTimelineLimited = Boolean(focusedAppKey) && unscopedEvents.length >= 10_000
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 "No events yet" empty state —
891
- // that reads as a quiet cluster rather than a load failure.
892
- if (isError) {
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.
@@ -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, danger-first, for inline tooltips.
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
  }