@skyhook-io/radar-app 1.14.3 → 1.14.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.14.3",
3
+ "version": "1.14.4",
4
4
  "description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/api/client.ts CHANGED
@@ -3366,6 +3366,8 @@ export interface RightsizingScanCoverage {
3366
3366
  completedBatches: number;
3367
3367
  restrictedKinds?: string[];
3368
3368
  unavailableKinds?: string[];
3369
+ partiallyCachedKinds?: string[];
3370
+ daemonSetsWithoutNodes?: number;
3369
3371
  }
3370
3372
 
3371
3373
  export interface RightsizingScanResponse {
@@ -15,6 +15,10 @@ export interface AgentInfo {
15
15
  hosted?: boolean;
16
16
  /** Hosted backends opt in only when they implement assessment-bound, tool-free explanations. */
17
17
  assessmentExplanations?: boolean;
18
+ /** The backend performs a user-confirmed Apply turn. Absent means read-only. */
19
+ apply?: boolean;
20
+ /** The backend re-checks the resource after an Apply. Absent means it does not. */
21
+ verification?: boolean;
18
22
  }
19
23
 
20
24
  export interface AgentsResponse {
@@ -56,7 +60,7 @@ export interface RootCauseEvidence {
56
60
  * How the agent frames one cited Radar result. Roles order cards; they never
57
61
  * hide one.
58
62
  *
59
- * One half of a Go↔TS contract: `evidenceRoles` in internal/ai/parse.go and
63
+ * One half of a Go↔TS contract: `Roles` in pkg/investigation/verdict.go and
60
64
  * EVIDENCE_ROLES in components/diagnose/investigationCase.ts must list exactly
61
65
  * these roles. Change all three together.
62
66
  */
@@ -133,6 +137,8 @@ export interface Diagnosis {
133
137
  unlinkedEvidence?: number;
134
138
  /** The agent's evidence field was not a list, so none of it could be read. */
135
139
  evidenceMalformed?: boolean;
140
+ /** Valid next steps, open items and ruled-out hypotheses a count cap left out of this record. */
141
+ omittedEntries?: number;
136
142
  ruledOut?: DiagnosisRuledOut[];
137
143
  report: string;
138
144
  /** The agent's evidence ledger, written before its verdict block; shown in Activity, never in Findings. */
@@ -664,7 +664,7 @@ function RunningStatus({ label }: { label: string }) {
664
664
  · still working — no update for {sinceChange}s
665
665
  </span>
666
666
  )}
667
- {counter && (
667
+ {counter && !stalled && (
668
668
  <span className="ml-auto shrink-0 tabular-nums text-theme-text-tertiary">
669
669
  {counter}
670
670
  </span>
@@ -446,6 +446,7 @@ export function AssessmentSources({
446
446
  investigationCase,
447
447
  unlinkedEvidence = 0,
448
448
  evidenceMalformed = false,
449
+ omittedEntries = 0,
449
450
  readOnly = false,
450
451
  renderedGroupIds,
451
452
  onViewSource,
@@ -464,6 +465,12 @@ export function AssessmentSources({
464
465
  * and no count describes how many were lost.
465
466
  */
466
467
  evidenceMalformed?: boolean;
468
+ /**
469
+ * Next steps, open items and ruled-out hypotheses the server left out of
470
+ * the record because the list ran past the page's limit. They were valid;
471
+ * the reader learns the list was longer than what is shown.
472
+ */
473
+ omittedEntries?: number;
467
474
  readOnly?: boolean;
468
475
  /**
469
476
  * Groups the Evidence pane actually rendered. A card-placed note whose card
@@ -475,7 +482,12 @@ export function AssessmentSources({
475
482
  onViewSource: (sourceId: string) => void;
476
483
  }) {
477
484
  const rows = assessmentSourceRows(resolution, investigationCase);
478
- if (rows.length === 0 && unlinkedEvidence === 0 && !evidenceMalformed)
485
+ if (
486
+ rows.length === 0 &&
487
+ unlinkedEvidence === 0 &&
488
+ !evidenceMalformed &&
489
+ omittedEntries === 0
490
+ )
479
491
  return null;
480
492
  return (
481
493
  <div className="mt-3 border-t border-theme-border/60 pt-2">
@@ -566,6 +578,13 @@ export function AssessmentSources({
566
578
  : `${unlinkedEvidence} agent notes could not be linked to Radar results and are not shown.`}
567
579
  </p>
568
580
  ) : null}
581
+ {omittedEntries > 0 ? (
582
+ <p className="mt-2 text-[11px] text-theme-text-tertiary">
583
+ {omittedEntries === 1
584
+ ? "1 next step, open item or ruled-out hypothesis went past the page's limit and is not shown."
585
+ : `${omittedEntries} next steps, open items or ruled-out hypotheses went past the page's limits and are not shown.`}
586
+ </p>
587
+ ) : null}
569
588
  </div>
570
589
  );
571
590
  }
@@ -176,19 +176,17 @@ export function InvestigationView({
176
176
  onOpenTimeline?: (scope: InvestigationTimelineScope) => void;
177
177
  }) {
178
178
  const { kind, namespace, name } = run;
179
- // Apply is off for hosted agents (read-only server-side). Keyed on the selected
180
- // agent, which matches run.agent unless a deployment mixes hosted + local agents.
181
- const {
182
- refreshRuns,
183
- openInvestigation,
184
- startError,
185
- dismissError,
186
- hosted,
187
- agents,
188
- } = useDiagnose();
189
- const explanationEnabled = supportsAssessmentExplanation(
190
- agents.find((agent) => agent.name === run.agent),
191
- );
179
+ const { refreshRuns, openInvestigation, startError, dismissError, agents } =
180
+ useDiagnose();
181
+ // Capabilities are the declared ones of the agent that ran this run, not
182
+ // the picker's: a reopened run keeps the backend it was made with.
183
+ const runAgent = agents.find((agent) => agent.name === run.agent);
184
+ const explanationEnabled = supportsAssessmentExplanation(runAgent);
185
+ const canApply = runAgent?.apply === true;
186
+ // Read through a ref by the stream callback, which lives as long as the
187
+ // run and would otherwise keep the value from before the agents loaded.
188
+ const verifiesAfterApplyRef = useRef(false);
189
+ verifiesAfterApplyRef.current = runAgent?.verification === true;
192
190
  // Investigate again means look again, so it asks for a new session explicitly and only
193
191
  // carries the issue forward — being handed the previous answer is the one
194
192
  // thing someone clicking this doesn't want.
@@ -536,10 +534,13 @@ export function InvestigationView({
536
534
  });
537
535
  pendingApplyStartedLiveRef.current = false;
538
536
  if (effects.refreshClusterState) refreshClusterState();
539
- // A successful apply is one compound server-owned job. Its next
540
- // durable event is the automatic read-only verification turn; hold
541
- // the controls through that adjacent event so there is no idle flash.
542
- if (effects.verificationPending) setVerificationPending(true);
537
+ // On a backend that verifies, a successful apply is one compound
538
+ // server-owned job whose next durable event is the automatic
539
+ // read-only verification turn; hold the controls through that
540
+ // adjacent event so there is no idle flash. A backend that
541
+ // declares no verification sends no such turn, so nothing waits.
542
+ if (effects.verificationPending && verifiesAfterApplyRef.current)
543
+ setVerificationPending(true);
543
544
  }
544
545
  if (live || (isApply && applyStartedLive)) refreshRuns();
545
546
  break;
@@ -1299,13 +1300,15 @@ export function InvestigationView({
1299
1300
  (rootCauseEvidenceResolution?.links.length ||
1300
1301
  investigationCase?.items.length ||
1301
1302
  currentAssessment.diagnosis.unlinkedEvidence ||
1302
- currentAssessment.diagnosis.evidenceMalformed) ? (
1303
+ currentAssessment.diagnosis.evidenceMalformed ||
1304
+ currentAssessment.diagnosis.omittedEntries) ? (
1303
1305
  <AssessmentSources
1304
1306
  renderedGroupIds={visibleEvidenceGroupIds}
1305
1307
  resolution={rootCauseEvidenceResolution}
1306
1308
  investigationCase={investigationCase}
1307
1309
  unlinkedEvidence={currentAssessment.diagnosis.unlinkedEvidence}
1308
1310
  evidenceMalformed={currentAssessment.diagnosis.evidenceMalformed}
1311
+ omittedEntries={currentAssessment.diagnosis.omittedEntries}
1309
1312
  onViewSource={viewActivitySource}
1310
1313
  />
1311
1314
  ) : undefined;
@@ -1313,7 +1316,8 @@ export function InvestigationView({
1313
1316
  storyShape &&
1314
1317
  (investigationCase?.items.some((item) => item.placement === "source") ||
1315
1318
  currentAssessment?.diagnosis?.unlinkedEvidence ||
1316
- currentAssessment?.diagnosis?.evidenceMalformed)
1319
+ currentAssessment?.diagnosis?.evidenceMalformed ||
1320
+ currentAssessment?.diagnosis?.omittedEntries)
1317
1321
  ? assessmentSourcesNode
1318
1322
  : undefined;
1319
1323
  const assessmentLimits = useMemo(() => {
@@ -1345,8 +1349,7 @@ export function InvestigationView({
1345
1349
  );
1346
1350
  // While the first assessment is still running the pane keeps the story
1347
1351
  // shape, so the page fills in rather than rearranging when the verdict lands.
1348
- const storyShell =
1349
- !hosted && !currentAssessment && lastTurn?.status === "running";
1352
+ const storyShell = !currentAssessment && lastTurn?.status === "running";
1350
1353
  const currentAssessmentEvidenceConflict =
1351
1354
  currentAssessment?.diagnosis?.healthy === true &&
1352
1355
  investigationEvidenceConflictsWithHealthy(projection);
@@ -1660,7 +1663,7 @@ export function InvestigationView({
1660
1663
  lastApplyAttemptIdx,
1661
1664
  localApplyAttemptAssessmentIdx,
1662
1665
  interactionsBlocked,
1663
- hosted,
1666
+ canApply,
1664
1667
  hasNewerEvidence: hasEvidenceCollectedAfterAssessment,
1665
1668
  })
1666
1669
  ? requestApply
@@ -1966,7 +1969,8 @@ export function InvestigationView({
1966
1969
  return turnCase.items.length ||
1967
1970
  turnResolution?.links.length ||
1968
1971
  turn.diagnosis.unlinkedEvidence ||
1969
- turn.diagnosis.evidenceMalformed ? (
1972
+ turn.diagnosis.evidenceMalformed ||
1973
+ turn.diagnosis.omittedEntries ? (
1970
1974
  <AssessmentSources
1971
1975
  renderedGroupIds={visibleEvidenceGroupIds}
1972
1976
  resolution={turnResolution}
@@ -1977,6 +1981,7 @@ export function InvestigationView({
1977
1981
  evidenceMalformed={
1978
1982
  turn.diagnosis.evidenceMalformed
1979
1983
  }
1984
+ omittedEntries={turn.diagnosis.omittedEntries}
1980
1985
  readOnly
1981
1986
  onViewSource={viewActivitySource}
1982
1987
  />
@@ -1072,6 +1072,33 @@ describe("agent case placement (D-1, D-1b)", () => {
1072
1072
  ]);
1073
1073
  });
1074
1074
 
1075
+ it.each(["issue", "issues"])(
1076
+ "pins a %s citation to the issue observation",
1077
+ (observation) => {
1078
+ const resolved = resolveInvestigationCase(
1079
+ projection,
1080
+ {
1081
+ evidence: [
1082
+ linked(ref, "symptom", "The issue is the symptom.", {
1083
+ kind: "Deployment",
1084
+ group: "apps",
1085
+ namespace: "shop",
1086
+ name: "api",
1087
+ observation,
1088
+ }),
1089
+ ],
1090
+ },
1091
+ 0,
1092
+ );
1093
+ expect(resolved.items[0].placement).toBe("card");
1094
+ expect(
1095
+ projection.groups.find(
1096
+ (group) => group.id === resolved.items[0].groupId,
1097
+ )?.kind,
1098
+ ).toBe("issue");
1099
+ },
1100
+ );
1101
+
1075
1102
  it("pins a subject-bearing item to exactly the observation it names", () => {
1076
1103
  const resolved = resolveInvestigationCase(
1077
1104
  projection,
@@ -54,7 +54,7 @@ export interface InvestigationCaseResolution {
54
54
  }
55
55
 
56
56
  /**
57
- * One half of a Go↔TS contract: `evidenceRoles` in internal/ai/parse.go and the
57
+ * One half of a Go↔TS contract: `Roles` in pkg/investigation/verdict.go and the
58
58
  * DiagnosisEvidenceRole union in api/diagnose.ts must list exactly these roles.
59
59
  * A role the parser accepts but this set omits is bound server-side and then
60
60
  * silently dropped here. Change all three together.
@@ -388,7 +388,8 @@ function observationMatchesSubject(
388
388
  // "metrics" alone cannot name one; "metrics:<category>" picks the chart
389
389
  // whose identity ends in that category. An agent-run query is one chart,
390
390
  // so a qualifier on it carries no meaning.
391
- const [kind, qualifier] = subject.observation.toLowerCase().split(":", 2);
391
+ const [word, qualifier] = subject.observation.toLowerCase().split(":", 2);
392
+ const kind = word === "issues" ? "issue" : word;
392
393
  if (
393
394
  kind !== observation.data.type &&
394
395
  !(kind === "resource" && RESOURCE_SHAPED.has(observation.data.type))
@@ -434,6 +434,28 @@ describe("posture scans that did not finish", () => {
434
434
  });
435
435
 
436
436
  describe("listing cards (helm releases, packages, search)", () => {
437
+ it.each([null, []])("accepts an empty Helm result: %j", (payload) => {
438
+ const projection = project([
439
+ tool("helm", "list_helm_releases", payload, {
440
+ summary: JSON.stringify({ namespace: "shop" }),
441
+ }),
442
+ ]);
443
+ expect(projection.groups).toHaveLength(0);
444
+ expect(projection.limitations).toEqual([
445
+ expect.objectContaining({
446
+ source: "Resource inventory",
447
+ kind: "unknown",
448
+ }),
449
+ ]);
450
+ });
451
+
452
+ it.each([{}, "", 0])("rejects malformed Helm results: %j", (payload) => {
453
+ const projection = project([tool("helm", "list_helm_releases", payload)]);
454
+ expect(projection.limitations).toEqual([
455
+ expect.objectContaining({ source: "Helm releases", kind: "unknown" }),
456
+ ]);
457
+ });
458
+
437
459
  it("lists Helm releases with their status and chart", () => {
438
460
  const projection = project([
439
461
  tool(
@@ -264,7 +264,7 @@ describe("diagnose metrics evidence", () => {
264
264
  expect(groups.map((group) => group.latest.title)).toEqual([
265
265
  "CPU usage · Deployment shop/api",
266
266
  "Memory working set · Deployment shop/api",
267
- "Restarts · Deployment shop/api",
267
+ "Restarts in trailing 1h · Deployment shop/api",
268
268
  ]);
269
269
  for (const group of groups) {
270
270
  expect(group.latest.relevance).toBe("target");
@@ -289,6 +289,12 @@ describe("diagnose metrics evidence", () => {
289
289
  expect(investigationEvidenceSubjectRef(data)).toEqual(data.subject);
290
290
  }
291
291
  const [cpu, , restarts] = groups;
292
+ expect(restarts.latest.title).toContain("Restarts in trailing 1h");
293
+ expect(restarts.latest.data).toMatchObject({
294
+ type: "metrics",
295
+ label: "Restarts in trailing 1h",
296
+ unit: "count",
297
+ });
292
298
  expect(cpu.latest.summary).toBe("2 current pods · 60m window · 1m2s step");
293
299
  const cpuData = cpu.latest.data;
294
300
  if (cpuData.type !== "metrics") throw new Error("expected metrics");
@@ -562,7 +568,7 @@ describe("diagnose metrics evidence", () => {
562
568
  ]);
563
569
  const groups = groupsOf(projection.groups, "metrics");
564
570
  expect(groups.map((group) => group.latest.title)).toEqual([
565
- "Restarts · Deployment shop/api",
571
+ "Restarts in trailing 1h · Deployment shop/api",
566
572
  ]);
567
573
  expect(
568
574
  projection.limitations.some(
@@ -821,7 +821,7 @@ export function adaptDiagnose(
821
821
  const DIAGNOSE_METRICS_LABELS: Record<string, string> = {
822
822
  cpu: "CPU usage",
823
823
  memory: "Memory working set",
824
- restarts: "Restarts",
824
+ restarts: "Restarts in trailing 1h",
825
825
  };
826
826
  /**
827
827
  * The vitals `diagnose` captured inside the same call: one chart per category
@@ -13,6 +13,8 @@ export function adaptListHelmReleases(
13
13
  source: InvestigationEvidenceSource,
14
14
  payload: unknown,
15
15
  ): void {
16
+ // Retained runs may contain the Helm producer's nil-slice encoding.
17
+ if (payload === null) payload = [];
16
18
  if (!Array.isArray(payload)) {
17
19
  invalidPayload(builder, source);
18
20
  return;
@@ -193,6 +193,8 @@ const INVESTIGATION_RESULT_LABELS: Readonly<Record<string, string>> = {
193
193
  list_packages: "Installed packages",
194
194
  search: "Search results",
195
195
  discover_metrics: "Metric discovery",
196
+ get_cost: "Cost",
197
+ get_rightsizing: "Rightsizing",
196
198
  };
197
199
  export function investigationResultLabel(
198
200
  source: InvestigationEvidenceSource,
@@ -611,14 +611,14 @@ describe("investigation action gating", () => {
611
611
  lastApplyAttemptIdx: -1,
612
612
  localApplyAttemptAssessmentIdx: -1,
613
613
  interactionsBlocked: false,
614
- hosted: false,
614
+ canApply: true,
615
615
  hasNewerEvidence: false,
616
616
  };
617
617
  expect(canOfferInvestigationApply(base)).toBe(true);
618
618
  expect(
619
619
  canOfferInvestigationApply({ ...base, hasNewerEvidence: true }),
620
620
  ).toBe(false);
621
- expect(canOfferInvestigationApply({ ...base, hosted: true })).toBe(false);
621
+ expect(canOfferInvestigationApply({ ...base, canApply: false })).toBe(false);
622
622
  expect(
623
623
  canOfferInvestigationApply({ ...base, interactionsBlocked: true }),
624
624
  ).toBe(false);
@@ -76,7 +76,7 @@ export function canOfferInvestigationApply(input: {
76
76
  lastApplyAttemptIdx: number;
77
77
  localApplyAttemptAssessmentIdx: number;
78
78
  interactionsBlocked: boolean;
79
- hosted: boolean;
79
+ canApply: boolean;
80
80
  hasNewerEvidence: boolean;
81
81
  }): boolean {
82
82
  return (
@@ -87,7 +87,7 @@ export function canOfferInvestigationApply(input: {
87
87
  input.localApplyAttemptAssessmentIdx,
88
88
  ) &&
89
89
  !input.interactionsBlocked &&
90
- !input.hosted &&
90
+ input.canApply &&
91
91
  !input.hasNewerEvidence
92
92
  );
93
93
  }
@@ -48,7 +48,7 @@ export type StorySegment =
48
48
  | {
49
49
  kind: "placement";
50
50
  index: number;
51
- /** The agent asked for the header only: the reader needs the fact, not the detail. */
51
+ /** Header and role only, except logs retain their last two selected lines. */
52
52
  compact?: boolean;
53
53
  /** Placed by Radar under the paragraph that first mentioned it inline. */
54
54
  auto?: boolean;
@@ -1571,4 +1571,16 @@ describe("assessment provenance disclosure", () => {
1571
1571
  expect(shown).not.toContain("WITHHELD_CLAIM");
1572
1572
  expect(shown).toContain("1 agent note on an evidence card");
1573
1573
  });
1574
+
1575
+ it("discloses entries a count cap left out even when nothing else was lost", () => {
1576
+ const only = renderToStaticMarkup(
1577
+ <AssessmentSources omittedEntries={2} onViewSource={noop} />,
1578
+ );
1579
+ expect(only).toContain(
1580
+ "2 next steps, open items or ruled-out hypotheses went past the page",
1581
+ );
1582
+ expect(
1583
+ renderToStaticMarkup(<AssessmentSources onViewSource={noop} />),
1584
+ ).toBe("");
1585
+ });
1574
1586
  });
@@ -256,6 +256,33 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
256
256
  { arg: 'limit', desc: 'max rules returned (default 50, max 200)' },
257
257
  ],
258
258
  },
259
+ {
260
+ name: 'get_cost',
261
+ desc: 'Cluster spend from OpenCost or Kubecost — cluster and per-namespace totals, per-workload and per-node breakdowns, and spend over time. Hourly rates plus the same monthly projection the Costs page shows.',
262
+ params: [
263
+ { arg: 'view', desc: 'summary (default), workloads, nodes (cluster-wide), or trend' },
264
+ { arg: 'namespace', desc: 'required for view=workloads; filters summary and trend' },
265
+ { arg: 'kind', desc: 'view=workloads: target one workload instead of the top spenders; needs name' },
266
+ { arg: 'name', desc: 'workloads: exact workload name (needs kind); nodes: exact node name' },
267
+ { arg: 'range', desc: 'trend only: 6h, 24h (default), or 7d' },
268
+ { arg: 'include_points', desc: 'trend only: include raw points (default false); summaries are always returned' },
269
+ { arg: 'limit', desc: 'max rows (default 20, max 100)' },
270
+ ],
271
+ },
272
+ {
273
+ name: 'get_rightsizing',
274
+ desc: 'CPU/memory request recommendations, with current limits as context, from 7 days of observed usage, with a fit verdict and confidence tier per container. Scans can take 45 seconds; inspect confidence and missing evidence. Request reductions do not directly imply bill savings.',
275
+ params: [
276
+ { arg: 'scope', required: true, desc: 'workload (needs kind/name/namespace), namespace, or cluster' },
277
+ { arg: 'kind', desc: 'scope=workload: Deployment, StatefulSet, or DaemonSet' },
278
+ { arg: 'name', desc: 'scope=workload: the workload name' },
279
+ { arg: 'namespace', desc: 'required for scope=workload; for scope=namespace unless namespaces is set' },
280
+ { arg: 'namespaces', desc: 'scope=namespace: scan several namespaces in one call' },
281
+ { arg: 'include_all', desc: 'include balanced and unevidenced rows (default false); workload scope always returns all rows' },
282
+ { arg: 'classification', desc: 'scan only: reduction, increase, review, need_data, or in_range; returns all rows of matching workloads' },
283
+ { arg: 'limit', desc: 'max workloads returned by a namespace or cluster scan (default 20, max 100)' },
284
+ ],
285
+ },
259
286
  {
260
287
  name: 'get_workload_logs',
261
288
  desc: 'Aggregated logs across all pods of a workload. Without grep, filters for diagnostic relevance; grep returns only matching timestamp-prefixed lines instead.',
@@ -8,6 +8,7 @@ import {
8
8
  PageHeader,
9
9
  SearchBox,
10
10
  SelectMenu,
11
+ pluralize,
11
12
  } from '@skyhook-io/k8s-ui'
12
13
  import { Badge } from '@skyhook-io/k8s-ui/components/ui/Badge'
13
14
  import {
@@ -335,7 +336,13 @@ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
335
336
  onSelect={(value) => setFilter('rfClass', value === 'actions' ? undefined : value)}
336
337
  />
337
338
  <ScanNotices result={result} rows={rows} />
338
- {result.coverage.workloadsDiscovered === 0 || rows.length === 0 ? (
339
+ {result.reason === 'only_daemonsets_without_nodes' ? (
340
+ <EmptyState
341
+ variant="card"
342
+ headline="Only DaemonSets with no nodes in this scope"
343
+ body="Every workload here is a DaemonSet that runs on no node right now, so none was scanned. Open one from Resources to see recommendations from its retained history."
344
+ />
345
+ ) : result.coverage.workloadsDiscovered === 0 || rows.length === 0 ? (
339
346
  <EmptyState
340
347
  variant="card"
341
348
  headline="No supported workloads in this scope"
@@ -537,6 +544,18 @@ function ScanNotices({ result, rows }: { result: ScanResult; rows: RightsizingSc
537
544
  notices.push('Some workload kinds or namespaces were excluded by your Kubernetes access.')
538
545
  if ((result.coverage.unavailableKinds?.length ?? 0) > 0)
539
546
  notices.push('Some workload kinds could not be evaluated with the available ownership data.')
547
+ // Distinct from the partial notice above: these workloads were analyzed
548
+ // completely, the cache simply never held the other namespaces.
549
+ if ((result.coverage.partiallyCachedKinds?.length ?? 0) > 0)
550
+ notices.push(
551
+ 'Radar is caching only some namespaces, so this scan covered a narrower scope than the whole cluster.',
552
+ )
553
+ const daemonSetsWithoutNodes = result.coverage.daemonSetsWithoutNodes ?? 0
554
+ // The DaemonSet-only empty state already says this.
555
+ if (daemonSetsWithoutNodes > 0 && result.reason !== 'only_daemonsets_without_nodes')
556
+ notices.push(
557
+ `${pluralize(daemonSetsWithoutNodes, 'DaemonSet')} run on no node right now and ${daemonSetsWithoutNodes === 1 ? 'is' : 'are'} not listed. Open one from Resources to see recommendations from its retained history.`,
558
+ )
540
559
  for (const warning of result.warnings ?? []) notices.push(warningMessage(warning.code))
541
560
  if (rows.length > 0 && rows.every((row) => row.classification === 'need_data'))
542
561
  notices.push('There is not enough recent history to recommend request changes yet.')
@@ -1,4 +1,4 @@
1
- import { useState, useMemo, useRef, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react'
1
+ import { useState, useMemo, useRef, useEffect, useCallback, forwardRef, useImperativeHandle, type ReactNode } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import { Search, CornerDownLeft, Loader2, AlertTriangle } from 'lucide-react'
4
4
  import { clsx } from 'clsx'
@@ -424,74 +424,12 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
424
424
 
425
425
  const hero = size === 'hero'
426
426
 
427
- return (
428
- <div
429
- ref={containerRef}
430
- className={clsx('relative w-full', hero ? 'max-w-3xl' : 'max-w-lg', open && hero && 'z-[16]')}
431
- // Open on click even when the field is already focused — onFocus alone
432
- // never fires again, so an autofocused hero (Home) wouldn't reveal the
433
- // launcher on a click.
434
- onMouseDown={() => setOpen(true)}
435
- >
436
- <SearchPillInput
437
- className={hero
438
- ? `min-h-14 px-5 rounded-2xl bg-theme-surface border border-theme-border shadow-theme-sm transition-[color,background-color,border-color,box-shadow] duration-[140ms] ${TW_EASE_UI} focus-within:border-[var(--color-brand-500)] focus-within:shadow-[0_0_0_4px_color-mix(in_srgb,var(--color-brand-500)_15%,transparent)]`
439
- : `min-h-8 px-2.5 rounded-md bg-theme-elevated border border-transparent focus-within:border-theme-border focus-within:bg-theme-surface transition-[color,background-color,border-color] duration-[140ms] ${TW_EASE_UI}`}
440
- inputClassName={hero ? 'text-lg py-4' : undefined}
441
- text={text}
442
- pills={pills}
443
- onChange={({ text: t, pills: p }) => { setText(t); setPills(p); setOpen(true) }}
444
- onKeyDown={handleKeyDown}
445
- onFocus={() => { if (skipFocusOpen.current) { skipFocusOpen.current = false; return } setOpen(true) }}
446
- onSuggestingChange={setSuggesting}
447
- modifierOptions={modifierOptions}
448
- placeholder={placeholder}
449
- aria-label="Search resources and commands"
450
- inputRef={inputRef}
451
- leftSlot={<Search className={hero ? 'w-5 h-5 shrink-0 text-theme-text-tertiary' : 'w-3.5 h-3.5 shrink-0 text-theme-text-tertiary'} />}
452
- rightSlot={
453
- <div className="flex items-center gap-1.5 shrink-0">
454
- <SearchSyntaxHelp />
455
- {!hero && !text && pills.length === 0 && (
456
- <kbd className="text-[10px] text-theme-text-tertiary bg-theme-surface px-1 py-0.5 rounded border border-theme-border-light">
457
- {mac ? '⌘' : 'Ctrl+'}K
458
- </kbd>
459
- )}
460
- </div>
461
- }
462
- />
463
-
464
- {portal.shouldRender && anchor && createPortal(
465
- <>
466
- {/* Scrim — separates the dropdown from the page, consistently in both
467
- modes. At z-[15] it sits BELOW the rail/top bar (z-20/30), so the
468
- nav chrome stays lit while the content behind the panel dims+blurs:
469
- a "spotlight on search", not a full-screen modal dim (which fits a
470
- centered command palette, not an anchored omnibar). The hero covers
471
- from the top (its box is in the content, lifted to z-[16]); the
472
- top-bar launcher covers from below the field (its box is already in
473
- the z-20 chrome). Click closes. */}
474
- <div
475
- className={clsx(
476
- 'fixed left-0 right-0 bottom-0 z-[15] bg-black/15 dark:bg-black/50 backdrop-blur-[3px]',
477
- TRANSITION_BACKDROP,
478
- portal.isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none',
479
- )}
480
- style={{ top: hero ? 0 : anchor.top, ...overlayTransitionStyle(portal.isOpen, 'menu') }}
481
- onClick={open ? () => { setOpen(false); inputRef.current?.blur() } : undefined}
482
- />
483
- {showPanel && (
484
- <div
485
- ref={panelRef}
486
- inert={!open || undefined}
487
- style={{ position: 'fixed', top: anchor.top + 8, left: anchor.centerX, transform: 'translateX(-50%)', width: hero ? Math.round(anchor.width) : 640, maxWidth: 'calc(100vw - 2rem)', ...overlayTransitionStyle(portal.isOpen, 'menu') }}
488
- className={clsx(
489
- 'z-[121] dialog shadow-theme-lg ring-1 ring-black/5 dark:ring-white/10 overflow-hidden origin-top',
490
- TRANSITION_MENU,
491
- portal.isOpen ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 -translate-y-1 scale-[0.97]',
492
- !open && 'pointer-events-none',
493
- )}
494
- >
427
+ // The results panel keeps showing what it showed at logical close while it
428
+ // fades: picking a result clears the query (and recents re-key off `open`)
429
+ // on the same render, and a panel that swaps to a different list mid-exit
430
+ // reads as a glitch. The last open-frame content is frozen for the exit.
431
+ const panelContent = (
432
+ <>
495
433
  <div ref={listRef} className="max-h-[60vh] overflow-y-auto py-1">
496
434
  {recentRows.length > 0 && (
497
435
  <div>
@@ -572,6 +510,81 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
572
510
  <span>⇞⇟ page</span>
573
511
  <span>esc close</span>
574
512
  </div>
513
+ </>
514
+ )
515
+ const frozenPanel = useRef<ReactNode>(null)
516
+ if (open) frozenPanel.current = panelContent
517
+ const shownPanel = open ? panelContent : frozenPanel.current
518
+
519
+ return (
520
+ <div
521
+ ref={containerRef}
522
+ className={clsx('relative w-full', hero ? 'max-w-3xl' : 'max-w-lg', portal.shouldRender && hero && 'z-[16]')}
523
+ // Open on click even when the field is already focused — onFocus alone
524
+ // never fires again, so an autofocused hero (Home) wouldn't reveal the
525
+ // launcher on a click.
526
+ onMouseDown={() => setOpen(true)}
527
+ >
528
+ <SearchPillInput
529
+ className={hero
530
+ ? `min-h-14 px-5 rounded-2xl bg-theme-surface border border-theme-border shadow-theme-sm transition-[color,background-color,border-color,box-shadow] duration-[140ms] ${TW_EASE_UI} focus-within:border-[var(--color-brand-500)] focus-within:shadow-[0_0_0_4px_color-mix(in_srgb,var(--color-brand-500)_15%,transparent)]`
531
+ : `min-h-8 px-2.5 rounded-md bg-theme-elevated border border-transparent focus-within:border-theme-border focus-within:bg-theme-surface transition-[color,background-color,border-color] duration-[140ms] ${TW_EASE_UI}`}
532
+ inputClassName={hero ? 'text-lg py-4' : undefined}
533
+ text={text}
534
+ pills={pills}
535
+ onChange={({ text: t, pills: p }) => { setText(t); setPills(p); setOpen(true) }}
536
+ onKeyDown={handleKeyDown}
537
+ onFocus={() => { if (skipFocusOpen.current) { skipFocusOpen.current = false; return } setOpen(true) }}
538
+ onSuggestingChange={setSuggesting}
539
+ modifierOptions={modifierOptions}
540
+ placeholder={placeholder}
541
+ aria-label="Search resources and commands"
542
+ inputRef={inputRef}
543
+ leftSlot={<Search className={hero ? 'w-5 h-5 shrink-0 text-theme-text-tertiary' : 'w-3.5 h-3.5 shrink-0 text-theme-text-tertiary'} />}
544
+ rightSlot={
545
+ <div className="flex items-center gap-1.5 shrink-0">
546
+ <SearchSyntaxHelp />
547
+ {!hero && !text && pills.length === 0 && (
548
+ <kbd className="text-[10px] text-theme-text-tertiary bg-theme-surface px-1 py-0.5 rounded border border-theme-border-light">
549
+ {mac ? '⌘' : 'Ctrl+'}K
550
+ </kbd>
551
+ )}
552
+ </div>
553
+ }
554
+ />
555
+
556
+ {portal.shouldRender && anchor && createPortal(
557
+ <>
558
+ {/* Scrim — separates the dropdown from the page, consistently in both
559
+ modes. At z-[15] it sits BELOW the rail/top bar (z-20/30), so the
560
+ nav chrome stays lit while the content behind the panel dims+blurs:
561
+ a "spotlight on search", not a full-screen modal dim (which fits a
562
+ centered command palette, not an anchored omnibar). The hero covers
563
+ from the top (its box is in the content, lifted to z-[16]); the
564
+ top-bar launcher covers from below the field (its box is already in
565
+ the z-20 chrome). Click closes. */}
566
+ <div
567
+ className={clsx(
568
+ 'fixed left-0 right-0 bottom-0 z-[15] bg-black/15 dark:bg-black/50 backdrop-blur-[3px]',
569
+ TRANSITION_BACKDROP,
570
+ portal.isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none',
571
+ )}
572
+ style={{ top: hero ? 0 : anchor.top, ...overlayTransitionStyle(portal.isOpen, 'menu') }}
573
+ onClick={open ? () => { setOpen(false); inputRef.current?.blur() } : undefined}
574
+ />
575
+ {showPanel && (
576
+ <div
577
+ ref={panelRef}
578
+ inert={!open || undefined}
579
+ style={{ position: 'fixed', top: anchor.top + 8, left: anchor.centerX, transform: 'translateX(-50%)', width: hero ? Math.round(anchor.width) : 640, maxWidth: 'calc(100vw - 2rem)', ...overlayTransitionStyle(portal.isOpen, 'menu') }}
580
+ className={clsx(
581
+ 'z-[121] dialog shadow-theme-lg ring-1 ring-black/5 dark:ring-white/10 overflow-hidden origin-top',
582
+ TRANSITION_MENU,
583
+ portal.isOpen ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 -translate-y-1 scale-[0.97]',
584
+ !open && 'pointer-events-none',
585
+ )}
586
+ >
587
+ {shownPanel}
575
588
  </div>
576
589
  )}
577
590
  </>,
@@ -742,9 +742,15 @@ export function WorkloadView({
742
742
  [helmOwner, helmSourceResource],
743
743
  )
744
744
 
745
- // Fetch topology for hierarchy building (only when expanded)
745
+ // Fetch topology for hierarchy building (only when expanded). Polled like
746
+ // useTrace's "drawer feeling live" pattern — without this, a resource
747
+ // whose status/labels/edges change without its node/edge identity changing
748
+ // (a canary weight step, a Pod's traffic role flipping to stable, an
749
+ // AnalysisRun finishing) never refreshes until something else forces a
750
+ // remount; a genuinely stuck-looking Topology tab, not just a stale one.
746
751
  const { data: topology } = useTopology([namespace], 'resources', {
747
752
  enabled: expanded,
753
+ refetchInterval: expanded ? 5000 : false,
748
754
  })
749
755
 
750
756
  // Always fetched so Recent Events populates on drawer open; allEvents below is