@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 +1 -1
- package/src/api/client.ts +2 -0
- package/src/api/diagnose.ts +7 -1
- package/src/components/diagnose/ActivityTurn.tsx +1 -1
- package/src/components/diagnose/AssessmentCard.tsx +20 -1
- package/src/components/diagnose/InvestigationView.tsx +28 -23
- package/src/components/diagnose/investigationCase.test.tsx +27 -0
- package/src/components/diagnose/investigationCase.ts +3 -2
- package/src/components/diagnose/investigationEvidence/adapters/coverage.test.tsx +22 -0
- package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +8 -2
- package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +1 -1
- package/src/components/diagnose/investigationEvidence/adapters/listings.ts +2 -0
- package/src/components/diagnose/investigationEvidence/observations.ts +2 -0
- package/src/components/diagnose/investigationState.test.ts +2 -2
- package/src/components/diagnose/investigationState.ts +2 -2
- package/src/components/diagnose/investigationStory.ts +1 -1
- package/src/components/diagnose/parts.test.tsx +12 -0
- package/src/components/home/mcpToolCatalog.ts +27 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +20 -1
- package/src/components/ui/Omnibar.tsx +82 -69
- package/src/components/workload/WorkloadView.tsx +7 -1
package/package.json
CHANGED
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 {
|
package/src/api/diagnose.ts
CHANGED
|
@@ -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: `
|
|
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 (
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
//
|
|
540
|
-
// durable event is the automatic
|
|
541
|
-
// the controls through that
|
|
542
|
-
|
|
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
|
-
|
|
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: `
|
|
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 [
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|