@skyhook-io/radar-app 1.13.0 → 1.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +55 -9
  3. package/src/RadarApp.tsx +3 -3
  4. package/src/api/diagnose.test.ts +268 -0
  5. package/src/api/diagnose.ts +117 -14
  6. package/src/components/diagnose/AISettings.tsx +10 -5
  7. package/src/components/diagnose/AgentSetupNotice.tsx +5 -5
  8. package/src/components/diagnose/ApplyDialog.test.tsx +72 -0
  9. package/src/components/diagnose/DiagnoseContext.tsx +228 -65
  10. package/src/components/diagnose/DiagnoseSurface.test.tsx +317 -9
  11. package/src/components/diagnose/DiagnoseSurface.tsx +621 -132
  12. package/src/components/diagnose/Home.test.tsx +293 -0
  13. package/src/components/diagnose/Home.tsx +307 -108
  14. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +2170 -0
  15. package/src/components/diagnose/InvestigationEvidencePane.tsx +2253 -0
  16. package/src/components/diagnose/InvestigationResourceEvidence.test.tsx +257 -0
  17. package/src/components/diagnose/InvestigationResourceEvidence.tsx +214 -0
  18. package/src/components/diagnose/InvestigationView.test.ts +17 -0
  19. package/src/components/diagnose/InvestigationView.tsx +1911 -338
  20. package/src/components/diagnose/LocalDiagnoseAction.tsx +42 -25
  21. package/src/components/diagnose/agentCatalog.ts +1 -1
  22. package/src/components/diagnose/diagnoseEvidenceTypes.ts +151 -0
  23. package/src/components/diagnose/investigationEvidence.test.ts +3109 -0
  24. package/src/components/diagnose/investigationEvidence.ts +3492 -0
  25. package/src/components/diagnose/investigationEvidencePresentation.test.ts +447 -0
  26. package/src/components/diagnose/investigationEvidencePresentation.ts +167 -0
  27. package/src/components/diagnose/investigationExplanation.test.ts +63 -0
  28. package/src/components/diagnose/investigationExplanation.ts +22 -0
  29. package/src/components/diagnose/investigationResourceEvidenceModel.ts +322 -0
  30. package/src/components/diagnose/investigationSourceFocus.test.ts +143 -0
  31. package/src/components/diagnose/investigationSourceFocus.ts +98 -0
  32. package/src/components/diagnose/investigationState.test.ts +695 -0
  33. package/src/components/diagnose/investigationState.ts +451 -0
  34. package/src/components/diagnose/parts.test.tsx +864 -3
  35. package/src/components/diagnose/parts.tsx +1340 -536
  36. package/src/components/diagnose/target.test.ts +39 -0
  37. package/src/components/diagnose/target.ts +36 -0
  38. package/src/components/diagnose/useDisclosureReveal.ts +117 -0
  39. package/src/components/execution/BatchExecutionView.render.test.tsx +35 -0
  40. package/src/components/execution/BatchExecutionView.tsx +2 -3
  41. package/src/components/execution/execution-definition.test.ts +19 -0
  42. package/src/components/execution/execution-definition.ts +2 -0
  43. package/src/components/gitops/GitOpsView.tsx +25 -3
  44. package/src/components/home/MCPSetupDialog.tsx +2 -2
  45. package/src/components/home/mcpToolCatalog.test.ts +22 -0
  46. package/src/components/home/mcpToolCatalog.ts +3 -2
  47. package/src/components/issues/IssuesPane.tsx +5 -1
  48. package/src/components/nav/navigation.test.ts +26 -0
  49. package/src/components/nav/navigation.ts +10 -0
  50. package/src/components/settings/SettingsDialog.tsx +11 -13
  51. package/src/components/workload/WorkloadView.tsx +1 -1
  52. package/src/context/DiagnoseCustomization.tsx +11 -8
  53. package/src/index.css +63 -79
  54. package/src/index.ts +1 -1
@@ -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
- // is a no-op; the header already represents this resource.
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
- destination: formatGitOpsDestination(detailRow?.destination, detailRow?.destinationNamespace),
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, diagnose, and operate your cluster through Radar.
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. Live route diagnosis can create up
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: 'One-call root-cause bundle. Workloads get spec + resourceContext + current AND previous logs across pods + warning events + startup blockers; GitOps reconcilers, including Flux HelmRelease, get status summary + parsed related issues; network entry kinds (Service / Ingress / HTTPRoute / GRPCRoute / Gateway) get a path-shaped trace naming the first broken hop, with an optional one-shot reachability test.',
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 { useAPIResources, karpenterCapacityAvailable } from "../../api/apiResources";
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 diagnose) — applied immediately.
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 diagnose) stay usable by everyone. Non-Cloud
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 Diagnosis prefs are client-side (localStorage) and now SELF-SAVING: the
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 diagnose is always shown (the section
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 diagnose', icon: Sparkles, ownerOnly: false, dirty: aiDirty },
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 diagnose — self-saving, usable by everyone. Same heading block
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 diagnose</h3>
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 explain what's wrong.`
734
- : "Investigate incidents with an AI agent that runs on your own machine — reading logs, events, and topology to explain what's wrong. No Radar cloud, no API key."}
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 diagnose',
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 diagnose tab when no supported agent
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="Diagnose network path">
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 "Diagnose" action, and of the
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 a "Diagnose with AI" button
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 Diagnose button — OSS stays
10
+ // Default (no provider): Radar renders no Investigate button — OSS stays
11
11
  // agent-free.
12
- import { createContext, useContext, useMemo } from 'react';
13
- import type { ReactNode } from 'react';
12
+ import { createContext, useContext, useMemo } from "react";
13
+ import type { ReactNode } from "react";
14
14
 
15
- /** Render prop for the resource-level Diagnose action. */
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 "Diagnose" on a problem vs. a quiet "ask AI" when fine/unknown. */
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 = createContext<DiagnoseCustomization>(DEFAULTS);
67
+ const DiagnoseCustomizationContext =
68
+ createContext<DiagnoseCustomization>(DEFAULTS);
66
69
 
67
70
  export function DiagnoseCustomizationProvider({
68
71
  value,