@skyhook-io/radar-app 1.14.4 → 1.14.6
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/README.md +2 -9
- package/package.json +6 -6
- package/src/App.tsx +91 -328
- package/src/RadarApp.tsx +3 -8
- package/src/api/client.jobset.test.ts +18 -0
- package/src/api/client.resource-identity.test.ts +44 -0
- package/src/api/client.rightsizing.test.ts +7 -3
- package/src/api/client.ts +233 -78
- package/src/api/preferences.test.ts +54 -0
- package/src/api/preferences.ts +38 -0
- package/src/components/CloudFunnelButton.test.tsx +155 -0
- package/src/components/CloudFunnelButton.tsx +14 -8
- package/src/components/ContextSwitcher.tsx +13 -132
- package/src/components/applications/ApplicationsView.identity.test.ts +39 -0
- package/src/components/applications/ApplicationsView.tsx +5 -3
- package/src/components/audit/AuditSettingsDialog.tsx +15 -11
- package/src/components/diagnose/AIMarkdown.tsx +8 -3
- package/src/components/diagnose/AgentControls.tsx +26 -3
- package/src/components/diagnose/AssessmentCard.tsx +249 -237
- package/src/components/diagnose/DiagnoseContext.tsx +1 -0
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +47 -0
- package/src/components/diagnose/InvestigationEvidencePane.tsx +15 -2
- package/src/components/diagnose/InvestigationEvidenceSections.tsx +1 -1
- package/src/components/diagnose/InvestigationView.tsx +4 -1
- package/src/components/diagnose/agentCatalog.ts +6 -0
- package/src/components/diagnose/investigationCase.test.tsx +56 -0
- package/src/components/diagnose/investigationEvidence/adapters/logs.test.ts +60 -0
- package/src/components/diagnose/investigationEvidence/adapters/logs.ts +7 -3
- package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +1 -1
- package/src/components/diagnose/investigationEvidence/observations.ts +8 -5
- package/src/components/diagnose/investigationEvidence/types.ts +1 -1
- package/src/components/diagnose/launch.test.ts +23 -0
- package/src/components/diagnose/launch.ts +4 -0
- package/src/components/diagnose/parts.test.tsx +159 -2
- package/src/components/dock/NodeTerminalTab.test.tsx +34 -0
- package/src/components/dock/NodeTerminalTab.tsx +5 -3
- package/src/components/dock/WorkloadLogsTab.test.tsx +66 -0
- package/src/components/dock/WorkloadLogsTab.tsx +2 -1
- package/src/components/execution/BatchExecutionView.render.test.tsx +2 -1
- package/src/components/execution/BatchExecutionView.test.ts +52 -1
- package/src/components/execution/BatchExecutionView.tsx +324 -65
- package/src/components/execution/JobSetAdmission.test.tsx +42 -0
- package/src/components/execution/JobSetAdmission.tsx +17 -0
- package/src/components/execution/JobSetMemberComparison.test.tsx +30 -0
- package/src/components/execution/JobSetMemberComparison.tsx +88 -0
- package/src/components/execution/batch-run-actions.test.ts +1 -0
- package/src/components/execution/batch-timeline.test.ts +3 -0
- package/src/components/execution/execution-definition.test.ts +15 -1
- package/src/components/execution/execution-definition.ts +4 -0
- package/src/components/execution/member-collection.render.test.tsx +142 -0
- package/src/components/gitops/GitOpsView.tsx +33 -2
- package/src/components/gitops/destination-toast.test.ts +33 -0
- package/src/components/gitops/destination-toast.ts +46 -0
- package/src/components/gitops/useDestinationCluster.ts +30 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +20 -11
- package/src/components/home/MCPSetupDialog.tsx +10 -0
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
- package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
- package/src/components/nav/PrimaryNavRail.tsx +2 -5
- package/src/components/resource/PVCUsageBar.render.test.tsx +168 -0
- package/src/components/resource/PVCUsageBar.tsx +62 -16
- package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -1
- package/src/components/resources/ResourcesView.tsx +3 -3
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +8 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +39 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +37 -0
- package/src/components/resources/renderers/RayClusterRenderer.test.tsx +18 -0
- package/src/components/resources/renderers/RayClusterRenderer.tsx +17 -0
- package/src/components/resources/renderers/RayServiceRenderer.test.tsx +69 -0
- package/src/components/resources/renderers/RayServiceRenderer.tsx +38 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +207 -83
- package/src/components/rightsizing/copy.test.ts +7 -0
- package/src/components/rightsizing/empty-state.test.tsx +67 -0
- package/src/components/rightsizing/model.test.ts +120 -2
- package/src/components/rightsizing/model.ts +36 -13
- package/src/components/rightsizing/notices.test.tsx +176 -0
- package/src/components/settings/OperatorManagedNotice.tsx +17 -0
- package/src/components/settings/SettingsDialog.tsx +157 -42
- package/src/components/settings/settings-state.test.ts +18 -0
- package/src/components/settings/settings-state.ts +18 -0
- package/src/components/timeline/TimelineView.tsx +9 -3
- package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
- package/src/components/ui/Markdown.tsx +33 -6
- package/src/components/ui/command-items.ts +0 -3
- package/src/components/useContextSwitchFlow.tsx +147 -0
- package/src/components/workload/WorkloadView.test.ts +17 -1
- package/src/components/workload/WorkloadView.tsx +36 -18
- package/src/context/ContextSwitchContext.tsx +18 -2
- package/src/context/NavCustomization.tsx +4 -58
- package/src/context/ThemeContext.tsx +3 -11
- package/src/hooks/useClusterLoadState.ts +2 -2
- package/src/hooks/useFavorites.ts +3 -5
- package/src/utils/auditBadges.ts +1 -1
- package/src/utils/navigation.test.ts +18 -1
- package/src/utils/navigation.ts +12 -5
- package/src/utils/topology-selection.test.ts +56 -0
- package/src/utils/topology-selection.ts +9 -9
- package/src/components/ui/CommandPalette.tsx +0 -261
|
@@ -71,6 +71,7 @@ export function InvestigationEvidencePane({
|
|
|
71
71
|
afterEvidence,
|
|
72
72
|
recordNotes,
|
|
73
73
|
storyShell = false,
|
|
74
|
+
summarizedLimits = [],
|
|
74
75
|
revealRequest,
|
|
75
76
|
onRevealReady,
|
|
76
77
|
}: {
|
|
@@ -85,7 +86,11 @@ export function InvestigationEvidencePane({
|
|
|
85
86
|
* `evidence` array. Absent on backends and runs without the story contract,
|
|
86
87
|
* which keep the previous layout.
|
|
87
88
|
*/
|
|
88
|
-
story?: {
|
|
89
|
+
story?: {
|
|
90
|
+
summary?: string;
|
|
91
|
+
report: string;
|
|
92
|
+
evidence?: DiagnosisEvidenceItem[];
|
|
93
|
+
};
|
|
89
94
|
/** Open the story fully instead of the bounded preview — when the layout has room for it. */
|
|
90
95
|
collecting: boolean;
|
|
91
96
|
animateGroupIds: ReadonlySet<string>;
|
|
@@ -105,6 +110,8 @@ export function InvestigationEvidencePane({
|
|
|
105
110
|
recordNotes?: ReactNode;
|
|
106
111
|
/** No verdict yet, but the story shape is coming: keep its cards in place. */
|
|
107
112
|
storyShell?: boolean;
|
|
113
|
+
/** Coverage lines already visible in the current assessment's Still open list. */
|
|
114
|
+
summarizedLimits?: readonly string[];
|
|
108
115
|
/** Explicit Activity → Findings navigation, including repeat clicks. */
|
|
109
116
|
revealRequest?: { sourceId: string; requestId: number };
|
|
110
117
|
onRevealReady?: (sourceId: string) => void;
|
|
@@ -279,6 +286,12 @@ export function InvestigationEvidencePane({
|
|
|
279
286
|
const limitationSummary = coverageGroups
|
|
280
287
|
.map((group) => `${group.label}: ${group.summary}`)
|
|
281
288
|
.join(" · ");
|
|
289
|
+
const additionalLimitations = coverageGroups
|
|
290
|
+
.map((group) => `${group.label}: ${group.summary}`)
|
|
291
|
+
.filter(
|
|
292
|
+
(line) => !story?.summary?.trim() || !summarizedLimits.includes(line),
|
|
293
|
+
)
|
|
294
|
+
.join(" · ");
|
|
282
295
|
// Resolves a story placement to the agent item it names, or says why it
|
|
283
296
|
// cannot: an index Radar does not know, an item the server could not bind,
|
|
284
297
|
// or a bound item that matches no single observation. Only the last two
|
|
@@ -657,7 +670,7 @@ export function InvestigationEvidencePane({
|
|
|
657
670
|
<CoverageStrip
|
|
658
671
|
groups={coverageGroups}
|
|
659
672
|
visibleGroupIds={new Set(partition.collectionByGroup.keys())}
|
|
660
|
-
summary={
|
|
673
|
+
summary={additionalLimitations}
|
|
661
674
|
onViewSource={onViewSource}
|
|
662
675
|
open={coverageOpen}
|
|
663
676
|
onOpenChange={setCoverageOpen}
|
|
@@ -318,7 +318,7 @@ export function CoverageStrip({
|
|
|
318
318
|
? "Change history is limited"
|
|
319
319
|
: "Evidence coverage is incomplete"}
|
|
320
320
|
</span>
|
|
321
|
-
{!historyOnly && !open ? (
|
|
321
|
+
{!historyOnly && !open && summary ? (
|
|
322
322
|
<span className="line-clamp-2 text-xs text-theme-text-tertiary [overflow-wrap:anywhere]">
|
|
323
323
|
{summary}
|
|
324
324
|
</span>
|
|
@@ -788,7 +788,8 @@ export function InvestigationView({
|
|
|
788
788
|
assessment: Turn,
|
|
789
789
|
): AssessmentExplanation | undefined => {
|
|
790
790
|
const sequence = assessment.resultSequence;
|
|
791
|
-
if (!sequence || !assessment
|
|
791
|
+
if (!sequence || !investigationIsAssessmentTurn(assessment))
|
|
792
|
+
return undefined;
|
|
792
793
|
const saved = investigationExplanation(turns, sequence);
|
|
793
794
|
if ((readOnly || !explanationEnabled) && saved.status === "idle")
|
|
794
795
|
return undefined;
|
|
@@ -2361,12 +2362,14 @@ export function InvestigationView({
|
|
|
2361
2362
|
story={
|
|
2362
2363
|
storyShape && currentAssessment?.diagnosis
|
|
2363
2364
|
? {
|
|
2365
|
+
summary: currentAssessment.diagnosis.summary,
|
|
2364
2366
|
report: currentAssessment.diagnosis.report,
|
|
2365
2367
|
evidence: currentAssessment.diagnosis.evidence,
|
|
2366
2368
|
}
|
|
2367
2369
|
: undefined
|
|
2368
2370
|
}
|
|
2369
2371
|
storyShell={storyShell}
|
|
2372
|
+
summarizedLimits={storyShape ? assessmentLimits : undefined}
|
|
2370
2373
|
collecting={
|
|
2371
2374
|
explanationRequest?.status !== "running" &&
|
|
2372
2375
|
(requestPending ||
|
|
@@ -27,4 +27,10 @@ export const SUPPORTED_AGENTS: AgentInstall[] = [
|
|
|
27
27
|
install: "curl https://cursor.com/install -fsS | bash",
|
|
28
28
|
docs: "https://docs.cursor.com/en/cli/overview",
|
|
29
29
|
},
|
|
30
|
+
{
|
|
31
|
+
name: "opencode",
|
|
32
|
+
label: "OpenCode",
|
|
33
|
+
install: "npm install -g opencode-ai",
|
|
34
|
+
docs: "https://opencode.ai/docs/",
|
|
35
|
+
},
|
|
30
36
|
];
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from "./investigationCase";
|
|
22
22
|
import { AssessmentSources, ResultCard, assessmentSourceRows } from "./parts";
|
|
23
23
|
import { namedInventoryRows } from "./investigationEvidence/bodies/inventory";
|
|
24
|
+
import { LogsBody } from "./investigationEvidence/bodies/streams";
|
|
24
25
|
import type { Diagnosis, DiagnosisEvidenceItem } from "../../api/diagnose";
|
|
25
26
|
|
|
26
27
|
const onViewSource = vi.fn();
|
|
@@ -1214,6 +1215,61 @@ describe("agent case placement (D-1, D-1b)", () => {
|
|
|
1214
1215
|
]);
|
|
1215
1216
|
});
|
|
1216
1217
|
|
|
1218
|
+
it.each([
|
|
1219
|
+
["resolved", "app", "app", "card"],
|
|
1220
|
+
["wrong container", "proxy", "app", "source"],
|
|
1221
|
+
["unknown container", undefined, "app", "source"],
|
|
1222
|
+
] as const)(
|
|
1223
|
+
"places a named log claim only against its %s stream",
|
|
1224
|
+
(_name, container, claimedContainer, placement) => {
|
|
1225
|
+
const ref = evidenceRef("a", "b");
|
|
1226
|
+
const projection = project(
|
|
1227
|
+
tool(
|
|
1228
|
+
"logs",
|
|
1229
|
+
"get_pod_logs",
|
|
1230
|
+
{
|
|
1231
|
+
container,
|
|
1232
|
+
lines: ["ERROR missing configuration"],
|
|
1233
|
+
totalLines: 1,
|
|
1234
|
+
matchedLines: 1,
|
|
1235
|
+
fallback: false,
|
|
1236
|
+
},
|
|
1237
|
+
{
|
|
1238
|
+
summary: JSON.stringify({ namespace: "shop", name: "api-abc" }),
|
|
1239
|
+
evidenceRef: ref,
|
|
1240
|
+
},
|
|
1241
|
+
),
|
|
1242
|
+
);
|
|
1243
|
+
const resolved = resolveInvestigationCase(
|
|
1244
|
+
projection,
|
|
1245
|
+
{
|
|
1246
|
+
evidence: [
|
|
1247
|
+
linked(ref, "cause", "The application is missing configuration.", {
|
|
1248
|
+
kind: "Pod",
|
|
1249
|
+
namespace: "shop",
|
|
1250
|
+
name: "api-abc",
|
|
1251
|
+
container: claimedContainer,
|
|
1252
|
+
observation: "logs",
|
|
1253
|
+
}),
|
|
1254
|
+
],
|
|
1255
|
+
},
|
|
1256
|
+
0,
|
|
1257
|
+
);
|
|
1258
|
+
expect(resolved.items[0].placement).toBe(placement);
|
|
1259
|
+
if (placement === "card") {
|
|
1260
|
+
expect(resolved.items[0].groupId).toBe(projection.groups[0].id);
|
|
1261
|
+
}
|
|
1262
|
+
if (container === undefined) {
|
|
1263
|
+
const data = projection.groups[0].latest.data;
|
|
1264
|
+
expect(data.type).toBe("logs");
|
|
1265
|
+
if (data.type !== "logs") throw new Error("expected log evidence");
|
|
1266
|
+
const html = renderToStaticMarkup(<LogsBody data={data} />);
|
|
1267
|
+
expect(html).toContain("container unknown");
|
|
1268
|
+
expect(html).not.toContain("default container");
|
|
1269
|
+
}
|
|
1270
|
+
},
|
|
1271
|
+
);
|
|
1272
|
+
|
|
1217
1273
|
it("binds to the exact earlier read and renders on its revision row, not the card head", () => {
|
|
1218
1274
|
const first = evidenceRef("a", "c");
|
|
1219
1275
|
const second = evidenceRef("a", "d");
|
|
@@ -13,6 +13,66 @@ import {
|
|
|
13
13
|
workloadLogsPayload,
|
|
14
14
|
} from "../evidenceFixtures";
|
|
15
15
|
|
|
16
|
+
describe("pod logs container identity", () => {
|
|
17
|
+
const payload = {
|
|
18
|
+
lines: ["ERROR missing configuration"],
|
|
19
|
+
totalLines: 1,
|
|
20
|
+
matchedLines: 1,
|
|
21
|
+
fallback: false,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
it.each([
|
|
25
|
+
["resolved identity", { ...payload, container: "app" }, {}, "app"],
|
|
26
|
+
[
|
|
27
|
+
"producer identity wins",
|
|
28
|
+
{ ...payload, container: "app" },
|
|
29
|
+
{ container: "proxy" },
|
|
30
|
+
"app",
|
|
31
|
+
],
|
|
32
|
+
["saved explicit request", payload, { container: "app" }, "app"],
|
|
33
|
+
["saved unspecified request", payload, {}, undefined],
|
|
34
|
+
])("uses %s", (_name, result, args, expected) => {
|
|
35
|
+
const projection = project([
|
|
36
|
+
tool("logs", "get_pod_logs", result, {
|
|
37
|
+
summary: JSON.stringify({ namespace: "shop", name: "api", ...args }),
|
|
38
|
+
}),
|
|
39
|
+
]);
|
|
40
|
+
const observation = groupsOf(projection.groups, "logs")[0].latest;
|
|
41
|
+
expect(observation.data).toMatchObject({
|
|
42
|
+
type: "logs",
|
|
43
|
+
container: expected,
|
|
44
|
+
});
|
|
45
|
+
expect(observation.title).toContain(expected ?? "container unknown");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("does not merge separate unknown streams or current and previous instances", () => {
|
|
49
|
+
const projection = project([
|
|
50
|
+
tool("unknown-1", "get_pod_logs", payload),
|
|
51
|
+
tool("unknown-2", "get_pod_logs", payload),
|
|
52
|
+
tool("current", "get_pod_logs", { ...payload, container: "app" }),
|
|
53
|
+
tool(
|
|
54
|
+
"previous",
|
|
55
|
+
"get_pod_logs",
|
|
56
|
+
{ ...payload, container: "app" },
|
|
57
|
+
{
|
|
58
|
+
summary: JSON.stringify({
|
|
59
|
+
namespace: "shop",
|
|
60
|
+
name: "api",
|
|
61
|
+
previous: true,
|
|
62
|
+
}),
|
|
63
|
+
},
|
|
64
|
+
),
|
|
65
|
+
]);
|
|
66
|
+
const groups = groupsOf(projection.groups, "logs");
|
|
67
|
+
expect(groups).toHaveLength(4);
|
|
68
|
+
expect(new Set(groups.map((group) => group.identity)).size).toBe(4);
|
|
69
|
+
expect(groups.slice(2).map((group) => group.identity)).toEqual([
|
|
70
|
+
"logs:current:api:app",
|
|
71
|
+
"logs:previous:api:app",
|
|
72
|
+
]);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
16
76
|
// The get_workload_logs response shape, with the stream rows fetchPodLogs
|
|
17
77
|
// emits per pod and container.
|
|
18
78
|
|
|
@@ -39,9 +39,13 @@ export function adaptPodLogs(
|
|
|
39
39
|
const warnings = stringArray(value?.warnings) ?? [];
|
|
40
40
|
const args = record(source.args ? parseJSON(source.args) : undefined);
|
|
41
41
|
const pod = nonEmptyString(args?.name) ? args.name : "Pod";
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
// Saved results may omit container identity while their arguments explicitly
|
|
43
|
+
// name the requested container. Otherwise its identity is unknown.
|
|
44
|
+
const container = nonEmptyString(value?.container)
|
|
45
|
+
? value.container
|
|
46
|
+
: nonEmptyString(args?.container)
|
|
47
|
+
? args.container
|
|
48
|
+
: undefined;
|
|
45
49
|
addLogs(
|
|
46
50
|
builder,
|
|
47
51
|
source,
|
|
@@ -33,7 +33,7 @@ export function LogsBody({
|
|
|
33
33
|
{condensed ? null : (
|
|
34
34
|
<>
|
|
35
35
|
<Badge tone="structural" size="sm">
|
|
36
|
-
{data.pod} / {data.container}
|
|
36
|
+
{data.pod} / {data.container ?? "container unknown"}
|
|
37
37
|
</Badge>
|
|
38
38
|
<Badge severity="neutral" size="sm">
|
|
39
39
|
{data.previous ? "previous instance" : "current instance"}
|
|
@@ -912,19 +912,20 @@ export function changesSubjectFromArgs(
|
|
|
912
912
|
export function addLogs(
|
|
913
913
|
builder: ProjectionBuilder,
|
|
914
914
|
source: InvestigationEvidenceSource,
|
|
915
|
-
value: DiagnosisPodLogEntry,
|
|
915
|
+
value: Omit<DiagnosisPodLogEntry, "container"> & { container?: string },
|
|
916
916
|
previous: boolean,
|
|
917
917
|
warnings: string[] = [],
|
|
918
918
|
relevance: InvestigationEvidenceRelevance = "broader",
|
|
919
919
|
namespace?: string,
|
|
920
920
|
): void {
|
|
921
921
|
const lines = (value.logs?.lines ?? []).map((line) => stripAnsi(line));
|
|
922
|
+
const containerLabel = value.container ?? "container unknown";
|
|
922
923
|
const normalizedWarnings = warnings.map((warning) => stripAnsi(warning));
|
|
923
924
|
const normalizedError = value.error ? stripAnsi(value.error) : undefined;
|
|
924
925
|
if (lines.length === 0) {
|
|
925
926
|
builder.limit(
|
|
926
927
|
source,
|
|
927
|
-
`${value.pod} / ${
|
|
928
|
+
`${value.pod} / ${containerLabel}`,
|
|
928
929
|
value.error ||
|
|
929
930
|
"No log lines were available. This does not mean the container is healthy.",
|
|
930
931
|
value.error ? "error" : "unknown",
|
|
@@ -941,7 +942,9 @@ export function addLogs(
|
|
|
941
942
|
),
|
|
942
943
|
);
|
|
943
944
|
const selectedEvidence = value.logs?.fallback !== true && diagnosticSignal;
|
|
944
|
-
|
|
945
|
+
// Unknown streams from separate reads need not belong to the same container.
|
|
946
|
+
const streamIdentity = value.container ?? `unknown:${source.id}`;
|
|
947
|
+
const identity = `logs:${previous ? "previous" : "current"}:${value.pod}:${streamIdentity}`;
|
|
945
948
|
builder.observe(identity, "logs", source, {
|
|
946
949
|
// FilterLogs' raw-tail fallback is useful provenance, but the producer did
|
|
947
950
|
// not select it as diagnostic signal. Keep it in Context; only filtered
|
|
@@ -952,7 +955,7 @@ export function addLogs(
|
|
|
952
955
|
),
|
|
953
956
|
relevance,
|
|
954
957
|
tone: selectedEvidence || normalizedError ? "warning" : "neutral",
|
|
955
|
-
title: `${previous ? "Previous" : "Current"} logs · ${value.pod} / ${
|
|
958
|
+
title: `${previous ? "Previous" : "Current"} logs · ${value.pod} / ${containerLabel}`,
|
|
956
959
|
summary:
|
|
957
960
|
lines.length > 0
|
|
958
961
|
? `${lines.length} selected line${lines.length === 1 ? "" : "s"}`
|
|
@@ -971,7 +974,7 @@ export function addLogs(
|
|
|
971
974
|
if (normalizedError) {
|
|
972
975
|
builder.limit(
|
|
973
976
|
source,
|
|
974
|
-
`${value.pod} / ${
|
|
977
|
+
`${value.pod} / ${containerLabel}`,
|
|
975
978
|
normalizedError,
|
|
976
979
|
"error",
|
|
977
980
|
);
|
|
@@ -407,7 +407,7 @@ export type InvestigationEvidenceData =
|
|
|
407
407
|
| {
|
|
408
408
|
type: "logs";
|
|
409
409
|
pod: string;
|
|
410
|
-
container
|
|
410
|
+
container?: string;
|
|
411
411
|
/** Namespace the producing call actually read; absent when unstated. */
|
|
412
412
|
namespace?: string;
|
|
413
413
|
previous: boolean;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { RunSummary } from "../../api/diagnose";
|
|
3
|
+
import { buildLaunchCommand, launchAgentLabel } from "./launch";
|
|
4
|
+
|
|
5
|
+
describe("OpenCode terminal handoff", () => {
|
|
6
|
+
it("uses the correct label without launching a different agent", () => {
|
|
7
|
+
const run: RunSummary = {
|
|
8
|
+
id: "run-test",
|
|
9
|
+
kind: "Deployment",
|
|
10
|
+
group: "apps",
|
|
11
|
+
namespace: "default",
|
|
12
|
+
name: "demo",
|
|
13
|
+
context: "test",
|
|
14
|
+
agent: "opencode",
|
|
15
|
+
status: "done",
|
|
16
|
+
sessionId: "ses_opencode",
|
|
17
|
+
createdAt: "2026-09-21T00:00:00Z",
|
|
18
|
+
updatedAt: "2026-09-21T00:00:00Z",
|
|
19
|
+
};
|
|
20
|
+
expect(launchAgentLabel(run)).toBe("OpenCode");
|
|
21
|
+
expect(buildLaunchCommand(run, "http://localhost:9280/mcp")).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -15,6 +15,7 @@ function sq(s: string): string {
|
|
|
15
15
|
|
|
16
16
|
export function launchAgentLabel(run: RunSummary): string {
|
|
17
17
|
if (run.agent === "codex") return "Codex";
|
|
18
|
+
if (run.agent === "opencode") return "OpenCode";
|
|
18
19
|
if (run.agent === "cursor-agent") return "Cursor";
|
|
19
20
|
return "Claude Code";
|
|
20
21
|
}
|
|
@@ -42,6 +43,9 @@ export function buildLaunchCommand(
|
|
|
42
43
|
): string | null {
|
|
43
44
|
if (!run.sessionId || run.status === "stale") return null;
|
|
44
45
|
|
|
46
|
+
// OpenCode terminal handoff needs a persistent MCP configuration outside the run workspace.
|
|
47
|
+
if (run.agent === "opencode") return null;
|
|
48
|
+
|
|
45
49
|
if (run.agent === "cursor-agent") {
|
|
46
50
|
// Cursor's --resume is workspace-scoped, and Radar runs each investigation in a
|
|
47
51
|
// throwaway per-run workspace the user doesn't have — no command can reattach
|
|
@@ -31,6 +31,82 @@ import { ThemeProvider } from "../../context/ThemeContext";
|
|
|
31
31
|
|
|
32
32
|
const noop = vi.fn();
|
|
33
33
|
|
|
34
|
+
describe("explanations without a root cause", () => {
|
|
35
|
+
it("keeps inconclusive explanation, sources and action in one control row", () => {
|
|
36
|
+
const html = renderToStaticMarkup(
|
|
37
|
+
<ResultCard
|
|
38
|
+
diagnosis={
|
|
39
|
+
{
|
|
40
|
+
inconclusive: true,
|
|
41
|
+
summary: "Not enough evidence",
|
|
42
|
+
report: "Logs unavailable",
|
|
43
|
+
rootCause: "",
|
|
44
|
+
remediation: [],
|
|
45
|
+
} as Diagnosis
|
|
46
|
+
}
|
|
47
|
+
explanation={{ status: "idle", onGenerate: noop }}
|
|
48
|
+
assessmentSources={<span>Saved sources</span>}
|
|
49
|
+
assessmentAction={<button>Next steps</button>}
|
|
50
|
+
/>,
|
|
51
|
+
);
|
|
52
|
+
expect(html.match(/data-assessment-actions/g)).toHaveLength(1);
|
|
53
|
+
const row = html.slice(
|
|
54
|
+
html.indexOf("data-assessment-actions"),
|
|
55
|
+
html.indexOf("data-assessment-actions") + 6000,
|
|
56
|
+
);
|
|
57
|
+
expect(row).toContain("Assessment details");
|
|
58
|
+
expect(row).toContain("Explain simply");
|
|
59
|
+
expect(row).toContain("Next steps");
|
|
60
|
+
});
|
|
61
|
+
it.each(["healthy", "inconclusive"] as const)(
|
|
62
|
+
"offers Explain for a %s assessment",
|
|
63
|
+
(state) => {
|
|
64
|
+
const diagnosis = {
|
|
65
|
+
[state]: true,
|
|
66
|
+
summary: "Saved assessment",
|
|
67
|
+
report: "Saved analysis",
|
|
68
|
+
rootCause: "",
|
|
69
|
+
remediation: [],
|
|
70
|
+
} as Diagnosis;
|
|
71
|
+
const html = renderToStaticMarkup(
|
|
72
|
+
<ResultCard
|
|
73
|
+
diagnosis={diagnosis}
|
|
74
|
+
explanation={{ status: "idle", onGenerate: noop }}
|
|
75
|
+
section="conclusion"
|
|
76
|
+
/>,
|
|
77
|
+
);
|
|
78
|
+
expect(html.match(/Explain simply/g)).toHaveLength(1);
|
|
79
|
+
expect(html).not.toContain('disabled=""');
|
|
80
|
+
const running = renderToStaticMarkup(
|
|
81
|
+
<ResultCard
|
|
82
|
+
diagnosis={diagnosis}
|
|
83
|
+
explanation={{ status: "running" }}
|
|
84
|
+
section="conclusion"
|
|
85
|
+
/>,
|
|
86
|
+
);
|
|
87
|
+
expect(running).toContain("Explaining this assessment");
|
|
88
|
+
expect(
|
|
89
|
+
renderToStaticMarkup(
|
|
90
|
+
<ResultCard
|
|
91
|
+
diagnosis={diagnosis}
|
|
92
|
+
explanation={{ status: "idle", onGenerate: noop }}
|
|
93
|
+
section="actions"
|
|
94
|
+
/>,
|
|
95
|
+
),
|
|
96
|
+
).not.toContain("Explain simply");
|
|
97
|
+
expect(
|
|
98
|
+
renderToStaticMarkup(
|
|
99
|
+
<ResultCard
|
|
100
|
+
diagnosis={diagnosis}
|
|
101
|
+
explanation={{ status: "idle", onGenerate: noop }}
|
|
102
|
+
followup
|
|
103
|
+
/>,
|
|
104
|
+
),
|
|
105
|
+
).not.toContain("Explain simply");
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
34
110
|
it("keeps earlier remediation copyable without suggesting it is executable", () => {
|
|
35
111
|
const html = renderToStaticMarkup(
|
|
36
112
|
<ResultCard
|
|
@@ -95,10 +171,73 @@ describe("remediation commands", () => {
|
|
|
95
171
|
compactActions
|
|
96
172
|
/>,
|
|
97
173
|
);
|
|
98
|
-
expect(html
|
|
99
|
-
|
|
174
|
+
expect(html.match(/aria-label="Copy command from step 1"/g)).toHaveLength(
|
|
175
|
+
2,
|
|
176
|
+
);
|
|
100
177
|
expect(html).not.toContain("Copy remediation step");
|
|
178
|
+
expect(
|
|
179
|
+
html.indexOf("kubectl rollout undo deployment/api -n dev</code>"),
|
|
180
|
+
).toBeLessThan(html.indexOf("Copy command from step 1"));
|
|
181
|
+
expect(html.indexOf("Copy command from step 1")).toBeLessThan(
|
|
182
|
+
html.indexOf("kubectl -n dev get pods</code>"),
|
|
183
|
+
);
|
|
101
184
|
});
|
|
185
|
+
|
|
186
|
+
it.each(["bash", ""])(
|
|
187
|
+
"places each copy button inside its own %s fenced command box",
|
|
188
|
+
(language) => {
|
|
189
|
+
const html = renderToStaticMarkup(
|
|
190
|
+
<ResultCard
|
|
191
|
+
diagnosis={
|
|
192
|
+
{
|
|
193
|
+
rootCause: "Image pull failure",
|
|
194
|
+
report: "Assessment",
|
|
195
|
+
remediation: [
|
|
196
|
+
`Roll back:\n\`\`\`${language}\nkubectl rollout undo deployment/api -n dev\n\`\`\`\nThen watch:\n\`\`\`${language}\nkubectl rollout status deployment/api -n dev\n\`\`\``,
|
|
197
|
+
],
|
|
198
|
+
recommendedIndex: 1,
|
|
199
|
+
} as Diagnosis
|
|
200
|
+
}
|
|
201
|
+
section="actions"
|
|
202
|
+
compactActions
|
|
203
|
+
onApply={noop}
|
|
204
|
+
/>,
|
|
205
|
+
);
|
|
206
|
+
const blocks = [...html.matchAll(/<pre\b[^>]*>([\s\S]*?)<\/pre>/g)].map(
|
|
207
|
+
(match) => match[1],
|
|
208
|
+
);
|
|
209
|
+
expect(blocks).toHaveLength(2);
|
|
210
|
+
expect(blocks[0]).toContain("kubectl rollout undo");
|
|
211
|
+
expect(blocks[0]).toContain("Copy command from step 1");
|
|
212
|
+
expect(blocks[1]).toContain("kubectl rollout status");
|
|
213
|
+
expect(blocks[1]).toContain("Copy command from step 1");
|
|
214
|
+
expect(html.match(/aria-label="Copy command/g)).toHaveLength(2);
|
|
215
|
+
expect(html.indexOf("Apply…")).toBeLessThan(html.indexOf("<pre"));
|
|
216
|
+
},
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("keeps repeated and normalized commands copyable at their rendered locations", () => {
|
|
221
|
+
const step =
|
|
222
|
+
"Run `kubectl get pods -n dev`, then run this: ```bash kubectl get pods -n dev ```";
|
|
223
|
+
expect(remediationCommands(step)).toEqual([
|
|
224
|
+
"kubectl get pods -n dev",
|
|
225
|
+
"kubectl get pods -n dev",
|
|
226
|
+
]);
|
|
227
|
+
const html = renderToStaticMarkup(
|
|
228
|
+
<ResultCard
|
|
229
|
+
diagnosis={
|
|
230
|
+
{ rootCause: "Check", report: "", remediation: [step] } as Diagnosis
|
|
231
|
+
}
|
|
232
|
+
section="actions"
|
|
233
|
+
compactActions
|
|
234
|
+
/>,
|
|
235
|
+
);
|
|
236
|
+
expect(html.match(/aria-label="Copy command from step 1"/g)).toHaveLength(2);
|
|
237
|
+
expect(html).toMatch(
|
|
238
|
+
/<pre\b[^>]*>[\s\S]*kubectl get pods -n dev[\s\S]*Copy command from step 1[\s\S]*<\/pre>/,
|
|
239
|
+
);
|
|
240
|
+
expect(html).not.toContain("bash kubectl");
|
|
102
241
|
});
|
|
103
242
|
|
|
104
243
|
describe("explanation placement", () => {
|
|
@@ -892,6 +1031,24 @@ describe("ConsentCard execution profile treatment", () => {
|
|
|
892
1031
|
expect(html).toContain("Radar does not override them");
|
|
893
1032
|
expect(html).not.toContain("Radar still enables the agent CLI");
|
|
894
1033
|
});
|
|
1034
|
+
|
|
1035
|
+
it("does not claim Radar enables a sandbox for OpenCode's normal setup", () => {
|
|
1036
|
+
const html = renderToStaticMarkup(
|
|
1037
|
+
<ConsentCard
|
|
1038
|
+
agentName="OpenCode"
|
|
1039
|
+
agent="opencode"
|
|
1040
|
+
profile="full-local"
|
|
1041
|
+
onApprove={noop}
|
|
1042
|
+
onCancel={noop}
|
|
1043
|
+
/>,
|
|
1044
|
+
);
|
|
1045
|
+
expect(html).toContain("Radar runs OpenCode with --auto");
|
|
1046
|
+
expect(html).toContain("automatically approves actions");
|
|
1047
|
+
expect(html).toContain("built-in tools and configured MCP servers");
|
|
1048
|
+
expect(html).toContain("Explicit denials still apply");
|
|
1049
|
+
expect(html).toContain("Radar does not enforce a CLI sandbox");
|
|
1050
|
+
expect(html).not.toContain("Radar still enables the agent CLI");
|
|
1051
|
+
});
|
|
895
1052
|
});
|
|
896
1053
|
|
|
897
1054
|
// The consent card's error box is the ONLY place a refused approval is
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { renderToString } from 'react-dom/server'
|
|
2
|
+
import { afterEach, expect, it, vi } from 'vitest'
|
|
3
|
+
import type { NodeTerminalTabProps } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import { NodeTerminalTab } from './NodeTerminalTab'
|
|
5
|
+
|
|
6
|
+
const captured = vi.hoisted(() => ({ props: null as NodeTerminalTabProps | null }))
|
|
7
|
+
vi.mock('@skyhook-io/k8s-ui', () => ({
|
|
8
|
+
NodeTerminalTab: (props: NodeTerminalTabProps) => { captured.props = props; return null },
|
|
9
|
+
}))
|
|
10
|
+
vi.mock('../../api/config', () => ({
|
|
11
|
+
apiUrl: (path: string) => `/api${path}`,
|
|
12
|
+
getWsUrl: (path: string) => path,
|
|
13
|
+
getAuthHeaders: () => ({ Authorization: 'Bearer test' }),
|
|
14
|
+
getCredentialsMode: () => 'include',
|
|
15
|
+
}))
|
|
16
|
+
afterEach(() => vi.unstubAllGlobals())
|
|
17
|
+
|
|
18
|
+
it('passes the created UID to exact-pod cleanup query parameters with keepalive', async () => {
|
|
19
|
+
const pod = { namespace: 'default', podName: 'debug-a', uid: 'uid/a+b', containerName: 'debug' }
|
|
20
|
+
const fetch = vi.fn().mockResolvedValueOnce(new Response(JSON.stringify(pod)))
|
|
21
|
+
.mockResolvedValueOnce(new Response('{}'))
|
|
22
|
+
vi.stubGlobal('fetch', fetch)
|
|
23
|
+
renderToString(<NodeTerminalTab nodeName="node/a" />)
|
|
24
|
+
const props = captured.props!
|
|
25
|
+
const result = await props.createNodeDebugPod('node/a')
|
|
26
|
+
expect(result).toEqual(pod)
|
|
27
|
+
await props.cleanupNodeDebugPod('node/a', result)
|
|
28
|
+
const [url, options] = fetch.mock.calls[1]
|
|
29
|
+
const parsed = new URL(url, 'http://localhost')
|
|
30
|
+
expect(parsed.pathname).toBe('/api/nodes/node%2Fa/debug')
|
|
31
|
+
expect(Object.fromEntries(parsed.searchParams)).toEqual({ namespace: 'default', podName: 'debug-a', uid: 'uid/a+b' })
|
|
32
|
+
expect(options).toMatchObject({ method: 'DELETE', keepalive: true, credentials: 'include', headers: { Authorization: 'Bearer test' } })
|
|
33
|
+
expect(options).not.toHaveProperty('body')
|
|
34
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { NodeTerminalTab as SharedNodeTerminalTab } from '@skyhook-io/k8s-ui'
|
|
1
|
+
import { NodeTerminalTab as SharedNodeTerminalTab, type NodeTerminalTabProps as SharedNodeTerminalTabProps } from '@skyhook-io/k8s-ui'
|
|
2
2
|
import { apiUrl, getWsUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
|
|
3
3
|
|
|
4
4
|
interface NodeTerminalTabProps {
|
|
@@ -21,14 +21,16 @@ export function NodeTerminalTab({ nodeName, isActive }: NodeTerminalTabProps) {
|
|
|
21
21
|
return response.json()
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
const cleanupNodeDebugPod = async (name
|
|
24
|
+
const cleanupNodeDebugPod: SharedNodeTerminalTabProps['cleanupNodeDebugPod'] = async (name, pod) => {
|
|
25
|
+
const query = new URLSearchParams({ namespace: pod.namespace, podName: pod.podName, uid: pod.uid })
|
|
25
26
|
try {
|
|
26
|
-
await fetch(apiUrl(`/nodes/${encodeURIComponent(name)}/debug`), {
|
|
27
|
+
const response = await fetch(apiUrl(`/nodes/${encodeURIComponent(name)}/debug?${query}`), {
|
|
27
28
|
method: 'DELETE',
|
|
28
29
|
credentials: getCredentialsMode(),
|
|
29
30
|
headers: getAuthHeaders(),
|
|
30
31
|
keepalive: true,
|
|
31
32
|
})
|
|
33
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
32
34
|
} catch (err) {
|
|
33
35
|
console.warn(`[NodeTerminal] Failed to cleanup debug pod for node ${name}:`, err)
|
|
34
36
|
}
|