@skyhook-io/radar-app 1.13.2 → 1.13.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.
@@ -12,6 +12,7 @@ import { clsx } from "clsx";
12
12
  import {
13
13
  Activity,
14
14
  AlertTriangle,
15
+ BellRing,
15
16
  Boxes,
16
17
  Bug,
17
18
  CheckCircle2,
@@ -20,8 +21,10 @@ import {
20
21
  FileClock,
21
22
  FileSearch,
22
23
  Info,
24
+ KeyRound,
23
25
  ListTree,
24
26
  Network,
27
+ Package,
25
28
  ScrollText,
26
29
  SearchCheck,
27
30
  ShieldAlert,
@@ -79,8 +82,15 @@ export {
79
82
  } from "./useDisclosureReveal";
80
83
  export const VISIBLE_LOG_EVIDENCE_LINES = 12;
81
84
 
85
+ /** The Timeline scope a changes card can open: one resource name in one namespace. */
86
+ export interface InvestigationTimelineScope {
87
+ namespace?: string;
88
+ name: string;
89
+ }
90
+
82
91
  const EvidenceNavigationContext = createContext<{
83
92
  onOpenResource?: (ref: DiagnosisResourceRef) => void;
93
+ onOpenTimeline?: (scope: InvestigationTimelineScope) => void;
84
94
  revealSourceId?: string;
85
95
  revealRequestId?: number;
86
96
  expandedGroupIds?: ReadonlySet<string>;
@@ -91,7 +101,7 @@ const EvidenceNavigationContext = createContext<{
91
101
  function evidenceTypePrefersFullRow(
92
102
  type: InvestigationEvidenceData["type"],
93
103
  ): boolean {
94
- return type === "logs" || type === "events";
104
+ return type === "logs" || type === "events" || type === "alerts";
95
105
  }
96
106
 
97
107
  // Supporting evidence becomes a two-column grid when the pane is wide enough.
@@ -134,6 +144,10 @@ export function partitionInvestigationEvidence(
134
144
  const collectionByGroup = new Map<string, EvidenceCollection>();
135
145
  const adverse = (group: InvestigationEvidenceGroup) =>
136
146
  ["error", "alert", "warning"].includes(group.latest.tone);
147
+ // Broader cards are facts about something other than the target. They are
148
+ // withheld unless cited, and the pane says how many were withheld so a
149
+ // reader knows the agent looked at things it did not build its case on.
150
+ let hiddenBroader = 0;
137
151
  for (const group of groups) {
138
152
  const broader = group.latest.relevance === "broader";
139
153
  // Citations select tool results, not individual rows in a broad search.
@@ -142,22 +156,20 @@ export function partitionInvestigationEvidence(
142
156
  const link = resolution?.links.find(
143
157
  (item) => item.originalGroupId === group.id,
144
158
  );
145
- const focused = ["resource", "logs", "crash"].includes(
146
- group.latest.data.type,
147
- );
159
+ const focused = FOCUSED_EVIDENCE_TYPES.includes(group.latest.data.type);
148
160
  const sourceGroups = link
149
161
  ? groups.filter(
150
162
  (candidate) =>
151
- ["resource", "logs", "crash"].includes(
152
- candidate.latest.data.type,
153
- ) &&
163
+ FOCUSED_EVIDENCE_TYPES.includes(candidate.latest.data.type) &&
154
164
  candidate.observations.some(
155
165
  (observation) => observation.source.id === link.source.id,
156
166
  ),
157
167
  )
158
168
  : [];
159
- if (!selected.has(group.id) || !focused || sourceGroups.length !== 1)
169
+ if (!selected.has(group.id) || !focused || sourceGroups.length !== 1) {
170
+ if (!group.historical) hiddenBroader += 1;
160
171
  continue;
172
+ }
161
173
  }
162
174
  const main =
163
175
  !group.historical &&
@@ -180,9 +192,21 @@ export function partitionInvestigationEvidence(
180
192
  Number(adverse(right)) - Number(adverse(left)) ||
181
193
  left.firstOrder - right.firstOrder,
182
194
  );
183
- return { ...collections, collectionByGroup };
195
+ return { ...collections, collectionByGroup, hiddenBroader };
184
196
  }
185
197
 
198
+ // Kinds whose card is one unambiguous subject, so a citation of their source
199
+ // can promote exactly that fact. A broad search (issues, inventory, events)
200
+ // yields many rows per source; a citation cannot pick one of those.
201
+ const FOCUSED_EVIDENCE_TYPES: readonly InvestigationEvidenceData["type"][] = [
202
+ "resource",
203
+ "logs",
204
+ "crash",
205
+ "helm",
206
+ "alerts",
207
+ "permissions",
208
+ ];
209
+
186
210
  export function investigationEvidenceRevealCollection(
187
211
  projection: InvestigationEvidenceProjection,
188
212
  sourceId: string,
@@ -210,6 +234,7 @@ export function InvestigationEvidencePane({
210
234
  onViewSource,
211
235
  onViewActivity,
212
236
  onOpenResource,
237
+ onOpenTimeline,
213
238
  afterEvidence,
214
239
  revealRequest,
215
240
  onRevealReady,
@@ -227,6 +252,8 @@ export function InvestigationEvidencePane({
227
252
  onViewActivity: () => void;
228
253
  /** Opens an evidence subject in Radar when the producer identified it exactly. */
229
254
  onOpenResource?: (ref: DiagnosisResourceRef) => void;
255
+ /** Opens Radar's Timeline filtered to the resource a changes card is about. */
256
+ onOpenTimeline?: (scope: InvestigationTimelineScope) => void;
230
257
  /** Actions follow the complete evidence section, including its disclosures. */
231
258
  afterEvidence?: ReactNode;
232
259
  /** Explicit Activity → Findings navigation, including repeat clicks. */
@@ -388,6 +415,17 @@ export function InvestigationEvidencePane({
388
415
  ))}
389
416
  </div>
390
417
 
418
+ {partition.hiddenBroader > 0 ? (
419
+ <p
420
+ className="text-xs text-theme-text-tertiary"
421
+ data-testid="investigation-hidden-evidence"
422
+ >
423
+ {partition.hiddenBroader === 1
424
+ ? "1 result about another resource is not shown; it appears here when the assessment cites it."
425
+ : `${partition.hiddenBroader} results about other resources are not shown; they appear here when the assessment cites them.`}
426
+ </p>
427
+ ) : null}
428
+
391
429
  {!hasCurrentEvidence ? (
392
430
  <EmptyCollection
393
431
  collecting={collecting}
@@ -426,6 +464,7 @@ export function InvestigationEvidencePane({
426
464
  <EvidenceNavigationContext.Provider
427
465
  value={{
428
466
  onOpenResource,
467
+ onOpenTimeline,
429
468
  expandedGroupIds,
430
469
  onGroupOpenChange,
431
470
  revealSourceId: revealRequest?.sourceId,
@@ -633,6 +672,7 @@ function CoverageStrip({
633
672
  }) {
634
673
  const hasError = groups.some((group) => group.hasError);
635
674
  const historyOnly = groups.every((group) => group.historyOnly);
675
+ const quiet = historyOnly;
636
676
  const { elementRef, revealAfterToggle } =
637
677
  useDisclosureReveal<HTMLDivElement>();
638
678
  const regionId = "investigation-evidence-coverage";
@@ -652,7 +692,7 @@ function CoverageStrip({
652
692
  }}
653
693
  className="flex w-full min-w-0 items-center gap-2 px-3 py-2 text-left hover:bg-theme-hover/50"
654
694
  >
655
- {historyOnly ? (
695
+ {quiet ? (
656
696
  <Info
657
697
  className="h-4 w-4 shrink-0 text-theme-text-tertiary"
658
698
  aria-hidden
@@ -669,7 +709,7 @@ function CoverageStrip({
669
709
  <span
670
710
  className={clsx(
671
711
  "block text-xs",
672
- historyOnly
712
+ quiet
673
713
  ? "font-medium text-theme-text-secondary"
674
714
  : "font-semibold text-theme-text-primary",
675
715
  )}
@@ -735,6 +775,65 @@ function CoverageGroupRow({
735
775
  const regionId = useId();
736
776
  const { elementRef, revealAfterToggle } =
737
777
  useDisclosureReveal<HTMLDivElement>();
778
+ // One limitation whose message is the whole summary would repeat itself as
779
+ // its own detail row; show its source link on the summary row instead.
780
+ const single =
781
+ group.limitations.length === 1 &&
782
+ group.limitations[0].message === group.summary
783
+ ? group.limitations[0]
784
+ : undefined;
785
+ const anchors = sourceIds.map((id) => (
786
+ <span
787
+ key={id}
788
+ id={investigationEvidenceSourceDomId(id)}
789
+ className="sr-only scroll-mt-14"
790
+ aria-hidden
791
+ />
792
+ ));
793
+ if (single) {
794
+ return (
795
+ <div
796
+ ref={elementRef}
797
+ data-evidence-source-container
798
+ tabIndex={-1}
799
+ aria-label={`Evidence limitation for ${group.label}: ${group.summary}`}
800
+ className="flex items-center gap-2 px-3 py-2.5 text-xs outline-none focus:ring-2 focus:ring-accent/40"
801
+ >
802
+ {anchors}
803
+ {group.hasError ? (
804
+ <CircleAlert
805
+ className="h-3.5 w-3.5 shrink-0 text-red-400"
806
+ aria-hidden
807
+ />
808
+ ) : single.kind === "truncated" ? (
809
+ <AlertTriangle
810
+ className="h-3.5 w-3.5 shrink-0 text-amber-500"
811
+ aria-hidden
812
+ />
813
+ ) : (
814
+ <Info
815
+ className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary"
816
+ aria-hidden
817
+ />
818
+ )}
819
+ <p className="min-w-0 flex-1 leading-relaxed text-theme-text-secondary">
820
+ <span className="font-medium text-theme-text-primary">
821
+ {group.label}:
822
+ </span>{" "}
823
+ {group.summary}
824
+ </p>
825
+ {single.sources.at(-1) ? (
826
+ <SourceButton
827
+ ariaLabel={`View source for ${group.label}`}
828
+ buttonLabel={
829
+ single.sources.length > 1 ? "View latest in Activity" : undefined
830
+ }
831
+ onClick={() => onViewSource(single.sources.at(-1)!.id)}
832
+ />
833
+ ) : null}
834
+ </div>
835
+ );
836
+ }
738
837
  return (
739
838
  <div
740
839
  ref={elementRef}
@@ -743,14 +842,7 @@ function CoverageGroupRow({
743
842
  aria-label={`Evidence limitation for ${group.label}: ${group.summary}`}
744
843
  className="outline-none focus:ring-2 focus:ring-accent/40"
745
844
  >
746
- {sourceIds.map((id) => (
747
- <span
748
- key={id}
749
- id={investigationEvidenceSourceDomId(id)}
750
- className="sr-only scroll-mt-14"
751
- aria-hidden
752
- />
753
- ))}
845
+ {anchors}
754
846
  <button
755
847
  type="button"
756
848
  aria-expanded={open}
@@ -891,6 +983,7 @@ function EvidenceCard({
891
983
  }) {
892
984
  const {
893
985
  onOpenResource,
986
+ onOpenTimeline,
894
987
  revealSourceId,
895
988
  revealRequestId,
896
989
  citedOrderByGroup,
@@ -1072,6 +1165,12 @@ function EvidenceCard({
1072
1165
  onOpenResource={onOpenResource}
1073
1166
  compact
1074
1167
  />
1168
+ {observation.data.type === "changes" && observation.data.subject ? (
1169
+ <OpenTimelineButton
1170
+ scope={observation.data.subject}
1171
+ onOpenTimeline={onOpenTimeline}
1172
+ />
1173
+ ) : null}
1075
1174
  </div>
1076
1175
  </div>
1077
1176
  {canExpand ? (
@@ -1146,6 +1245,36 @@ function OpenResourceButton({
1146
1245
  );
1147
1246
  }
1148
1247
 
1248
+ function OpenTimelineButton({
1249
+ scope,
1250
+ onOpenTimeline,
1251
+ }: {
1252
+ scope: InvestigationTimelineScope;
1253
+ onOpenTimeline?: (scope: InvestigationTimelineScope) => void;
1254
+ }) {
1255
+ if (!onOpenTimeline) return null;
1256
+ const identity = `${scope.namespace ? `${scope.namespace}/` : ""}${scope.name}`;
1257
+ const label = `Open the Timeline for ${identity}`;
1258
+ return (
1259
+ <Tooltip
1260
+ content={label}
1261
+ delay={350}
1262
+ position="left"
1263
+ wrapperClassName="flex shrink-0"
1264
+ >
1265
+ <button
1266
+ type="button"
1267
+ aria-label={label}
1268
+ onClick={() => onOpenTimeline(scope)}
1269
+ className="flex h-7 shrink-0 items-center justify-center gap-1 rounded px-2 text-xs font-medium text-theme-text-tertiary transition-colors hover:bg-theme-hover hover:text-accent-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
1270
+ >
1271
+ <FileClock className="h-3.5 w-3.5" aria-hidden />
1272
+ <span className="hidden @min-[540px]/card:inline">Timeline</span>
1273
+ </button>
1274
+ </Tooltip>
1275
+ );
1276
+ }
1277
+
1149
1278
  function uniquePrimarySources(
1150
1279
  group: InvestigationEvidenceGroup,
1151
1280
  ): InvestigationEvidenceSource[] {
@@ -1197,6 +1326,12 @@ function EvidenceBody({
1197
1326
  return (
1198
1327
  <p className="text-xs text-theme-text-secondary">{data.message}</p>
1199
1328
  );
1329
+ case "alerts":
1330
+ return <AlertsBody data={data} />;
1331
+ case "helm":
1332
+ return <HelmBody data={data} />;
1333
+ case "permissions":
1334
+ return <PermissionsBody data={data} />;
1200
1335
  }
1201
1336
  }
1202
1337
 
@@ -1211,10 +1346,12 @@ function evidenceHasDetails(
1211
1346
  const message = data.issue.message?.trim();
1212
1347
  return Boolean(
1213
1348
  (cause && cause !== summary) ||
1214
- (message && message !== summary && message !== cause),
1349
+ (message && message !== summary && message !== cause) ||
1350
+ data.pods?.length,
1215
1351
  );
1216
1352
  }
1217
1353
  case "startup":
1354
+ return (data.pods?.length ?? 0) > 1;
1218
1355
  case "receipt":
1219
1356
  return false;
1220
1357
  case "resource": {
@@ -1271,6 +1408,18 @@ function IssueBody({
1271
1408
  {issue.namespace ? `${issue.namespace}/` : ""}
1272
1409
  {issue.name}
1273
1410
  </Badge>
1411
+ {data.pods && data.pods.length > 0 ? (
1412
+ <>
1413
+ <Badge tone="structural" size="sm">
1414
+ {data.pods.length === 1 ? "1 Pod" : `${data.pods.length} Pods`}
1415
+ </Badge>
1416
+ {data.pods.map((pod) => (
1417
+ <Badge key={pod} tone="structural" size="sm">
1418
+ {pod}
1419
+ </Badge>
1420
+ ))}
1421
+ </>
1422
+ ) : null}
1274
1423
  </div>
1275
1424
  {showCause ? (
1276
1425
  <p className="text-sm font-medium leading-relaxed text-theme-text-primary">
@@ -1288,15 +1437,18 @@ function IssueBody({
1288
1437
 
1289
1438
  function StartupBody({ data }: { data: EvidenceDataOf<"startup"> }) {
1290
1439
  const blocker = data.blocker;
1440
+ const pods = data.pods && data.pods.length > 1 ? data.pods : [blocker.name];
1291
1441
  return (
1292
1442
  <div className="space-y-2">
1293
1443
  <div className="flex flex-wrap gap-1.5">
1294
1444
  <Badge tone="structural" size="sm">
1295
- {blocker.kind}
1296
- </Badge>
1297
- <Badge tone="structural" size="sm">
1298
- {blocker.name}
1445
+ {pods.length > 1 ? `${pods.length} ${blocker.kind}s` : blocker.kind}
1299
1446
  </Badge>
1447
+ {pods.map((pod) => (
1448
+ <Badge key={pod} tone="structural" size="sm">
1449
+ {pod}
1450
+ </Badge>
1451
+ ))}
1300
1452
  <Badge severity={severityBadge(blocker.severity)} size="sm">
1301
1453
  {blocker.severity}
1302
1454
  </Badge>
@@ -1585,7 +1737,11 @@ function EventsBody({ data }: { data: EvidenceDataOf<"events"> }) {
1585
1737
  <div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
1586
1738
  <span className="text-xs font-semibold text-theme-text-primary">
1587
1739
  {event.reason}
1588
- {event.count > 1 ? ` ×${event.count}` : ""}
1740
+ {event.count > 1 ? (
1741
+ <span className="ml-1.5 font-mono text-[11px] font-normal text-theme-text-tertiary">
1742
+ ×{event.count}
1743
+ </span>
1744
+ ) : null}
1589
1745
  </span>
1590
1746
  <Tooltip
1591
1747
  content={new Date(event.lastTimestamp).toLocaleString()}
@@ -2003,6 +2159,444 @@ function InventoryBody({ data }: { data: EvidenceDataOf<"inventory"> }) {
2003
2159
  );
2004
2160
  }
2005
2161
 
2162
+ const VISIBLE_ALERT_LABELS = 6;
2163
+
2164
+ function alertStateSeverity(state: string | undefined) {
2165
+ switch ((state ?? "").toLowerCase()) {
2166
+ case "firing":
2167
+ return "error" as const;
2168
+ case "pending":
2169
+ return "warning" as const;
2170
+ case "inactive":
2171
+ return "success" as const;
2172
+ default:
2173
+ return "neutral" as const;
2174
+ }
2175
+ }
2176
+
2177
+ function AlertsBody({ data }: { data: EvidenceDataOf<"alerts"> }) {
2178
+ const { rule, instances, annotations } = data;
2179
+ const health = rule.health?.toLowerCase();
2180
+ // Target instances lead; the rest stay visible in producer order.
2181
+ const ordered = [...instances].sort(
2182
+ (left, right) => Number(right.namesTarget) - Number(left.namesTarget),
2183
+ );
2184
+ const annotationEntries = Object.entries(annotations);
2185
+ return (
2186
+ <div className="space-y-2.5">
2187
+ <div className="flex flex-wrap items-center gap-1.5">
2188
+ {rule.state ? (
2189
+ <Badge severity={alertStateSeverity(rule.state)} size="sm">
2190
+ {rule.state}
2191
+ </Badge>
2192
+ ) : null}
2193
+ {rule.labels.severity ? (
2194
+ <Badge severity={severityBadge(rule.labels.severity)} size="sm">
2195
+ severity {rule.labels.severity}
2196
+ </Badge>
2197
+ ) : null}
2198
+ <Badge tone="structural" size="sm">
2199
+ {rule.group}
2200
+ </Badge>
2201
+ {health && health !== "ok" ? (
2202
+ <Badge severity={health === "err" ? "error" : "neutral"} size="sm">
2203
+ health {health}
2204
+ </Badge>
2205
+ ) : null}
2206
+ </div>
2207
+ {ordered.length > 0 ? (
2208
+ <ol className="max-h-64 space-y-1.5 overflow-y-auto pr-1">
2209
+ {ordered.map((instance, index) => {
2210
+ const labels = Object.entries(instance.labels).filter(
2211
+ ([key]) => key !== "alertname" && key !== "severity",
2212
+ );
2213
+ const hidden = labels.length - VISIBLE_ALERT_LABELS;
2214
+ return (
2215
+ <li
2216
+ key={`${instance.state}-${index}-${labels.map(([k, v]) => `${k}=${v}`).join(",")}`}
2217
+ className="rounded-md border border-theme-border/70 bg-theme-base/30 px-2.5 py-2"
2218
+ >
2219
+ <div className="flex flex-wrap items-center gap-1.5">
2220
+ <Badge
2221
+ severity={alertStateSeverity(instance.state)}
2222
+ size="sm"
2223
+ >
2224
+ {instance.state}
2225
+ </Badge>
2226
+ {instance.namesTarget ? (
2227
+ <Badge severity="info" size="sm">
2228
+ names this resource
2229
+ </Badge>
2230
+ ) : null}
2231
+ {instance.value ? (
2232
+ <span className="font-mono text-xs text-theme-text-secondary">
2233
+ value {instance.value}
2234
+ </span>
2235
+ ) : null}
2236
+ {instance.activeAt ? (
2237
+ <Tooltip
2238
+ content={new Date(instance.activeAt).toLocaleString()}
2239
+ delay={150}
2240
+ position="left"
2241
+ wrapperClassName="ml-auto"
2242
+ >
2243
+ <time
2244
+ dateTime={instance.activeAt}
2245
+ className="text-xs text-theme-text-tertiary"
2246
+ >
2247
+ active {formatRelativeAgeTime(instance.activeAt)}
2248
+ </time>
2249
+ </Tooltip>
2250
+ ) : null}
2251
+ </div>
2252
+ {labels.length > 0 ? (
2253
+ <div className="mt-1.5 flex flex-wrap gap-1 font-mono text-[11px] text-theme-text-secondary">
2254
+ {labels
2255
+ .slice(0, VISIBLE_ALERT_LABELS)
2256
+ .map(([key, value]) => (
2257
+ <span
2258
+ key={key}
2259
+ className="rounded bg-theme-base px-1.5 py-0.5 [overflow-wrap:anywhere]"
2260
+ >
2261
+ {key}={value}
2262
+ </span>
2263
+ ))}
2264
+ {hidden > 0 ? (
2265
+ <span className="px-1 py-0.5 text-theme-text-tertiary">
2266
+ +{hidden} more
2267
+ </span>
2268
+ ) : null}
2269
+ </div>
2270
+ ) : null}
2271
+ </li>
2272
+ );
2273
+ })}
2274
+ </ol>
2275
+ ) : (
2276
+ <p className="text-xs italic text-theme-text-tertiary">
2277
+ No active instances were reported for this rule.
2278
+ </p>
2279
+ )}
2280
+ {annotationEntries.length > 0 ? (
2281
+ <dl className="space-y-1 text-xs">
2282
+ {annotationEntries.map(([key, value]) => (
2283
+ <div key={key} className="flex min-w-0 gap-2">
2284
+ <dt className="shrink-0 text-theme-text-tertiary">{key}</dt>
2285
+ <dd className="min-w-0 leading-relaxed text-theme-text-secondary [overflow-wrap:anywhere]">
2286
+ {value}
2287
+ </dd>
2288
+ </div>
2289
+ ))}
2290
+ </dl>
2291
+ ) : null}
2292
+ {rule.query ? (
2293
+ <TerminalBlock label="Rule expression">{rule.query}</TerminalBlock>
2294
+ ) : null}
2295
+ </div>
2296
+ );
2297
+ }
2298
+
2299
+ function helmStatusSeverity(status: string) {
2300
+ const normalized = status.toLowerCase();
2301
+ if (normalized === "deployed") return "success" as const;
2302
+ if (normalized.includes("failed")) return "error" as const;
2303
+ if (normalized.startsWith("pending") || normalized === "uninstalling")
2304
+ return "info" as const;
2305
+ return "warning" as const;
2306
+ }
2307
+
2308
+ function HelmBody({ data }: { data: EvidenceDataOf<"helm"> }) {
2309
+ const { onOpenResource } = useContext(EvidenceNavigationContext);
2310
+ const { release } = data;
2311
+ const operation = release.lastOperation;
2312
+ return (
2313
+ <div className="space-y-3">
2314
+ <div className="flex flex-wrap items-center gap-1.5">
2315
+ <Badge severity={helmStatusSeverity(release.status)} size="sm">
2316
+ {release.status}
2317
+ </Badge>
2318
+ <Badge tone="structural" size="sm">
2319
+ {release.chart}
2320
+ {release.chartVersion ? ` ${release.chartVersion}` : ""}
2321
+ </Badge>
2322
+ <Badge tone="structural" size="sm">
2323
+ revision {release.revision}
2324
+ </Badge>
2325
+ {release.appVersion ? (
2326
+ <Badge tone="note" size="sm">
2327
+ app {release.appVersion}
2328
+ </Badge>
2329
+ ) : null}
2330
+ {release.resourceHealth ? (
2331
+ <Badge
2332
+ severity={
2333
+ mapHealthToTone(release.resourceHealth) === "healthy"
2334
+ ? "success"
2335
+ : mapHealthToTone(release.resourceHealth) === "unhealthy"
2336
+ ? "error"
2337
+ : "warning"
2338
+ }
2339
+ size="sm"
2340
+ >
2341
+ resources {release.resourceHealth}
2342
+ </Badge>
2343
+ ) : null}
2344
+ <Tooltip
2345
+ content={new Date(release.updated).toLocaleString()}
2346
+ delay={150}
2347
+ position="left"
2348
+ wrapperClassName="ml-auto"
2349
+ >
2350
+ <time
2351
+ dateTime={release.updated}
2352
+ className="text-xs text-theme-text-tertiary"
2353
+ >
2354
+ updated {formatRelativeAgeTime(release.updated)}
2355
+ </time>
2356
+ </Tooltip>
2357
+ </div>
2358
+ {release.healthIssue ? (
2359
+ <p className="text-xs leading-relaxed text-warning-text">
2360
+ {release.healthIssue}
2361
+ </p>
2362
+ ) : null}
2363
+ {release.healthSummary &&
2364
+ release.healthSummary !== release.healthIssue ? (
2365
+ <p className="text-xs leading-relaxed text-theme-text-secondary">
2366
+ {release.healthSummary}
2367
+ </p>
2368
+ ) : null}
2369
+ {operation ? (
2370
+ <div className="rounded-md border border-theme-border bg-theme-base/40 px-2.5 py-2">
2371
+ <div className="flex flex-wrap items-center gap-1.5">
2372
+ <span className="text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
2373
+ Last operation
2374
+ </span>
2375
+ <Badge tone="note" size="sm">
2376
+ {operation.kind.replaceAll("_", " ")}
2377
+ </Badge>
2378
+ <Badge
2379
+ severity={
2380
+ operation.status === "completed"
2381
+ ? "success"
2382
+ : operation.status === "failed"
2383
+ ? "error"
2384
+ : "warning"
2385
+ }
2386
+ size="sm"
2387
+ >
2388
+ {operation.status.replaceAll("_", " ")}
2389
+ </Badge>
2390
+ </div>
2391
+ {operation.message ? (
2392
+ <p className="mt-1 text-xs leading-relaxed text-theme-text-secondary [overflow-wrap:anywhere]">
2393
+ {operation.message}
2394
+ </p>
2395
+ ) : null}
2396
+ </div>
2397
+ ) : null}
2398
+ {release.description ? (
2399
+ <p className="text-xs text-theme-text-tertiary [overflow-wrap:anywhere]">
2400
+ {release.description}
2401
+ </p>
2402
+ ) : null}
2403
+ {release.storageNamespace &&
2404
+ release.storageNamespace !== release.namespace ? (
2405
+ <p className="text-xs text-theme-text-tertiary">
2406
+ Release metadata stored in namespace {release.storageNamespace}
2407
+ </p>
2408
+ ) : null}
2409
+ {release.managedByFluxHelmRelease ? (
2410
+ <p className="text-xs text-theme-text-tertiary">
2411
+ Managed by Flux HelmRelease {release.managedByFluxHelmRelease}
2412
+ </p>
2413
+ ) : null}
2414
+ {release.resources.length > 0 ? (
2415
+ <div className="max-h-52 overflow-y-auto rounded-md border border-theme-border">
2416
+ {release.resources.map((owned, index) => (
2417
+ <div
2418
+ key={`${owned.kind}-${owned.namespace}-${owned.name}`}
2419
+ className={clsx(
2420
+ "flex min-w-0 items-center gap-2 px-2.5 py-1.5",
2421
+ index > 0 && "border-t border-theme-border/60",
2422
+ )}
2423
+ >
2424
+ <StatusDot
2425
+ tone={mapHealthToTone(
2426
+ owned.issue ? "unhealthy" : (owned.status ?? ""),
2427
+ )}
2428
+ className="shrink-0"
2429
+ />
2430
+ <Badge tone="structural" size="sm">
2431
+ {owned.kind}
2432
+ </Badge>
2433
+ <span className="min-w-0 flex-1">
2434
+ <span className="block truncate font-mono text-xs">
2435
+ <ResourceLink
2436
+ name={owned.name}
2437
+ kind={owned.kind}
2438
+ namespace={owned.namespace}
2439
+ group={
2440
+ owned.apiVersion
2441
+ ? apiVersionToGroup(owned.apiVersion)
2442
+ : undefined
2443
+ }
2444
+ label={`${owned.namespace ? `${owned.namespace}/` : ""}${owned.name}`}
2445
+ onNavigate={
2446
+ owned.apiVersion && onOpenResource
2447
+ ? (ref) => onOpenResource(ref)
2448
+ : undefined
2449
+ }
2450
+ />
2451
+ </span>
2452
+ {owned.issue ? (
2453
+ <span className="block truncate text-xs text-warning-text">
2454
+ {owned.issue}
2455
+ </span>
2456
+ ) : owned.summary || owned.message ? (
2457
+ <span className="block truncate text-xs text-theme-text-tertiary">
2458
+ {owned.summary || owned.message}
2459
+ </span>
2460
+ ) : null}
2461
+ </span>
2462
+ {owned.ready || owned.status ? (
2463
+ <span className="ml-auto shrink-0 font-mono text-xs text-theme-text-tertiary">
2464
+ {owned.ready || owned.status}
2465
+ </span>
2466
+ ) : null}
2467
+ </div>
2468
+ ))}
2469
+ </div>
2470
+ ) : null}
2471
+ </div>
2472
+ );
2473
+ }
2474
+
2475
+ function PermissionsBody({ data }: { data: EvidenceDataOf<"permissions"> }) {
2476
+ const { subject, accessCheck } = data;
2477
+ const subjectBadges = (
2478
+ <>
2479
+ <Badge tone="structural" size="sm">
2480
+ {subject.kind}
2481
+ </Badge>
2482
+ <span className="font-mono text-xs text-theme-text-secondary">
2483
+ {subject.namespace ? `${subject.namespace}/` : ""}
2484
+ {subject.name}
2485
+ </span>
2486
+ </>
2487
+ );
2488
+ if (accessCheck) {
2489
+ const facts = [
2490
+ ["Verb", accessCheck.verb],
2491
+ ["Resource", accessCheck.resource],
2492
+ ["Subresource", accessCheck.subresource],
2493
+ ["API group", accessCheck.group || "core"],
2494
+ ["Namespace", accessCheck.namespace || "cluster-wide"],
2495
+ ["Name", accessCheck.resourceName],
2496
+ ] as const;
2497
+ return (
2498
+ <div className="space-y-2.5">
2499
+ <div className="flex flex-wrap items-center gap-1.5">
2500
+ {subjectBadges}
2501
+ <Badge
2502
+ severity={accessCheck.allowed ? "success" : "warning"}
2503
+ size="sm"
2504
+ >
2505
+ {accessCheck.allowed
2506
+ ? "allowed"
2507
+ : accessCheck.denied
2508
+ ? "denied"
2509
+ : "not allowed"}
2510
+ </Badge>
2511
+ </div>
2512
+ <dl className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
2513
+ {facts.map(([label, value]) => (
2514
+ <ResourceFact key={label} label={label} value={value} />
2515
+ ))}
2516
+ </dl>
2517
+ {accessCheck.reason ? (
2518
+ <p className="text-xs leading-relaxed text-theme-text-secondary [overflow-wrap:anywhere]">
2519
+ {accessCheck.reason}
2520
+ </p>
2521
+ ) : null}
2522
+ {accessCheck.evaluationError ? (
2523
+ <p className="text-xs leading-relaxed text-semantic-error">
2524
+ {accessCheck.evaluationError}
2525
+ </p>
2526
+ ) : null}
2527
+ </div>
2528
+ );
2529
+ }
2530
+ const bindings = data.bindings ?? [];
2531
+ const usedByPods = data.usedByPods ?? [];
2532
+ return (
2533
+ <div className="space-y-2.5">
2534
+ <div className="flex flex-wrap items-center gap-1.5">
2535
+ {subjectBadges}
2536
+ <Badge tone="note" size="sm">
2537
+ {data.flatRulesCount ?? 0}
2538
+ {data.truncated ? "+" : ""} effective rules
2539
+ </Badge>
2540
+ {data.truncated ? (
2541
+ <Badge severity="warning" size="sm">
2542
+ rule list truncated
2543
+ </Badge>
2544
+ ) : null}
2545
+ </div>
2546
+ {bindings.length > 0 ? (
2547
+ <div className="max-h-52 overflow-y-auto rounded-md border border-theme-border">
2548
+ {bindings.map((binding, index) => (
2549
+ <div
2550
+ key={`${binding.bindingKind}-${binding.bindingNamespace ?? ""}-${binding.bindingName}`}
2551
+ className={clsx(
2552
+ "flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 px-2.5 py-1.5 text-xs",
2553
+ index > 0 && "border-t border-theme-border/60",
2554
+ )}
2555
+ >
2556
+ <Badge tone="structural" size="sm">
2557
+ {binding.bindingKind}
2558
+ </Badge>
2559
+ <span className="min-w-0 truncate font-mono text-theme-text-secondary">
2560
+ {binding.bindingNamespace ? `${binding.bindingNamespace}/` : ""}
2561
+ {binding.bindingName}
2562
+ </span>
2563
+ <span className="text-theme-text-tertiary">→</span>
2564
+ <Badge tone="structural" size="sm">
2565
+ {binding.roleKind}
2566
+ </Badge>
2567
+ <span className="min-w-0 truncate font-mono text-theme-text-primary">
2568
+ {binding.roleNamespace ? `${binding.roleNamespace}/` : ""}
2569
+ {binding.roleName}
2570
+ </span>
2571
+ <span className="ml-auto shrink-0 font-mono text-theme-text-tertiary">
2572
+ {binding.rulesCount} rule{binding.rulesCount === 1 ? "" : "s"}
2573
+ </span>
2574
+ {binding.inheritedFromGroup ? (
2575
+ <Badge tone="note" size="sm">
2576
+ via {binding.inheritedFromGroup}
2577
+ </Badge>
2578
+ ) : null}
2579
+ </div>
2580
+ ))}
2581
+ </div>
2582
+ ) : (
2583
+ <p className="text-xs italic text-theme-text-tertiary">
2584
+ No RoleBinding or ClusterRoleBinding grants this subject anything.
2585
+ </p>
2586
+ )}
2587
+ {usedByPods.length > 0 ? (
2588
+ <p className="text-xs leading-relaxed text-theme-text-secondary [overflow-wrap:anywhere]">
2589
+ <span className="text-theme-text-tertiary">Used by pods: </span>
2590
+ <span className="font-mono">{usedByPods.join(", ")}</span>
2591
+ {data.podsTotal && data.podsTotal > usedByPods.length
2592
+ ? ` and ${data.podsTotal - usedByPods.length} more`
2593
+ : ""}
2594
+ </p>
2595
+ ) : null}
2596
+ </div>
2597
+ );
2598
+ }
2599
+
2006
2600
  function RevisionHistory({
2007
2601
  observations,
2008
2602
  citedOrder,
@@ -2111,6 +2705,12 @@ function EvidenceCaveat({ data }: { data: InvestigationEvidenceData }) {
2111
2705
  } else if (data.type === "relationships" || data.type === "topology") {
2112
2706
  text =
2113
2707
  "This shows direct relationships Radar found, not an inferred blast radius.";
2708
+ } else if (data.type === "alerts") {
2709
+ text =
2710
+ "Instances are matched to this investigation by their Prometheus labels; a firing rule alone does not establish the cause.";
2711
+ } else if (data.type === "permissions") {
2712
+ text =
2713
+ "This is what RBAC grants the subject, not what the workload has exercised.";
2114
2714
  }
2115
2715
  if (!text) return null;
2116
2716
  return (
@@ -2210,6 +2810,12 @@ function evidenceIcon(type: InvestigationEvidenceData["type"]) {
2210
2810
  return ListTree;
2211
2811
  case "receipt":
2212
2812
  return CheckCircle2;
2813
+ case "alerts":
2814
+ return BellRing;
2815
+ case "helm":
2816
+ return Package;
2817
+ case "permissions":
2818
+ return KeyRound;
2213
2819
  }
2214
2820
  }
2215
2821