@skyhook-io/radar-app 1.13.0 → 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 (54) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +55 -9
  3. package/src/RadarApp.tsx +3 -3
  4. package/src/api/diagnose.test.ts +268 -0
  5. package/src/api/diagnose.ts +117 -14
  6. package/src/components/diagnose/AISettings.tsx +10 -5
  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 +228 -65
  10. package/src/components/diagnose/DiagnoseSurface.test.tsx +317 -9
  11. package/src/components/diagnose/DiagnoseSurface.tsx +621 -132
  12. package/src/components/diagnose/Home.test.tsx +293 -0
  13. package/src/components/diagnose/Home.tsx +307 -108
  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 +1911 -338
  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 +1340 -536
  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/execution/BatchExecutionView.render.test.tsx +35 -0
  40. package/src/components/execution/BatchExecutionView.tsx +2 -3
  41. package/src/components/execution/execution-definition.test.ts +19 -0
  42. package/src/components/execution/execution-definition.ts +2 -0
  43. package/src/components/gitops/GitOpsView.tsx +25 -3
  44. package/src/components/home/MCPSetupDialog.tsx +2 -2
  45. package/src/components/home/mcpToolCatalog.test.ts +22 -0
  46. package/src/components/home/mcpToolCatalog.ts +3 -2
  47. package/src/components/issues/IssuesPane.tsx +5 -1
  48. package/src/components/nav/navigation.test.ts +26 -0
  49. package/src/components/nav/navigation.ts +10 -0
  50. package/src/components/settings/SettingsDialog.tsx +11 -13
  51. package/src/components/workload/WorkloadView.tsx +1 -1
  52. package/src/context/DiagnoseCustomization.tsx +11 -8
  53. package/src/index.css +63 -79
  54. package/src/index.ts +1 -1
@@ -1,58 +1,111 @@
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";
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";
6
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";
7
12
 
8
- // Compact "3m ago" / "2h ago" / date label.
9
- function relativeTime(ts: number, now: number): string {
10
- const s = Math.max(0, Math.round((now - ts) / 1000));
11
- if (s < 60) return "just now";
12
- if (s < 3600) return `${Math.floor(s / 60)}m ago`;
13
- if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
14
- if (s < 7 * 86400) return `${Math.floor(s / 86400)}d ago`;
15
- 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
+ });
16
23
  }
17
24
 
18
- // Map a run status to the design-system status tone (StatusDot). stopped is
19
- // user-initiated → neutral/unknown, NOT a failure (distinct from error).
20
- function runTone(status: RunSummary["status"]): StatusTone {
21
- switch (status) {
22
- case "error":
23
- return "unhealthy";
24
- case "stale":
25
- return "degraded";
26
- case "done":
27
- return "healthy";
28
- default: // stopped
29
- return "unknown";
30
- }
31
- }
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();
32
35
 
33
- function statusDot(status: RunSummary["status"]) {
34
- if (status === "running")
35
- return <Loader2 className="h-3 w-3 shrink-0 animate-spin text-accent" />;
36
- 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
+ );
37
50
  }
38
51
 
39
- // A short text status for terminal non-done states, so the run's outcome doesn't
40
- // rely on decoding a 6px colored dot (and so "I stopped it" reads differently from
41
- // "it failed"). Done/running are conveyed by the dot + time already.
42
- function statusWord(
43
- status: RunSummary["status"],
44
- ): { 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
+ } {
45
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" };
46
101
  case "error":
47
102
  return { text: "Failed", cls: "text-red-400" };
48
103
  case "stopped":
49
104
  return { text: "Stopped", cls: "text-theme-text-tertiary" };
105
+ case "stopping":
106
+ return { text: "Stopping", cls: "text-theme-text-tertiary" };
50
107
  case "stale":
51
- // Plain words, not the internal status name: "stale" means the run was
52
- // about a cluster that's no longer connected.
53
- return { text: "Different cluster", cls: "text-amber-500" };
54
- default:
55
- return null;
108
+ return { text: "Read-only", cls: "text-theme-text-tertiary" };
56
109
  }
57
110
  }
58
111
 
@@ -62,14 +115,58 @@ export function RecentList({
62
115
  onSelect,
63
116
  selectedId,
64
117
  historyDegraded = false,
118
+ currentContext,
65
119
  }: {
66
120
  agentLabel: string;
67
121
  runs: RunSummary[];
68
122
  onSelect: (id: string) => void;
69
123
  selectedId?: string | null;
70
124
  historyDegraded?: boolean;
125
+ currentContext?: string;
71
126
  }) {
72
- 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
+ });
73
170
 
