@skyhook-io/radar-app 1.13.1 → 1.13.2

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.
Files changed (47) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +19 -1
  3. package/src/RadarApp.tsx +2 -2
  4. package/src/api/diagnose.test.ts +268 -0
  5. package/src/api/diagnose.ts +72 -9
  6. package/src/components/diagnose/AISettings.tsx +1 -1
  7. package/src/components/diagnose/AgentSetupNotice.tsx +5 -5
  8. package/src/components/diagnose/ApplyDialog.test.tsx +72 -0
  9. package/src/components/diagnose/DiagnoseContext.tsx +12 -17
  10. package/src/components/diagnose/DiagnoseSurface.test.tsx +211 -16
  11. package/src/components/diagnose/DiagnoseSurface.tsx +464 -133
  12. package/src/components/diagnose/Home.test.tsx +293 -0
  13. package/src/components/diagnose/Home.tsx +289 -119
  14. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +2170 -0
  15. package/src/components/diagnose/InvestigationEvidencePane.tsx +2253 -0
  16. package/src/components/diagnose/InvestigationResourceEvidence.test.tsx +257 -0
  17. package/src/components/diagnose/InvestigationResourceEvidence.tsx +214 -0
  18. package/src/components/diagnose/InvestigationView.test.ts +17 -0
  19. package/src/components/diagnose/InvestigationView.tsx +1900 -393
  20. package/src/components/diagnose/LocalDiagnoseAction.tsx +42 -25
  21. package/src/components/diagnose/agentCatalog.ts +1 -1
  22. package/src/components/diagnose/diagnoseEvidenceTypes.ts +151 -0
  23. package/src/components/diagnose/investigationEvidence.test.ts +3109 -0
  24. package/src/components/diagnose/investigationEvidence.ts +3492 -0
  25. package/src/components/diagnose/investigationEvidencePresentation.test.ts +447 -0
  26. package/src/components/diagnose/investigationEvidencePresentation.ts +167 -0
  27. package/src/components/diagnose/investigationExplanation.test.ts +63 -0
  28. package/src/components/diagnose/investigationExplanation.ts +22 -0
  29. package/src/components/diagnose/investigationResourceEvidenceModel.ts +322 -0
  30. package/src/components/diagnose/investigationSourceFocus.test.ts +143 -0
  31. package/src/components/diagnose/investigationSourceFocus.ts +98 -0
  32. package/src/components/diagnose/investigationState.test.ts +695 -0
  33. package/src/components/diagnose/investigationState.ts +451 -0
  34. package/src/components/diagnose/parts.test.tsx +864 -3
  35. package/src/components/diagnose/parts.tsx +1337 -541
  36. package/src/components/diagnose/target.test.ts +39 -0
  37. package/src/components/diagnose/target.ts +36 -0
  38. package/src/components/diagnose/useDisclosureReveal.ts +117 -0
  39. package/src/components/home/MCPSetupDialog.tsx +2 -2
  40. package/src/components/home/mcpToolCatalog.test.ts +22 -0
  41. package/src/components/home/mcpToolCatalog.ts +3 -2
  42. package/src/components/issues/IssuesPane.tsx +5 -1
  43. package/src/components/settings/SettingsDialog.tsx +11 -13
  44. package/src/components/workload/WorkloadView.tsx +1 -1
  45. package/src/context/DiagnoseCustomization.tsx +11 -8
  46. package/src/index.css +63 -79
  47. package/src/index.ts +1 -1
@@ -1,49 +1,103 @@
1
- // The recent-investigations list — now backed by server-side runs (the source of
2
- // truth), so background/running investigations appear here live. Used both as the
3
- // docked Home view and the master pane of the maximized workspace.
4
- import { Loader2, Sparkles } from "lucide-react";
5
- import { StatusDot, type StatusTone } from "@skyhook-io/k8s-ui";
6
- import { Badge } from "@skyhook-io/k8s-ui/components/ui/Badge";
1
+ // Server-side runs keep background and running investigations visible in both
2
+ // the docked Home view and the maximized workspace's master pane.
3
+ import { CircleAlert, Loader2, Server, Sparkles, Square } from "lucide-react";
7
4
  import { type RunSummary } from "../../api/diagnose";
5
+ import {
6
+ groupQualifiesLaneId,
7
+ pluralToKind,
8
+ } from "@skyhook-io/k8s-ui/utils/navigation";
9
+ import { parseContextName } from "../../utils/context-name";
10
+ import { formatInvestigationTarget } from "./target";
11
+ import { Tooltip } from "../ui/Tooltip";
8
12
 
