Skip to content

Commit 465c0df

Browse files
authored
fix(ai): preserve target evidence for plural Hub run kinds (#1748)
## What a user sees An investigation can reach a sound conclusion and show the raw tool calls in Activity, yet Findings shows little or no evidence for the workload. The evidence was collected; the UI put it in the wrong bucket. This is most visible when opening an investigation saved by Hub for a workload such as `dev/cloud-provisioner`. ## Why this happens Kubernetes gives the same object two names in different contexts: | Where the name comes from | Example | | --- | --- | | The resource page/API URL names the *collection* | `deployments` | | The fetched object and many tool results name its *Kind* | `Deployment` | The investigation stores a target—what the user asked Radar to investigate. Findings then checks each tool result against that target to decide whether it is evidence **about this workload** or merely **broader context**. Some Hub runs saved the collection name `deployments` as the target, while the evidence says `Deployment`. The previous exact-text comparison said they were different, so relevant events, changes, issues, and resource results could be treated as context instead of target evidence. Reopening a saved run kept the mismatch. ## What the fix does 1. **Starting an investigation:** when the resource page has loaded the Kubernetes object, it sends the object's actual Kind (`Deployment`). If it has not loaded yet, Radar uses its known Kubernetes resource definitions or API discovery to translate `deployments` to `Deployment`. Repeated starts take the same path. 2. **Reading an existing Hub run:** no migration or Hub API change is needed. Findings accepts a saved target named either `Deployment` or `deployments`. 3. **Sorting evidence:** Findings compares the *resource identity*, not the spelling of Kind: API group, plural resource name, namespace, and object name. Thus `apps/Deployment dev/cloud-provisioner` and `apps/deployments dev/cloud-provisioner` match. The API group matters because two different resources can both be named `pods`—a normal Pod and `metrics.k8s.io` PodMetrics must not be conflated. Tool arguments that still use plural names match by the same rule. 4. **Run state and history:** the running-investigation lookup treats singular and plural spellings as one target, and saved-run labels show the proper Kind when Radar knows it. For an unfamiliar CRD before API discovery, Radar keeps its valid plural resource name instead of inventing a singular Kind. That may leave the label less specific briefly, but cannot send a fabricated Kind or merge two API groups by accident. Some MCP tools, including `get_events`, cannot specify an API group; Findings infers their intended group from the investigation target, so a same-named resource in another group cannot always be distinguished from the tool result alone. This PR does not change that producer limitation. ## Verification - Radar web: 1,153 tests passed. Shared k8s-ui: 3,680 passed, 1 existing skip. TypeScript and production build passed; Go tests passed before the frontend-only review amendments. - Regression tests cover old saved plural targets, plural tool arguments, same-name resources in different API groups, PodMetrics, workload alerts, repeated-start identity, and an undiscovered irregular CRD. - Visual test skipped: no layout or new interaction changed; history-label behavior is covered by unit tests.
1 parent 18d77ba commit 465c0df

13 files changed

Lines changed: 207 additions & 11 deletions

File tree

‎packages/k8s-ui/src/components/workload/WorkloadView.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import {
3636
import type { TimelineEvent, ResourceRef, Relationships, SelectedResource, ResolvedEnvFrom, Topology, TopologyNode, HPADiagnosis, WorkloadPodInfo } from '../../types'
3737
import type { GitOpsStatus } from '../../types/gitops'
3838
import type { NavigateToResource } from '../../utils/navigation'
39-
import { refToSelectedResource, pluralToKind, kindToPlural, kindToPluralWithGroup, apiVersionToGroup } from '../../utils/navigation'
39+
import { refToSelectedResource, pluralToKind, knownKindForPluralWithGroup, kindToPlural, kindToPluralWithGroup, apiVersionToGroup } from '../../utils/navigation'
4040
import { neighborhoodFor, seedNodeIds } from '../../utils/topology-neighborhood'
4141
import { TopologyGraph } from '../topology/TopologyGraph'
4242
import { gitOpsOwnerFromRelationships, type GitOpsOwnerRef } from '../../utils/gitops-owner'
@@ -731,7 +731,7 @@ export function WorkloadView({
731731
| ((ctx: { kind: string; group?: string; namespace: string; name: string; health?: DiagnoseHealthHint }) => ReactNode)
732732
| undefined
733733
const diagnoseAction = renderDiagnose?.({
734-
kind: apiKind,
734+
kind: resource?.kind ?? knownKindForPluralWithGroup(apiKind, group ?? '') ?? apiKind,
735735
group,
736736
namespace,
737737
name,

‎packages/k8s-ui/src/utils/navigation.test.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, test, expect, afterEach } from 'vitest'
22
import { englishPlural } from './pluralize'
3-
import { kindToPlural, kindToPluralWithGroup, pluralToKind, refToSelectedResource, initNavigationMap, resetNavigationMap, laneId, laneResourceKey, groupQualifiesLaneId, parseLaneId } from './navigation'
3+
import { kindToPlural, kindToPluralWithGroup, pluralToKind, knownKindForPluralWithGroup, refToSelectedResource, initNavigationMap, resetNavigationMap, laneId, laneResourceKey, groupQualifiesLaneId, parseLaneId } from './navigation'
44

55
afterEach(() => {
66
resetNavigationMap()
@@ -223,6 +223,25 @@ describe('initNavigationMap', () => {
223223
{ group: 'metrics.k8s.io', version: 'v1beta1', kind: 'PodMetrics', name: 'pods', namespaced: true, isCrd: false, verbs: ['get'] },
224224
])
225225
expect(pluralToKind('pods')).toBe('Pod')
226+
expect(knownKindForPluralWithGroup('pods', '')).toBe('Pod')
227+
expect(knownKindForPluralWithGroup('pods', 'metrics.k8s.io')).toBe('PodMetrics')
228+
expect(kindToPluralWithGroup('PodMetrics', 'metrics.k8s.io')).toBe('pods')
229+
})
230+
231+
test('resolves discovered plural collisions by API group', () => {
232+
initNavigationMap([
233+
{ group: 'a.example.io', version: 'v1', kind: 'Widget', name: 'widgets', namespaced: true, isCrd: true, verbs: ['get'] },
234+
{ group: 'b.example.io', version: 'v1', kind: 'OtherWidget', name: 'widgets', namespaced: true, isCrd: true, verbs: ['get'] },
235+
])
236+
expect(knownKindForPluralWithGroup('widgets', 'a.example.io')).toBe('Widget')
237+
expect(knownKindForPluralWithGroup('widgets', 'b.example.io')).toBe('OtherWidget')
238+
expect(knownKindForPluralWithGroup('OtherWidget', 'b.example.io')).toBe('OtherWidget')
239+
})
240+
241+
test('does not invent a Kind for an undiscovered irregular CRD', () => {
242+
expect(knownKindForPluralWithGroup('databases', 'postgresql.cnpg.io')).toBeUndefined()
243+
expect(kindToPluralWithGroup('Database', 'postgresql.cnpg.io')).toBe('databases')
244+
expect(kindToPluralWithGroup('databases', 'postgresql.cnpg.io')).toBe('databases')
226245
})
227246

228247
test('keeps the virtual PodGroup alias distinct from scheduling PodGroup', () => {

‎packages/k8s-ui/src/utils/navigation.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { SelectedResource, ResourceRef, APIResource } from '../types/core'
22
import { englishPlural, englishSingular, isEnglishPlural } from './pluralize'
3+
import { CORE_RESOURCES } from './api-resources'
34

45
/**
56
* Canonical callback type for navigating to a resource.
@@ -44,13 +45,23 @@ const BUILTIN_PLURAL_TO_KIND: Record<string, string> = {
4445

4546
const BUILTIN_GROUP_KIND_TO_PLURAL: Record<string, string> = {
4647
'scheduling.k8s.io/podgroup': 'podgroups',
48+
'metrics.k8s.io/podmetrics': 'pods',
49+
'metrics.k8s.io/nodemetrics': 'nodes',
50+
}
51+
52+
const BUILTIN_GROUP_PLURAL_TO_KIND: Record<string, string> = {
53+
...Object.fromEntries(CORE_RESOURCES.map(r => [`${r.group}/${r.name}`, r.kind])),
54+
'metrics.k8s.io/pods': 'PodMetrics',
55+
'metrics.k8s.io/nodes': 'NodeMetrics',
56+
'scheduling.k8s.io/podgroups': 'PodGroup',
4757
}
4858

4959
// Dynamic map built from API discovery — populated by initNavigationMap().
5060
// Once populated, this is the source of truth for all kind↔plural lookups.
5161
let discoveredPluralToKind: Record<string, string> | null = null
5262
let discoveredKindToPlural: Record<string, string> | null = null
5363
let discoveredGroupKindToPlural: Record<string, string> | null = null
64+
let discoveredGroupPluralToKind: Record<string, string> | null = null
5465

5566
/**
5667
* Initialize navigation maps from discovered API resources.
@@ -61,6 +72,7 @@ export function initNavigationMap(resources: APIResource[]) {
6172
const p2k: Record<string, string> = { ...BUILTIN_PLURAL_TO_KIND }
6273
const k2p: Record<string, string> = {}
6374
const gk2p: Record<string, string> = {}
75+
const gp2k: Record<string, string> = { ...BUILTIN_GROUP_PLURAL_TO_KIND }
6476
for (const r of resources) {
6577
const plural = r.name.toLowerCase()
6678
// First-wins on plurals: BUILTIN_PLURAL_TO_KIND seeds canonical core mappings
@@ -69,17 +81,21 @@ export function initNavigationMap(resources: APIResource[]) {
6981
if (!(plural in p2k)) p2k[plural] = r.kind
7082
k2p[r.kind.toLowerCase()] = plural
7183
gk2p[`${r.group}/${r.kind.toLowerCase()}`] = plural
84+
const groupPlural = `${r.group}/${plural}`
85+
if (!(groupPlural in gp2k)) gp2k[groupPlural] = r.kind
7286
}
7387
discoveredPluralToKind = p2k
7488
discoveredKindToPlural = k2p
7589
discoveredGroupKindToPlural = gk2p
90+
discoveredGroupPluralToKind = gp2k
7691
}
7792

7893
/** Reset navigation maps to builtin-only state. For testing. */
7994
export function resetNavigationMap() {
8095
discoveredPluralToKind = null
8196
discoveredKindToPlural = null
8297
discoveredGroupKindToPlural = null
98+
discoveredGroupPluralToKind = null
8399
}
84100

85101
function getPluralToKind(): Record<string, string> {
@@ -166,6 +182,15 @@ export function pluralToKind(plural: string): string {
166182
return singular.charAt(0).toUpperCase() + singular.slice(1)
167183
}
168184

185+
/** Return an exact Kind only when the input or group-qualified discovery proves it. */
186+
export function knownKindForPluralWithGroup(plural: string, group: string): string | undefined {
187+
if (!plural) return undefined
188+
if (plural[0] !== plural[0].toLowerCase()) return plural
189+
const groupPlural = `${group}/${plural.toLowerCase()}`
190+
return discoveredGroupPluralToKind?.[groupPlural]
191+
?? BUILTIN_GROUP_PLURAL_TO_KIND[groupPlural]
192+
}
193+
169194
/**
170195
* Convert a ResourceRef (from backend relationships) to a SelectedResource (for navigation).
171196
* Handles kind singular→plural conversion.

‎web/src/api/diagnose.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ export interface DiagnoseStreamEvent {
183183
// server runs.
184184
export interface RunSummary {
185185
id: string;
186+
/** Stored target Kind; existing hosted runs may contain a plural API resource name. */
186187
kind: string;
187188
/** Kubernetes API group; empty means the core API group. */
188189
group: string;

‎web/src/components/diagnose/DiagnoseContext.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
type ExecutionProfile,
3737
} from "../../api/diagnose";
3838
import { runTargetKey } from "./target";
39+
import { knownKindForPluralWithGroup } from "../../utils/navigation";
3940

4041
export interface Target {
4142
kind: string;
@@ -660,7 +661,7 @@ function RoutedDiagnoseProvider({
660661
const startRunRef = useRef<(t: Target) => void>(() => {});
661662
startRunRef.current = (t: Target) => {
662663
const seq = ++startSeqRef.current;
663-
createRun(t, {
664+
createRun({ ...t, kind: knownKindForPluralWithGroup(t.kind, t.group) ?? t.kind }, {
664665
agent: selectedAgent || undefined,
665666
profile: hosted ? undefined : effectiveProfile,
666667
model: model || undefined,

‎web/src/components/diagnose/investigationEvidence/adapters/prometheus.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,35 @@ function diagnoseWithPods(...pods: string[]) {
7171
}
7272

7373
describe("prometheus rules adapter", () => {
74+
it("attributes workload-labelled alerts in saved plural-kind Hub runs", () => {
75+
const projection = projectInvestigationEvidence(
76+
[
77+
{
78+
timeline: [
79+
tool("rules", "get_prometheus_rules", {
80+
count: 1,
81+
rules: [
82+
alertingRule({
83+
alerts: [
84+
{
85+
state: "firing",
86+
labels: { namespace: "shop", deployment: "api" },
87+
},
88+
],
89+
}),
90+
],
91+
}),
92+
],
93+
},
94+
],
95+
{ ...target, kind: "deployments" },
96+
);
97+
98+
expect(groupsOf(projection.groups, "alerts")[0].latest.relevance).toBe(
99+
"target",
100+
);
101+
});
102+
74103
it("relates a rule to the target through its instances, never the rule alone", () => {
75104
const projection = project([
76105
diagnoseWithPods("api-68c7b766dc-fmphn"),

‎web/src/components/diagnose/investigationEvidence/builder.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { apiVersionToGroup } from "../../../utils/navigation";
1+
import { apiVersionToGroup, knownKindForPluralWithGroup } from "../../../utils/navigation";
22
import { adaptChanges } from "./adapters/changes";
33
import { adaptDiagnose } from "./adapters/diagnose";
44
import { adaptEvents } from "./adapters/events";
@@ -118,7 +118,10 @@ export function projectInvestigationEvidence(
118118
turns: readonly InvestigationEvidenceTurn[],
119119
target: InvestigationEvidenceTarget,
120120
): InvestigationEvidenceProjection {
121-
const builder = new ProjectionBuilder(target);
121+
const builder = new ProjectionBuilder({
122+
...target,
123+
kind: knownKindForPluralWithGroup(target.kind, target.group) ?? target.kind,
124+
});
122125
collectEstablishedTargetPods(builder, turns);
123126
const evidenceRefSources: InvestigationEvidenceSource[] = [];
124127
const citableSources: InvestigationEvidenceSource[] = [];

‎web/src/components/diagnose/investigationEvidence/observations.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
diagnosisSeverityTone,
1717
} from "../diagnoseEvidenceTypes";
1818
import { investigationResourceEvidenceSummary } from "../investigationResourceEvidenceModel";
19+
import { kindToPluralWithGroup } from "../../../utils/navigation";
1920
import { stableHash } from "./identity";
2021
import { nonEmptyString, parseJSON, record, stringArray } from "./parse";
2122
import {
@@ -198,9 +199,11 @@ export function resourceMatchesTarget(
198199
name: string;
199200
},
200201
): boolean {
202+
const group = (resource.group ?? "").toLowerCase();
201203
return (
202-
resource.kind.toLowerCase() === target.kind.toLowerCase() &&
203-
(resource.group ?? "").toLowerCase() === target.group.toLowerCase() &&
204+
group === target.group.toLowerCase() &&
205+
kindToPluralWithGroup(resource.kind, group) ===
206+
kindToPluralWithGroup(target.kind, group) &&
204207
(resource.namespace ?? "") === (target.namespace ?? "") &&
205208
resource.name === target.name
206209
);

‎web/src/components/diagnose/investigationEvidence/projection.test.ts‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,100 @@ describe("strict evidence adapters", () => {
469469
);
470470
});
471471

472+
it("attributes evidence to saved Hub runs with a plural resource kind", () => {
473+
const result = projectInvestigationEvidence(
474+
[
475+
{
476+
timeline: [
477+
tool("deployment", "get_resource", deployment),
478+
tool("wrong-group", "get_resource", {
479+
...deployment,
480+
apiVersion: "example.com/v1",
481+
}),
482+
tool(
483+
"plural-events",
484+
"get_events",
485+
{ events: [warningEvent] },
486+
{
487+
summary: JSON.stringify({
488+
kind: "deployments",
489+
namespace: "shop",
490+
name: "api",
491+
}),
492+
},
493+
),
494+
tool("plural-issue", "issues", {
495+
issues: [{ ...criticalIssue, kind: "deployments" }],
496+
total: 1,
497+
total_matched: 1,
498+
}),
499+
],
500+
},
501+
],
502+
{ ...target, kind: "deployments" },
503+
);
504+
505+
const resources = groupsOf(result.groups, "resource");
506+
expect(resources.map((group) => group.latest.relevance)).toEqual([
507+
"target",
508+
"broader",
509+
]);
510+
expect(groupsOf(result.groups, "events")[0].latest.relevance).toBe(
511+
"target",
512+
);
513+
expect(groupsOf(result.groups, "issue")[0].latest.relevance).toBe(
514+
"target",
515+
);
516+
});
517+
518+
it("keeps colliding non-core plurals on their own API group", () => {
519+
const podMetrics = {
520+
apiVersion: "metrics.k8s.io/v1beta1",
521+
kind: "PodMetrics",
522+
metadata: { namespace: "shop", name: "api" },
523+
};
524+
const result = projectInvestigationEvidence(
525+
[
526+
{
527+
timeline: [
528+
tool("metrics-pod", "get_resource", podMetrics),
529+
tool("core-pod", "get_resource", {
530+
...podMetrics,
531+
apiVersion: "v1",
532+
kind: "Pod",
533+
}),
534+
],
535+
},
536+
],
537+
{ ...target, kind: "pods", group: "metrics.k8s.io" },
538+
);
539+
expect(
540+
groupsOf(result.groups, "resource").map(
541+
(group) => group.latest.relevance,
542+
),
543+
).toEqual(["target", "broader"]);
544+
});
545+
546+
it("matches an undiscovered irregular CRD without inventing its Kind", () => {
547+
const result = projectInvestigationEvidence(
548+
[
549+
{
550+
timeline: [
551+
tool("database", "get_resource", {
552+
apiVersion: "postgresql.cnpg.io/v1",
553+
kind: "Database",
554+
metadata: { namespace: "shop", name: "api" },
555+
}),
556+
],
557+
},
558+
],
559+
{ ...target, kind: "databases", group: "postgresql.cnpg.io" },
560+
);
561+
expect(groupsOf(result.groups, "resource")[0].latest.relevance).toBe(
562+
"target",
563+
);
564+
});
565+
472566
it("treats an unexpressible event group as unspecified while honoring explicit groups", () => {
473567
const eventsFor = (group?: string) =>
474568
project([

‎web/src/components/diagnose/target.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,25 @@ describe("investigation target identity", () => {
2626
).toBe("Rollout.argoproj.io prod/checkout");
2727
});
2828

29+
it("formats saved plural targets as Kubernetes Kinds", () => {
30+
expect(
31+
formatInvestigationTarget({
32+
kind: "deployments",
33+
group: "apps",
34+
namespace: "prod",
35+
name: "api",
36+
}),
37+
).toBe("Deployment.apps prod/api");
38+
expect(
39+
formatInvestigationTarget({
40+
kind: "pods",
41+
group: "metrics.k8s.io",
42+
namespace: "prod",
43+
name: "api",
44+
}),
45+
).toBe("PodMetrics.metrics.k8s.io prod/api");
46+
});
47+
2948
it("keeps the core target label compact", () => {
3049
expect(
3150
formatInvestigationTarget({

0 commit comments

Comments
 (0)