@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.
Files changed (83) hide show
  1. package/package.json +2 -2
  2. package/src/api/client.ts +32 -0
  3. package/src/api/diagnose.ts +7 -1
  4. package/src/components/CloudConnectFlow.tsx +21 -7
  5. package/src/components/CloudFunnelButton.tsx +85 -8
  6. package/src/components/cloudConnectHandoff.test.ts +10 -1
  7. package/src/components/cloudConnectHandoff.ts +15 -1
  8. package/src/components/diagnose/AIMarkdown.tsx +87 -0
  9. package/src/components/diagnose/ActivityTurn.tsx +1244 -0
  10. package/src/components/diagnose/AgentControls.tsx +603 -0
  11. package/src/components/diagnose/AnalysisStory.test.tsx +31 -25
  12. package/src/components/diagnose/AnalysisStory.tsx +13 -18
  13. package/src/components/diagnose/ApplyDialog.tsx +289 -0
  14. package/src/components/diagnose/AssessmentCard.tsx +1579 -0
  15. package/src/components/diagnose/DiagnoseSurface.test.tsx +9 -9
  16. package/src/components/diagnose/DiagnoseSurface.tsx +5 -1
  17. package/src/components/diagnose/EvidenceCard.tsx +611 -0
  18. package/src/components/diagnose/Home.test.tsx +1 -3
  19. package/src/components/diagnose/Home.tsx +2 -1
  20. package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +6 -8
  21. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +5 -3
  22. package/src/components/diagnose/InvestigationEvidencePane.tsx +31 -3445
  23. package/src/components/diagnose/InvestigationEvidenceSections.tsx +523 -0
  24. package/src/components/diagnose/InvestigationView.tsx +39 -51
  25. package/src/components/diagnose/StoryExcerpt.tsx +118 -0
  26. package/src/components/diagnose/assessmentCopy.ts +169 -0
  27. package/src/components/diagnose/investigationCase.test.tsx +658 -2
  28. package/src/components/diagnose/investigationCase.ts +135 -26
  29. package/src/components/diagnose/investigationEvidence/adapters/coverage.test.tsx +682 -0
  30. package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +8 -2
  31. package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +1 -1
  32. package/src/components/diagnose/investigationEvidence/adapters/{helm.test.ts → helm.test.tsx} +63 -0
  33. package/src/components/diagnose/investigationEvidence/adapters/helm.ts +70 -1
  34. package/src/components/diagnose/investigationEvidence/adapters/listings.ts +136 -0
  35. package/src/components/diagnose/investigationEvidence/adapters/{permissions.test.ts → permissions.test.tsx} +50 -0
  36. package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +18 -0
  37. package/src/components/diagnose/investigationEvidence/adapters/posture.ts +272 -0
  38. package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +49 -0
  39. package/src/components/diagnose/investigationEvidence/adapters/rankings.ts +147 -0
  40. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +20 -10
  41. package/src/components/diagnose/investigationEvidence/bodies/index.tsx +127 -0
  42. package/src/components/diagnose/investigationEvidence/bodies/inventory.tsx +158 -0
  43. package/src/components/diagnose/investigationEvidence/bodies/metrics.tsx +373 -0
  44. package/src/components/diagnose/investigationEvidence/bodies/network.tsx +274 -0
  45. package/src/components/diagnose/investigationEvidence/bodies/platform.tsx +475 -0
  46. package/src/components/diagnose/investigationEvidence/bodies/posture.tsx +60 -0
  47. package/src/components/diagnose/investigationEvidence/bodies/ranking.tsx +76 -0
  48. package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +212 -0
  49. package/src/components/diagnose/investigationEvidence/bodies/workload.tsx +335 -0
  50. package/src/components/diagnose/investigationEvidence/builder.ts +15 -0
  51. package/src/components/diagnose/investigationEvidence/cardParts.tsx +312 -0
  52. package/src/components/diagnose/investigationEvidence/excerpt.test.ts +105 -0
  53. package/src/components/diagnose/investigationEvidence/excerpt.ts +92 -0
  54. package/src/components/diagnose/investigationEvidence/identity.test.ts +5 -7
  55. package/src/components/diagnose/investigationEvidence/index.ts +1 -1
  56. package/src/components/diagnose/investigationEvidence/navigation.tsx +26 -0
  57. package/src/components/diagnose/investigationEvidence/observations.ts +13 -0
  58. package/src/components/diagnose/investigationEvidence/projection.test.ts +1 -1
  59. package/src/components/diagnose/investigationEvidence/types.ts +97 -2
  60. package/src/components/diagnose/investigationEvidenceKinds.ts +22 -0
  61. package/src/components/diagnose/investigationEvidencePartition.ts +296 -0
  62. package/src/components/diagnose/investigationState.test.ts +2 -2
  63. package/src/components/diagnose/investigationState.ts +2 -2
  64. package/src/components/diagnose/investigationStory.ts +1 -1
  65. package/src/components/diagnose/parts.test.tsx +15 -1
  66. package/src/components/diagnose/parts.tsx +26 -3904
  67. package/src/components/diagnose/target.ts +9 -3
  68. package/src/components/diagnose/toolCallLabel.test.ts +47 -1
  69. package/src/components/diagnose/toolCallLabel.ts +41 -2
  70. package/src/components/helm/HelmReleaseDrawer.tsx +1 -1
  71. package/src/components/home/MCPSetupDialog.tsx +1 -1
  72. package/src/components/home/mcpToolCatalog.ts +27 -0
  73. package/src/components/resource/PrometheusChartsGrid.tsx +1 -1
  74. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
  75. package/src/components/resource/WorkloadMetricsSection.tsx +1 -1
  76. package/src/components/resources/PodFilePreview.tsx +1 -1
  77. package/src/components/rightsizing/RightsizingScanView.tsx +20 -1
  78. package/src/components/ui/CommandPalette.tsx +12 -8
  79. package/src/components/ui/DiagnosticsOverlay.tsx +7 -5
  80. package/src/components/ui/Omnibar.tsx +96 -60
  81. package/src/components/ui/ShortcutHelpOverlay.tsx +8 -5
  82. package/src/components/workload/WorkloadView.tsx +7 -1
  83. 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
- builder.observe(`inventory:${source.args ?? scope}`, "inventory", source, {
278
- tier: "context",
279
- relevance: "broader",
280
- tone: hasAdverseResource ? "warning" : "neutral",
281
- title,
282
- summary: `${resources.length} returned${nonEmptyString(args?.group) ? ` · ${args.group}` : ""}`,
283
- data: { type: "inventory", resources, scope },
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
+ }