@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.
- package/package.json +2 -2
- package/src/App.tsx +55 -9
- package/src/RadarApp.tsx +3 -3
- package/src/api/diagnose.test.ts +268 -0
- package/src/api/diagnose.ts +117 -14
- package/src/components/diagnose/AISettings.tsx +10 -5
- package/src/components/diagnose/AgentSetupNotice.tsx +5 -5
- package/src/components/diagnose/ApplyDialog.test.tsx +72 -0
- package/src/components/diagnose/DiagnoseContext.tsx +228 -65
- package/src/components/diagnose/DiagnoseSurface.test.tsx +317 -9
- package/src/components/diagnose/DiagnoseSurface.tsx +621 -132
- package/src/components/diagnose/Home.test.tsx +293 -0
- package/src/components/diagnose/Home.tsx +307 -108
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +2170 -0
- package/src/components/diagnose/InvestigationEvidencePane.tsx +2253 -0
- package/src/components/diagnose/InvestigationResourceEvidence.test.tsx +257 -0
- package/src/components/diagnose/InvestigationResourceEvidence.tsx +214 -0
- package/src/components/diagnose/InvestigationView.test.ts +17 -0
- package/src/components/diagnose/InvestigationView.tsx +1911 -338
- package/src/components/diagnose/LocalDiagnoseAction.tsx +42 -25
- package/src/components/diagnose/agentCatalog.ts +1 -1
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +151 -0
- package/src/components/diagnose/investigationEvidence.test.ts +3109 -0
- package/src/components/diagnose/investigationEvidence.ts +3492 -0
- package/src/components/diagnose/investigationEvidencePresentation.test.ts +447 -0
- package/src/components/diagnose/investigationEvidencePresentation.ts +167 -0
- package/src/components/diagnose/investigationExplanation.test.ts +63 -0
- package/src/components/diagnose/investigationExplanation.ts +22 -0
- package/src/components/diagnose/investigationResourceEvidenceModel.ts +322 -0
- package/src/components/diagnose/investigationSourceFocus.test.ts +143 -0
- package/src/components/diagnose/investigationSourceFocus.ts +98 -0
- package/src/components/diagnose/investigationState.test.ts +695 -0
- package/src/components/diagnose/investigationState.ts +451 -0
- package/src/components/diagnose/parts.test.tsx +864 -3
- package/src/components/diagnose/parts.tsx +1340 -536
- package/src/components/diagnose/target.test.ts +39 -0
- package/src/components/diagnose/target.ts +36 -0
- package/src/components/diagnose/useDisclosureReveal.ts +117 -0
- 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/home/MCPSetupDialog.tsx +2 -2
- package/src/components/home/mcpToolCatalog.test.ts +22 -0
- package/src/components/home/mcpToolCatalog.ts +3 -2
- package/src/components/issues/IssuesPane.tsx +5 -1
- package/src/components/nav/navigation.test.ts +26 -0
- package/src/components/nav/navigation.ts +10 -0
- package/src/components/settings/SettingsDialog.tsx +11 -13
- package/src/components/workload/WorkloadView.tsx +1 -1
- package/src/context/DiagnoseCustomization.tsx +11 -8
- package/src/index.css +63 -79
- package/src/index.ts +1 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { formatInvestigationTarget, runTargetKey } from "./target";
|
|
4
|
+
|
|
5
|
+
describe("investigation target identity", () => {
|
|
6
|
+
it("uses one running key for singular Kinds and plural resource names", () => {
|
|
7
|
+
expect(runTargetKey("Deployment", "prod", "api", "apps")).toBe(
|
|
8
|
+
runTargetKey("deployments", "prod", "api", "apps"),
|
|
9
|
+
);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("keeps same-named resources in different API groups distinct", () => {
|
|
13
|
+
expect(runTargetKey("Service", "prod", "api", "")).not.toBe(
|
|
14
|
+
runTargetKey("services", "prod", "api", "serving.knative.dev"),
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("formats a non-core target with its Kubernetes-qualified Kind", () => {
|
|
19
|
+
expect(
|
|
20
|
+
formatInvestigationTarget({
|
|
21
|
+
kind: "Rollout",
|
|
22
|
+
group: "argoproj.io",
|
|
23
|
+
namespace: "prod",
|
|
24
|
+
name: "checkout",
|
|
25
|
+
}),
|
|
26
|
+
).toBe("Rollout.argoproj.io prod/checkout");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("keeps the core target label compact", () => {
|
|
30
|
+
expect(
|
|
31
|
+
formatInvestigationTarget({
|
|
32
|
+
kind: "Service",
|
|
33
|
+
group: "",
|
|
34
|
+
namespace: "prod",
|
|
35
|
+
name: "api",
|
|
36
|
+
}),
|
|
37
|
+
).toBe("Service prod/api");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { resourceKey } from "@skyhook-io/k8s-ui";
|
|
2
|
+
|
|
3
|
+
import { kindToPluralWithGroup } from "../../utils/navigation";
|
|
4
|
+
|
|
5
|
+
export interface InvestigationTargetIdentity {
|
|
6
|
+
kind: string;
|
|
7
|
+
/** Kubernetes API group; empty means core. */
|
|
8
|
+
group: string;
|
|
9
|
+
namespace: string;
|
|
10
|
+
name: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Normalize the Kind/resource-name boundary before comparing a run returned by
|
|
14
|
+
// the API (singular Kind) with a UI action (often a plural resource name).
|
|
15
|
+
export function runTargetKey(
|
|
16
|
+
kind: string,
|
|
17
|
+
namespace: string,
|
|
18
|
+
name: string,
|
|
19
|
+
group: string,
|
|
20
|
+
): string {
|
|
21
|
+
return resourceKey(
|
|
22
|
+
group,
|
|
23
|
+
kindToPluralWithGroup(kind, group),
|
|
24
|
+
namespace,
|
|
25
|
+
name,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Keep the UI aligned with the CLI and the prompts: a non-core target is shown
|
|
30
|
+
// as Kind.api.group, which is the Kubernetes-qualified identity users can copy.
|
|
31
|
+
export function formatInvestigationTarget(
|
|
32
|
+
target: InvestigationTargetIdentity,
|
|
33
|
+
): string {
|
|
34
|
+
const kind = target.group ? `${target.kind}.${target.group}` : target.kind;
|
|
35
|
+
return `${kind} ${target.namespace ? `${target.namespace}/` : ""}${target.name}`;
|
|
36
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { useCallback, useLayoutEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
// Shared Collapse uses a 200 ms grid-row transition. Keep a small paint margin
|
|
4
|
+
// before moving focus so the destination is stationary. Reduced-motion users get
|
|
5
|
+
// an immediate disclosure and focus hand-off because Collapse disables motion.
|
|
6
|
+
export const INVESTIGATION_DISCLOSURE_SETTLE_MS = 220;
|
|
7
|
+
export function prefersReducedMotion(): boolean {
|
|
8
|
+
return (
|
|
9
|
+
typeof window !== "undefined" &&
|
|
10
|
+
typeof window.matchMedia === "function" &&
|
|
11
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function investigationDisclosureSettleDelay(
|
|
16
|
+
reducedMotion: boolean,
|
|
17
|
+
): number {
|
|
18
|
+
return reducedMotion ? 0 : INVESTIGATION_DISCLOSURE_SETTLE_MS;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function investigationDisclosureScrollTop({
|
|
22
|
+
scrollTop,
|
|
23
|
+
viewportTop,
|
|
24
|
+
viewportBottom,
|
|
25
|
+
disclosureTop,
|
|
26
|
+
disclosureBottom,
|
|
27
|
+
inset = 8,
|
|
28
|
+
}: {
|
|
29
|
+
scrollTop: number;
|
|
30
|
+
viewportTop: number;
|
|
31
|
+
viewportBottom: number;
|
|
32
|
+
disclosureTop: number;
|
|
33
|
+
disclosureBottom: number;
|
|
34
|
+
inset?: number;
|
|
35
|
+
}): number | undefined {
|
|
36
|
+
const visibleTop = viewportTop + inset;
|
|
37
|
+
const visibleBottom = viewportBottom - inset;
|
|
38
|
+
if (disclosureTop >= visibleTop && disclosureBottom <= visibleBottom) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
if (disclosureTop < visibleTop) {
|
|
42
|
+
return Math.max(0, scrollTop + disclosureTop - visibleTop);
|
|
43
|
+
}
|
|
44
|
+
const viewportHeight = visibleBottom - visibleTop;
|
|
45
|
+
const disclosureHeight = disclosureBottom - disclosureTop;
|
|
46
|
+
if (disclosureHeight <= viewportHeight) {
|
|
47
|
+
return Math.max(0, scrollTop + disclosureBottom - visibleBottom);
|
|
48
|
+
}
|
|
49
|
+
return Math.max(0, scrollTop + disclosureTop - visibleTop);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function useDisclosureReveal<T extends HTMLElement>() {
|
|
53
|
+
const elementRef = useRef<T>(null);
|
|
54
|
+
const timerRef = useRef<number | undefined>(undefined);
|
|
55
|
+
useLayoutEffect(
|
|
56
|
+
() => () => {
|
|
57
|
+
if (timerRef.current !== undefined) {
|
|
58
|
+
window.clearTimeout(timerRef.current);
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
[],
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const revealAfterToggle = useCallback((opening: boolean) => {
|
|
65
|
+
if (timerRef.current !== undefined) {
|
|
66
|
+
window.clearTimeout(timerRef.current);
|
|
67
|
+
timerRef.current = undefined;
|
|
68
|
+
}
|
|
69
|
+
if (!opening) return;
|
|
70
|
+
const settleDelay = investigationDisclosureSettleDelay(
|
|
71
|
+
prefersReducedMotion(),
|
|
72
|
+
);
|
|
73
|
+
const initialScroller = elementRef.current?.closest<HTMLElement>(
|
|
74
|
+
"[data-investigation-findings-scroll], [data-investigation-activity-scroll]",
|
|
75
|
+
);
|
|
76
|
+
const initialScrollTop = initialScroller?.scrollTop;
|
|
77
|
+
// Even reduced-motion disclosures need one task boundary so React can
|
|
78
|
+
// commit the open layout before it is measured.
|
|
79
|
+
timerRef.current = window.setTimeout(() => {
|
|
80
|
+
timerRef.current = undefined;
|
|
81
|
+
const element = elementRef.current;
|
|
82
|
+
if (!element) return;
|
|
83
|
+
const scroller = element.closest<HTMLElement>(
|
|
84
|
+
"[data-investigation-findings-scroll], [data-investigation-activity-scroll]",
|
|
85
|
+
);
|
|
86
|
+
// This component lives in a bounded Diagnose surface. Never fall back to
|
|
87
|
+
// scrolling the document (or an overflow-hidden ancestor), which can move
|
|
88
|
+
// the entire workspace and expose blank space below it.
|
|
89
|
+
if (!scroller) return;
|
|
90
|
+
// Expansion is delayed until Collapse has settled. If the reader scrolls
|
|
91
|
+
// during that interval, their newer intent wins over the automatic reveal.
|
|
92
|
+
if (
|
|
93
|
+
scroller === initialScroller &&
|
|
94
|
+
initialScrollTop !== undefined &&
|
|
95
|
+
Math.abs(scroller.scrollTop - initialScrollTop) > 2
|
|
96
|
+
) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const viewport = scroller.getBoundingClientRect();
|
|
100
|
+
const disclosure = element.getBoundingClientRect();
|
|
101
|
+
const top = investigationDisclosureScrollTop({
|
|
102
|
+
scrollTop: scroller.scrollTop,
|
|
103
|
+
viewportTop: viewport.top,
|
|
104
|
+
viewportBottom: viewport.bottom,
|
|
105
|
+
disclosureTop: disclosure.top,
|
|
106
|
+
disclosureBottom: disclosure.bottom,
|
|
107
|
+
});
|
|
108
|
+
if (top === undefined) return;
|
|
109
|
+
scroller.scrollTo({
|
|
110
|
+
top,
|
|
111
|
+
behavior: settleDelay === 0 ? "auto" : "smooth",
|
|
112
|
+
});
|
|
113
|
+
}, settleDelay);
|
|
114
|
+
}, []);
|
|
115
|
+
|
|
116
|
+
return { elementRef, revealAfterToggle };
|
|
117
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { BatchExecutionFullscreen } from './BatchExecutionView'
|
|
4
|
+
|
|
5
|
+
vi.mock('../../api/client', () => ({
|
|
6
|
+
useResource: () => ({}),
|
|
7
|
+
useWorkloadPods: () => ({}),
|
|
8
|
+
useWorkloadRuns: () => ({ data: { runs: [] } }),
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
describe.each(['Job', 'CronJob', 'ScaledJob'])('%s cleanup retention display', (kind) => {
|
|
12
|
+
function render(ttlSecondsAfterFinished?: number) {
|
|
13
|
+
const jobSpec = { ttlSecondsAfterFinished, activeDeadlineSeconds: 60 }
|
|
14
|
+
const spec = kind === 'CronJob'
|
|
15
|
+
? { jobTemplate: { spec: jobSpec } }
|
|
16
|
+
: kind === 'ScaledJob'
|
|
17
|
+
? { jobTargetRef: jobSpec }
|
|
18
|
+
: jobSpec
|
|
19
|
+
return renderToStaticMarkup(
|
|
20
|
+
<BatchExecutionFullscreen kind={kind} apiKind={`${kind.toLowerCase()}s`} namespace="default" name="example" resource={{ spec }} />,
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
it.each([0, 300])('shows a configured TTL of %i seconds exactly once alongside the definition', (ttl) => {
|
|
25
|
+
const html = render(ttl)
|
|
26
|
+
expect(html.match(/TTL after finish/g)).toHaveLength(1)
|
|
27
|
+
expect(html).toContain(`>${ttl}s<`)
|
|
28
|
+
expect(html).toContain('>Deadline<')
|
|
29
|
+
expect(html).toContain('>60s<')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('omits TTL when cleanup retention is unconfigured', () => {
|
|
33
|
+
expect(render()).not.toContain('TTL after finish')
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -475,9 +475,7 @@ function sourceFacts(kind: string, resource: any, runs: WorkloadRun[]) {
|
|
|
475
475
|
progress: latest?.progress,
|
|
476
476
|
duration: latest ? formatRunDuration(latest) : '',
|
|
477
477
|
work: latest ? workCount(latest) : '',
|
|
478
|
-
facts: [
|
|
479
|
-
['TTL after finish', spec.ttlSecondsAfterFinished != null ? `${spec.ttlSecondsAfterFinished}s` : 'None'],
|
|
480
|
-
],
|
|
478
|
+
facts: [],
|
|
481
479
|
definition,
|
|
482
480
|
}
|
|
483
481
|
}
|
|
@@ -544,6 +542,7 @@ function ExecutionDefinitionDetails({ summary, namespace, compact = false }: { s
|
|
|
544
542
|
<DefinitionFact label="Service account" value={summary.serviceAccount} mono />
|
|
545
543
|
{summary.configMaps.length > 0 && <DefinitionFact label="ConfigMaps" value={summary.configMaps.join(', ')} mono />}
|
|
546
544
|
{summary.secrets.length > 0 && <DefinitionFact label="Secrets" value={summary.secrets.join(', ')} mono />}
|
|
545
|
+
{summary.ttlAfterFinished && <DefinitionFact label="TTL after finish" value={summary.ttlAfterFinished} />}
|
|
547
546
|
</>
|
|
548
547
|
)}
|
|
549
548
|
</div>
|
|
@@ -48,6 +48,25 @@ describe('executionDefinitionSummary', () => {
|
|
|
48
48
|
})
|
|
49
49
|
})
|
|
50
50
|
|
|
51
|
+
describe.each(['Job', 'CronJob', 'ScaledJob'])('%s retention', (kind) => {
|
|
52
|
+
function resource(jobSpec: Record<string, unknown>) {
|
|
53
|
+
if (kind === 'CronJob') return { spec: { jobTemplate: { spec: jobSpec } } }
|
|
54
|
+
if (kind === 'ScaledJob') return { spec: { jobTargetRef: jobSpec } }
|
|
55
|
+
return { spec: jobSpec }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
it.each([0, 300])('keeps a configured TTL of %i seconds separate from the execution deadline', (ttl) => {
|
|
59
|
+
expect(executionDefinitionSummary(kind, resource({
|
|
60
|
+
ttlSecondsAfterFinished: ttl,
|
|
61
|
+
activeDeadlineSeconds: 60,
|
|
62
|
+
}))).toMatchObject({ ttlAfterFinished: `${ttl}s`, deadline: '60s' })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('does not invent a TTL when cleanup retention is unconfigured', () => {
|
|
66
|
+
expect(executionDefinitionSummary(kind, resource({}))).not.toHaveProperty('ttlAfterFinished')
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
51
70
|
it('explains Argo DAG shape, executable templates, policy, and dependencies', () => {
|
|
52
71
|
const summary = executionDefinitionSummary('WorkflowTemplate', {
|
|
53
72
|
spec: {
|
|
@@ -19,6 +19,7 @@ export interface ExecutionDefinitionSummary {
|
|
|
19
19
|
serviceAccount: string
|
|
20
20
|
retry: string
|
|
21
21
|
deadline?: string
|
|
22
|
+
ttlAfterFinished?: string
|
|
22
23
|
parallelism?: string
|
|
23
24
|
}
|
|
24
25
|
|
|
@@ -79,6 +80,7 @@ function kubernetesJobSummary(jobSpec: any): ExecutionDefinitionSummary {
|
|
|
79
80
|
serviceAccount: podSpec.serviceAccountName || 'default',
|
|
80
81
|
retry: `Backoff limit ${jobSpec.backoffLimit ?? 6} · restart ${podSpec.restartPolicy || 'Never'}`,
|
|
81
82
|
...(jobSpec.activeDeadlineSeconds != null ? { deadline: `${jobSpec.activeDeadlineSeconds}s` } : {}),
|
|
83
|
+
...(jobSpec.ttlSecondsAfterFinished != null ? { ttlAfterFinished: `${jobSpec.ttlSecondsAfterFinished}s` } : {}),
|
|
82
84
|
parallelism: `${parallelism} parallel · ${completions} ${completions === 1 ? 'completion' : 'completions'} · ${completionMode}`,
|
|
83
85
|
}
|
|
84
86
|
}
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
initNavigationMap,
|
|
26
26
|
kindToPluralWithGroup,
|
|
27
27
|
normalizeArgoApplication,
|
|
28
|
+
normalizeArgoApplicationSet,
|
|
28
29
|
normalizeFluxHelmRelease,
|
|
29
30
|
normalizeFluxKustomization,
|
|
30
31
|
parseArgoRollbackID,
|
|
@@ -159,6 +160,7 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
|
|
|
159
160
|
|
|
160
161
|
const hasGitOpsRowResource = useMemo(() => (
|
|
161
162
|
hasAPIResource(apiResources, 'applications', 'argoproj.io') ||
|
|
163
|
+
hasAPIResource(apiResources, 'applicationsets', 'argoproj.io') ||
|
|
162
164
|
hasAPIResource(apiResources, 'kustomizations', 'kustomize.toolkit.fluxcd.io') ||
|
|
163
165
|
hasAPIResource(apiResources, 'helmreleases', 'helm.toolkit.fluxcd.io')
|
|
164
166
|
), [apiResources])
|
|
@@ -186,6 +188,7 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
|
|
|
186
188
|
queryKey: ['gitops-rows-main', namespaces, apiResources?.length ?? 0],
|
|
187
189
|
queryFn: async () => {
|
|
188
190
|
const hasApplications = hasAPIResource(apiResources, 'applications', 'argoproj.io')
|
|
191
|
+
const hasApplicationSets = hasAPIResource(apiResources, 'applicationsets', 'argoproj.io')
|
|
189
192
|
const hasKustomizations = hasAPIResource(apiResources, 'kustomizations', 'kustomize.toolkit.fluxcd.io')
|
|
190
193
|
const hasHelmReleases = hasAPIResource(apiResources, 'helmreleases', 'helm.toolkit.fluxcd.io')
|
|
191
194
|
const hasFluxSources = hasKustomizations || hasHelmReleases
|
|
@@ -193,8 +196,9 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
|
|
|
193
196
|
const hasHelmRepos = hasFluxSources && hasAPIResource(apiResources, 'helmrepositories', 'source.toolkit.fluxcd.io')
|
|
194
197
|
const hasOCIRepos = hasFluxSources && hasAPIResource(apiResources, 'ocirepositories', 'source.toolkit.fluxcd.io')
|
|
195
198
|
const hasBuckets = hasFluxSources && hasAPIResource(apiResources, 'buckets', 'source.toolkit.fluxcd.io')
|
|
196
|
-
const [applications, kustomizations, helmReleases, gitRepos, helmRepos, ociRepos, buckets] = await Promise.all([
|
|
199
|
+
const [applications, applicationSets, kustomizations, helmReleases, gitRepos, helmRepos, ociRepos, buckets] = await Promise.all([
|
|
197
200
|
hasApplications ? fetchResourceList('applications', 'argoproj.io', namespacesParam) : Promise.resolve([]),
|
|
201
|
+
hasApplicationSets ? fetchResourceList('applicationsets', 'argoproj.io', namespacesParam) : Promise.resolve([]),
|
|
198
202
|
hasKustomizations ? fetchResourceList('kustomizations', 'kustomize.toolkit.fluxcd.io', namespacesParam) : Promise.resolve([]),
|
|
199
203
|
hasHelmReleases ? fetchResourceList('helmreleases', 'helm.toolkit.fluxcd.io', namespacesParam) : Promise.resolve([]),
|
|
200
204
|
hasGitRepos ? fetchResourceList('gitrepositories', 'source.toolkit.fluxcd.io', '') : Promise.resolve([]),
|
|
@@ -205,6 +209,7 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
|
|
|
205
209
|
const fluxSourceUrls = buildFluxSourceUrlMap([...gitRepos, ...helmRepos, ...ociRepos, ...buckets])
|
|
206
210
|
return [
|
|
207
211
|
...applications.map((r) => normalizeArgoApplication(r)),
|
|
212
|
+
...applicationSets.map((r) => normalizeArgoApplicationSet(r)),
|
|
208
213
|
...kustomizations.map((r) => normalizeFluxKustomization(r, fluxSourceUrls)),
|
|
209
214
|
...helmReleases.map((r) => normalizeFluxHelmRelease(r, fluxSourceUrls)),
|
|
210
215
|
]
|
|
@@ -462,8 +467,17 @@ function GitOpsDetailView({ namespaces, onOpenResource, onOpenSettings }: GitOps
|
|
|
462
467
|
// The tree's root node is this page's own subject — clicking it must not
|
|
463
468
|
// open a nested copy of the same detail page (which stacks an identical
|
|
464
469
|
// "GitOps / X / X" breadcrumb, and again, ad infinitum). A self-reference
|
|
465
|
-
//
|
|
470
|
+
// opens the standard resource drawer instead, matching how every other
|
|
471
|
+
// resource in Radar responds to a click — the header already represents
|
|
472
|
+
// this resource, so a full navigation would be redundant, but a raw
|
|
473
|
+
// metadata/YAML drawer is not.
|
|
466
474
|
if (detailKind === kind && (ref.namespace || '') === (namespace || '') && ref.name === name) {
|
|
475
|
+
onOpenResource({
|
|
476
|
+
kind: kindToPluralWithGroup(ref.kind, ref.group ?? ''),
|
|
477
|
+
namespace: ref.namespace || '',
|
|
478
|
+
name: ref.name,
|
|
479
|
+
group: ref.group,
|
|
480
|
+
})
|
|
467
481
|
return
|
|
468
482
|
}
|
|
469
483
|
const params = new URLSearchParams()
|
|
@@ -555,12 +569,19 @@ function GitOpsDetailView({ namespaces, onOpenResource, onOpenSettings }: GitOps
|
|
|
555
569
|
// The bulk of the JSX is now in <GitOpsDetailLayout>; this wrapper does
|
|
556
570
|
// the OSS-specific things the layout can't (call OSS-side data hooks,
|
|
557
571
|
// open OSS dialogs, talk to OSS Toast, hit OSS keyboard registry).
|
|
572
|
+
const isApplicationSet = kind === 'applicationsets'
|
|
558
573
|
const detail: GitOpsDetailMetadata = {
|
|
559
574
|
project: detailRow?.project,
|
|
560
575
|
repository: detailRow?.repository ? formatGitOpsSourceUrl(detailRow.repository) : undefined,
|
|
561
576
|
path: detailRow?.path || undefined,
|
|
562
577
|
chart: detailRow?.chart || undefined,
|
|
563
|
-
|
|
578
|
+
// An empty destination means "in-cluster" for an Application - that is
|
|
579
|
+
// Argo's default when the field is unset. For an ApplicationSet it means
|
|
580
|
+
// the template's destination was a generator placeholder, which is not the
|
|
581
|
+
// same claim, so the server half is dropped rather than asserted.
|
|
582
|
+
destination: isApplicationSet && !detailRow?.destination
|
|
583
|
+
? (detailRow?.destinationNamespace ? `Namespace: ${detailRow.destinationNamespace}` : '')
|
|
584
|
+
: formatGitOpsDestination(detailRow?.destination, detailRow?.destinationNamespace),
|
|
564
585
|
autoSyncMode: insightsQ.data?.summary?.autoSyncMode,
|
|
565
586
|
}
|
|
566
587
|
|
|
@@ -920,6 +941,7 @@ function tryParseYaml(value: string): unknown {
|
|
|
920
941
|
|
|
921
942
|
function normalizeDetailResource(kind: string, group: string, resource: any): GitOpsRow | null {
|
|
922
943
|
if (kind === 'applications') return normalizeArgoApplication(resource)
|
|
944
|
+
if (kind === 'applicationsets') return normalizeArgoApplicationSet(resource)
|
|
923
945
|
if (kind === 'kustomizations') return normalizeFluxKustomization(resource)
|
|
924
946
|
if (kind === 'helmreleases') return normalizeFluxHelmRelease(resource)
|
|
925
947
|
const status = getGitOpsResourceStatus(kind, resource)
|
|
@@ -204,14 +204,14 @@ export function MCPSetupDialog({ open, onClose, mcpUrl }: MCPSetupDialogProps) {
|
|
|
204
204
|
<a href="https://modelcontextprotocol.io" target="_blank" rel="noopener noreferrer" className="text-purple-400 hover:text-purple-300 underline underline-offset-2">
|
|
205
205
|
Model Context Protocol
|
|
206
206
|
</a>{' '}
|
|
207
|
-
(MCP) server that lets AI agents inspect,
|
|
207
|
+
(MCP) server that lets AI agents inspect, investigate, and operate your cluster through Radar.
|
|
208
208
|
Unlike raw kubectl access, Radar gives your AI pre-processed, enriched data —
|
|
209
209
|
topology graphs, health assessments, deduplicated events, filtered logs — so it
|
|
210
210
|
can understand your cluster state quickly without burning through context on
|
|
211
211
|
verbose YAML output.
|
|
212
212
|
</p>
|
|
213
213
|
<p className="text-sm text-theme-text-secondary leading-relaxed">
|
|
214
|
-
Most read tools do not change cluster state.
|
|
214
|
+
Most read tools do not change cluster state. An in-cluster route probe can create up
|
|
215
215
|
to five self-deleting probe pods when explicitly requested. Write tools (restart,
|
|
216
216
|
scale, sync, apply, node drain) are annotated as destructive so your AI client
|
|
217
217
|
can flag them and prompt before running.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { MCP_TOOL_CATALOG } from './mcpToolCatalog'
|
|
3
|
+
|
|
4
|
+
describe('diagnose catalog copy', () => {
|
|
5
|
+
const description = MCP_TOOL_CATALOG.find((tool) => tool.name === 'diagnose')?.desc
|
|
6
|
+
|
|
7
|
+
it('describes a bounded evidence bundle without promising complete sources or a verdict', () => {
|
|
8
|
+
expect(description).toContain('Bounded, point-in-time evidence bundle')
|
|
9
|
+
expect(description).toContain('not an agent run')
|
|
10
|
+
expect(description).toContain('not an authoritative root-cause verdict')
|
|
11
|
+
expect(description).toContain('attempts selected, capped current and previous logs where available')
|
|
12
|
+
expect(description).toContain('a capped warning-event sample')
|
|
13
|
+
expect(description).toContain('only when the evidence establishes one')
|
|
14
|
+
expect(description).not.toContain('One-call root-cause bundle')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('documents group-qualified Argo Rollout evidence', () => {
|
|
18
|
+
const diagnose = MCP_TOOL_CATALOG.find((tool) => tool.name === 'diagnose')
|
|
19
|
+
expect(diagnose?.desc).toContain('Argo Rollout')
|
|
20
|
+
expect(diagnose?.params.find((param) => param.arg === 'group')?.desc).toContain('argoproj.io')
|
|
21
|
+
})
|
|
22
|
+
})
|
|
@@ -109,9 +109,10 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
|
|
|
109
109
|
},
|
|
110
110
|
{
|
|
111
111
|
name: 'diagnose',
|
|
112
|
-
desc: '
|
|
112
|
+
desc: 'Bounded, point-in-time evidence bundle for one narrowed target — not an agent run and not an authoritative root-cause verdict. For workloads, including Argo Rollout, Radar attempts selected, capped current and previous logs where available, alongside resource context, a capped warning-event sample, and startup blockers; GitOps reconcilers, including Flux HelmRelease, get status summary + parsed related issues; network entry kinds (Service / Ingress / HTTPRoute / GRPCRoute / Gateway) get a coverage-honest path trace that identifies a broken hop only when the evidence establishes one, with an optional one-shot reachability test.',
|
|
113
113
|
params: [
|
|
114
|
-
{ arg: 'kind', required: true, desc: 'pod, deployment, statefulset, daemonset, application, kustomization, Flux HelmRelease, service, ingress, httproute, grpcroute, or gateway' },
|
|
114
|
+
{ arg: 'kind', required: true, desc: 'pod, deployment, statefulset, daemonset, Argo Rollout, application, kustomization, Flux HelmRelease, service, ingress, httproute, grpcroute, or gateway' },
|
|
115
|
+
{ arg: 'group', desc: 'API group for CRDs or kind collisions (for example argoproj.io for Rollout); built-ins are inferred' },
|
|
115
116
|
{ arg: 'namespace', required: true, desc: 'resource namespace' },
|
|
116
117
|
{ arg: 'name', required: true, desc: 'resource name' },
|
|
117
118
|
{ arg: 'probe', desc: 'network kinds only: add active DNS/TCP/TLS/HTTP probes against the declared path (0-3s wall time)' },
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { useMemo, useState } from "react";
|
|
2
2
|
import { useNavigate } from "react-router-dom";
|
|
3
3
|
import { useIssues } from "../../api/client";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
useAPIResources,
|
|
6
|
+
karpenterCapacityAvailable,
|
|
7
|
+
} from "../../api/apiResources";
|
|
5
8
|
import { useCapabilitiesContext } from "../../contexts/CapabilitiesContext";
|
|
6
9
|
import { useConnection } from "../../context/ConnectionContext";
|
|
7
10
|
import type { SelectedResource } from "../../types";
|
|
@@ -242,6 +245,7 @@ export function IssuesPane({
|
|
|
242
245
|
)}
|
|
243
246
|
<IssueDiagnoseButton
|
|
244
247
|
kind={issue.kind}
|
|
248
|
+
group={issue.group}
|
|
245
249
|
namespace={issue.namespace ?? ""}
|
|
246
250
|
name={issue.name}
|
|
247
251
|
/>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { navigateFromPrimaryRail } from './navigation'
|
|
3
|
+
|
|
4
|
+
describe('navigateFromPrimaryRail', () => {
|
|
5
|
+
it('closes an expanded investigation before showing the selected destination', () => {
|
|
6
|
+
const calls: string[] = []
|
|
7
|
+
|
|
8
|
+
navigateFromPrimaryRail(
|
|
9
|
+
true,
|
|
10
|
+
() => calls.push('close'),
|
|
11
|
+
() => calls.push('navigate'),
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
expect(calls).toEqual(['close', 'navigate'])
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('keeps a docked investigation open across sidebar navigation', () => {
|
|
18
|
+
const close = vi.fn()
|
|
19
|
+
const navigate = vi.fn()
|
|
20
|
+
|
|
21
|
+
navigateFromPrimaryRail(false, close, navigate)
|
|
22
|
+
|
|
23
|
+
expect(close).not.toHaveBeenCalled()
|
|
24
|
+
expect(navigate).toHaveBeenCalledOnce()
|
|
25
|
+
})
|
|
26
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Sidebar navigation means “show me that destination.” An expanded investigation
|
|
2
|
+
// obscures it, while a docked investigation intentionally stays alongside it.
|
|
3
|
+
export function navigateFromPrimaryRail(
|
|
4
|
+
expandedInvestigationOpen: boolean,
|
|
5
|
+
closeDiagnose: () => void,
|
|
6
|
+
navigate: () => void,
|
|
7
|
+
) {
|
|
8
|
+
if (expandedInvestigationOpen) closeDiagnose()
|
|
9
|
+
navigate()
|
|
10
|
+
}
|
|
@@ -107,7 +107,7 @@ interface SettingsDialogProps {
|
|
|
107
107
|
// the owner-gated footer and applied after restart.
|
|
108
108
|
// • Live integrations (Prometheus, cost source, Argo CD) — their own Apply/Connect endpoints
|
|
109
109
|
// re-point the running server; effect immediately, NOT part of footer dirty.
|
|
110
|
-
// • Self-saving preferences (cost currency, AI
|
|
110
|
+
// • Self-saving preferences (cost currency, AI investigations) — applied immediately.
|
|
111
111
|
// Integration fields (prometheusUrl, argoCdUrl, argoCdInsecureTls) apply through
|
|
112
112
|
// their own controls and are excluded here. Every field is normalized so
|
|
113
113
|
// unset≡default doesn't read as a change.
|
|
@@ -139,7 +139,7 @@ export function SettingsDialog({
|
|
|
139
139
|
const { data: versionInfo } = useVersionCheck()
|
|
140
140
|
// Radar configuration (kubeconfig, port, integrations…) is host-level and
|
|
141
141
|
// affects every user of this instance, so it's gated to owners. Personal
|
|
142
|
-
// sections (My permissions, AI
|
|
142
|
+
// sections (My permissions, AI investigations) stay usable by everyone. Non-Cloud
|
|
143
143
|
// callers (OSS, OIDC, kubectl plugin) have no role and pass — single-user
|
|
144
144
|
// laptops are never locked out of their own config. Backend enforces this too.
|
|
145
145
|
const { canAtLeast } = useCloudRole()
|
|
@@ -163,9 +163,7 @@ export function SettingsDialog({
|
|
|
163
163
|
open && section === 'argocd'
|
|
164
164
|
)
|
|
165
165
|
|
|
166
|
-
// AI
|
|
167
|
-
// section has its own Save that commits the draft to DiagnoseContext, so it's
|
|
168
|
-
// independent of the owner-gated footer. The draft is snapshotted on open.
|
|
166
|
+
// Local AI preferences save independently of the owner-gated server settings.
|
|
169
167
|
const diag = useDiagnose()
|
|
170
168
|
const aiAvailable = diag.available && diag.agents.length > 0
|
|
171
169
|
const [aiDraft, setAiDraft] = useState<AIDraft>({
|
|
@@ -443,7 +441,7 @@ export function SettingsDialog({
|
|
|
443
441
|
// Flat, un-grouped nav ordered as a narrative — at-a-glance, then you, then how
|
|
444
442
|
// Radar connects, then data integrations, then AI, then advanced. The
|
|
445
443
|
// per-section captions carry the restart-vs-live semantics, so group labels
|
|
446
|
-
// would only add visual weight. AI
|
|
444
|
+
// would only add visual weight. AI investigations is always shown (the section
|
|
447
445
|
// explains how to enable it when no agent CLI is installed).
|
|
448
446
|
const navItems: NavItemDef[] = [
|
|
449
447
|
{ id: 'overview', label: 'Overview', icon: LayoutDashboard, ownerOnly: false, dirty: false },
|
|
@@ -452,7 +450,7 @@ export function SettingsDialog({
|
|
|
452
450
|
{ id: 'prometheus', label: 'Metrics', icon: Activity, ownerOnly: true, dirty: false },
|
|
453
451
|
{ id: 'cost', label: 'Cost', icon: Coins, ownerOnly: true, dirty: costIntegrationDirty },
|
|
454
452
|
{ id: 'argocd', label: 'Argo CD', icon: GitBranch, ownerOnly: true, dirty: false },
|
|
455
|
-
{ id: 'ai', label: 'AI
|
|
453
|
+
{ id: 'ai', label: 'AI investigations', icon: Sparkles, ownerOnly: false, dirty: aiDirty },
|
|
456
454
|
{ id: 'advanced', label: 'Advanced', icon: SlidersHorizontal, ownerOnly: true, dirty: advancedDirty },
|
|
457
455
|
]
|
|
458
456
|
|
|
@@ -722,16 +720,16 @@ export function SettingsDialog({
|
|
|
722
720
|
/>
|
|
723
721
|
</SectionPane>
|
|
724
722
|
|
|
725
|
-
{/* AI
|
|
723
|
+
{/* AI investigations — self-saving, usable by everyone. Same heading block
|
|
726
724
|
as every other tab; the body is the agent controls (when a CLI is
|
|
727
725
|
installed) or an enable explainer (when not). */}
|
|
728
726
|
<div className={clsx(section !== 'ai' && 'hidden')} role="tabpanel" inert={section !== 'ai' || undefined}>
|
|
729
727
|
<div className="mb-4">
|
|
730
|
-
<h3 className="text-base font-semibold text-theme-text-primary">AI
|
|
728
|
+
<h3 className="text-base font-semibold text-theme-text-primary">AI investigations</h3>
|
|
731
729
|
<p className="mt-0.5 text-xs text-theme-text-tertiary">
|
|
732
730
|
{diag.hosted
|
|
733
|
-
? `Investigate incidents with ${diag.agentLabel} — reading logs, events, and topology to
|
|
734
|
-
: "Investigate incidents with an AI agent that runs on your own machine — reading logs, events, and topology to
|
|
731
|
+
? `Investigate incidents with ${diag.agentLabel} — reading logs, events, and topology to understand what's happening.`
|
|
732
|
+
: "Investigate incidents with an AI agent that runs on your own machine — reading logs, events, and topology to understand what's happening. No Radar cloud, no API key."}
|
|
735
733
|
</p>
|
|
736
734
|
</div>
|
|
737
735
|
{aiAvailable ? (
|
|
@@ -1140,7 +1138,7 @@ function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s
|
|
|
1140
1138
|
copyable: mcpOn,
|
|
1141
1139
|
},
|
|
1142
1140
|
{
|
|
1143
|
-
id: 'ai', icon: Sparkles, label: 'AI
|
|
1141
|
+
id: 'ai', icon: Sparkles, label: 'AI investigations',
|
|
1144
1142
|
tone: aiAvailable ? 'ok' : 'off',
|
|
1145
1143
|
value: aiAvailable ? 'Ready' : 'No agent CLI',
|
|
1146
1144
|
detail: aiAvailable ? agentLabel : undefined,
|
|
@@ -1220,7 +1218,7 @@ function OverviewStatus({ tone }: { tone: OverviewTone }) {
|
|
|
1220
1218
|
return <span className={clsx('w-2 h-2 rounded-full shrink-0', cls)} />
|
|
1221
1219
|
}
|
|
1222
1220
|
|
|
1223
|
-
// AIUnavailableNotice is the body of the AI
|
|
1221
|
+
// AIUnavailableNotice is the body of the AI investigations tab when no supported agent
|
|
1224
1222
|
// CLI is installed — the heading/description are provided by the tab itself, so
|
|
1225
1223
|
// this is just the enable explainer (keeping the feature discoverable to whoever
|
|
1226
1224
|
// would set it up).
|
|
@@ -1795,7 +1795,7 @@ function DiagnoseFromWorkloadHint({
|
|
|
1795
1795
|
}) {
|
|
1796
1796
|
if (services.length === 0) return null
|
|
1797
1797
|
return (
|
|
1798
|
-
<Section title="
|
|
1798
|
+
<Section title="Trace network path">
|
|
1799
1799
|
<div className="flex items-start gap-2 text-xs text-theme-text-secondary">
|
|
1800
1800
|
<Stethoscope className="w-4 h-4 mt-0.5 shrink-0 text-theme-text-tertiary" aria-hidden />
|
|
1801
1801
|
<div className="flex-1 min-w-0">
|
|
@@ -1,24 +1,26 @@
|
|
|
1
|
-
// Slot-based injection of a resource-level "
|
|
1
|
+
// Slot-based injection of a resource-level "Investigate" action, and of the
|
|
2
2
|
// consent card's trust copy.
|
|
3
3
|
//
|
|
4
|
-
// Lets an embedding host (e.g. Radar Hub) inject
|
|
4
|
+
// Lets an embedding host (e.g. Radar Hub) inject an "Investigate with AI" button
|
|
5
5
|
// into every resource detail action bar — without forking WorkloadView or the
|
|
6
6
|
// shared ResourceActionsBar. The host returns whatever node should render in
|
|
7
7
|
// the action bar's right-aligned universal-actions area, given the resource
|
|
8
8
|
// context.
|
|
9
9
|
//
|
|
10
|
-
// Default (no provider): Radar renders no
|
|
10
|
+
// Default (no provider): Radar renders no Investigate button — OSS stays
|
|
11
11
|
// agent-free.
|
|
12
|
-
import { createContext, useContext, useMemo } from
|
|
13
|
-
import type { ReactNode } from
|
|
12
|
+
import { createContext, useContext, useMemo } from "react";
|
|
13
|
+
import type { ReactNode } from "react";
|
|
14
14
|
|
|
15
|
-
/** Render prop for the resource-level
|
|
15
|
+
/** Render prop for the resource-level Investigate action. */
|
|
16
16
|
export type RenderDiagnoseAction = (ctx: {
|
|
17
17
|
kind: string;
|
|
18
|
+
/** Kubernetes API group; empty means core. */
|
|
19
|
+
group?: string;
|
|
18
20
|
namespace: string;
|
|
19
21
|
name: string;
|
|
20
22
|
/** Coarse health of the resource (from its status badge), so the entry point can
|
|
21
|
-
* adapt: an urgent "
|
|
23
|
+
* adapt: an urgent "Investigate" on a problem vs. a quiet "ask AI" when fine/unknown. */
|
|
22
24
|
health?: "problem" | "healthy" | "unknown";
|
|
23
25
|
}) => ReactNode;
|
|
24
26
|
|
|
@@ -62,7 +64,8 @@ const DEFAULTS: DiagnoseCustomization = {
|
|
|
62
64
|
onOpenSettings: undefined,
|
|
63
65
|
};
|
|
64
66
|
|
|
65
|
-
const DiagnoseCustomizationContext =
|
|
67
|
+
const DiagnoseCustomizationContext =
|
|
68
|
+
createContext<DiagnoseCustomization>(DEFAULTS);
|
|
66
69
|
|
|
67
70
|
export function DiagnoseCustomizationProvider({
|
|
68
71
|
value,
|