@skyhook-io/radar-app 1.14.2 → 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 +2 -2
- package/src/api/client.ts +32 -0
- package/src/api/diagnose.ts +7 -1
- package/src/components/CloudConnectFlow.tsx +21 -7
- package/src/components/CloudFunnelButton.tsx +85 -8
- package/src/components/cloudConnectHandoff.test.ts +10 -1
- package/src/components/cloudConnectHandoff.ts +15 -1
- package/src/components/diagnose/AIMarkdown.tsx +87 -0
- package/src/components/diagnose/ActivityTurn.tsx +1244 -0
- package/src/components/diagnose/AgentControls.tsx +603 -0
- package/src/components/diagnose/AnalysisStory.test.tsx +31 -25
- package/src/components/diagnose/AnalysisStory.tsx +13 -18
- package/src/components/diagnose/ApplyDialog.tsx +289 -0
- package/src/components/diagnose/AssessmentCard.tsx +1579 -0
- package/src/components/diagnose/DiagnoseSurface.test.tsx +9 -9
- package/src/components/diagnose/DiagnoseSurface.tsx +5 -1
- package/src/components/diagnose/EvidenceCard.tsx +611 -0
- package/src/components/diagnose/Home.test.tsx +1 -3
- package/src/components/diagnose/Home.tsx +2 -1
- package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +6 -8
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +5 -3
- package/src/components/diagnose/InvestigationEvidencePane.tsx +31 -3445
- package/src/components/diagnose/InvestigationEvidenceSections.tsx +523 -0
- package/src/components/diagnose/InvestigationView.tsx +39 -51
- package/src/components/diagnose/StoryExcerpt.tsx +118 -0
- package/src/components/diagnose/assessmentCopy.ts +169 -0
- package/src/components/diagnose/investigationCase.test.tsx +658 -2
- package/src/components/diagnose/investigationCase.ts +135 -26
- package/src/components/diagnose/investigationEvidence/adapters/coverage.test.tsx +682 -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/{helm.test.ts → helm.test.tsx} +63 -0
- package/src/components/diagnose/investigationEvidence/adapters/helm.ts +70 -1
- package/src/components/diagnose/investigationEvidence/adapters/listings.ts +136 -0
- package/src/components/diagnose/investigationEvidence/adapters/{permissions.test.ts → permissions.test.tsx} +50 -0
- package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +18 -0
- package/src/components/diagnose/investigationEvidence/adapters/posture.ts +272 -0
- package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +49 -0
- package/src/components/diagnose/investigationEvidence/adapters/rankings.ts +147 -0
- package/src/components/diagnose/investigationEvidence/adapters/resource.ts +20 -10
- package/src/components/diagnose/investigationEvidence/bodies/index.tsx +127 -0
- package/src/components/diagnose/investigationEvidence/bodies/inventory.tsx +158 -0
- package/src/components/diagnose/investigationEvidence/bodies/metrics.tsx +373 -0
- package/src/components/diagnose/investigationEvidence/bodies/network.tsx +274 -0
- package/src/components/diagnose/investigationEvidence/bodies/platform.tsx +475 -0
- package/src/components/diagnose/investigationEvidence/bodies/posture.tsx +60 -0
- package/src/components/diagnose/investigationEvidence/bodies/ranking.tsx +76 -0
- package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +212 -0
- package/src/components/diagnose/investigationEvidence/bodies/workload.tsx +335 -0
- package/src/components/diagnose/investigationEvidence/builder.ts +15 -0
- package/src/components/diagnose/investigationEvidence/cardParts.tsx +312 -0
- package/src/components/diagnose/investigationEvidence/excerpt.test.ts +105 -0
- package/src/components/diagnose/investigationEvidence/excerpt.ts +92 -0
- package/src/components/diagnose/investigationEvidence/identity.test.ts +5 -7
- package/src/components/diagnose/investigationEvidence/index.ts +1 -1
- package/src/components/diagnose/investigationEvidence/navigation.tsx +26 -0
- package/src/components/diagnose/investigationEvidence/observations.ts +13 -0
- package/src/components/diagnose/investigationEvidence/projection.test.ts +1 -1
- package/src/components/diagnose/investigationEvidence/types.ts +97 -2
- package/src/components/diagnose/investigationEvidenceKinds.ts +22 -0
- package/src/components/diagnose/investigationEvidencePartition.ts +296 -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 +15 -1
- package/src/components/diagnose/parts.tsx +26 -3904
- package/src/components/diagnose/target.ts +9 -3
- package/src/components/diagnose/toolCallLabel.test.ts +47 -1
- package/src/components/diagnose/toolCallLabel.ts +41 -2
- package/src/components/helm/HelmReleaseDrawer.tsx +1 -1
- package/src/components/home/MCPSetupDialog.tsx +1 -1
- package/src/components/home/mcpToolCatalog.ts +27 -0
- package/src/components/resource/PrometheusChartsGrid.tsx +1 -1
- package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
- package/src/components/resource/WorkloadMetricsSection.tsx +1 -1
- package/src/components/resources/PodFilePreview.tsx +1 -1
- package/src/components/rightsizing/RightsizingScanView.tsx +20 -1
- package/src/components/ui/CommandPalette.tsx +12 -8
- package/src/components/ui/DiagnosticsOverlay.tsx +7 -5
- package/src/components/ui/Omnibar.tsx +96 -60
- package/src/components/ui/ShortcutHelpOverlay.tsx +8 -5
- package/src/components/workload/WorkloadView.tsx +7 -1
- package/src/components/ui/Disclosure.tsx +0 -47
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
ProjectionBuilder,
|
|
7
7
|
evidenceTierForRelevance,
|
|
8
8
|
invalidPayload,
|
|
9
|
+
scopeFromArgs,
|
|
9
10
|
} from "../observations";
|
|
10
11
|
import {
|
|
11
12
|
nonEmptyString,
|
|
@@ -767,3 +768,51 @@ export function adaptQueryPrometheus(
|
|
|
767
768
|
},
|
|
768
769
|
);
|
|
769
770
|
}
|
|
771
|
+
|
|
772
|
+
// A metric catalogue is bookkeeping for the query that follows; when the
|
|
773
|
+
// agent cites it, the receipt says what was searched and how much came back.
|
|
774
|
+
export function adaptDiscoverMetrics(
|
|
775
|
+
builder: ProjectionBuilder,
|
|
776
|
+
source: InvestigationEvidenceSource,
|
|
777
|
+
payload: unknown,
|
|
778
|
+
): void {
|
|
779
|
+
const value = record(payload);
|
|
780
|
+
if (!value || typeof value.count !== "number") {
|
|
781
|
+
invalidPayload(builder, source);
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
if (!source.confirmedSuccess) return;
|
|
785
|
+
const match = nonEmptyString(value.match) ? value.match : undefined;
|
|
786
|
+
const label = nonEmptyString(value.label) ? value.label : undefined;
|
|
787
|
+
const scope = match ?? scopeFromArgs(source);
|
|
788
|
+
builder.observe(
|
|
789
|
+
`metrics:discover:${scope}:${label ?? "names"}`,
|
|
790
|
+
"receipt",
|
|
791
|
+
source,
|
|
792
|
+
{
|
|
793
|
+
tier: evidenceTierForRelevance("checked", "broader"),
|
|
794
|
+
relevance: "broader",
|
|
795
|
+
tone: "neutral",
|
|
796
|
+
title: label
|
|
797
|
+
? `Values of ${label}`
|
|
798
|
+
: match
|
|
799
|
+
? `Metric names matching ${match}`
|
|
800
|
+
: "Metric names",
|
|
801
|
+
summary: `${value.count} ${label ? "values" : "metrics"} · last hour${value.truncated === true ? " · truncated" : ""}`,
|
|
802
|
+
data: {
|
|
803
|
+
type: "receipt",
|
|
804
|
+
checked: "metrics",
|
|
805
|
+
scope,
|
|
806
|
+
message: nonEmptyString(value.note) ? value.note : undefined,
|
|
807
|
+
},
|
|
808
|
+
},
|
|
809
|
+
);
|
|
810
|
+
if (value.truncated === true) {
|
|
811
|
+
builder.limit(
|
|
812
|
+
source,
|
|
813
|
+
"Metric discovery",
|
|
814
|
+
"The catalogue was truncated; a narrower selector would show the rest.",
|
|
815
|
+
"unknown",
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { nonEmptyString, parseJSON, record } from "../parse";
|
|
2
|
+
import {
|
|
3
|
+
invalidPayload,
|
|
4
|
+
evidenceTierForRelevance,
|
|
5
|
+
sameKind,
|
|
6
|
+
scopeFromArgs,
|
|
7
|
+
type ProjectionBuilder,
|
|
8
|
+
} from "../observations";
|
|
9
|
+
import type {
|
|
10
|
+
InvestigationEvidenceSource,
|
|
11
|
+
InvestigationRankingRow,
|
|
12
|
+
} from "../types";
|
|
13
|
+
|
|
14
|
+
function quantity(milli: unknown, unit: "m" | "Mi"): string {
|
|
15
|
+
return typeof milli === "number" ? `${milli}${unit}` : "";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// A ranking is one card: the rows as the tool ordered them, with the
|
|
19
|
+
// investigated resource's own row (or its pods) marked so the reader sees
|
|
20
|
+
// where it stands without hunting.
|
|
21
|
+
export function adaptTopResources(
|
|
22
|
+
builder: ProjectionBuilder,
|
|
23
|
+
source: InvestigationEvidenceSource,
|
|
24
|
+
payload: unknown,
|
|
25
|
+
): void {
|
|
26
|
+
const value = record(payload);
|
|
27
|
+
if (!value || !nonEmptyString(value.kind)) {
|
|
28
|
+
invalidPayload(builder, source);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (value.metricsAvailable === false) {
|
|
32
|
+
builder.limit(
|
|
33
|
+
source,
|
|
34
|
+
"Live metrics",
|
|
35
|
+
nonEmptyString(value.reason)
|
|
36
|
+
? value.reason
|
|
37
|
+
: "Live metrics were not available for this ranking.",
|
|
38
|
+
"unknown",
|
|
39
|
+
);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const raw = Array.isArray(value.items)
|
|
43
|
+
? value.items
|
|
44
|
+
: Array.isArray(value.workloads)
|
|
45
|
+
? value.workloads
|
|
46
|
+
: [];
|
|
47
|
+
const rows: InvestigationRankingRow[] = raw.flatMap((entry) => {
|
|
48
|
+
const item = record(entry);
|
|
49
|
+
if (!item || !nonEmptyString(item.kind) || !nonEmptyString(item.name))
|
|
50
|
+
return [];
|
|
51
|
+
const namespace = nonEmptyString(item.namespace)
|
|
52
|
+
? item.namespace
|
|
53
|
+
: undefined;
|
|
54
|
+
// Rows carry kind, namespace and name and no API group; the kinds a
|
|
55
|
+
// ranking holds (pods, the built-in workloads, nodes) are not ambiguous.
|
|
56
|
+
const target =
|
|
57
|
+
(item.kind === "Pod" && builder.establishedTargetPods.has(item.name)) ||
|
|
58
|
+
(sameKind(item.kind, builder.target.kind) &&
|
|
59
|
+
(namespace ?? "") === (builder.target.namespace ?? "") &&
|
|
60
|
+
item.name === builder.target.name);
|
|
61
|
+
const ownerRaw = record(item.owner);
|
|
62
|
+
const owner =
|
|
63
|
+
ownerRaw && nonEmptyString(ownerRaw.kind) && nonEmptyString(ownerRaw.name)
|
|
64
|
+
? {
|
|
65
|
+
kind: ownerRaw.kind,
|
|
66
|
+
...(nonEmptyString(ownerRaw.group)
|
|
67
|
+
? { group: ownerRaw.group }
|
|
68
|
+
: {}),
|
|
69
|
+
...(namespace ? { namespace } : {}),
|
|
70
|
+
name: ownerRaw.name,
|
|
71
|
+
}
|
|
72
|
+
: undefined;
|
|
73
|
+
return [
|
|
74
|
+
{
|
|
75
|
+
kind: item.kind,
|
|
76
|
+
namespace,
|
|
77
|
+
name: item.name,
|
|
78
|
+
...(owner ? { owner } : {}),
|
|
79
|
+
cpu: quantity(item.cpuMilli, "m"),
|
|
80
|
+
memory: quantity(item.memoryMi, "Mi"),
|
|
81
|
+
...(typeof item.cpuLimitMilli === "number" && item.cpuLimitMilli > 0
|
|
82
|
+
? { cpuLimit: quantity(item.cpuLimitMilli, "m") }
|
|
83
|
+
: {}),
|
|
84
|
+
...(typeof item.memoryLimitMi === "number" && item.memoryLimitMi > 0
|
|
85
|
+
? { memoryLimit: quantity(item.memoryLimitMi, "Mi") }
|
|
86
|
+
: {}),
|
|
87
|
+
...(typeof item.restarts === "number"
|
|
88
|
+
? { restarts: item.restarts }
|
|
89
|
+
: {}),
|
|
90
|
+
...(nonEmptyString(item.ready) ? { ready: item.ready } : {}),
|
|
91
|
+
...(nonEmptyString(item.status) ? { status: item.status } : {}),
|
|
92
|
+
target,
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
});
|
|
96
|
+
const args = record(parseJSON(source.args ?? ""));
|
|
97
|
+
const scope = scopeFromArgs(source);
|
|
98
|
+
const sort = nonEmptyString(value.sort)
|
|
99
|
+
? value.sort
|
|
100
|
+
: nonEmptyString(args?.sort)
|
|
101
|
+
? args.sort
|
|
102
|
+
: "cpu";
|
|
103
|
+
const relevance = rows.some((row) => row.target)
|
|
104
|
+
? "producer-related"
|
|
105
|
+
: "broader";
|
|
106
|
+
const targetRows = rows.filter((row) => row.target).length;
|
|
107
|
+
// A pod ranking of the workload's own namespace that holds none of its
|
|
108
|
+
// established pods says so on the card, so the reader does not scan for a
|
|
109
|
+
// row that is not there. "Not in these results" is all that is known: a
|
|
110
|
+
// capped ranking omits low consumers, and the tool's skipped count does
|
|
111
|
+
// not say which pods it skipped.
|
|
112
|
+
const rankedNamespace = nonEmptyString(args?.namespace)
|
|
113
|
+
? args.namespace
|
|
114
|
+
: undefined;
|
|
115
|
+
const targetPodsAbsent =
|
|
116
|
+
value.kind === "pods" &&
|
|
117
|
+
targetRows === 0 &&
|
|
118
|
+
builder.establishedTargetPods.size > 0 &&
|
|
119
|
+
(rankedNamespace === undefined ||
|
|
120
|
+
rankedNamespace === builder.target.namespace);
|
|
121
|
+
const skipped =
|
|
122
|
+
typeof value.skippedNoMetrics === "number" && value.skippedNoMetrics > 0
|
|
123
|
+
? value.skippedNoMetrics
|
|
124
|
+
: 0;
|
|
125
|
+
builder.observe(`ranking:${value.kind}:${sort}:${scope}`, "ranking", source, {
|
|
126
|
+
tier: evidenceTierForRelevance("context", relevance),
|
|
127
|
+
relevance,
|
|
128
|
+
tone: "neutral",
|
|
129
|
+
title: `Top ${value.kind} by ${sort}`,
|
|
130
|
+
summary: [
|
|
131
|
+
`${rows.length} ranked`,
|
|
132
|
+
targetRows > 0
|
|
133
|
+
? `${targetRows === 1 ? "this workload's row" : `${targetRows} of its pods`} marked`
|
|
134
|
+
: undefined,
|
|
135
|
+
rankedNamespace ? `in ${rankedNamespace}` : "cluster-wide",
|
|
136
|
+
targetPodsAbsent
|
|
137
|
+
? "this workload's pods aren't in these results"
|
|
138
|
+
: undefined,
|
|
139
|
+
skipped > 0
|
|
140
|
+
? `${skipped} ${value.kind === "nodes" ? (skipped === 1 ? "node" : "nodes") : skipped === 1 ? "pod" : "pods"} omitted: no metrics`
|
|
141
|
+
: undefined,
|
|
142
|
+
]
|
|
143
|
+
.filter(Boolean)
|
|
144
|
+
.join(" · "),
|
|
145
|
+
data: { type: "ranking", kind: value.kind, sort, rows, scope },
|
|
146
|
+
});
|
|
147
|
+
}
|
|
@@ -230,6 +230,7 @@ export function adaptListResources(
|
|
|
230
230
|
builder: ProjectionBuilder,
|
|
231
231
|
source: InvestigationEvidenceSource,
|
|
232
232
|
payload: unknown,
|
|
233
|
+
options: { title?: string } = {},
|
|
233
234
|
): void {
|
|
234
235
|
if (!Array.isArray(payload)) {
|
|
235
236
|
invalidPayload(builder, source);
|
|
@@ -254,7 +255,7 @@ export function adaptListResources(
|
|
|
254
255
|
const namespace = nonEmptyString(args?.namespace)
|
|
255
256
|
? args.namespace
|
|
256
257
|
: undefined;
|
|
257
|
-
const title = namespace ? `${noun} in ${namespace}` : noun;
|
|
258
|
+
const title = options.title ?? (namespace ? `${noun} in ${namespace}` : noun);
|
|
258
259
|
if (resources.length === 0) {
|
|
259
260
|
// list_resources intentionally returns [] for some RBAC-filtered reads;
|
|
260
261
|
// even a successful transport outcome therefore cannot prove absence.
|
|
@@ -271,15 +272,24 @@ export function adaptListResources(
|
|
|
271
272
|
return (
|
|
272
273
|
health === "unhealthy" ||
|
|
273
274
|
health === "degraded" ||
|
|
274
|
-
(resource.summaryContext?.issueCount ?? 0) > 0
|
|
275
|
+
(resource.summaryContext?.issueCount ?? 0) > 0 ||
|
|
276
|
+
nonEmptyString(resource.issue)
|
|
275
277
|
);
|
|
276
278
|
});
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
279
|
+
// Listings of different things share an argument shape (a cluster-wide
|
|
280
|
+
// package list and a cluster-wide Helm list are both "{}"), so the tool is
|
|
281
|
+
// part of what makes one listing the same card as an earlier read.
|
|
282
|
+
builder.observe(
|
|
283
|
+
`inventory:${source.tool}:${source.args ?? scope}`,
|
|
284
|
+
"inventory",
|
|
285
|
+
source,
|
|
286
|
+
{
|
|
287
|
+
tier: "context",
|
|
288
|
+
relevance: "broader",
|
|
289
|
+
tone: hasAdverseResource ? "warning" : "neutral",
|
|
290
|
+
title,
|
|
291
|
+
summary: `${resources.length} returned${nonEmptyString(args?.group) ? ` · ${args.group}` : ""}`,
|
|
292
|
+
data: { type: "inventory", resources, scope },
|
|
293
|
+
},
|
|
294
|
+
);
|
|
285
295
|
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { MetricsChangeCoverage } from "../../investigationMetrics";
|
|
2
|
+
import type {
|
|
3
|
+
InvestigationEvidenceData,
|
|
4
|
+
InvestigationResourceSummary,
|
|
5
|
+
} from "..";
|
|
6
|
+
import { investigationResourceEvidenceHasDetails } from "../../investigationResourceEvidenceModel";
|
|
7
|
+
import {
|
|
8
|
+
CrashBody,
|
|
9
|
+
IssueBody,
|
|
10
|
+
ResourceBody,
|
|
11
|
+
StartupBody,
|
|
12
|
+
diagnosedScalers,
|
|
13
|
+
} from "./workload";
|
|
14
|
+
import { ChangesBody, EventsBody, LogsBody } from "./streams";
|
|
15
|
+
import {
|
|
16
|
+
DNSBody,
|
|
17
|
+
NetworkBody,
|
|
18
|
+
RelationshipsBody,
|
|
19
|
+
TopologyBody,
|
|
20
|
+
} from "./network";
|
|
21
|
+
import { InventoryBody } from "./inventory";
|
|
22
|
+
import { AlertsBody, MetricsBody } from "./metrics";
|
|
23
|
+
import { HelmBody, PermissionsBody } from "./platform";
|
|
24
|
+
import { RankingBody } from "./ranking";
|
|
25
|
+
import { PostureBody } from "./posture";
|
|
26
|
+
|
|
27
|
+
export function EvidenceBody({
|
|
28
|
+
data,
|
|
29
|
+
cardSummary,
|
|
30
|
+
changeCoverage,
|
|
31
|
+
condensed = false,
|
|
32
|
+
citedRows,
|
|
33
|
+
}: {
|
|
34
|
+
data: InvestigationEvidenceData;
|
|
35
|
+
cardSummary?: string;
|
|
36
|
+
/** Change markers for a metrics chart; derived by the pane, never by data. */
|
|
37
|
+
changeCoverage?: MetricsChangeCoverage;
|
|
38
|
+
/** The card's title already names the stream: skip the repeated badges. */
|
|
39
|
+
condensed?: boolean;
|
|
40
|
+
/** Listing rows the card's citations name; they lead the inventory. */
|
|
41
|
+
citedRows?: readonly InvestigationResourceSummary[];
|
|
42
|
+
}) {
|
|
43
|
+
switch (data.type) {
|
|
44
|
+
case "issue":
|
|
45
|
+
return <IssueBody data={data} cardSummary={cardSummary} />;
|
|
46
|
+
case "startup":
|
|
47
|
+
return <StartupBody data={data} />;
|
|
48
|
+
case "crash":
|
|
49
|
+
return <CrashBody data={data} />;
|
|
50
|
+
case "resource":
|
|
51
|
+
return <ResourceBody data={data} />;
|
|
52
|
+
case "logs":
|
|
53
|
+
return <LogsBody data={data} condensed={condensed} />;
|
|
54
|
+
case "events":
|
|
55
|
+
return <EventsBody data={data} />;
|
|
56
|
+
case "changes":
|
|
57
|
+
return <ChangesBody data={data} />;
|
|
58
|
+
case "dns":
|
|
59
|
+
return <DNSBody data={data} />;
|
|
60
|
+
case "network":
|
|
61
|
+
return <NetworkBody data={data} />;
|
|
62
|
+
case "relationships":
|
|
63
|
+
return <RelationshipsBody data={data} />;
|
|
64
|
+
case "topology":
|
|
65
|
+
return <TopologyBody data={data} />;
|
|
66
|
+
case "inventory":
|
|
67
|
+
return <InventoryBody data={data} cited={citedRows} />;
|
|
68
|
+
case "receipt":
|
|
69
|
+
// The title and the scope above it are the answer. A body appears only
|
|
70
|
+
// when it adds the reason or the limit, so an absent one renders nothing
|
|
71
|
+
// rather than an empty line.
|
|
72
|
+
return data.message ? (
|
|
73
|
+
<p className="text-xs text-theme-text-secondary">{data.message}</p>
|
|
74
|
+
) : null;
|
|
75
|
+
case "alerts":
|
|
76
|
+
return <AlertsBody data={data} />;
|
|
77
|
+
case "helm":
|
|
78
|
+
return <HelmBody data={data} />;
|
|
79
|
+
case "permissions":
|
|
80
|
+
return <PermissionsBody data={data} />;
|
|
81
|
+
case "metrics":
|
|
82
|
+
return <MetricsBody data={data} changeCoverage={changeCoverage} />;
|
|
83
|
+
case "ranking":
|
|
84
|
+
return <RankingBody data={data} />;
|
|
85
|
+
case "posture":
|
|
86
|
+
return <PostureBody data={data} />;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function evidenceHasDetails(
|
|
91
|
+
data: InvestigationEvidenceData,
|
|
92
|
+
cardSummary?: string,
|
|
93
|
+
): boolean {
|
|
94
|
+
switch (data.type) {
|
|
95
|
+
case "issue": {
|
|
96
|
+
const summary = cardSummary?.trim();
|
|
97
|
+
const cause = data.issue.cause?.trim();
|
|
98
|
+
const message = data.issue.message?.trim();
|
|
99
|
+
return Boolean(
|
|
100
|
+
(cause && cause !== summary) ||
|
|
101
|
+
(message && message !== summary && message !== cause) ||
|
|
102
|
+
data.pods?.length,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
case "startup":
|
|
106
|
+
return (data.pods?.length ?? 0) > 1;
|
|
107
|
+
case "receipt":
|
|
108
|
+
return false;
|
|
109
|
+
case "resource": {
|
|
110
|
+
const replicas = data.resourceContext?.workloadSummary?.replicas;
|
|
111
|
+
return Boolean(
|
|
112
|
+
investigationResourceEvidenceHasDetails(data.resource) ||
|
|
113
|
+
replicas?.desired !== undefined ||
|
|
114
|
+
data.resourceContext?.statusSummary?.conditions?.length ||
|
|
115
|
+
diagnosedScalers(data.resourceContext).length ||
|
|
116
|
+
data.gitOpsDiagnosis ||
|
|
117
|
+
data.warnings.length,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
case "logs":
|
|
121
|
+
return (data.logs?.lines?.length ?? 0) > 0 || Boolean(data.error);
|
|
122
|
+
case "changes":
|
|
123
|
+
return data.changes.length > 0 || Boolean(data.changeContext?.evidence);
|
|
124
|
+
default:
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { clsx } from "clsx";
|
|
2
|
+
import { Badge, StatusDot, mapHealthToTone } from "@skyhook-io/k8s-ui";
|
|
3
|
+
import type { DiagnosisEvidenceSubject } from "../../../../api/diagnose";
|
|
4
|
+
import {
|
|
5
|
+
investigationSourceArgs,
|
|
6
|
+
type InvestigationResourceSummary,
|
|
7
|
+
type InvestigationEvidenceSource,
|
|
8
|
+
} from "..";
|
|
9
|
+
import { listingRowNamesSubject, sameKind } from "../../investigationCase";
|
|
10
|
+
import type { EvidenceDataOf } from "../cardParts";
|
|
11
|
+
|
|
12
|
+
// The rows the citations on a card name, in citation order: one row when the
|
|
13
|
+
// name picks exactly one entry, otherwise a line saying the entry is absent
|
|
14
|
+
// or which entries the name could mean.
|
|
15
|
+
export function listingScopeNamespace(
|
|
16
|
+
source: InvestigationEvidenceSource,
|
|
17
|
+
): string | undefined {
|
|
18
|
+
const namespace = investigationSourceArgs(source)?.namespace;
|
|
19
|
+
return typeof namespace === "string" && namespace ? namespace : undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function namedInventoryRows(
|
|
23
|
+
resources: InvestigationResourceSummary[],
|
|
24
|
+
items: readonly { subject?: DiagnosisEvidenceSubject }[],
|
|
25
|
+
/** The namespace the listing call was scoped to; rows omit it then. */
|
|
26
|
+
scopeNamespace?: string,
|
|
27
|
+
): {
|
|
28
|
+
key: string;
|
|
29
|
+
label: string;
|
|
30
|
+
matches: number;
|
|
31
|
+
row?: InvestigationResourceSummary;
|
|
32
|
+
}[] {
|
|
33
|
+
const seen = new Set<string>();
|
|
34
|
+
const out: {
|
|
35
|
+
key: string;
|
|
36
|
+
label: string;
|
|
37
|
+
matches: number;
|
|
38
|
+
row?: InvestigationResourceSummary;
|
|
39
|
+
}[] = [];
|
|
40
|
+
for (const item of items) {
|
|
41
|
+
const subject = item.subject;
|
|
42
|
+
if (!subject?.name) continue;
|
|
43
|
+
// Agents write namespace "" for a cluster-scoped kind; that states none.
|
|
44
|
+
const namespace = subject.namespace || undefined;
|
|
45
|
+
// Kind is part of what a subject names once a package can be named by
|
|
46
|
+
// its declaring object: "Package staging/podinfo" and "HelmRelease
|
|
47
|
+
// staging/podinfo" ask different questions of the same listing.
|
|
48
|
+
const key = `${subject.kind}/${namespace ?? ""}/${subject.name}`;
|
|
49
|
+
if (seen.has(key)) continue;
|
|
50
|
+
seen.add(key);
|
|
51
|
+
// A row without a namespace lives in the listing's scope, or in none
|
|
52
|
+
// (a cluster-scoped kind); neither satisfies a namespace the citation
|
|
53
|
+
// states unless it is the scope itself. A package row's namespace is
|
|
54
|
+
// where the package runs, while the object the agent names as declaring
|
|
55
|
+
// it (a Flux HelmRelease in flux-system) lives elsewhere, so that
|
|
56
|
+
// namespace is not held against a package; a subject that names the
|
|
57
|
+
// package as a Package means the namespace it says.
|
|
58
|
+
const declaringObject = (resource: InvestigationResourceSummary) =>
|
|
59
|
+
resource.kind === "Package" && !sameKind(subject.kind, "Package");
|
|
60
|
+
const matches = resources.filter(
|
|
61
|
+
(resource) =>
|
|
62
|
+
listingRowNamesSubject(resource, subject) &&
|
|
63
|
+
(namespace === undefined ||
|
|
64
|
+
declaringObject(resource) ||
|
|
65
|
+
(resource.namespace ?? scopeNamespace) === namespace),
|
|
66
|
+
);
|
|
67
|
+
out.push({
|
|
68
|
+
key,
|
|
69
|
+
label: namespace ? `${namespace}/${subject.name}` : subject.name,
|
|
70
|
+
matches: matches.length,
|
|
71
|
+
row: matches.length === 1 ? matches[0] : undefined,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function InventoryRow({
|
|
78
|
+
resource,
|
|
79
|
+
divider = false,
|
|
80
|
+
cited = false,
|
|
81
|
+
}: {
|
|
82
|
+
resource: InvestigationResourceSummary;
|
|
83
|
+
divider?: boolean;
|
|
84
|
+
cited?: boolean;
|
|
85
|
+
}) {
|
|
86
|
+
return (
|
|
87
|
+
<div
|
|
88
|
+
data-inventory-row={cited ? "cited" : undefined}
|
|
89
|
+
className={clsx(
|
|
90
|
+
"flex min-w-0 items-center gap-2 px-2.5 py-1.5",
|
|
91
|
+
divider && "border-t border-theme-border/60",
|
|
92
|
+
cited && "bg-theme-hover/40",
|
|
93
|
+
)}
|
|
94
|
+
>
|
|
95
|
+
<StatusDot
|
|
96
|
+
tone={mapHealthToTone(resource.summaryContext?.health ?? "")}
|
|
97
|
+
className="shrink-0"
|
|
98
|
+
/>
|
|
99
|
+
<Badge tone="structural" size="sm">
|
|
100
|
+
{resource.kind}
|
|
101
|
+
</Badge>
|
|
102
|
+
<span className="min-w-0 flex-1">
|
|
103
|
+
<span className="block truncate font-mono text-xs text-theme-text-secondary">
|
|
104
|
+
{resource.namespace ? `${resource.namespace}/` : ""}
|
|
105
|
+
{resource.name}
|
|
106
|
+
</span>
|
|
107
|
+
{resource.issue ? (
|
|
108
|
+
<span className="block truncate text-xs text-warning-text">
|
|
109
|
+
{resource.issue}
|
|
110
|
+
</span>
|
|
111
|
+
) : null}
|
|
112
|
+
{resource.match ? (
|
|
113
|
+
<span className="block truncate font-mono text-[11px] text-theme-text-tertiary">
|
|
114
|
+
{resource.match}
|
|
115
|
+
</span>
|
|
116
|
+
) : null}
|
|
117
|
+
</span>
|
|
118
|
+
{resource.ready || resource.status ? (
|
|
119
|
+
<span className="ml-auto shrink-0 font-mono text-xs text-theme-text-tertiary">
|
|
120
|
+
{resource.ready || resource.status}
|
|
121
|
+
</span>
|
|
122
|
+
) : null}
|
|
123
|
+
{(resource.summaryContext?.issueCount ?? 0) > 0 ? (
|
|
124
|
+
<Badge severity="warning" size="sm">
|
|
125
|
+
{resource.summaryContext?.issueCount} issues
|
|
126
|
+
</Badge>
|
|
127
|
+
) : null}
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The rows the card's citations name lead the listing, so the entry a claim
|
|
133
|
+
// is about is in view without scrolling a long inventory for it.
|
|
134
|
+
export function InventoryBody({
|
|
135
|
+
data,
|
|
136
|
+
cited = [],
|
|
137
|
+
}: {
|
|
138
|
+
data: EvidenceDataOf<"inventory">;
|
|
139
|
+
cited?: readonly InvestigationResourceSummary[];
|
|
140
|
+
}) {
|
|
141
|
+
const lead = [...new Set(cited)];
|
|
142
|
+
const rows = [
|
|
143
|
+
...lead,
|
|
144
|
+
...data.resources.filter((resource) => !lead.includes(resource)),
|
|
145
|
+
];
|
|
146
|
+
return (
|
|
147
|
+
<div className="max-h-72 overflow-y-auto rounded-md border border-theme-border">
|
|
148
|
+
{rows.map((resource, index) => (
|
|
149
|
+
<InventoryRow
|
|
150
|
+
key={`${resource.kind}-${resource.namespace ?? ""}-${resource.name}`}
|
|
151
|
+
resource={resource}
|
|
152
|
+
divider={index > 0}
|
|
153
|
+
cited={lead.includes(resource)}
|
|
154
|
+
/>
|
|
155
|
+
))}
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|