@skyhook-io/radar-app 1.8.12 → 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}
@@ -18,7 +18,15 @@ export interface AIDraft {
18
18
  // ClearHistoryRow is an immediate action (not part of the staged draft): a
19
19
  // two-step confirm button that wipes finished investigations from the local
20
20
  // history DB. Live investigations survive.
21
- function ClearHistoryRow({ onCleared }: { onCleared: () => void }) {
21
+ function ClearHistoryRow({
22
+ hosted,
23
+ agentLabel,
24
+ onCleared,
25
+ }: {
26
+ hosted: boolean;
27
+ agentLabel: string;
28
+ onCleared: () => void;
29
+ }) {
22
30
  const [confirming, setConfirming] = useState(false);
23
31
  const [state, setState] = useState<"idle" | "busy" | "done" | "error">(
24
32
  "idle",
@@ -36,9 +44,15 @@ function ClearHistoryRow({ onCleared }: { onCleared: () => void }) {
36
44
  return (
37
45
  <div className="mt-3 flex items-center justify-between gap-2 border-t border-theme-border/60 pt-3">
38
46
  <p className="text-[11px] leading-snug text-theme-text-tertiary">
39
- Investigation transcripts are kept on this machine (
40
- <code className="font-mono">~/.radar</code>) so history survives
41
- restarts.
47
+ {hosted ? (
48
+ `Investigation transcripts are stored by ${agentLabel} so history survives restarts.`
49
+ ) : (
50
+ <>
51
+ Investigation transcripts are kept on this machine (
52
+ <code className="font-mono">~/.radar</code>) so history survives
53
+ restarts.
54
+ </>
55
+ )}
42
56
  {state === "done" && (
43
57
  <span className="ml-1 font-medium text-theme-text-secondary">
44
58
  History cleared.
@@ -85,12 +99,16 @@ function ClearHistoryRow({ onCleared }: { onCleared: () => void }) {
85
99
  export function AISettingsSection({
86
100
  available,
87
101
  agents,
102
+ hosted,
103
+ agentLabel,
88
104
  draft,
89
105
  onChange,
90
106
  onHistoryCleared,
91
107
  }: {
92
108
  available: boolean;
93
109
  agents: AgentInfo[];
110
+ hosted: boolean;
111
+ agentLabel: string;
94
112
  draft: AIDraft;
95
113
  onChange: (patch: Partial<AIDraft>) => void;
96
114
  onHistoryCleared: () => void;
@@ -98,19 +116,32 @@ export function AISettingsSection({
98
116
  if (!available || agents.length === 0) return null;
99
117
  return (
100
118
  <>
101
- <AgentControls
102
- agents={agents}
103
- selectedAgent={draft.agent}
104
- // Model + effort are agent-specific; reset them when the agent changes.
105
- onSelectAgent={(a) => onChange({ agent: a, model: "", effort: "" })}
106
- isolated={draft.isolated}
107
- onSetIsolated={(v) => onChange({ isolated: v })}
108
- model={draft.model}
109
- onSetModel={(v) => onChange({ model: v })}
110
- effort={draft.effort}
111
- onSetEffort={(v) => onChange({ effort: v })}
119
+ {hosted ? (
120
+ // The agent, its model, and how it runs are all fixed by the host — none
121
+ // of the local BYO-agent knobs apply, so there's nothing to configure.
122
+ <p className="text-xs leading-snug text-theme-text-tertiary">
123
+ {agentLabel} manages the model and how it runs there&apos;s nothing to
124
+ configure here.
125
+ </p>
126
+ ) : (
127
+ <AgentControls
128
+ agents={agents}
129
+ selectedAgent={draft.agent}
130
+ // Model + effort are agent-specific; reset them when the agent changes.
131
+ onSelectAgent={(a) => onChange({ agent: a, model: "", effort: "" })}
132
+ isolated={draft.isolated}
133
+ onSetIsolated={(v) => onChange({ isolated: v })}
134
+ model={draft.model}
135
+ onSetModel={(v) => onChange({ model: v })}
136
+ effort={draft.effort}
137
+ onSetEffort={(v) => onChange({ effort: v })}
138
+ />
139
+ )}
140
+ <ClearHistoryRow
141
+ hosted={hosted}
142
+ agentLabel={agentLabel}
143
+ onCleared={onHistoryCleared}
112
144
  />
113
- <ClearHistoryRow onCleared={onHistoryCleared} />
114
145
  </>
115
146
  );
116
147
  }
@@ -34,6 +34,7 @@ export type DiagnoseView = "home" | "investigation";
34
34
  interface DiagnoseCtx {
35
35
  available: boolean; // an agent CLI is present (button/entry gate)
36
36
  agentLabel: string; // label of the selected agent, e.g. "Claude Code"
37
+ hosted: boolean; // selected agent runs on the host's backend, not this machine
37
38
  agents: AgentInfo[]; // supported agents detected on PATH (for the picker)
38
39
  selectedAgent: string; // name of the chosen backend ("claude"/"codex")
39
40
  setSelectedAgent: (name: string) => void;
@@ -262,6 +263,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
262
263
  selectedAgent,
263
264
  agents.find((a) => a.name === selectedAgent)?.label,
264
265
  );
266
+ const hosted = !!agents.find((a) => a.name === selectedAgent)?.hosted;
265
267
 
266
268
  useEffect(() => {
267
269
  const onResize = () => setViewportW(window.innerWidth);
@@ -434,6 +436,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
434
436
  const value: DiagnoseCtx = {
435
437
  available,
436
438
  agentLabel,
439
+ hosted,
437
440
  agents,
438
441
  selectedAgent,
439
442
  setSelectedAgent,
@@ -44,7 +44,9 @@ export function InvestigationView({
44
44
  maximized: boolean;
45
45
  }) {
46
46
  const { kind, namespace, name } = run;
47
- const { refreshRuns, openInvestigation, startError } = useDiagnose();
47
+ // Apply is off for hosted agents (read-only server-side). Keyed on the selected
48
+ // agent, which matches run.agent unless a deployment mixes hosted + local agents.
49
+ const { refreshRuns, openInvestigation, startError, hosted } = useDiagnose();
48
50
  const retryDiagnosis = useCallback(
49
51
  () => openInvestigation({ kind, namespace, name }),
50
52
  [openInvestigation, kind, namespace, name],
@@ -498,7 +500,8 @@ export function InvestigationView({
498
500
  <RunContextCard run={run} />
499
501
  {turns.map((t, i) => {
500
502
  const isLast = i === turns.length - 1;
501
- const canApply = i === lastRemediationIdx && !stale;
503
+ // Hosted runners are read-only the server refuses apply turns.
504
+ const canApply = i === lastRemediationIdx && !stale && !hosted;
502
505
  const canCheck = isLast && t.status === "done" && !!t.apply;
503
506
  return (
504
507
  <TurnView
@@ -541,7 +544,9 @@ export function InvestigationView({
541
544
  <ResultCard
542
545
  diagnosis={turns[pinnedIdx].diagnosis!}
543
546
  onApply={
544
- pinnedIdx === lastRemediationIdx && !stale ? requestApply : undefined
547
+ pinnedIdx === lastRemediationIdx && !stale && !hosted
548
+ ? requestApply
549
+ : undefined
545
550
  }
546
551
  onAsk={!busy && !stale ? askFollowup : undefined}
547
552
  reveal="full"
@@ -5,14 +5,15 @@ import type { RenderDiagnoseAction } from "../../context/DiagnoseCustomization";
5
5
 
6
6
  // The per-resource AI entry point. It no longer owns a panel — it just dispatches
7
7
  // to the single app-level AI surface (DiagnoseContext), opening a new investigation
8
- // for this resource. Self-hides when no agent CLI is present. A host like Radar Hub
9
- // overrides this slot with its own action.
8
+ // for this resource. Self-hides when no agent CLI is present. Hosts can override
9
+ // this slot with their own action.
10
10
  //
11
11
  // Adaptive by health: on a resource with a live problem it reads as a prominent
12
12
  // "Diagnose" (find the root cause); when the resource is fine or health is unknown
13
13
  // it shrinks to a quiet colored-icon affordance ("ask my agent about this") — so it
14
14
  // never implies "something is wrong here" on a healthy resource. The tooltip leads
15
- // with the BYO framing: this runs the user's OWN agent, locally.
15
+ // with the BYO framing (the user's OWN agent, locally) — unless the agent is
16
+ // hosted, where those claims would be false.
16
17
  function DiagnoseResourceButton({
17
18
  kind,
18
19
  namespace,
@@ -31,9 +32,13 @@ function DiagnoseResourceButton({
31
32
  const running = runningKeys.has(runTargetKey(kind, namespace, name));
32
33
  const tooltip = running
33
34
  ? `${d.agentLabel} is investigating this resource — click to watch it live.`
34
- : problem
35
- ? `Diagnose with your own ${d.agentLabel} — runs locally, reads this resource's spec, events & logs to find the root cause.`
36
- : `Ask your own ${d.agentLabel} about this resource runs locally, reads its spec, events & logs.`;
35
+ : d.hosted
36
+ ? problem
37
+ ? `Diagnose with ${d.agentLabel} reads this resource's spec, events & logs to find the root cause.`
38
+ : `Ask ${d.agentLabel} about this resource — reads its spec, events & logs.`
39
+ : problem
40
+ ? `Diagnose with your own ${d.agentLabel} — runs locally, reads this resource's spec, events & logs to find the root cause.`
41
+ : `Ask your own ${d.agentLabel} about this resource — runs locally, reads its spec, events & logs.`;
37
42
  // While an investigation is live, the button advertises it (and clicking focuses
38
43
  // the existing run rather than starting a new one — openInvestigation dedups).
39
44
  const showLabel = problem || running;
@@ -95,7 +100,11 @@ export function IssueDiagnoseButton({
95
100
  if (!d.available) return null;
96
101
  return (
97
102
  <Tooltip
98
- content={`Runs ${d.agentLabel} on your machine and sends it this resource's context to find the root cause`}
103
+ content={
104
+ d.hosted
105
+ ? `Sends this resource's context to ${d.agentLabel} to find the root cause`
106
+ : `Runs ${d.agentLabel} on your machine and sends it this resource's context to find the root cause`
107
+ }
99
108
  position="left"
100
109
  >
101
110
  <button
@@ -119,12 +128,15 @@ export function GlobalDiagnoseButton() {
119
128
  const { runningKeys } = useDiagnoseLayout();
120
129
  if (!d.available) return null;
121
130
  const runningCount = runningKeys.size;
131
+ const agentSuffix = d.hosted
132
+ ? `powered by ${d.agentLabel}`
133
+ : `runs your own ${d.agentLabel} locally`;
122
134
  return (
123
135
  <Tooltip
124
136
  content={
125
137
  runningCount > 0
126
- ? `${runningCount} investigation${runningCount > 1 ? "s" : ""} running — runs your own ${d.agentLabel} locally`
127
- : `AI investigations — runs your own ${d.agentLabel} locally`
138
+ ? `${runningCount} investigation${runningCount > 1 ? "s" : ""} running — ${agentSuffix}`
139
+ : `AI investigations — ${agentSuffix}`
128
140
  }
129
141
  position="bottom"
130
142
  >
@@ -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
  },
@@ -517,8 +517,9 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
517
517
  <div className="mb-4">
518
518
  <h3 className="text-base font-semibold text-theme-text-primary">AI diagnose</h3>
519
519
  <p className="mt-0.5 text-xs text-theme-text-tertiary">
520
- Investigate incidents with an AI agent that runs on your own machine — reading
521
- logs, events, and topology to explain what's wrong. No Radar cloud, no API key.
520
+ {diag.hosted
521
+ ? `Investigate incidents with ${diag.agentLabel} — reading logs, events, and topology to explain what's wrong.`
522
+ : "Investigate incidents with an AI agent that runs on your own machine — reading logs, events, and topology to explain what's wrong. No Radar cloud, no API key."}
522
523
  </p>
523
524
  </div>
524
525
  {aiAvailable ? (
@@ -526,6 +527,8 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
526
527
  <AISettingsSection
527
528
  available={diag.available}
528
529
  agents={diag.agents}
530
+ hosted={diag.hosted}
531
+ agentLabel={diag.agentLabel}
529
532
  draft={aiDraft}
530
533
  onChange={(patch) => {
531
534
  setAiDraft((d) => ({ ...d, ...patch }))
@@ -533,21 +536,23 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
533
536
  }}
534
537
  onHistoryCleared={diag.refreshRuns}
535
538
  />
536
- <div className="flex items-center justify-end gap-3">
537
- {aiSaved && !aiDirty && (
538
- <span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400/80">
539
- <Check className="w-3 h-3" />
540
- Saved
541
- </span>
542
- )}
543
- <button
544
- onClick={saveAi}
545
- disabled={!aiDirty}
546
- className="px-4 py-1.5 text-sm font-medium btn-brand rounded-md disabled:opacity-50 disabled:pointer-events-none"
547
- >
548
- Save
549
- </button>
550
- </div>
539
+ {!diag.hosted && (
540
+ <div className="flex items-center justify-end gap-3">
541
+ {aiSaved && !aiDirty && (
542
+ <span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400/80">
543
+ <Check className="w-3 h-3" />
544
+ Saved
545
+ </span>
546
+ )}
547
+ <button
548
+ onClick={saveAi}
549
+ disabled={!aiDirty}
550
+ className="px-4 py-1.5 text-sm font-medium btn-brand rounded-md disabled:opacity-50 disabled:pointer-events-none"
551
+ >
552
+ Save
553
+ </button>
554
+ </div>
555
+ )}
551
556
  </div>
552
557
  ) : (
553
558
  <AIUnavailableNotice />
@@ -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
  }