@skyhook-io/radar-app 1.13.0 → 1.13.1
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 +36 -8
- package/src/RadarApp.tsx +1 -1
- package/src/api/diagnose.ts +45 -5
- package/src/components/diagnose/AISettings.tsx +9 -4
- package/src/components/diagnose/DiagnoseContext.tsx +216 -48
- package/src/components/diagnose/DiagnoseSurface.test.tsx +118 -5
- package/src/components/diagnose/DiagnoseSurface.tsx +190 -32
- package/src/components/diagnose/Home.tsx +93 -64
- package/src/components/diagnose/InvestigationView.tsx +190 -124
- package/src/components/diagnose/parts.tsx +10 -2
- package/src/components/execution/BatchExecutionView.render.test.tsx +35 -0
- package/src/components/execution/BatchExecutionView.tsx +2 -3
- package/src/components/execution/execution-definition.test.ts +19 -0
- package/src/components/execution/execution-definition.ts +2 -0
- package/src/components/gitops/GitOpsView.tsx +25 -3
- package/src/components/nav/navigation.test.ts +26 -0
- package/src/components/nav/navigation.ts +10 -0
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { canStartNewInvestigation } from "./DiagnoseSurface";
|
|
2
|
+
import { canCopyRunLink, canStartNewInvestigation } from "./DiagnoseSurface";
|
|
3
|
+
import {
|
|
4
|
+
canContinueInvestigation,
|
|
5
|
+
canStopInvestigation,
|
|
6
|
+
} from "./InvestigationView";
|
|
3
7
|
import type { RunSummary } from "../../api/diagnose";
|
|
4
8
|
|
|
5
9
|
// The "new investigation" button dispatches an agent and spends the user's own
|
|
@@ -34,9 +38,25 @@ describe("canStartNewInvestigation", () => {
|
|
|
34
38
|
|
|
35
39
|
it("stays hidden while a turn is in flight", () => {
|
|
36
40
|
// A start would be handed back the live run, so the button does nothing.
|
|
37
|
-
expect(
|
|
38
|
-
false,
|
|
39
|
-
);
|
|
41
|
+
expect(
|
|
42
|
+
canStartNewInvestigation("investigation", run("running"), false),
|
|
43
|
+
).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("stays hidden while a stopped turn is draining", () => {
|
|
47
|
+
expect(
|
|
48
|
+
canStartNewInvestigation("investigation", run("stopping"), false),
|
|
49
|
+
).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("offers a separate human investigation while an automatic run is in flight", () => {
|
|
53
|
+
expect(
|
|
54
|
+
canStartNewInvestigation(
|
|
55
|
+
"investigation",
|
|
56
|
+
{ ...run("running"), trigger: "background" },
|
|
57
|
+
false,
|
|
58
|
+
),
|
|
59
|
+
).toBe(true);
|
|
40
60
|
});
|
|
41
61
|
|
|
42
62
|
it("stays hidden on a stale run", () => {
|
|
@@ -63,8 +83,101 @@ describe("canStartNewInvestigation", () => {
|
|
|
63
83
|
expect(canStartNewInvestigation("investigation", run("error"), false)).toBe(
|
|
64
84
|
true,
|
|
65
85
|
);
|
|
66
|
-
expect(
|
|
86
|
+
expect(
|
|
87
|
+
canStartNewInvestigation("investigation", run("stopped"), false),
|
|
88
|
+
).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("canStopInvestigation", () => {
|
|
93
|
+
it("lets the terminal transcript outrank a lagging running summary", () => {
|
|
94
|
+
expect(canStopInvestigation(run("running"), false, false, "done")).toBe(
|
|
95
|
+
false,
|
|
96
|
+
);
|
|
97
|
+
expect(canStopInvestigation(run("running"), false, false, "error")).toBe(
|
|
98
|
+
false,
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("keeps Stop available for an active human turn", () => {
|
|
103
|
+
expect(canStopInvestigation(run("running"), true, false, "running")).toBe(
|
|
67
104
|
true,
|
|
68
105
|
);
|
|
69
106
|
});
|
|
107
|
+
|
|
108
|
+
it("does not offer another Stop while the server drains the turn", () => {
|
|
109
|
+
expect(canStopInvestigation(run("stopping"), true, false, "running")).toBe(
|
|
110
|
+
false,
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("never lets a missing or automatic run expose Stop", () => {
|
|
115
|
+
expect(canStopInvestigation(run("running"), true, true, "running")).toBe(
|
|
116
|
+
false,
|
|
117
|
+
);
|
|
118
|
+
expect(
|
|
119
|
+
canStopInvestigation(
|
|
120
|
+
{ ...run("running"), trigger: "background" },
|
|
121
|
+
true,
|
|
122
|
+
false,
|
|
123
|
+
"running",
|
|
124
|
+
),
|
|
125
|
+
).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("canContinueInvestigation", () => {
|
|
130
|
+
it("lets a terminal transcript outrank only a lagging running human summary", () => {
|
|
131
|
+
expect(
|
|
132
|
+
canContinueInvestigation(
|
|
133
|
+
{ ...run("running"), canContinue: false },
|
|
134
|
+
"done",
|
|
135
|
+
),
|
|
136
|
+
).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("keeps genuinely read-only and sessionless investigations read-only", () => {
|
|
140
|
+
expect(
|
|
141
|
+
canContinueInvestigation(
|
|
142
|
+
{
|
|
143
|
+
...run("running"),
|
|
144
|
+
trigger: "background",
|
|
145
|
+
canContinue: false,
|
|
146
|
+
},
|
|
147
|
+
"done",
|
|
148
|
+
),
|
|
149
|
+
).toBe(false);
|
|
150
|
+
expect(
|
|
151
|
+
canContinueInvestigation({ ...run("done"), canContinue: false }, "done"),
|
|
152
|
+
).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("does not offer a follow-up after the retained run disappears", () => {
|
|
156
|
+
expect(
|
|
157
|
+
canContinueInvestigation(
|
|
158
|
+
{ ...run("running"), canContinue: false },
|
|
159
|
+
"error",
|
|
160
|
+
true,
|
|
161
|
+
),
|
|
162
|
+
).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("canCopyRunLink", () => {
|
|
167
|
+
it("does not expose collaboration UI for an OSS run", () => {
|
|
168
|
+
expect(canCopyRunLink(run("done"))).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("exposes the copy action only for a canonical hosted URL", () => {
|
|
172
|
+
expect(
|
|
173
|
+
canCopyRunLink({
|
|
174
|
+
...run("done"),
|
|
175
|
+
radarUrl: "/c/cluster-1?org=org-1&ai-run=r1",
|
|
176
|
+
}),
|
|
177
|
+
).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("treats an empty hosted URL as unavailable", () => {
|
|
181
|
+
expect(canCopyRunLink({ ...run("done"), radarUrl: "" })).toBe(false);
|
|
182
|
+
});
|
|
70
183
|
});
|
|
@@ -16,8 +16,13 @@ import {
|
|
|
16
16
|
Copy,
|
|
17
17
|
Check,
|
|
18
18
|
Plus,
|
|
19
|
+
Link,
|
|
20
|
+
Lock,
|
|
21
|
+
Users,
|
|
19
22
|
} from "lucide-react";
|
|
20
23
|
import { Tooltip } from "../ui/Tooltip";
|
|
24
|
+
import { ConfirmDialog } from "../ui/ConfirmDialog";
|
|
25
|
+
import { Badge } from "@skyhook-io/k8s-ui/components/ui/Badge";
|
|
21
26
|
import {
|
|
22
27
|
useDiagnose,
|
|
23
28
|
useDiagnoseLayout,
|
|
@@ -31,7 +36,11 @@ import { RecentList } from "./Home";
|
|
|
31
36
|
import { AgentSetupNotice } from "./AgentSetupNotice";
|
|
32
37
|
import { ConsentCard } from "./parts";
|
|
33
38
|
import { buildLaunchCommand, launchAgentLabel, openInTerminal } from "./launch";
|
|
34
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
updateRunVisibility,
|
|
41
|
+
type RunSummary,
|
|
42
|
+
type ExecutionProfile,
|
|
43
|
+
} from "../../api/diagnose";
|
|
35
44
|
import { routePath } from "../../api/config";
|
|
36
45
|
import { useCapabilitiesContext } from "../../contexts/CapabilitiesContext";
|
|
37
46
|
|
|
@@ -71,7 +80,10 @@ function InvestigationMenu({ run }: { run: RunSummary }) {
|
|
|
71
80
|
const [copied, setCopied] = useState(false);
|
|
72
81
|
const { localTerminal } = useCapabilitiesContext();
|
|
73
82
|
const label = launchAgentLabel(run);
|
|
74
|
-
const command = buildLaunchCommand(
|
|
83
|
+
const command = buildLaunchCommand(
|
|
84
|
+
run,
|
|
85
|
+
`${window.location.origin}${routePath("/mcp")}`,
|
|
86
|
+
);
|
|
75
87
|
// No resumable session yet (or stale run) → nothing to hand off.
|
|
76
88
|
if (!command) return null;
|
|
77
89
|
|
|
@@ -143,6 +155,135 @@ function InvestigationMenu({ run }: { run: RunSummary }) {
|
|
|
143
155
|
);
|
|
144
156
|
}
|
|
145
157
|
|
|
158
|
+
export function canCopyRunLink(
|
|
159
|
+
run: RunSummary | null | undefined,
|
|
160
|
+
): run is RunSummary & { radarUrl: string } {
|
|
161
|
+
return typeof run?.radarUrl === "string" && run.radarUrl.length > 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function CopyRunLink({ radarUrl, visibility }: { radarUrl: string; visibility: RunSummary["visibility"] }) {
|
|
165
|
+
const label = visibility === "private"
|
|
166
|
+
? "Copy private link (only you can open it)"
|
|
167
|
+
: "Copy investigation link";
|
|
168
|
+
const [copyState, setCopyState] = useState<"idle" | "copied" | "error">(
|
|
169
|
+
"idle",
|
|
170
|
+
);
|
|
171
|
+
const copy = async () => {
|
|
172
|
+
try {
|
|
173
|
+
if (!navigator.clipboard) throw new Error("clipboard unavailable");
|
|
174
|
+
await navigator.clipboard.writeText(
|
|
175
|
+
new URL(radarUrl, window.location.origin).href,
|
|
176
|
+
);
|
|
177
|
+
setCopyState("copied");
|
|
178
|
+
} catch {
|
|
179
|
+
setCopyState("error");
|
|
180
|
+
}
|
|
181
|
+
setTimeout(() => setCopyState("idle"), 1500);
|
|
182
|
+
};
|
|
183
|
+
return (
|
|
184
|
+
<Tooltip
|
|
185
|
+
content={
|
|
186
|
+
copyState === "copied"
|
|
187
|
+
? "Link copied"
|
|
188
|
+
: copyState === "error"
|
|
189
|
+
? "Couldn’t copy link"
|
|
190
|
+
: label
|
|
191
|
+
}
|
|
192
|
+
position="bottom"
|
|
193
|
+
>
|
|
194
|
+
<button
|
|
195
|
+
onClick={copy}
|
|
196
|
+
className="rounded-md p-1 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
|
|
197
|
+
aria-label={label}
|
|
198
|
+
>
|
|
199
|
+
{copyState === "copied" ? (
|
|
200
|
+
<Check className="h-4 w-4 text-emerald-500" />
|
|
201
|
+
) : (
|
|
202
|
+
<Link className="h-4 w-4" />
|
|
203
|
+
)}
|
|
204
|
+
</button>
|
|
205
|
+
</Tooltip>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function VisibilityControl({
|
|
210
|
+
run,
|
|
211
|
+
onChanged,
|
|
212
|
+
}: {
|
|
213
|
+
run: RunSummary;
|
|
214
|
+
onChanged: (run: RunSummary) => void;
|
|
215
|
+
}) {
|
|
216
|
+
const [busy, setBusy] = useState(false);
|
|
217
|
+
const [error, setError] = useState(false);
|
|
218
|
+
const [confirmShare, setConfirmShare] = useState(false);
|
|
219
|
+
if (!run.canManageVisibility) {
|
|
220
|
+
return run.visibility === "organization" ? (
|
|
221
|
+
<Tooltip content="Shared with your organization" position="bottom">
|
|
222
|
+
<Badge severity="neutral" size="sm" className="shrink-0">
|
|
223
|
+
<Users className="h-3 w-3" />
|
|
224
|
+
Organization
|
|
225
|
+
</Badge>
|
|
226
|
+
</Tooltip>
|
|
227
|
+
) : null;
|
|
228
|
+
}
|
|
229
|
+
const shared = run.visibility === "organization";
|
|
230
|
+
const update = () => {
|
|
231
|
+
if (busy) return;
|
|
232
|
+
setBusy(true);
|
|
233
|
+
setError(false);
|
|
234
|
+
updateRunVisibility(run.id, shared ? "private" : "organization")
|
|
235
|
+
.then((updated) => {
|
|
236
|
+
onChanged(updated);
|
|
237
|
+
setConfirmShare(false);
|
|
238
|
+
})
|
|
239
|
+
.catch(() => setError(true))
|
|
240
|
+
.finally(() => setBusy(false));
|
|
241
|
+
};
|
|
242
|
+
const label = shared ? "Organization" : "Private";
|
|
243
|
+
return (
|
|
244
|
+
<>
|
|
245
|
+
<Tooltip
|
|
246
|
+
content={
|
|
247
|
+
error
|
|
248
|
+
? "Couldn't change sharing"
|
|
249
|
+
: shared
|
|
250
|
+
? "Shared with your organization — make private"
|
|
251
|
+
: "Private — click to let organization members access this investigation"
|
|
252
|
+
}
|
|
253
|
+
position="bottom"
|
|
254
|
+
>
|
|
255
|
+
<button
|
|
256
|
+
onClick={() => (shared ? update() : setConfirmShare(true))}
|
|
257
|
+
disabled={busy}
|
|
258
|
+
className="flex items-center gap-1 rounded-md border border-theme-border/70 px-1.5 py-1 text-[11px] font-medium text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary disabled:opacity-50"
|
|
259
|
+
aria-label={
|
|
260
|
+
shared
|
|
261
|
+
? "Shared with your organization — make private"
|
|
262
|
+
: "Private — click to let organization members access this investigation"
|
|
263
|
+
}
|
|
264
|
+
>
|
|
265
|
+
{shared ? (
|
|
266
|
+
<Users className="h-3.5 w-3.5" />
|
|
267
|
+
) : (
|
|
268
|
+
<Lock className="h-3.5 w-3.5" />
|
|
269
|
+
)}
|
|
270
|
+
{label}
|
|
271
|
+
</button>
|
|
272
|
+
</Tooltip>
|
|
273
|
+
<ConfirmDialog
|
|
274
|
+
open={confirmShare}
|
|
275
|
+
onClose={() => !busy && setConfirmShare(false)}
|
|
276
|
+
onConfirm={update}
|
|
277
|
+
title="Share this investigation?"
|
|
278
|
+
message="Everyone in your organization can read this entire investigation—including your questions and the logs and manifests Radar read—and can continue or stop it."
|
|
279
|
+
confirmLabel="Share with organization"
|
|
280
|
+
variant="warning"
|
|
281
|
+
isLoading={busy}
|
|
282
|
+
/>
|
|
283
|
+
</>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
146
287
|
// The panel is an ABSOLUTE slot inside the app's body frame (the column under the
|
|
147
288
|
// header, right of the nav rail) — App renders it there and passes topInset (the
|
|
148
289
|
// header height; 0 in chromeless embeds). It shares that frame with the resource/
|
|
@@ -154,8 +295,9 @@ function InvestigationMenu({ run }: { run: RunSummary }) {
|
|
|
154
295
|
// the last focused run. Without this the button dispatches an
|
|
155
296
|
// agent — real tokens — from a screen showing an unrelated list.
|
|
156
297
|
// run nothing to take a resource from.
|
|
157
|
-
// running
|
|
158
|
-
//
|
|
298
|
+
// running/stopping a human start is handed back the live run, so the click does
|
|
299
|
+
// nothing. An automatic run is a different, immutable session,
|
|
300
|
+
// so it deliberately keeps the fresh human escape hatch.
|
|
159
301
|
// stale the body already offers "Re-run on current cluster" WITH the
|
|
160
302
|
// warning that the context changed; a bare + carries none of it,
|
|
161
303
|
// and the resource may not exist in the context it'd run against.
|
|
@@ -168,7 +310,8 @@ export function canStartNewInvestigation(
|
|
|
168
310
|
return (
|
|
169
311
|
view === "investigation" &&
|
|
170
312
|
!!run &&
|
|
171
|
-
run.status !== "running" &&
|
|
313
|
+
((run.status !== "running" && run.status !== "stopping") ||
|
|
314
|
+
run.trigger === "background") &&
|
|
172
315
|
run.status !== "stale" &&
|
|
173
316
|
!needsConsent
|
|
174
317
|
);
|
|
@@ -220,23 +363,28 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
|
|
|
220
363
|
d.setupState === "needs-install" || d.setupState === "needs-restart";
|
|
221
364
|
|
|
222
365
|
const activeRun = d.runs.find((r) => r.id === d.activeRunId) ?? null;
|
|
366
|
+
// Docked Home is a list, not the previously focused run. Scope every header
|
|
367
|
+
// label/action to what is visibly on screen so Copy/Share cannot act on a run
|
|
368
|
+
// the user has navigated away from. Expanded mode remains master-detail and
|
|
369
|
+
// therefore keeps the selected run active beside its history list.
|
|
370
|
+
const headerRun = !maximized && d.view === "home" ? null : activeRun;
|
|
223
371
|
// A focused run shows the agent it actually ran with; Home reflects the current pick.
|
|
224
|
-
const activeAgentLabel =
|
|
225
|
-
? agentLabelFor(
|
|
372
|
+
const activeAgentLabel = headerRun?.agent
|
|
373
|
+
? agentLabelFor(headerRun.agent)
|
|
226
374
|
: d.agentLabel;
|
|
227
375
|
// Header subtitle: the config a focused run actually used (it records agent /
|
|
228
376
|
// profile / model / effort), or the current defaults on Home. Codex shows mode
|
|
229
377
|
// + reasoning effort; model is shown only when overridden. Clicking opens Settings.
|
|
230
378
|
const configLine = buildConfigLine(
|
|
231
|
-
|
|
379
|
+
headerRun ?? {
|
|
232
380
|
agent: d.selectedAgent,
|
|
233
381
|
profile: d.hosted ? undefined : d.profile,
|
|
234
382
|
model: d.model,
|
|
235
383
|
effort: d.effort,
|
|
236
384
|
},
|
|
237
385
|
);
|
|
238
|
-
const detailTitle =
|
|
239
|
-
? `${
|
|
386
|
+
const detailTitle = headerRun
|
|
387
|
+
? `${headerRun.kind} ${headerRun.namespace ? `${headerRun.namespace}/` : ""}${headerRun.name}`
|
|
240
388
|
: "AI investigations";
|
|
241
389
|
|
|
242
390
|
// Absolute within the body frame: maximized fills it; docked is a right slot.
|
|
@@ -280,12 +428,13 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
|
|
|
280
428
|
</div>
|
|
281
429
|
) : d.activeRunId && d.runsLoaded ? (
|
|
282
430
|
// A focused id that isn't in the loaded list — a deep link (?ai-run=…) to a
|
|
283
|
-
// cleared/
|
|
431
|
+
// private/revoked/cleared/unknown run. The generic "select an
|
|
284
432
|
// investigation" placeholder would read as a broken link.
|
|
285
433
|
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
|
286
434
|
<p className="text-sm text-theme-text-secondary">
|
|
287
|
-
This investigation is
|
|
288
|
-
|
|
435
|
+
This investigation is unavailable. It may be private, your access may
|
|
436
|
+
have changed, or its history may have been cleared. Check your account
|
|
437
|
+
and organization, or ask the creator for access.
|
|
289
438
|
</p>
|
|
290
439
|
<button
|
|
291
440
|
onClick={d.goHome}
|
|
@@ -375,27 +524,36 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
|
|
|
375
524
|
</div>
|
|
376
525
|
</div>
|
|
377
526
|
<div className="flex shrink-0 items-center gap-0.5">
|
|
378
|
-
{
|
|
379
|
-
canStartNewInvestigation(d.view,
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
d.openInvestigation({
|
|
384
|
-
kind: activeRun.kind,
|
|
385
|
-
namespace: activeRun.namespace,
|
|
386
|
-
name: activeRun.name,
|
|
387
|
-
issueId: activeRun.issueId,
|
|
388
|
-
fresh: true,
|
|
389
|
-
})
|
|
390
|
-
}
|
|
391
|
-
className="rounded-md p-1 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
|
|
392
|
-
aria-label="New investigation on this resource"
|
|
527
|
+
{headerRun &&
|
|
528
|
+
canStartNewInvestigation(d.view, headerRun, d.needsConsent) && (
|
|
529
|
+
<Tooltip
|
|
530
|
+
content="New investigation on this resource"
|
|
531
|
+
position="bottom"
|
|
393
532
|
>
|
|
394
|
-
<
|
|
395
|
-
|
|
396
|
-
|
|
533
|
+
<button
|
|
534
|
+
onClick={() =>
|
|
535
|
+
d.openInvestigation({
|
|
536
|
+
kind: headerRun.kind,
|
|
537
|
+
namespace: headerRun.namespace,
|
|
538
|
+
name: headerRun.name,
|
|
539
|
+
issueId: headerRun.issueId,
|
|
540
|
+
fresh: true,
|
|
541
|
+
})
|
|
542
|
+
}
|
|
543
|
+
className="rounded-md p-1 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
|
|
544
|
+
aria-label="New investigation on this resource"
|
|
545
|
+
>
|
|
546
|
+
<Plus className="h-4 w-4" />
|
|
547
|
+
</button>
|
|
548
|
+
</Tooltip>
|
|
549
|
+
)}
|
|
550
|
+
{headerRun && (
|
|
551
|
+
<VisibilityControl run={headerRun} onChanged={d.updateRunSummary} />
|
|
552
|
+
)}
|
|
553
|
+
{canCopyRunLink(headerRun) && (
|
|
554
|
+
<CopyRunLink radarUrl={headerRun.radarUrl} visibility={headerRun.visibility} />
|
|
397
555
|
)}
|
|
398
|
-
{
|
|
556
|
+
{headerRun && <InvestigationMenu run={headerRun} />}
|
|
399
557
|
<Tooltip content={maximized ? "Restore" : "Expand"} position="bottom">
|
|
400
558
|
<button
|
|
401
559
|
onClick={() => setMaximized((v) => !v)}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// docked Home view and the master pane of the maximized workspace.
|
|
4
4
|
import { Loader2, Sparkles } from "lucide-react";
|
|
5
5
|
import { StatusDot, type StatusTone } from "@skyhook-io/k8s-ui";
|
|
6
|
+
import { Badge } from "@skyhook-io/k8s-ui/components/ui/Badge";
|
|
6
7
|
import { type RunSummary } from "../../api/diagnose";
|
|
7
8
|
|
|
8
9
|
// Compact "3m ago" / "2h ago" / date label.
|
|
@@ -31,7 +32,7 @@ function runTone(status: RunSummary["status"]): StatusTone {
|
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
function statusDot(status: RunSummary["status"]) {
|
|
34
|
-
if (status === "running")
|
|
35
|
+
if (status === "running" || status === "stopping")
|
|
35
36
|
return <Loader2 className="h-3 w-3 shrink-0 animate-spin text-accent" />;
|
|
36
37
|
return <StatusDot tone={runTone(status)} className="shrink-0" />;
|
|
37
38
|
}
|
|
@@ -47,6 +48,8 @@ function statusWord(
|
|
|
47
48
|
return { text: "Failed", cls: "text-red-400" };
|
|
48
49
|
case "stopped":
|
|
49
50
|
return { text: "Stopped", cls: "text-theme-text-tertiary" };
|
|
51
|
+
case "stopping":
|
|
52
|
+
return { text: "Stopping", cls: "text-theme-text-tertiary" };
|
|
50
53
|
case "stale":
|
|
51
54
|
// Plain words, not the internal status name: "stale" means the run was
|
|
52
55
|
// about a cluster that's no longer connected.
|
|
@@ -83,80 +86,106 @@ export function RecentList({
|
|
|
83
86
|
if (runs.length === 0) {
|
|
84
87
|
return (
|
|
85
88
|
<div>
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
{degradedNote}
|
|
90
|
+
<div className="flex flex-col items-center px-4 py-12 text-center">
|
|
91
|
+
<Sparkles className="mb-3 h-7 w-7 text-accent" />
|
|
92
|
+
<div className="text-sm font-medium text-theme-text-primary">
|
|
93
|
+
No investigations yet
|
|
94
|
+
</div>
|
|
95
|
+
<p className="mt-1 max-w-xs text-sm text-theme-text-tertiary">
|
|
96
|
+
Open a resource and use its{" "}
|
|
97
|
+
<Sparkles className="inline h-3.5 w-3.5 align-text-bottom text-accent" />{" "}
|
|
98
|
+
action to investigate it with {agentLabel} —{" "}
|
|
99
|
+
<span className="font-medium text-theme-text-secondary">
|
|
100
|
+
Diagnose
|
|
101
|
+
</span>{" "}
|
|
102
|
+
a problem, or just ask about it. Investigations run in the
|
|
103
|
+
background and are kept in your history here.
|
|
104
|
+
</p>
|
|
91
105
|
</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
106
|
</div>
|
|
102
107
|
);
|
|
103
108
|
}
|
|
104
109
|
|
|
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
|
+
|
|
105
121
|
return (
|
|
106
122
|
<div className="space-y-2">
|
|
107
123
|
{degradedNote}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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} ·{" "}
|
|
137
|
-
</span>
|
|
138
|
-
) : null;
|
|
139
|
-
})()}
|
|
140
|
-
{relativeTime(new Date(r.updatedAt).getTime(), now)}
|
|
141
|
-
</>
|
|
142
|
-
)}
|
|
143
|
-
</span>
|
|
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}
|
|
144
128
|
</div>
|
|
145
|
-
{(r
|
|
146
|
-
<
|
|
147
|
-
{
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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}
|
|
153
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}
|
|
180
|
+
</span>
|
|
181
|
+
) : null}
|
|
182
|
+
{r.status === "stale" && r.context && r.preview ? " · " : ""}
|
|
183
|
+
{r.preview}
|
|
184
|
+
</div>
|
|
154
185
|
) : null}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
) : null}
|
|
159
|
-
</button>
|
|
186
|
+
</button>
|
|
187
|
+
))}
|
|
188
|
+
</div>
|
|
160
189
|
))}
|
|
161
190
|
</div>
|
|
162
191
|
);
|