74
171
  // Persistence broke (disk error) — without this the user reasonably assumes
75
172
  // their history survives a restart, and it won't.
@@ -83,80 +180,182 @@ export function RecentList({
83
180
  if (runs.length === 0) {
84
181
  return (
85
182
  <div>
86
- {degradedNote}
87
- <div className="flex flex-col items-center px-4 py-12 text-center">
88
- <Sparkles className="mb-3 h-7 w-7 text-accent" />
89
- <div className="text-sm font-medium text-theme-text-primary">
90
- No investigations yet
183
+ {degradedNote}
184
+ <div className="flex flex-col items-center px-4 py-12 text-center">
185
+ <Sparkles className="mb-3 h-7 w-7 text-accent" />
186
+ <div className="text-sm font-medium text-theme-text-primary">
187
+ No investigations yet
188
+ </div>
189
+ <p className="mt-1 max-w-xs text-sm text-theme-text-tertiary">
190
+ Open a resource and use its{" "}
191
+ <Sparkles className="inline h-3.5 w-3.5 align-text-bottom text-accent" />{" "}
192
+ action to investigate it with {agentLabel} —{" "}
193
+ <span className="font-medium text-theme-text-secondary">
194
+ Investigate
195
+ </span>{" "}
196
+ a problem, or just ask about it. Investigations run in the
197
+ background and are kept in your history here.
198
+ </p>
91
199
  </div>
92
- <p className="mt-1 max-w-xs text-sm text-theme-text-tertiary">
93
- Open a resource and use its{" "}
94
- <Sparkles className="inline h-3.5 w-3.5 align-text-bottom text-accent" />{" "}
95
- action to investigate it with {agentLabel} —{" "}
96
- <span className="font-medium text-theme-text-secondary">Diagnose</span>{" "}
97
- a problem, or just ask about it. Investigations run in the background
98
- and are kept in your history here.
99
- </p>
100
- </div>
101
200
  </div>
102
201
  );
103
202
  }
104
203
 
105
204
  return (
106
- <div className="space-y-2">
205
+ <div className="space-y-4">
107
206
  {degradedNote}
108
- <div className="text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary">
207
+ <h2 className="px-2 text-sm font-medium text-theme-text-secondary">
109
208
  Investigations
110
- </div>
111
- {runs.map((r) => (
112
- <button
113
- key={r.id}
114
- onClick={() => onSelect(r.id)}
115
- className={`flex w-full flex-col gap-0.5 rounded-md border px-2.5 py-2 text-left ${
116
- r.id === selectedId
117
- ? "border-accent/50 bg-accent/10"
118
- : "border-theme-border/60 bg-theme-base/40 hover:bg-theme-hover"
119
- }`}
120
- >
121
- <div className="flex items-center gap-2">
122
- {statusDot(r.status)}
123
- <span className="min-w-0 flex-1 truncate text-sm text-theme-text-primary">
124
- {r.kind} {r.namespace ? `${r.namespace}/` : ""}
125
- {r.name}
126
- </span>
127
- <span className="shrink-0 text-[11px] text-theme-text-tertiary">
128
- {r.status === "running" ? (
129
- "running…"
130
- ) : (
131
- <>
132
- {(() => {
133
- const w = statusWord(r.status);
134
- return w ? (
135
- <span className={`font-medium ${w.cls}`}>
136
- {w.text} ·{" "}
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}
137
306
  </span>
138
- ) : null;
139
- })()}
140
- {relativeTime(new Date(r.updatedAt).getTime(), now)}
141
- </>
142
- )}
143
- </span>
144
- </div>
145
- {(r.status === "stale" && r.context) || r.preview ? (
146
- <div className="truncate pl-3.5 text-xs text-theme-text-tertiary">
147
- {/* A foreign-cluster run names its cluster — in mixed multi-
148
- context history, identical-looking rows otherwise give no way
149
- to tell WHICH cluster an investigation was about. */}
150
- {r.status === "stale" && r.context ? (
151
- <span className="text-amber-600/80 dark:text-amber-500/80">
152
- {r.context}
153
- </span>
154
- ) : null}
155
- {r.status === "stale" && r.context && r.preview ? " · " : ""}
156
- {r.preview}
157
- </div>
158
- ) : null}
159
- </button>
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>
316
+ </span>
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>
357
+ ))}
358
+ </div>
160
359
  ))}
161
360
  </div>
162
361
  );