@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.
- package/package.json +1 -1
- package/src/App.tsx +7 -4
- package/src/api/diagnose.ts +1 -0
- 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 +47 -16
- package/src/components/diagnose/DiagnoseContext.tsx +3 -0
- package/src/components/diagnose/InvestigationView.tsx +8 -3
- package/src/components/diagnose/LocalDiagnoseAction.tsx +21 -9
- package/src/components/home/mcpToolCatalog.ts +1 -1
- package/src/components/settings/SettingsDialog.tsx +22 -17
- 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}
|
|
@@ -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({
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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'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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
9
|
-
//
|
|
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
|
|
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
|
-
:
|
|
35
|
-
?
|
|
36
|
-
|
|
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={
|
|
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 —
|
|
127
|
-
: `AI investigations —
|
|
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: '
|
|
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
|
-
|
|
521
|
-
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
<
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
|
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
|
}
|