@skyhook-io/radar-app 1.16.3 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/App.tsx +14 -7
- package/src/RadarApp.tsx +27 -17
- package/src/api/client.goneResource.test.ts +49 -0
- package/src/api/client.ts +115 -8
- package/src/api/diagnose.test.ts +40 -0
- package/src/api/diagnose.ts +12 -1
- package/src/api/radarFeatures.render.test.tsx +19 -1
- package/src/api/radarFeatures.ts +3 -0
- package/src/api/traffic.sources.test.ts +55 -0
- package/src/api/traffic.ts +173 -14
- package/src/components/applications/ApplicationsView.tsx +3 -0
- package/src/components/capacity/CapacityPoolDetail.tsx +18 -7
- package/src/components/capacity/CapacityView.test.tsx +31 -1
- package/src/components/capacity/shared.tsx +12 -21
- package/src/components/cnpg/CNPGProtection.tsx +2 -2
- package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
- package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
- package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
- package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
- package/src/components/diagnose/ApplyDialog.tsx +26 -28
- package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
- package/src/components/diagnose/DiagnoseContext.tsx +136 -54
- package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
- package/src/components/diagnose/InvestigationView.tsx +39 -2
- package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
- package/src/components/diagnose/agentCatalog.ts +2 -2
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
- package/src/components/dock/TrafficFlowListTab.tsx +27 -6
- package/src/components/execution/BatchExecutionView.tsx +11 -11
- package/src/components/helm/ChartBrowser.tsx +3 -3
- package/src/components/helm/RevisionHistory.tsx +3 -2
- package/src/components/home/ClusterHealthCard.tsx +7 -0
- package/src/components/home/mcpToolCatalog.ts +1 -1
- package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
- package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
- package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
- package/src/components/settings/SettingsDialog.tsx +122 -19
- package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
- package/src/components/timeline/TimelineList.tsx +3 -0
- package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
- package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
- package/src/components/timeline/TimelineView.tsx +7 -0
- package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
- package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
- package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
- package/src/components/traffic/TrafficGraph.tsx +109 -26
- package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
- package/src/components/traffic/TrafficView.tsx +582 -149
- package/src/components/traffic/trafficFilters.test.ts +398 -1
- package/src/components/traffic/trafficFilters.ts +408 -0
- package/src/components/workload/WorkloadView.test.ts +2 -1
- package/src/components/workload/WorkloadView.tsx +135 -290
- package/src/context/TimelineCustomization.tsx +15 -0
- package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
- package/src/hooks/useGitOpsWriteGuard.ts +193 -0
- package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
|
@@ -1,20 +1,31 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
1
|
+
import { useState, type ReactNode } from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
2
3
|
import { Sparkles, Copy, Check, ExternalLink, RotateCw } from "lucide-react";
|
|
4
|
+
import { copyText } from "@skyhook-io/k8s-ui/utils/clipboard";
|
|
3
5
|
import { SUPPORTED_AGENTS, type AgentInstall } from "./agentCatalog";
|
|
4
6
|
import { type DiagnoseSetup } from "./DiagnoseContext";
|
|
7
|
+
import { openExternal } from "../../utils/navigation";
|
|
5
8
|
|
|
6
|
-
|
|
9
|
+
const REPORT_URL =
|
|
10
|
+
"https://github.com/skyhook-io/radar/issues/new?labels=bug&template=bug_report.md";
|
|
11
|
+
|
|
12
|
+
function CopyButton({ text, label }: { text: string; label: string }) {
|
|
7
13
|
const [copied, setCopied] = useState(false);
|
|
8
14
|
return (
|
|
9
15
|
<button
|
|
10
16
|
type="button"
|
|
11
17
|
onClick={() => {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
18
|
+
// The shared helper, not navigator.clipboard directly: a Radar served
|
|
19
|
+
// over plain HTTP is an insecure origin where the async API rejects,
|
|
20
|
+
// and the tick must not claim a copy that didn't happen.
|
|
21
|
+
void copyText(text).then((ok) => {
|
|
22
|
+
if (!ok) return;
|
|
23
|
+
setCopied(true);
|
|
24
|
+
setTimeout(() => setCopied(false), 1100);
|
|
25
|
+
});
|
|
15
26
|
}}
|
|
16
27
|
className="shrink-0 rounded-md p-1 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
|
|
17
|
-
aria-label={copied ? "Copied" :
|
|
28
|
+
aria-label={copied ? "Copied" : label}
|
|
18
29
|
>
|
|
19
30
|
{copied ? (
|
|
20
31
|
<Check className="h-3.5 w-3.5 text-emerald-500" />
|
|
@@ -46,68 +57,148 @@ function AgentRow({ agent }: { agent: AgentInstall }) {
|
|
|
46
57
|
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-xs text-theme-text-secondary">
|
|
47
58
|
{agent.install}
|
|
48
59
|
</code>
|
|
49
|
-
<CopyButton text={agent.install} />
|
|
60
|
+
<CopyButton text={agent.install} label="Copy install command" />
|
|
50
61
|
</div>
|
|
51
62
|
</div>
|
|
52
63
|
);
|
|
53
64
|
}
|
|
54
65
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
66
|
+
function CheckAgainButton({
|
|
67
|
+
checking,
|
|
68
|
+
onCheck,
|
|
69
|
+
}: {
|
|
70
|
+
checking: boolean;
|
|
71
|
+
onCheck: () => void;
|
|
72
|
+
}) {
|
|
73
|
+
return (
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
disabled={checking}
|
|
77
|
+
onClick={onCheck}
|
|
78
|
+
className="btn-brand mt-4 inline-flex items-center gap-1.5 px-3 py-1.5 text-sm"
|
|
79
|
+
>
|
|
80
|
+
<RotateCw className={clsx("h-3.5 w-3.5", checking && "animate-spin")} />
|
|
81
|
+
{checking ? "Checking…" : "Check again"}
|
|
82
|
+
</button>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Shown in the AI surface's Home when investigations are eligible here but not
|
|
87
|
+
// runnable yet:
|
|
88
|
+
// - cliOverride: RADAR_AI_CLI_BIN pins the engine to one CLI, so detection is
|
|
89
|
+
// off and installing another changes nothing. Investigations being off means
|
|
90
|
+
// the variable names a file Radar can't run. A working file put at that path
|
|
91
|
+
// is picked up on the next check; changing the variable needs a restart.
|
|
92
|
+
// - "needs-install": no agent CLI found. The server picks one up as soon as
|
|
93
|
+
// it's installed, so the notice offers a re-check rather than a restart.
|
|
94
|
+
// - "needs-restart": an agent is reported but investigations are off. Locally
|
|
95
|
+
// that's a race with an install in progress; an embedding host reports it
|
|
96
|
+
// when its own runner is down. Neither has a cause the user can act on, so
|
|
97
|
+
// say only that and offer a re-check.
|
|
59
98
|
export function AgentSetupNotice({
|
|
60
99
|
setupState,
|
|
100
|
+
cliOverride,
|
|
101
|
+
checkingAgents,
|
|
102
|
+
agentsCheckFailed,
|
|
103
|
+
recheckAgents,
|
|
61
104
|
}: {
|
|
62
105
|
setupState: DiagnoseSetup;
|
|
106
|
+
cliOverride: boolean;
|
|
107
|
+
checkingAgents: boolean;
|
|
108
|
+
agentsCheckFailed: boolean;
|
|
109
|
+
recheckAgents: () => Promise<void>;
|
|
110
|
+
}) {
|
|
111
|
+
// The state the panel was in when a check started. "Still…" is only true when
|
|
112
|
+
// the finished check left it there.
|
|
113
|
+
const [checkedState, setCheckedState] = useState<DiagnoseSetup | null>(null);
|
|
114
|
+
const check = () => {
|
|
115
|
+
void recheckAgents().then(() => setCheckedState(setupState));
|
|
116
|
+
};
|
|
117
|
+
const showResult = checkedState === setupState && !checkingAgents;
|
|
118
|
+
const unreachable = showResult && agentsCheckFailed && (
|
|
119
|
+
<p className="mt-2 text-xs text-theme-text-secondary">
|
|
120
|
+
Couldn't check. Try again.
|
|
121
|
+
</p>
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
if (cliOverride) {
|
|
125
|
+
return (
|
|
126
|
+
<SetupFrame title="Radar can't run your RADAR_AI_CLI_BIN">
|
|
127
|
+
<p className="mt-1 text-sm text-theme-text-secondary">
|
|
128
|
+
Fix the path, or remove it and restart Radar.
|
|
129
|
+
</p>
|
|
130
|
+
<CheckAgainButton checking={checkingAgents} onCheck={check} />
|
|
131
|
+
{unreachable ||
|
|
132
|
+
(showResult && (
|
|
133
|
+
<p className="mt-2 text-xs text-theme-text-secondary">
|
|
134
|
+
Still can't run it.
|
|
135
|
+
</p>
|
|
136
|
+
))}
|
|
137
|
+
</SetupFrame>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (setupState === "needs-restart") {
|
|
141
|
+
return (
|
|
142
|
+
<SetupFrame title="AI investigations aren't available right now">
|
|
143
|
+
<p className="mt-1 text-sm text-theme-text-secondary">
|
|
144
|
+
Try again in a moment.
|
|
145
|
+
</p>
|
|
146
|
+
<CheckAgainButton checking={checkingAgents} onCheck={check} />
|
|
147
|
+
{unreachable ||
|
|
148
|
+
(showResult && (
|
|
149
|
+
<p className="mt-2 text-xs text-theme-text-secondary">
|
|
150
|
+
Still not available.
|
|
151
|
+
</p>
|
|
152
|
+
))}
|
|
153
|
+
</SetupFrame>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return (
|
|
157
|
+
<SetupFrame title="Set up AI investigations">
|
|
158
|
+
<p className="mt-1 text-sm text-theme-text-secondary">
|
|
159
|
+
Radar runs investigations through a coding agent CLI on your own
|
|
160
|
+
machine, with no Radar cloud and no API key. Install one of these:
|
|
161
|
+
</p>
|
|
162
|
+
<div className="mt-4 flex flex-col gap-2.5">
|
|
163
|
+
{SUPPORTED_AGENTS.map((a) => (
|
|
164
|
+
<AgentRow key={a.name} agent={a} />
|
|
165
|
+
))}
|
|
166
|
+
</div>
|
|
167
|
+
<CheckAgainButton checking={checkingAgents} onCheck={check} />
|
|
168
|
+
{unreachable ||
|
|
169
|
+
(showResult && (
|
|
170
|
+
<p className="mt-2 text-xs text-theme-text-secondary">
|
|
171
|
+
Still not found. Already installed one?{" "}
|
|
172
|
+
<button
|
|
173
|
+
type="button"
|
|
174
|
+
onClick={() => openExternal(REPORT_URL)}
|
|
175
|
+
className="underline hover:text-theme-text-primary"
|
|
176
|
+
>
|
|
177
|
+
Report it
|
|
178
|
+
</button>
|
|
179
|
+
.
|
|
180
|
+
</p>
|
|
181
|
+
))}
|
|
182
|
+
</SetupFrame>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function SetupFrame({
|
|
187
|
+
title,
|
|
188
|
+
children,
|
|
189
|
+
}: {
|
|
190
|
+
title: string;
|
|
191
|
+
children: ReactNode;
|
|
63
192
|
}) {
|
|
64
|
-
const needsRestart = setupState === "needs-restart";
|
|
65
193
|
return (
|
|
66
194
|
<div className="mx-auto max-w-md px-1 py-6">
|
|
67
195
|
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-xl border border-accent/30 bg-accent/5">
|
|
68
196
|
<Sparkles className="h-5 w-5 text-accent" />
|
|
69
197
|
</div>
|
|
70
198
|
<h3 className="text-base font-semibold text-theme-text-primary">
|
|
71
|
-
{
|
|
72
|
-
? "Restart Radar to enable AI investigations"
|
|
73
|
-
: "Set up AI investigations"}
|
|
199
|
+
{title}
|
|
74
200
|
</h3>
|
|
75
|
-
|
|
76
|
-
{needsRestart ? (
|
|
77
|
-
<>
|
|
78
|
-
A supported agent CLI is now installed, but Radar started before it
|
|
79
|
-
was — restart Radar to pick it up. Investigations then run locally
|
|
80
|
-
on your machine, keyless, using your own agent.
|
|
81
|
-
</>
|
|
82
|
-
) : (
|
|
83
|
-
<>
|
|
84
|
-
Radar runs investigations through a coding agent CLI on your own
|
|
85
|
-
machine — no Radar cloud, no API key. Install one of these, then
|
|
86
|
-
restart Radar:
|
|
87
|
-
</>
|
|
88
|
-
)}
|
|
89
|
-
</p>
|
|
90
|
-
|
|
91
|
-
{!needsRestart && (
|
|
92
|
-
<div className="mt-4 flex flex-col gap-2.5">
|
|
93
|
-
{SUPPORTED_AGENTS.map((a) => (
|
|
94
|
-
<AgentRow key={a.name} agent={a} />
|
|
95
|
-
))}
|
|
96
|
-
</div>
|
|
97
|
-
)}
|
|
98
|
-
|
|
99
|
-
{/* The panel only re-reads /api/agents on load, so after installing or
|
|
100
|
-
restarting the user needs a way to refresh in place rather than
|
|
101
|
-
hunting for a manual browser reload. */}
|
|
102
|
-
<button
|
|
103
|
-
type="button"
|
|
104
|
-
onClick={() => window.location.reload()}
|
|
105
|
-
className="btn-brand mt-4 inline-flex items-center gap-1.5 px-3 py-1.5 text-sm"
|
|
106
|
-
>
|
|
107
|
-
<RotateCw className="h-3.5 w-3.5" />
|
|
108
|
-
{needsRestart ? "Reload after restarting" : "Reload after installing"}
|
|
109
|
-
</button>
|
|
110
|
-
|
|
201
|
+
{children}
|
|
111
202
|
<p className="mt-4 text-xs text-theme-text-tertiary">
|
|
112
203
|
Your agent runs locally. Resource details and logs are sent to its model
|
|
113
204
|
provider under your account, not to Radar.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
6
|
+
import { evaluateGitOpsWriteGuard } from "@skyhook-io/k8s-ui/utils/gitops-write-guard";
|
|
7
|
+
import { ApplyDialog } from "./parts";
|
|
8
|
+
|
|
9
|
+
vi.mock("@skyhook-io/k8s-ui/components/ui/DialogPortal", () => ({
|
|
10
|
+
DialogPortal: ({ open, children }: { open: boolean; children: ReactNode }) => (open ? children : null),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
const target = { kind: "Deployment", group: "apps", namespace: "dev", name: "api" };
|
|
14
|
+
const owner = { tool: "argocd" as const, kind: "applications" as const, namespace: "argocd", name: "api" };
|
|
15
|
+
const writes = [{ scope: "spec" as const }];
|
|
16
|
+
// Ownership unknown at first; then the owner resolves to an Argo CD Application.
|
|
17
|
+
const guardWith = (resolved: boolean) =>
|
|
18
|
+
resolved
|
|
19
|
+
? evaluateGitOpsWriteGuard({ target, owner, writes, evidence: { uid: "u", resourceVersion: "1", owner: null, policy: { tool: "argocd", auto: true, selfHeal: true, prune: false, suspended: null }, paths: [] } })
|
|
20
|
+
: evaluateGitOpsWriteGuard({ target, owner: null, writes, ownershipError: "Radar hasn't mapped this resource's owners yet, so it can't tell whether a GitOps tool or Helm manages it." });
|
|
21
|
+
|
|
22
|
+
let container: HTMLDivElement;
|
|
23
|
+
afterEach(() => container?.remove());
|
|
24
|
+
|
|
25
|
+
it("asks again when the verdict changes after the user acknowledged", async () => {
|
|
26
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
27
|
+
container = document.createElement("div");
|
|
28
|
+
document.body.appendChild(container);
|
|
29
|
+
const root = createRoot(container);
|
|
30
|
+
const render = (resolved: boolean) =>
|
|
31
|
+
root.render(
|
|
32
|
+
<ApplyDialog open onClose={() => {}} onConfirm={() => {}} agentLabel="Local agent" resourceLabel="Deployment dev/api" context="kind-dev" fix="Raise the memory limit." gitOpsGuard={guardWith(resolved)} />,
|
|
33
|
+
);
|
|
34
|
+
const applyButton = () => [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Apply fix"))!;
|
|
35
|
+
try {
|
|
36
|
+
await act(async () => render(false));
|
|
37
|
+
expect(applyButton().disabled).toBe(true);
|
|
38
|
+
const checkbox = container.querySelector<HTMLInputElement>('input[type="checkbox"]')!;
|
|
39
|
+
await act(async () => checkbox.click());
|
|
40
|
+
expect(applyButton().disabled).toBe(false);
|
|
41
|
+
await act(async () => render(true));
|
|
42
|
+
expect(applyButton().disabled).toBe(true);
|
|
43
|
+
expect(container.querySelector<HTMLInputElement>('input[type="checkbox"]')!.checked).toBe(false);
|
|
44
|
+
} finally {
|
|
45
|
+
await act(async () => root.unmount());
|
|
46
|
+
}
|
|
47
|
+
});
|
|
@@ -3,6 +3,29 @@ import { renderToStaticMarkup } from "react-dom/server";
|
|
|
3
3
|
import { describe, expect, it, vi } from "vitest";
|
|
4
4
|
import { ApplyDialog } from "./parts";
|
|
5
5
|
import { AgentSetupNotice } from "./AgentSetupNotice";
|
|
6
|
+
import { evaluateGitOpsWriteGuard } from "@skyhook-io/k8s-ui/utils/gitops-write-guard";
|
|
7
|
+
|
|
8
|
+
const target = { kind: "Deployment", group: "apps", namespace: "dev", name: "api" };
|
|
9
|
+
const APPLY_WRITES = [{ scope: "spec" as const }];
|
|
10
|
+
const unmanaged = evaluateGitOpsWriteGuard({ target, owner: null, writes: APPLY_WRITES });
|
|
11
|
+
const argoManaged = evaluateGitOpsWriteGuard({
|
|
12
|
+
target,
|
|
13
|
+
owner: { tool: "argocd", kind: "applications", namespace: "argocd", name: "api" },
|
|
14
|
+
writes: APPLY_WRITES,
|
|
15
|
+
evidence: {
|
|
16
|
+
uid: "u",
|
|
17
|
+
resourceVersion: "1",
|
|
18
|
+
owner: null,
|
|
19
|
+
policy: { tool: "argocd", auto: true, selfHeal: true, prune: false, suspended: null },
|
|
20
|
+
paths: [],
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function applyButton(html: string) {
|
|
25
|
+
return html
|
|
26
|
+
.match(/<button\b[^>]*>[\s\S]*?<\/button>/g)
|
|
27
|
+
?.find((button) => button.includes("Apply fix"));
|
|
28
|
+
}
|
|
6
29
|
|
|
7
30
|
vi.mock("@skyhook-io/k8s-ui/components/ui/DialogPortal", () => ({
|
|
8
31
|
DialogPortal: ({ open, children }: { open: boolean; children: ReactNode }) =>
|
|
@@ -25,6 +48,7 @@ describe("Apply confirmation context", () => {
|
|
|
25
48
|
resourceLabel="Deployment dev/api"
|
|
26
49
|
context={context}
|
|
27
50
|
fix="Only after verifying credentials, update the Secret reference."
|
|
51
|
+
gitOpsGuard={unmanaged}
|
|
28
52
|
/>,
|
|
29
53
|
);
|
|
30
54
|
expect(html).toContain("Cluster:");
|
|
@@ -48,23 +72,52 @@ describe("Apply confirmation context", () => {
|
|
|
48
72
|
resourceLabel="Deployment dev/api"
|
|
49
73
|
context="kind-dev"
|
|
50
74
|
fix="Update its configuration"
|
|
51
|
-
|
|
75
|
+
gitOpsGuard={argoManaged}
|
|
52
76
|
confidence={0.3}
|
|
53
77
|
/>,
|
|
54
78
|
);
|
|
79
|
+
expect(html).toContain("Argo CD Application argocd/api may revert this change.");
|
|
55
80
|
expect(html).toContain("I understand Argo CD may revert this");
|
|
56
|
-
|
|
57
|
-
.match(/<button\b[^>]*>[\s\S]*?<\/button>/g)
|
|
58
|
-
?.find((button) => button.includes("Apply fix"));
|
|
59
|
-
expect(applyButton).toContain('disabled=""');
|
|
81
|
+
expect(applyButton(html)).toContain('disabled=""');
|
|
60
82
|
expect(html).toContain("low confidence");
|
|
61
83
|
expect(html.match(/kubeconfig/g)).toHaveLength(1);
|
|
62
84
|
});
|
|
63
85
|
});
|
|
64
86
|
|
|
87
|
+
describe("Apply GitOps gate", () => {
|
|
88
|
+
const props = {
|
|
89
|
+
open: true,
|
|
90
|
+
onClose: () => {},
|
|
91
|
+
onConfirm: () => {},
|
|
92
|
+
agentLabel: "Local agent",
|
|
93
|
+
resourceLabel: "Deployment dev/api",
|
|
94
|
+
context: "kind-dev",
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
it("enables Apply for an unmanaged target without acknowledgement", () => {
|
|
98
|
+
const html = renderToStaticMarkup(<ApplyDialog {...props} gitOpsGuard={unmanaged} />);
|
|
99
|
+
expect(applyButton(html)).not.toContain('disabled=""');
|
|
100
|
+
expect(html).not.toContain('type="checkbox"');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("blocks Apply while ownership is being checked or unknown", () => {
|
|
104
|
+
const pending = evaluateGitOpsWriteGuard({ target, owner: null, ownerPending: true, writes: APPLY_WRITES });
|
|
105
|
+
const checking = renderToStaticMarkup(<ApplyDialog {...props} gitOpsGuard={pending} />);
|
|
106
|
+
expect(checking).toContain("Checking GitOps ownership");
|
|
107
|
+
expect(applyButton(checking)).toContain('disabled=""');
|
|
108
|
+
expect(applyButton(renderToStaticMarkup(<ApplyDialog {...props} />))).toContain('disabled=""');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
65
112
|
it("distinguishes local execution from sending resource data to the model provider", () => {
|
|
66
113
|
const html = renderToStaticMarkup(
|
|
67
|
-
<AgentSetupNotice
|
|
114
|
+
<AgentSetupNotice
|
|
115
|
+
setupState="needs-install"
|
|
116
|
+
cliOverride={false}
|
|
117
|
+
checkingAgents={false}
|
|
118
|
+
agentsCheckFailed={false}
|
|
119
|
+
recheckAgents={async () => {}}
|
|
120
|
+
/>,
|
|
68
121
|
);
|
|
69
122
|
expect(html).toContain("Your agent runs locally");
|
|
70
123
|
expect(html).toContain("model provider under your account, not to Radar");
|
|
@@ -8,6 +8,12 @@ import {
|
|
|
8
8
|
RefreshCw,
|
|
9
9
|
} from "lucide-react";
|
|
10
10
|
import { DialogPortal } from "@skyhook-io/k8s-ui/components/ui/DialogPortal";
|
|
11
|
+
import { GitOpsWriteWarning } from "@skyhook-io/k8s-ui";
|
|
12
|
+
import {
|
|
13
|
+
canConfirmGitOpsWrite,
|
|
14
|
+
gitOpsWriteGuardKey,
|
|
15
|
+
type GitOpsWriteGuard,
|
|
16
|
+
} from "@skyhook-io/k8s-ui/utils/gitops-write-guard";
|
|
11
17
|
import { parseContextName } from "../../utils/context-name";
|
|
12
18
|
import type { Diagnosis, ApplyMutationOutcome } from "../../api/diagnose";
|
|
13
19
|
import { AIMarkdown, CopyButton } from "./AIMarkdown";
|
|
@@ -24,7 +30,8 @@ export function ApplyDialog({
|
|
|
24
30
|
fix,
|
|
25
31
|
reason,
|
|
26
32
|
precondition,
|
|
27
|
-
|
|
33
|
+
gitOpsGuard,
|
|
34
|
+
onOpenGitOpsOwner,
|
|
28
35
|
confidence,
|
|
29
36
|
}: {
|
|
30
37
|
open: boolean;
|
|
@@ -38,7 +45,9 @@ export function ApplyDialog({
|
|
|
38
45
|
reason?: string;
|
|
39
46
|
/** The condition the agent attached to this step; shown before the operator confirms. */
|
|
40
47
|
precondition?: string;
|
|
41
|
-
|
|
48
|
+
/** GitOps/Helm write guard for the target; undefined blocks Apply. */
|
|
49
|
+
gitOpsGuard?: GitOpsWriteGuard;
|
|
50
|
+
onOpenGitOpsOwner?: () => void;
|
|
42
51
|
confidence?: number;
|
|
43
52
|
}) {
|
|
44
53
|
const titleId = useId();
|
|
@@ -47,14 +56,19 @@ export function ApplyDialog({
|
|
|
47
56
|
// A GitOps/Helm-managed resource needs an explicit acknowledgment before applying
|
|
48
57
|
// a direct change — it's the canonical footgun (the controller reverts it). Gating
|
|
49
58
|
// (not just warning) makes the user opt into "yes, I know this may be undone."
|
|
59
|
+
// Apply also waits until ownership has been checked.
|
|
50
60
|
// TODO(SKY-1075): once Radar can connect the user's SCM (GitHub/GitLab/…), replace
|
|
51
61
|
// direct apply on managed resources with "open a PR against the Git source"
|
|
52
62
|
// instead — the durable fix. See Linear SKY-1075.
|
|
53
|
-
|
|
63
|
+
// The guard key the acknowledgment was given for: a different verdict (the
|
|
64
|
+
// owner resolved, the policy changed) asks again.
|
|
65
|
+
const guardKey = gitOpsWriteGuardKey(gitOpsGuard);
|
|
66
|
+
const [ackedKey, setAckedKey] = useState("");
|
|
54
67
|
useEffect(() => {
|
|
55
|
-
if (open)
|
|
68
|
+
if (open) setAckedKey("");
|
|
56
69
|
}, [open]);
|
|
57
|
-
const
|
|
70
|
+
const acked = Boolean(guardKey) && ackedKey === guardKey;
|
|
71
|
+
const applyBlocked = !canConfirmGitOpsWrite(gitOpsGuard, acked);
|
|
58
72
|
return (
|
|
59
73
|
<DialogPortal
|
|
60
74
|
ariaLabelledBy={titleId}
|
|
@@ -130,29 +144,13 @@ export function ApplyDialog({
|
|
|
130
144
|
)}
|
|
131
145
|
</div>
|
|
132
146
|
<div className="shrink-0 space-y-2 p-4">
|
|
133
|
-
{
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
<span className="font-medium">Managed by {managedBy}.</span>{" "}
|
|
141
|
-
Unless you turn off auto-sync, a direct change here will be
|
|
142
|
-
undone within minutes when {managedBy} re-syncs from Git — the
|
|
143
|
-
durable fix is to change it in Git (the {managedBy} source).
|
|
144
|
-
</span>
|
|
145
|
-
</div>
|
|
146
|
-
<label className="flex cursor-pointer items-center gap-2 pl-6 text-xs text-theme-text-secondary">
|
|
147
|
-
<input
|
|
148
|
-
type="checkbox"
|
|
149
|
-
checked={acked}
|
|
150
|
-
onChange={(e) => setAcked(e.target.checked)}
|
|
151
|
-
className="h-3.5 w-3.5 accent-amber-500"
|
|
152
|
-
/>
|
|
153
|
-
I understand {managedBy} may revert this — apply anyway.
|
|
154
|
-
</label>
|
|
155
|
-
</div>
|
|
147
|
+
{gitOpsGuard && (
|
|
148
|
+
<GitOpsWriteWarning
|
|
149
|
+
guard={gitOpsGuard}
|
|
150
|
+
acknowledged={acked}
|
|
151
|
+
onAcknowledgedChange={(value) => setAckedKey(value ? guardKey : "")}
|
|
152
|
+
onOpenOwner={onOpenGitOpsOwner}
|
|
153
|
+
/>
|
|
156
154
|
)}
|
|
157
155
|
{lowConfidence && (
|
|
158
156
|
<div className="flex items-start gap-2 rounded border border-theme-border bg-theme-elevated p-3 text-sm text-theme-text-secondary">
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
isInvestigationWorkspacePath,
|
|
9
9
|
shouldExitUnavailableWorkspace,
|
|
10
10
|
workspaceRunIDFromPath,
|
|
11
|
+
resolveSetupState,
|
|
11
12
|
} from "./DiagnoseContext";
|
|
12
13
|
|
|
13
14
|
describe("investigation workspace routes", () => {
|
|
@@ -103,3 +104,45 @@ describe("investigationPanelFillsViewport", () => {
|
|
|
103
104
|
expect(investigationPanelFillsViewport(1200, 1000)).toBe(true);
|
|
104
105
|
});
|
|
105
106
|
});
|
|
107
|
+
|
|
108
|
+
describe("resolveSetupState", () => {
|
|
109
|
+
const base = {
|
|
110
|
+
available: false,
|
|
111
|
+
eligibilityResolved: true,
|
|
112
|
+
eligible: true,
|
|
113
|
+
supportedAgentCount: 0,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
it("does not call an unanswered probe a deployment restriction", () => {
|
|
117
|
+
// eligible starts false and the probe's catch leaves it false, so both the
|
|
118
|
+
// in-flight and the failed request land here.
|
|
119
|
+
expect(
|
|
120
|
+
resolveSetupState({
|
|
121
|
+
...base,
|
|
122
|
+
eligibilityResolved: false,
|
|
123
|
+
eligible: false,
|
|
124
|
+
}),
|
|
125
|
+
).toBe("unknown");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("reports off only once the probe said eligible: false", () => {
|
|
129
|
+
expect(resolveSetupState({ ...base, eligible: false })).toBe("off");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("separates a CLI that appeared after boot from none at all", () => {
|
|
133
|
+
expect(resolveSetupState({ ...base, supportedAgentCount: 1 })).toBe(
|
|
134
|
+
"needs-restart",
|
|
135
|
+
);
|
|
136
|
+
expect(resolveSetupState(base)).toBe("needs-install");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("lets a working engine win over an unresolved probe", () => {
|
|
140
|
+
expect(
|
|
141
|
+
resolveSetupState({
|
|
142
|
+
...base,
|
|
143
|
+
available: true,
|
|
144
|
+
eligibilityResolved: false,
|
|
145
|
+
}),
|
|
146
|
+
).toBe("ready");
|
|
147
|
+
});
|
|
148
|
+
});
|