9
- // Compact "3m ago" / "2h ago" / date label.
10
- function relativeTime(ts: number, now: number): string {
11
- const s = Math.max(0, Math.round((now - ts) / 1000));
12
- if (s < 60) return "just now";
13
- if (s < 3600) return `${Math.floor(s / 60)}m ago`;
14
- if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
15
- if (s < 7 * 86400) return `${Math.floor(s / 86400)}d ago`;
16
- return new Date(ts).toLocaleDateString();
13
+ function historyDay(date: Date, now: Date): string {
14
+ if (date.toDateString() === now.toDateString()) return "Today";
15
+ const yesterday = new Date(now);
16
+ yesterday.setDate(yesterday.getDate() - 1);
17
+ if (date.toDateString() === yesterday.toDateString()) return "Yesterday";
18
+ return date.toLocaleDateString(undefined, {
19
+ month: "short",
20
+ day: "numeric",
21
+ ...(date.getFullYear() === now.getFullYear() ? {} : { year: "numeric" }),
22
+ });
17
23
  }
18
24
 
19
- // Map a run status to the design-system status tone (StatusDot). stopped is
20
- // user-initiated → neutral/unknown, NOT a failure (distinct from error).
21
- function runTone(status: RunSummary["status"]): StatusTone {
22
- switch (status) {
23
- case "error":
24
- return "unhealthy";
25
- case "stale":
26
- return "degraded";
27
- case "done":
28
- return "healthy";
29
- default: // stopped
30
- return "unknown";
31
- }
32
- }
25
+ // Relative age makes the list easy to scan; a stable local timestamp makes two
26
+ // investigations of the same target distinguishable when they ran close
27
+ // together. Keep today's label compact because the date is redundant there.
28
+ export function absoluteTime(ts: number, now: number): string {
29
+ const date = new Date(ts);
30
+ const current = new Date(now);
31
+ const today =
32
+ date.getFullYear() === current.getFullYear() &&
33
+ date.getMonth() === current.getMonth() &&
34
+ date.getDate() === current.getDate();
33
35
 
34
- function statusDot(status: RunSummary["status"]) {
35
- if (status === "running" || status === "stopping")
36
- return <Loader2 className="h-3 w-3 shrink-0 animate-spin text-accent" />;
37
- return <StatusDot tone={runTone(status)} className="shrink-0" />;
36
+ return date.toLocaleString(
37
+ undefined,
38
+ today
39
+ ? { hour: "numeric", minute: "2-digit" }
40
+ : {
41
+ month: "short",
42
+ day: "numeric",
43
+ ...(date.getFullYear() === current.getFullYear()
44
+ ? {}
45
+ : { year: "numeric" as const }),
46
+ hour: "numeric",
47
+ minute: "2-digit",
48
+ },
49
+ );
38
50
  }
39
51
 
40
- // A short text status for terminal non-done states, so the run's outcome doesn't
41
- // rely on decoding a 6px colored dot (and so "I stopped it" reads differently from
42
- // "it failed"). Done/running are conveyed by the dot + time already.
43
- function statusWord(
44
- status: RunSummary["status"],
45
- ): { text: string; cls: string } | null {
52
+ const historyStatuses = {
53
+ running: {
54
+ label: "Running",
55
+ short: "Running",
56
+ Icon: Loader2,
57
+ className: "text-accent-text",
58
+ },
59
+ stopping: {
60
+ label: "Stopping",
61
+ short: "Stopping",
62
+ Icon: Loader2,
63
+ className: "text-theme-text-tertiary",
64
+ },
65
+ done: {
66
+ label: "Completed",
67
+ short: "",
68
+ Icon: undefined,
69
+ className: "text-theme-text-tertiary",
70
+ },
71
+ error: {
72
+ label: "Investigation failed",
73
+ short: "Failed",
74
+ Icon: CircleAlert,
75
+ className: "text-theme-text-secondary",
76
+ },
77
+ stopped: {
78
+ label: "Stopped",
79
+ short: "Stopped",
80
+ Icon: Square,
81
+ className: "text-theme-text-secondary",
82
+ },
83
+ stale: {
84
+ label: "Read-only investigation",
85
+ short: "",
86
+ Icon: undefined,
87
+ className: "text-theme-text-tertiary",
88
+ },
89
+ } as const;
90
+
91
+ // A short text status means no run outcome relies on decoding a 6px colored dot.
92
+ export function statusWord(status: RunSummary["status"]): {
93
+ text: string;
94
+ cls: string;
95
+ } {
46
96
  switch (status) {
97
+ case "running":
98
+ return { text: "Running", cls: "text-accent" };
99
+ case "done":
100
+ return { text: "Completed", cls: "text-theme-text-secondary" };
47
101
  case "error":
48
102
  return { text: "Failed", cls: "text-red-400" };
49
103
  case "stopped":
@@ -51,11 +105,7 @@ function statusWord(
51
105
  case "stopping":
52
106
  return { text: "Stopping", cls: "text-theme-text-tertiary" };
53
107
  case "stale":
54
- // Plain words, not the internal status name: "stale" means the run was
55
- // about a cluster that's no longer connected.
56
- return { text: "Different cluster", cls: "text-amber-500" };
57
- default:
58
- return null;
108
+ return { text: "Read-only", cls: "text-theme-text-tertiary" };
59
109
  }
60
110
  }
61
111
 
@@ -65,14 +115,58 @@ export function RecentList({
65
115
  onSelect,
66
116
  selectedId,
67
117
  historyDegraded = false,
118
+ currentContext,
68
119
  }: {
69
120
  agentLabel: string;
70
121
  runs: RunSummary[];
71
122
  onSelect: (id: string) => void;
72
123
  selectedId?: string | null;
73
124
  historyDegraded?: boolean;
125
+ currentContext?: string;
74
126
  }) {
75
- const now = Date.now();
127
+ const now = new Date();
128
+ const contexts = new Map(
129
+ runs.map((r) => [r.context, parseContextName(r.context)]),
130
+ );
131
+ const contextsByName = new Map<string, Set<string>>();
132
+ const groupsByKind = new Map<string, Set<string>>();
133
+ for (const [raw, parsed] of contexts) {
134
+ const names = contextsByName.get(parsed.clusterName) ?? new Set<string>();
135
+ names.add(raw);
136
+ contextsByName.set(parsed.clusterName, names);
137
+ }
138
+ for (const r of runs) {
139
+ const kind = pluralToKind(r.kind);
140
+ const groups = groupsByKind.get(kind) ?? new Set<string>();
141
+ // Match Radar's resource-lane display convention: built-in API groups
142
+ // share a readable kind label; custom groups may need disambiguation.
143
+ groups.add(groupQualifiesLaneId(r.group) ? r.group : "");
144
+ groupsByKind.set(kind, groups);
145
+ }
146
+ const organizationRuns = runs.filter(
147
+ (r) => r.trigger === "background" || r.ownedByMe === false,
148
+ );
149
+ const yourRuns = runs.filter((r) => !organizationRuns.includes(r));
150
+ const collections = organizationRuns.length
151
+ ? [
152
+ { label: "Your investigations", runs: yourRuns },
153
+ { label: "Organization", runs: organizationRuns },
154
+ ].filter((collection) => collection.runs.length > 0)
155
+ : [{ label: "", runs }];
156
+ // Status bookkeeping (including cluster switches) updates updatedAt. It must
157
+ // not change the apparent start time or reshuffle the navigation list.
158
+ const groupedCollections = collections.map((collection) => {
159
+ const days = new Map<string, RunSummary[]>();
160
+ for (const r of [...collection.runs].sort(
161
+ (a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt),
162
+ )) {
163
+ const day = historyDay(new Date(r.createdAt), now);
164
+ const entries = days.get(day) ?? [];
165
+ entries.push(r);
166
+ days.set(day, entries);
167
+ }
168
+ return { label: collection.label, days };
169
+ });
76
170
 
77
171
  // Persistence broke (disk error) — without this the user reasonably assumes
78
172
  // their history survives a restart, and it won't.
@@ -97,7 +191,7 @@ export function RecentList({
97
191
  <Sparkles className="inline h-3.5 w-3.5 align-text-bottom text-accent" />{" "}
98
192
  action to investigate it with {agentLabel} —{" "}
99
193
  <span className="font-medium text-theme-text-secondary">
100
- Diagnose
194
+ Investigate
101
195
  </span>{" "}
102
196
  a problem, or just ask about it. Investigations run in the
103
197
  background and are kept in your history here.
@@ -107,83 +201,159 @@ export function RecentList({
107
201
  );
108
202
  }
109
203
 
110
- const organizationRuns = runs.filter(
111
- (r) => r.trigger === "background" || r.ownedByMe === false,
112
- );
113
- const yourRuns = runs.filter((r) => !organizationRuns.includes(r));
114
- const groups = organizationRuns.length
115
- ? [
116
- { label: "Your investigations", runs: yourRuns },
117
- { label: "Organization", runs: organizationRuns },
118
- ].filter((group) => group.runs.length > 0)
119
- : [{ label: "Investigations", runs }];
120
-
121
204
  return (
122
- <div className="space-y-2">
205
+ <div className="space-y-4">
123
206
  {degradedNote}
124
- {groups.map((group) => (
125
- <div key={group.label} className="space-y-2">
126
- <div className="pt-1 text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary">
127
- {group.label}
128
- </div>
129
- {group.runs.map((r) => (
130
- <button
131
- key={r.id}
132
- onClick={() => onSelect(r.id)}
133
- className={`flex w-full flex-col gap-0.5 rounded-md border px-2.5 py-2 text-left ${
134
- r.id === selectedId
135
- ? "border-accent/50 bg-accent/10"
136
- : "border-theme-border/60 bg-theme-base/40 hover:bg-theme-hover"
137
- }`}
138
- >
139
- <div className="flex items-center gap-2">
140
- {statusDot(r.status)}
141
- <span className="min-w-0 flex-1 truncate text-sm text-theme-text-primary">
142
- {r.kind} {r.namespace ? `${r.namespace}/` : ""}
143
- {r.name}
144
- </span>
145
- {(r.trigger || r.visibility) && (
146
- <Badge severity="neutral" size="sm" className="shrink-0">
147
- {r.trigger === "background"
148
- ? "Automatic"
149
- : r.visibility === "organization"
150
- ? "Shared"
151
- : "Private"}
152
- </Badge>
153
- )}
154
- <span className="shrink-0 text-[11px] text-theme-text-tertiary">
155
- {r.status === "running" || r.status === "stopping" ? (
156
- r.status === "stopping" ? "stopping…" : "running…"
157
- ) : (
158
- <>
159
- {(() => {
160
- const w = statusWord(r.status);
161
- return w ? (
162
- <span className={`font-medium ${w.cls}`}>
163
- {w.text} ·{" "}
164
- </span>
165
- ) : null;
166
- })()}
167
- {relativeTime(new Date(r.updatedAt).getTime(), now)}
168
- </>
169
- )}
170
- </span>
171
- </div>
172
- {(r.status === "stale" && r.context) || r.preview ? (
173
- <div className="truncate pl-3.5 text-xs text-theme-text-tertiary">
174
- {/* A foreign-cluster run names its cluster — in mixed multi-
175
- context history, identical-looking rows otherwise give no way
176
- to tell WHICH cluster an investigation was about. */}
177
- {r.status === "stale" && r.context ? (
178
- <span className="text-amber-600/80 dark:text-amber-500/80">
179
- {r.context}
207
+ <h2 className="px-2 text-sm font-medium text-theme-text-secondary">
208
+ Investigations
209
+ </h2>
210
+ {groupedCollections.map((collection) => (
211
+ <div key={collection.label} className="space-y-4">
212
+ {collection.label && (
213
+ <h3 className="px-2 text-xs font-semibold text-theme-text-secondary">
214
+ {collection.label}
215
+ </h3>
216
+ )}
217
+ {[...collection.days].map(([day, entries]) => (
218
+ <section key={day} aria-label={day} className="space-y-1">
219
+ <h3 className="px-2 pb-1 text-xs font-medium text-theme-text-tertiary">
220
+ {day}
221
+ </h3>
222
+ {entries.map((r) => {
223
+ const { label, short, Icon, className } =
224
+ historyStatuses[r.status];
225
+ const parsed = contexts.get(r.context)!;
226
+ const collision =
227
+ contextsByName.get(parsed.clusterName)!.size > 1;
228
+ const peers = [
229
+ ...contextsByName.get(parsed.clusterName)!,
230
+ ].filter((raw) => raw !== r.context);
231
+ const qualifier =
232
+ parsed.account &&
233
+ peers.every(
234
+ (raw) => contexts.get(raw)!.account !== parsed.account,
235
+ )
236
+ ? parsed.account
237
+ : parsed.account &&
238
+ parsed.region &&
239
+ peers.every((raw) => {
240
+ const peer = contexts.get(raw)!;
241
+ return (
242
+ peer.account !== parsed.account ||
243
+ peer.region !== parsed.region
244
+ );
245
+ })
246
+ ? `${parsed.account} · ${parsed.region}`
247
+ : r.context;
248
+ const readableKind = pluralToKind(r.kind);
249
+ const kind =
250
+ groupsByKind.get(readableKind)!.size > 1
251
+ ? `${readableKind} · ${r.group || "core"}`
252
+ : readableKind;
253
+ const initialIssue = r.health?.topReason?.trim();
254
+ const isCurrentCluster = currentContext === r.context;
255
+ const visibility =
256
+ r.trigger === "background"
257
+ ? "Automatic"
258
+ : r.visibility === "organization"
259
+ ? "Shared"
260
+ : r.visibility === "private"
261
+ ? "Private"
262
+ : "";
263
+ const identity = `${formatInvestigationTarget(r)} · ${r.context}${isCurrentCluster ? " · Current cluster" : ""} · ${label}${visibility ? ` · ${visibility}` : ""} · Started ${new Date(r.createdAt).toLocaleString()}${initialIssue ? ` · Started with ${initialIssue}` : ""}`;
264
+ return (
265
+ <button
266
+ key={r.id}
267
+ onClick={() => onSelect(r.id)}
268
+ aria-label={identity}
269
+ aria-current={r.id === selectedId ? "true" : undefined}
270
+ className={`flex w-full min-w-0 flex-col gap-0.5 rounded-md border-l-2 px-2 py-2 text-left focus-visible:outline-2 focus-visible:outline-accent ${
271
+ r.id === selectedId
272
+ ? "border-accent bg-accent-muted"
273
+ : "border-transparent hover:bg-theme-hover"
274
+ }`}
275
+ >
276
+ <span className="flex w-full items-start gap-2">
277
+ <Tooltip
278
+ content={r.name}
279
+ position="right"
280
+ delay={600}
281
+ className="pointer-events-none"
282
+ wrapperClassName="min-w-0 flex-1"
283
+ >
284
+ <span className="min-w-0 flex-1 line-clamp-2 break-words text-sm font-medium leading-5 text-theme-text-primary">
285
+ {r.name}
286
+ </span>
287
+ </Tooltip>
288
+ {(Icon || short) && (
289
+ <span
290
+ aria-hidden="true"
291
+ className={`flex shrink-0 items-center gap-1 text-xs leading-5 ${className}`}
292
+ >
293
+ {Icon && (
294
+ <Icon
295
+ className={`mt-0.5 h-3.5 w-3.5 ${r.status === "running" || r.status === "stopping" ? "animate-spin motion-reduce:animate-none" : r.status === "error" ? "text-semantic-error" : ""}`}
296
+ />
297
+ )}
298
+ {short}
299
+ </span>
300
+ )}
301
+ </span>
302
+ <span className="flex w-full items-baseline gap-2 text-xs leading-4 text-theme-text-secondary">
303
+ <span className="min-w-0 flex-1 truncate">
304
+ {r.namespace ? `${r.namespace} · ` : ""}
305
+ {kind}
306
+ </span>
307
+ <time
308
+ dateTime={r.createdAt}
309
+ className="shrink-0 tabular-nums text-theme-text-tertiary"
310
+ >
311
+ {new Date(r.createdAt).toLocaleTimeString(undefined, {
312
+ hour: "numeric",
313
+ minute: "2-digit",
314
+ })}
315
+ </time>
180
316
  </span>
181
- ) : null}
182
- {r.status === "stale" && r.context && r.preview ? " · " : ""}
183
- {r.preview}
184
- </div>
185
- ) : null}
186
- </button>
317
+ <Tooltip
318
+ content={`${r.context}${isCurrentCluster ? " · Current cluster" : ""}`}
319
+ position="right"
320
+ delay={600}
321
+ className="pointer-events-none"
322
+ wrapperClassName="w-full min-w-0"
323
+ >
324
+ <span
325
+ aria-label={
326
+ isCurrentCluster
327
+ ? `Current cluster: ${parsed.clusterName}`
328
+ : `Cluster: ${parsed.clusterName}`
329
+ }
330
+ className={`flex w-full items-center gap-1 text-xs leading-4 ${isCurrentCluster ? "text-accent-text" : "text-theme-text-tertiary"}`}
331
+ >
332
+ <Server className="h-3 w-3 shrink-0" aria-hidden />
333
+ <span className="min-w-0 flex-1 truncate">
334
+ {parsed.clusterName}
335
+ </span>
336
+ {visibility && (
337
+ <span className="shrink-0 text-theme-text-tertiary">
338
+ {visibility}
339
+ </span>
340
+ )}
341
+ </span>
342
+ </Tooltip>
343
+ {collision && qualifier !== parsed.clusterName && (
344
+ <span className="w-full break-words text-xs leading-4 text-theme-text-secondary">
345
+ {qualifier}
346
+ </span>
347
+ )}
348
+ {initialIssue && (
349
+ <span className="line-clamp-1 w-full text-xs leading-4 text-theme-text-secondary">
350
+ Started with {initialIssue}
351
+ </span>
352
+ )}
353
+ </button>
354
+ );
355
+ })}
356
+ </section>
187
357
  ))}
188
358
  </div>
189
359
  ))}