@skyhook-io/radar-app 1.13.4 → 1.14.0
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 +8 -8
- package/src/App.tsx +60 -24
- package/src/RadarApp.tsx +5 -0
- package/src/api/client.authRedirect.test.ts +12 -1
- package/src/api/client.prometheusStatus.test.ts +24 -0
- package/src/api/client.ts +325 -16
- package/src/api/diagnose.ts +62 -15
- package/src/api/drain-plan.test.ts +86 -0
- package/src/api/quotas.test.ts +26 -0
- package/src/api/quotas.ts +20 -0
- package/src/api/workloadMetrics.test.ts +40 -0
- package/src/api/workloadMetrics.ts +68 -0
- package/src/components/CloudConnectFlow.tsx +179 -42
- package/src/components/CloudFunnelButton.tsx +107 -36
- package/src/components/ConnectionErrorView.test.tsx +30 -0
- package/src/components/ConnectionErrorView.tsx +31 -19
- package/src/components/SyncProgressPanel.tsx +73 -0
- package/src/components/audit/AuditView.tsx +2 -0
- package/src/components/cloudConnectHandoff.test.ts +140 -0
- package/src/components/cloudConnectHandoff.ts +124 -0
- package/src/components/diagnose/AgentCase.tsx +131 -0
- package/src/components/diagnose/DiagnoseContext.tsx +2 -1
- package/src/components/diagnose/DiagnoseSurface.test.tsx +0 -20
- package/src/components/diagnose/DiagnoseSurface.tsx +32 -195
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +783 -7
- package/src/components/diagnose/InvestigationEvidencePane.tsx +774 -133
- package/src/components/diagnose/InvestigationView.tsx +222 -5
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
- package/src/components/diagnose/investigationCase.test.tsx +1568 -0
- package/src/components/diagnose/investigationCase.ts +439 -0
- package/src/components/diagnose/investigationEvidence/adapters/changes.ts +60 -0
- package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +573 -0
- package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +935 -0
- package/src/components/diagnose/investigationEvidence/adapters/events.ts +96 -0
- package/src/components/diagnose/investigationEvidence/adapters/helm.test.ts +242 -0
- package/src/components/diagnose/investigationEvidence/adapters/helm.ts +208 -0
- package/src/components/diagnose/investigationEvidence/adapters/issues.ts +172 -0
- package/src/components/diagnose/investigationEvidence/adapters/logs.test.ts +283 -0
- package/src/components/diagnose/investigationEvidence/adapters/logs.ts +183 -0
- package/src/components/diagnose/investigationEvidence/adapters/permissions.test.ts +528 -0
- package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +292 -0
- package/src/components/diagnose/investigationEvidence/adapters/prometheus.test.ts +1641 -0
- package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +769 -0
- package/src/components/diagnose/investigationEvidence/adapters/resource.test.ts +54 -0
- package/src/components/diagnose/investigationEvidence/adapters/resource.ts +257 -0
- package/src/components/diagnose/investigationEvidence/adapters/topology.ts +207 -0
- package/src/components/diagnose/investigationEvidence/builder.ts +418 -0
- package/src/components/diagnose/investigationEvidence/evidenceFixtures.ts +179 -0
- package/src/components/diagnose/investigationEvidence/identity.test.ts +614 -0
- package/src/components/diagnose/investigationEvidence/identity.ts +261 -0
- package/src/components/diagnose/investigationEvidence/index.ts +55 -0
- package/src/components/diagnose/investigationEvidence/observations.ts +954 -0
- package/src/components/diagnose/investigationEvidence/parse.ts +467 -0
- package/src/components/diagnose/investigationEvidence/projection.test.ts +2703 -0
- package/src/components/diagnose/investigationEvidence/types.ts +512 -0
- package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
- package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
- package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
- package/src/components/diagnose/investigationMetrics.test.ts +712 -0
- package/src/components/diagnose/investigationMetrics.ts +393 -0
- package/src/components/diagnose/investigationSourceFocus.ts +2 -0
- package/src/components/diagnose/investigationState.test.ts +322 -0
- package/src/components/diagnose/investigationState.ts +147 -25
- package/src/components/diagnose/parts.test.tsx +482 -0
- package/src/components/diagnose/parts.tsx +418 -41
- package/src/components/diagnose/target.test.ts +19 -0
- package/src/components/diagnose/target.ts +3 -2
- package/src/components/gitops/GitOpsView.tsx +7 -0
- package/src/components/gitops/RemoteDestinationCloudHint.tsx +26 -0
- package/src/components/resource/PrometheusCharts.overlays.test.ts +22 -0
- package/src/components/resource/PrometheusCharts.tsx +4 -3
- package/src/components/resource/PrometheusChartsGrid.render.test.tsx +99 -0
- package/src/components/resource/PrometheusChartsGrid.tsx +245 -103
- package/src/components/resource/RightsizingStrip.test.ts +38 -0
- package/src/components/resource/RightsizingStrip.tsx +15 -3
- package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +79 -0
- package/src/components/resource/WorkloadMetricsHelpDialog.tsx +145 -0
- package/src/components/resource/WorkloadMetricsSection.render.test.tsx +351 -0
- package/src/components/resource/WorkloadMetricsSection.tsx +486 -0
- package/src/components/resource/workloadMetricValues.test.ts +65 -0
- package/src/components/resource/workloadMetricValues.ts +30 -0
- package/src/components/resources/PodFilePreview.test.tsx +131 -0
- package/src/components/resources/PodFilePreview.tsx +394 -0
- package/src/components/resources/PodFilesystemModal.tsx +157 -67
- package/src/components/resources/ResourcesView.tsx +44 -6
- package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -1
- package/src/components/resources/renderers/PodRenderer.tsx +4 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +15 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +3 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +21 -0
- package/src/components/settings/SettingsDialog.tsx +94 -14
- package/src/components/traffic/TrafficFlowList.tsx +229 -70
- package/src/components/traffic/TrafficGraph.tsx +6 -6
- package/src/components/traffic/TrafficView.tsx +10 -9
- package/src/components/traffic/trafficFilters.test.ts +28 -1
- package/src/components/traffic/trafficFilters.ts +31 -0
- package/src/components/workload/WorkloadView.tsx +19 -8
- package/src/context/ConnectionContext.tsx +48 -3
- package/src/context/DiagnoseCustomization.test.tsx +26 -0
- package/src/context/DiagnoseCustomization.tsx +15 -2
- package/src/index.css +17 -0
- package/src/index.ts +1 -0
- package/src/utils/navigation.ts +1 -1
- package/src/utils/shell-safe.test.ts +25 -1
- package/src/utils/shell-safe.ts +16 -0
- package/src/utils/topology-namespace.test.ts +64 -0
- package/src/utils/topology-namespace.ts +18 -0
- package/src/components/diagnose/investigationEvidence.test.ts +0 -5202
- package/src/components/diagnose/investigationEvidence.ts +0 -4784
- package/src/components/gitops/GitOpsActions.tsx +0 -1
- package/src/components/gitops/GitOpsStatusBadge.tsx +0 -1
- package/src/components/gitops/ManagedResourcesList.tsx +0 -1
- package/src/components/gitops/SyncCountdown.tsx +0 -1
- package/src/components/gitops/index.ts +0 -4
- package/src/components/helm/ManifestDiffViewer.tsx +0 -69
- package/src/components/logs/JsonLogLine.tsx +0 -1
- package/src/components/logs/LogCore.tsx +0 -2
- package/src/components/logs/useLogBuffer.ts +0 -2
- package/src/components/logs/useLogSearch.ts +0 -1
- package/src/components/resource-drawer/ResourceDrawer.tsx +0 -216
- package/src/components/resources/drawer-components.tsx +0 -1
- package/src/components/resources/renderers/AlertRenderer.tsx +0 -1
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +0 -1
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +0 -1
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +0 -1
- package/src/components/resources/renderers/CertificateRenderer.tsx +0 -1
- package/src/components/resources/renderers/CertificateRequestRenderer.tsx +0 -1
- package/src/components/resources/renderers/ChallengeRenderer.tsx +0 -1
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +0 -1
- package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +0 -1
- package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +0 -1
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +0 -1
- package/src/components/resources/renderers/ConfigMapRenderer.tsx +0 -1
- package/src/components/resources/renderers/CronJobRenderer.tsx +0 -1
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +0 -1
- package/src/components/resources/renderers/EventRenderer.tsx +0 -1
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +0 -1
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +0 -1
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +0 -1
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +0 -1
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +0 -1
- package/src/components/resources/renderers/GatewayRenderer.tsx +0 -1
- package/src/components/resources/renderers/GenericRenderer.tsx +0 -1
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +0 -1
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +0 -1
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +0 -1
- package/src/components/resources/renderers/IngressClassRenderer.tsx +0 -1
- package/src/components/resources/renderers/IngressRenderer.tsx +0 -1
- package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +0 -1
- package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +0 -1
- package/src/components/resources/renderers/IstioGatewayRenderer.tsx +0 -1
- package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +0 -1
- package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +0 -1
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +0 -1
- package/src/components/resources/renderers/JobRenderer.tsx +0 -1
- package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +0 -1
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +0 -1
- package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +0 -1
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +0 -1
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeRouteRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeServiceRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +0 -1
- package/src/components/resources/renderers/KustomizationRenderer.tsx +0 -1
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +0 -1
- package/src/components/resources/renderers/LeaseRenderer.tsx +0 -1
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +0 -1
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +0 -1
- package/src/components/resources/renderers/OrderRenderer.tsx +0 -1
- package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +0 -1
- package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +0 -1
- package/src/components/resources/renderers/PodMonitorRenderer.tsx +0 -1
- package/src/components/resources/renderers/PriorityClassRenderer.tsx +0 -1
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +0 -1
- package/src/components/resources/renderers/ReplicaSetRenderer.tsx +0 -1
- package/src/components/resources/renderers/RuntimeClassRenderer.tsx +0 -1
- package/src/components/resources/renderers/SbomReportRenderer.tsx +0 -1
- package/src/components/resources/renderers/SealedSecretRenderer.tsx +0 -1
- package/src/components/resources/renderers/SecretRenderer.tsx +0 -1
- package/src/components/resources/renderers/SecretStoreRenderer.tsx +0 -1
- package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +0 -1
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +0 -1
- package/src/components/resources/renderers/StorageClassRenderer.tsx +0 -1
- package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +0 -1
- package/src/components/resources/renderers/VPARenderer.tsx +0 -1
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +0 -1
- package/src/components/resources/renderers/VeleroVSLRenderer.tsx +0 -1
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +0 -1
- package/src/components/resources/renderers/WebhookConfigRenderer.tsx +0 -1
- package/src/components/resources/renderers/WorkflowRenderer.tsx +0 -1
- package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +0 -1
- package/src/components/resources/renderers/argo-cells.tsx +0 -1
- package/src/components/resources/renderers/certmanager-cells.tsx +0 -1
- package/src/components/resources/renderers/cnpg-cells.tsx +0 -1
- package/src/components/resources/renderers/eso-cells.tsx +0 -1
- package/src/components/resources/renderers/flux-cells.tsx +0 -1
- package/src/components/resources/renderers/index.ts +0 -94
- package/src/components/resources/renderers/istio-cells.tsx +0 -1
- package/src/components/resources/renderers/karpenter-cells.tsx +0 -1
- package/src/components/resources/renderers/keda-cells.tsx +0 -1
- package/src/components/resources/renderers/knative-cells.tsx +0 -1
- package/src/components/resources/renderers/kyverno-cells.tsx +0 -1
- package/src/components/resources/renderers/prometheus-cells.tsx +0 -1
- package/src/components/resources/renderers/traefik-cells.tsx +0 -1
- package/src/components/resources/renderers/trivy-cells.tsx +0 -1
- package/src/components/resources/renderers/trivy-shared.tsx +0 -1
- package/src/components/resources/renderers/velero-cells.tsx +0 -1
- package/src/components/resources/resource-utils-argo.ts +0 -2
- package/src/components/resources/resource-utils-certmanager.ts +0 -2
- package/src/components/resources/resource-utils-eso.ts +0 -2
- package/src/components/resources/resource-utils-flux.ts +0 -2
- package/src/components/resources/resource-utils-istio.ts +0 -2
- package/src/components/resources/resource-utils-karpenter.ts +0 -2
- package/src/components/resources/resource-utils-knative.ts +0 -2
- package/src/components/resources/resource-utils-kyverno.ts +0 -2
- package/src/components/resources/resource-utils-prometheus.ts +0 -2
- package/src/components/resources/resource-utils-traefik.ts +0 -1
- package/src/components/resources/resource-utils-trivy.ts +0 -2
- package/src/components/resources/resource-utils-velero.ts +0 -2
- package/src/components/shared/EditableYamlView.tsx +0 -24
- package/src/components/timeline/DiffViewer.tsx +0 -1
- package/src/components/traffic/index.ts +0 -3
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +0 -1
- package/src/components/ui/MetricsChart.tsx +0 -1
- package/src/components/ui/ResourceBar.tsx +0 -1
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
createContext,
|
|
3
3
|
useCallback,
|
|
4
4
|
useContext,
|
|
5
|
+
useEffect,
|
|
5
6
|
useId,
|
|
6
7
|
useLayoutEffect,
|
|
7
8
|
useRef,
|
|
@@ -9,25 +10,18 @@ import {
|
|
|
9
10
|
type ReactNode,
|
|
10
11
|
} from "react";
|
|
11
12
|
import { clsx } from "clsx";
|
|
13
|
+
import {
|
|
14
|
+
EVIDENCE_KIND_TRAITS,
|
|
15
|
+
evidenceKindIsFocused,
|
|
16
|
+
} from "./investigationEvidenceKinds";
|
|
12
17
|
import {
|
|
13
18
|
Activity,
|
|
14
19
|
AlertTriangle,
|
|
15
|
-
BellRing,
|
|
16
|
-
Boxes,
|
|
17
|
-
Bug,
|
|
18
|
-
CheckCircle2,
|
|
19
20
|
CircleAlert,
|
|
20
|
-
Clock3,
|
|
21
21
|
FileClock,
|
|
22
22
|
FileSearch,
|
|
23
23
|
Info,
|
|
24
|
-
KeyRound,
|
|
25
|
-
ListTree,
|
|
26
|
-
Network,
|
|
27
|
-
Package,
|
|
28
|
-
ScrollText,
|
|
29
24
|
SearchCheck,
|
|
30
|
-
ShieldAlert,
|
|
31
25
|
SquareArrowOutUpRight,
|
|
32
26
|
} from "lucide-react";
|
|
33
27
|
import {
|
|
@@ -40,12 +34,27 @@ import {
|
|
|
40
34
|
defaultConditionTone,
|
|
41
35
|
displayKind,
|
|
42
36
|
formatRelativeAgeTime,
|
|
37
|
+
HPADiagnosisSummary,
|
|
43
38
|
mapHealthToTone,
|
|
44
39
|
ResourceLink,
|
|
45
40
|
stripAnsi,
|
|
46
41
|
} from "@skyhook-io/k8s-ui";
|
|
42
|
+
import {
|
|
43
|
+
AreaChart,
|
|
44
|
+
SERIES_COLORS,
|
|
45
|
+
SeriesLegend,
|
|
46
|
+
formatMetricValue,
|
|
47
|
+
seriesDisplayLabels,
|
|
48
|
+
seriesFill,
|
|
49
|
+
type TimeSeries,
|
|
50
|
+
} from "@skyhook-io/k8s-ui/components/charts";
|
|
47
51
|
import { apiVersionToGroup } from "../../utils/navigation";
|
|
48
52
|
import { parseLogLine } from "../../utils/log-format";
|
|
53
|
+
import {
|
|
54
|
+
metricsChangeCoverage,
|
|
55
|
+
metricsDomain,
|
|
56
|
+
type MetricsChangeCoverage,
|
|
57
|
+
} from "./investigationMetrics";
|
|
49
58
|
import {
|
|
50
59
|
evidenceDisplaySnapshot,
|
|
51
60
|
groupEvidenceCoverage,
|
|
@@ -55,6 +64,7 @@ import {
|
|
|
55
64
|
import {
|
|
56
65
|
investigationEvidenceSubjectRef,
|
|
57
66
|
investigationEvidenceSourceDomId,
|
|
67
|
+
investigationSourceArgs,
|
|
58
68
|
type InvestigationEvidenceData,
|
|
59
69
|
type InvestigationEvidenceGroup,
|
|
60
70
|
type InvestigationEvidenceObservation,
|
|
@@ -63,12 +73,23 @@ import {
|
|
|
63
73
|
type InvestigationEvidenceSource,
|
|
64
74
|
type InvestigationEvidenceTier,
|
|
65
75
|
} from "./investigationEvidence";
|
|
66
|
-
import type {
|
|
76
|
+
import type {
|
|
77
|
+
DiagnosisResourceContext,
|
|
78
|
+
DiagnosisResourceRef,
|
|
79
|
+
DiagnosisScalerRef,
|
|
80
|
+
} from "./diagnoseEvidenceTypes";
|
|
67
81
|
import { InvestigationResourceEvidence } from "./InvestigationResourceEvidence";
|
|
68
82
|
import { investigationResourceEvidenceHasDetails } from "./investigationResourceEvidenceModel";
|
|
69
83
|
import type { InvestigationSourceExcerpt } from "./investigationSourceFocus";
|
|
70
84
|
import { evidenceSourceExcerpt } from "./investigationSourceFocus";
|
|
71
85
|
import { Tooltip } from "../ui/Tooltip";
|
|
86
|
+
import { AgentClaimNote, AgentRoleChip } from "./AgentCase";
|
|
87
|
+
import {
|
|
88
|
+
investigationCaseObservationKey,
|
|
89
|
+
type InvestigationCaseItem,
|
|
90
|
+
type InvestigationCaseResolution,
|
|
91
|
+
} from "./investigationCase";
|
|
92
|
+
import type { DiagnosisEvidenceRole } from "../../api/diagnose";
|
|
72
93
|
|
|
73
94
|
import {
|
|
74
95
|
investigationDisclosureSettleDelay,
|
|
@@ -96,12 +117,57 @@ const EvidenceNavigationContext = createContext<{
|
|
|
96
117
|
expandedGroupIds?: ReadonlySet<string>;
|
|
97
118
|
onGroupOpenChange?: (id: string, open: boolean) => void;
|
|
98
119
|
citedOrderByGroup?: ReadonlyMap<string, number>;
|
|
120
|
+
/** Change markers for each metrics observation, keyed by its source id. */
|
|
121
|
+
metricsMarkersBySource?: ReadonlyMap<string, MetricsChangeCoverage>;
|
|
122
|
+
/** Card- and revision-placed agent items, keyed by group id. */
|
|
123
|
+
caseByGroup?: ReadonlyMap<string, InvestigationCaseItem[]>;
|
|
124
|
+
/** The hypothesis each `rules_out` item excludes, keyed by item. */
|
|
125
|
+
excludedByItem?: ReadonlyMap<string, string>;
|
|
99
126
|
}>({});
|
|
100
127
|
|
|
128
|
+
/** Item identity for the excluded-hypothesis lookup; index alone repeats across turns. */
|
|
129
|
+
export function investigationCaseItemKey(item: InvestigationCaseItem): string {
|
|
130
|
+
return `${item.source.id}\u0000${item.index}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const EVIDENCE_ROLE_RANK: Readonly<Record<DiagnosisEvidenceRole, number>> = {
|
|
134
|
+
cause: 0,
|
|
135
|
+
symptom: 1,
|
|
136
|
+
// A ruled-out card keeps Radar's place in the list; the role only labels it.
|
|
137
|
+
rules_out: 2,
|
|
138
|
+
context: 3,
|
|
139
|
+
benign: 4,
|
|
140
|
+
demoted: 5,
|
|
141
|
+
};
|
|
142
|
+
const UNLABELLED_RANK = 2;
|
|
143
|
+
|
|
144
|
+
function sourceNamesOneResource(source: InvestigationEvidenceSource): boolean {
|
|
145
|
+
const args = investigationSourceArgs(source);
|
|
146
|
+
return (
|
|
147
|
+
typeof args?.kind === "string" &&
|
|
148
|
+
args.kind !== "" &&
|
|
149
|
+
typeof args.name === "string" &&
|
|
150
|
+
args.name !== ""
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function investigationCaseByGroup(
|
|
155
|
+
investigationCase?: InvestigationCaseResolution,
|
|
156
|
+
): Map<string, InvestigationCaseItem[]> {
|
|
157
|
+
const byGroup = new Map<string, InvestigationCaseItem[]>();
|
|
158
|
+
for (const item of investigationCase?.items ?? []) {
|
|
159
|
+
if (!item.groupId || item.placement === "source") continue;
|
|
160
|
+
const items = byGroup.get(item.groupId) ?? [];
|
|
161
|
+
items.push(item);
|
|
162
|
+
byGroup.set(item.groupId, items);
|
|
163
|
+
}
|
|
164
|
+
return byGroup;
|
|
165
|
+
}
|
|
166
|
+
|
|
101
167
|
function evidenceTypePrefersFullRow(
|
|
102
168
|
type: InvestigationEvidenceData["type"],
|
|
103
169
|
): boolean {
|
|
104
|
-
return type
|
|
170
|
+
return EVIDENCE_KIND_TRAITS[type].fullRow;
|
|
105
171
|
}
|
|
106
172
|
|
|
107
173
|
// Supporting evidence becomes a two-column grid when the pane is wide enough.
|
|
@@ -129,12 +195,31 @@ type EvidenceCollection = "main" | "workload" | "earlier";
|
|
|
129
195
|
export function partitionInvestigationEvidence(
|
|
130
196
|
groups: InvestigationEvidenceGroup[],
|
|
131
197
|
resolution?: InvestigationRootCauseEvidenceResolution,
|
|
198
|
+
investigationCase?: InvestigationCaseResolution,
|
|
199
|
+
/**
|
|
200
|
+
* What a later turn cited, with the source that cited it. A follow-up adds
|
|
201
|
+
* to what the displayed assessment selected and never takes a selection
|
|
202
|
+
* away. The source travels with it because promoting a broader card needs
|
|
203
|
+
* one: an id alone selects the group but leaves the broader gate below
|
|
204
|
+
* unable to find who cited it, and the card stays withheld.
|
|
205
|
+
*/
|
|
206
|
+
alsoSelected?: readonly {
|
|
207
|
+
groupId: string;
|
|
208
|
+
source: InvestigationEvidenceSource;
|
|
209
|
+
}[],
|
|
132
210
|
) {
|
|
133
|
-
|
|
134
|
-
|
|
211
|
+
// Selection: legacy root-cause links plus every placed agent item, of any
|
|
212
|
+
// role (placement promotes a group into main the way a citation does; the
|
|
213
|
+
// role does not matter here). Ordering below is the only place roles act,
|
|
214
|
+
// and nothing the agent sends can remove a group from a collection.
|
|
215
|
+
const caseByGroup = investigationCaseByGroup(investigationCase);
|
|
216
|
+
const selected = new Set([
|
|
217
|
+
...(resolution?.status === "linked"
|
|
135
218
|
? resolution.links.map((link) => link.originalGroupId)
|
|
136
|
-
: [],
|
|
137
|
-
|
|
219
|
+
: []),
|
|
220
|
+
...caseByGroup.keys(),
|
|
221
|
+
...(alsoSelected ?? []).map((entry) => entry.groupId),
|
|
222
|
+
]);
|
|
138
223
|
const collections: Record<EvidenceCollection, InvestigationEvidenceGroup[]> =
|
|
139
224
|
{
|
|
140
225
|
main: [],
|
|
@@ -148,26 +233,45 @@ export function partitionInvestigationEvidence(
|
|
|
148
233
|
// withheld unless cited, and the pane says how many were withheld so a
|
|
149
234
|
// reader knows the agent looked at things it did not build its case on.
|
|
150
235
|
let hiddenBroader = 0;
|
|
236
|
+
// Broader metrics are facts about something other than the target. They are
|
|
237
|
+
// withheld unless cited, and the pane says how many were withheld so a
|
|
238
|
+
// reader knows the agent ran queries it did not build its case on.
|
|
239
|
+
let hiddenMetrics = 0;
|
|
151
240
|
for (const group of groups) {
|
|
152
241
|
const broader = group.latest.relevance === "broader";
|
|
153
242
|
// Citations select tool results, not individual rows in a broad search.
|
|
154
243
|
// Only a focused, unambiguous fact can be promoted by a source citation.
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
244
|
+
// An agent item names this observation when its subject does, or when the
|
|
245
|
+
// call it cites was itself scoped to one resource; a subject-less item on
|
|
246
|
+
// a broad query only proves the query returned one row and is gated like
|
|
247
|
+
// a citation of its source.
|
|
248
|
+
const namedByAgent = (caseByGroup.get(group.id) ?? []).some(
|
|
249
|
+
(item) => item.subject || sourceNamesOneResource(item.source),
|
|
250
|
+
);
|
|
251
|
+
if (broader && !namedByAgent) {
|
|
252
|
+
const citingSource =
|
|
253
|
+
resolution?.links.find((item) => item.originalGroupId === group.id)
|
|
254
|
+
?.source ??
|
|
255
|
+
caseByGroup.get(group.id)?.[0]?.source ??
|
|
256
|
+
alsoSelected?.find((entry) => entry.groupId === group.id)?.source;
|
|
257
|
+
const focused = evidenceKindIsFocused(group.latest.data.type);
|
|
258
|
+
const sourceGroups = citingSource
|
|
161
259
|
? groups.filter(
|
|
162
260
|
(candidate) =>
|
|
163
|
-
|
|
261
|
+
evidenceKindIsFocused(candidate.latest.data.type) &&
|
|
164
262
|
candidate.observations.some(
|
|
165
|
-
(observation) => observation.source.id ===
|
|
263
|
+
(observation) => observation.source.id === citingSource.id,
|
|
166
264
|
),
|
|
167
265
|
)
|
|
168
266
|
: [];
|
|
169
267
|
if (!selected.has(group.id) || !focused || sourceGroups.length !== 1) {
|
|
170
|
-
|
|
268
|
+
// The two counts head separate lines in the pane, so they have to be
|
|
269
|
+
// disjoint: a withheld chart announced by both would read as two
|
|
270
|
+
// withheld results.
|
|
271
|
+
if (!group.historical) {
|
|
272
|
+
if (group.latest.data.type === "metrics") hiddenMetrics += 1;
|
|
273
|
+
else hiddenBroader += 1;
|
|
274
|
+
}
|
|
171
275
|
continue;
|
|
172
276
|
}
|
|
173
277
|
}
|
|
@@ -185,28 +289,53 @@ export function partitionInvestigationEvidence(
|
|
|
185
289
|
collections[collection].push(group);
|
|
186
290
|
collectionByGroup.set(group.id, collection);
|
|
187
291
|
}
|
|
188
|
-
|
|
189
|
-
|
|
292
|
+
// A group takes its strongest role (lowest rank); within one role the
|
|
293
|
+
// agent's own item order decides. Unlabelled and ruled-out groups keep
|
|
294
|
+
// Radar's order among themselves.
|
|
295
|
+
const roleOrder = (group: InvestigationEvidenceGroup) => {
|
|
296
|
+
let rank: number | undefined;
|
|
297
|
+
let itemIndex = Number.POSITIVE_INFINITY;
|
|
298
|
+
for (const item of caseByGroup.get(group.id) ?? []) {
|
|
299
|
+
const itemRank = EVIDENCE_ROLE_RANK[item.role];
|
|
300
|
+
if (rank === undefined || itemRank < rank) {
|
|
301
|
+
rank = itemRank;
|
|
302
|
+
itemIndex = item.index;
|
|
303
|
+
} else if (itemRank === rank) {
|
|
304
|
+
itemIndex = Math.min(itemIndex, item.index);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (rank === undefined || rank === UNLABELLED_RANK) {
|
|
308
|
+
return { rank: UNLABELLED_RANK, itemIndex: Number.POSITIVE_INFINITY };
|
|
309
|
+
}
|
|
310
|
+
return { rank, itemIndex };
|
|
311
|
+
};
|
|
312
|
+
collections.main.sort((left, right) => {
|
|
313
|
+
const leftRole = roleOrder(left);
|
|
314
|
+
const rightRole = roleOrder(right);
|
|
315
|
+
const agentOrder =
|
|
316
|
+
Number.isFinite(leftRole.itemIndex) ||
|
|
317
|
+
Number.isFinite(rightRole.itemIndex)
|
|
318
|
+
? leftRole.itemIndex - rightRole.itemIndex
|
|
319
|
+
: 0;
|
|
320
|
+
return (
|
|
321
|
+
leftRole.rank - rightRole.rank ||
|
|
322
|
+
agentOrder ||
|
|
190
323
|
Number(right.latest.tier === "key") -
|
|
191
324
|
Number(left.latest.tier === "key") ||
|
|
192
325
|
Number(adverse(right)) - Number(adverse(left)) ||
|
|
193
|
-
left.firstOrder - right.firstOrder
|
|
326
|
+
left.firstOrder - right.firstOrder
|
|
327
|
+
);
|
|
328
|
+
});
|
|
329
|
+
// A healthy workload's collection opens with several "nothing here"
|
|
330
|
+
// receipts; the cards that carry facts (its vitals above all) come first,
|
|
331
|
+
// and the receipts keep their order among themselves.
|
|
332
|
+
collections.workload.sort(
|
|
333
|
+
(left, right) =>
|
|
334
|
+
Number(left.kind === "receipt") - Number(right.kind === "receipt"),
|
|
194
335
|
);
|
|
195
|
-
return { ...collections, collectionByGroup, hiddenBroader };
|
|
336
|
+
return { ...collections, collectionByGroup, hiddenBroader, hiddenMetrics };
|
|
196
337
|
}
|
|
197
338
|
|
|
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
|
-
|
|
210
339
|
export function investigationEvidenceRevealCollection(
|
|
211
340
|
projection: InvestigationEvidenceProjection,
|
|
212
341
|
sourceId: string,
|
|
@@ -229,6 +358,8 @@ export function investigationEvidenceRevealCollection(
|
|
|
229
358
|
export function InvestigationEvidencePane({
|
|
230
359
|
projection,
|
|
231
360
|
rootCauseEvidence,
|
|
361
|
+
alsoSelectedGroupIds,
|
|
362
|
+
investigationCase,
|
|
232
363
|
collecting,
|
|
233
364
|
animateGroupIds,
|
|
234
365
|
onViewSource,
|
|
@@ -242,6 +373,13 @@ export function InvestigationEvidencePane({
|
|
|
242
373
|
projection: InvestigationEvidenceProjection;
|
|
243
374
|
/** Server-validated links for the current root cause; absent without one. */
|
|
244
375
|
rootCauseEvidence?: InvestigationRootCauseEvidenceResolution;
|
|
376
|
+
/** What a later turn cited; adds to the assessment's selection. */
|
|
377
|
+
alsoSelectedGroupIds?: readonly {
|
|
378
|
+
groupId: string;
|
|
379
|
+
source: InvestigationEvidenceSource;
|
|
380
|
+
}[];
|
|
381
|
+
/** The current assessment's agent case, resolved against this projection. */
|
|
382
|
+
investigationCase?: InvestigationCaseResolution;
|
|
245
383
|
collecting: boolean;
|
|
246
384
|
animateGroupIds: ReadonlySet<string>;
|
|
247
385
|
onViewSource: (
|
|
@@ -280,9 +418,57 @@ export function InvestigationEvidencePane({
|
|
|
280
418
|
const partition = partitionInvestigationEvidence(
|
|
281
419
|
projection.groups,
|
|
282
420
|
rootCauseEvidence,
|
|
421
|
+
investigationCase,
|
|
422
|
+
alsoSelectedGroupIds,
|
|
283
423
|
);
|
|
284
424
|
const hasCurrentEvidence =
|
|
285
425
|
partition.main.length + partition.workload.length > 0;
|
|
426
|
+
// A "Ruled out" link is an in-pane navigation to the placed observation. It
|
|
427
|
+
// reuses the Activity → Findings reveal path (open the card, open history
|
|
428
|
+
// when the observation is a superseded revision) and yields to a newer
|
|
429
|
+
// request from the parent.
|
|
430
|
+
const [caseReveal, setCaseReveal] = useState<
|
|
431
|
+
{ sourceId: string; requestId: number } | undefined
|
|
432
|
+
>(undefined);
|
|
433
|
+
// A ruled-out reveal belongs to the case that produced it; a new parent
|
|
434
|
+
// request or a new assessment's case supersedes it.
|
|
435
|
+
useEffect(() => {
|
|
436
|
+
setCaseReveal(undefined);
|
|
437
|
+
}, [revealRequest?.requestId, investigationCase]);
|
|
438
|
+
const collectionByGroup = partition.collectionByGroup;
|
|
439
|
+
// A placed item can still sit on a withheld broader card; a link to it
|
|
440
|
+
// would have nowhere to go.
|
|
441
|
+
const visibleRuledOut = (investigationCase?.ruledOut ?? []).filter(
|
|
442
|
+
(entry) => entry.item.groupId && collectionByGroup.has(entry.item.groupId),
|
|
443
|
+
);
|
|
444
|
+
const revealCaseItem = useCallback(
|
|
445
|
+
(item: InvestigationCaseItem) => {
|
|
446
|
+
const { groupId, observation } = item;
|
|
447
|
+
if (!groupId || !observation) return;
|
|
448
|
+
// A placed group is normally in main, but a historical one lives in the
|
|
449
|
+
// nested "Previous observations" collection; open the way to it first.
|
|
450
|
+
const collection = collectionByGroup.get(groupId);
|
|
451
|
+
let settle = 0;
|
|
452
|
+
if (collection === "workload" || collection === "earlier") {
|
|
453
|
+
setWorkloadOpen(true);
|
|
454
|
+
settle = investigationDisclosureSettleDelay(prefersReducedMotion());
|
|
455
|
+
}
|
|
456
|
+
if (collection === "earlier") setEarlierOpen(true);
|
|
457
|
+
onGroupOpenChange(groupId, true);
|
|
458
|
+
setCaseReveal({ sourceId: observation.source.id, requestId: Date.now() });
|
|
459
|
+
window.setTimeout(() => {
|
|
460
|
+
window.requestAnimationFrame(() => {
|
|
461
|
+
const element = document.getElementById(groupId);
|
|
462
|
+
element?.scrollIntoView({
|
|
463
|
+
block: "start",
|
|
464
|
+
behavior: prefersReducedMotion() ? "auto" : "smooth",
|
|
465
|
+
});
|
|
466
|
+
element?.focus({ preventScroll: true });
|
|
467
|
+
});
|
|
468
|
+
}, settle);
|
|
469
|
+
},
|
|
470
|
+
[onGroupOpenChange, collectionByGroup],
|
|
471
|
+
);
|
|
286
472
|
const revealCollection = revealRequest
|
|
287
473
|
? investigationEvidenceRevealCollection(
|
|
288
474
|
projection,
|
|
@@ -403,6 +589,13 @@ export function InvestigationEvidencePane({
|
|
|
403
589
|
/>
|
|
404
590
|
) : null}
|
|
405
591
|
|
|
592
|
+
{/* What the agent considered and rejected belongs with the conclusion
|
|
593
|
+
it argues for, not after the evidence list among the withheld
|
|
594
|
+
counts, where it read as bookkeeping. */}
|
|
595
|
+
{visibleRuledOut.length > 0 ? (
|
|
596
|
+
<RuledOutBlock entries={visibleRuledOut} onReveal={revealCaseItem} />
|
|
597
|
+
) : null}
|
|
598
|
+
|
|
406
599
|
<div className="grid items-start gap-2.5">
|
|
407
600
|
{partition.main.map((group) => (
|
|
408
601
|
<EvidenceCard
|
|
@@ -426,6 +619,17 @@ export function InvestigationEvidencePane({
|
|
|
426
619
|
</p>
|
|
427
620
|
) : null}
|
|
428
621
|
|
|
622
|
+
{partition.hiddenMetrics > 0 ? (
|
|
623
|
+
<p
|
|
624
|
+
className="text-xs text-theme-text-tertiary"
|
|
625
|
+
data-testid="investigation-hidden-metrics"
|
|
626
|
+
>
|
|
627
|
+
{partition.hiddenMetrics === 1
|
|
628
|
+
? "1 broader metric result is not shown; it appears here when the assessment cites it."
|
|
629
|
+
: `${partition.hiddenMetrics} broader metric results are not shown; they appear here when the assessment cites them.`}
|
|
630
|
+
</p>
|
|
631
|
+
) : null}
|
|
632
|
+
|
|
429
633
|
{!hasCurrentEvidence ? (
|
|
430
634
|
<EmptyCollection
|
|
431
635
|
collecting={collecting}
|
|
@@ -467,8 +671,29 @@ export function InvestigationEvidencePane({
|
|
|
467
671
|
onOpenTimeline,
|
|
468
672
|
expandedGroupIds,
|
|
469
673
|
onGroupOpenChange,
|
|
470
|
-
revealSourceId: revealRequest?.sourceId,
|
|
471
|
-
revealRequestId: revealRequest?.requestId,
|
|
674
|
+
revealSourceId: caseReveal?.sourceId ?? revealRequest?.sourceId,
|
|
675
|
+
revealRequestId: caseReveal?.requestId ?? revealRequest?.requestId,
|
|
676
|
+
caseByGroup: investigationCaseByGroup(investigationCase),
|
|
677
|
+
excludedByItem: new Map(
|
|
678
|
+
(investigationCase?.ruledOut ?? []).map((entry) => [
|
|
679
|
+
investigationCaseItemKey(entry.item),
|
|
680
|
+
entry.hypothesis,
|
|
681
|
+
]),
|
|
682
|
+
),
|
|
683
|
+
metricsMarkersBySource: new Map(
|
|
684
|
+
projection.groups.flatMap((group) =>
|
|
685
|
+
group.observations.flatMap((observation) =>
|
|
686
|
+
observation.data.type === "metrics"
|
|
687
|
+
? [
|
|
688
|
+
[
|
|
689
|
+
observation.source.id,
|
|
690
|
+
metricsChangeCoverage(projection.groups, observation),
|
|
691
|
+
] as const,
|
|
692
|
+
]
|
|
693
|
+
: [],
|
|
694
|
+
),
|
|
695
|
+
),
|
|
696
|
+
),
|
|
472
697
|
citedOrderByGroup: new Map(
|
|
473
698
|
rootCauseEvidence?.links.flatMap((link) =>
|
|
474
699
|
link.originalGroupId
|
|
@@ -484,6 +709,63 @@ export function InvestigationEvidencePane({
|
|
|
484
709
|
);
|
|
485
710
|
}
|
|
486
711
|
|
|
712
|
+
/**
|
|
713
|
+
* Hypotheses the agent dropped, each pointing at the Radar observation whose
|
|
714
|
+
* result contradicted it. Entries whose item could not be placed on an
|
|
715
|
+
* observation are filtered out by the resolver and never rendered here.
|
|
716
|
+
*/
|
|
717
|
+
function RuledOutBlock({
|
|
718
|
+
entries,
|
|
719
|
+
onReveal,
|
|
720
|
+
}: {
|
|
721
|
+
entries: InvestigationCaseResolution["ruledOut"];
|
|
722
|
+
onReveal: (item: InvestigationCaseItem) => void;
|
|
723
|
+
}) {
|
|
724
|
+
const headingId = useId();
|
|
725
|
+
return (
|
|
726
|
+
<section
|
|
727
|
+
aria-labelledby={headingId}
|
|
728
|
+
data-testid="investigation-ruled-out"
|
|
729
|
+
className="rounded-lg border border-theme-border/80 bg-theme-base/20 px-3 py-2.5"
|
|
730
|
+
>
|
|
731
|
+
<h3
|
|
732
|
+
id={headingId}
|
|
733
|
+
className="flex items-center gap-2 text-xs font-semibold text-theme-text-secondary"
|
|
734
|
+
>
|
|
735
|
+
Ruled out
|
|
736
|
+
<span className="text-[10px] font-semibold uppercase tracking-wide text-accent-text">
|
|
737
|
+
Agent
|
|
738
|
+
</span>
|
|
739
|
+
</h3>
|
|
740
|
+
<ul className="mt-1.5 space-y-1.5">
|
|
741
|
+
{entries.map((entry, position) => {
|
|
742
|
+
const observation = entry.item.observation!;
|
|
743
|
+
return (
|
|
744
|
+
<li
|
|
745
|
+
key={position}
|
|
746
|
+
className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5 text-xs"
|
|
747
|
+
>
|
|
748
|
+
<span className="min-w-0 text-theme-text-secondary [overflow-wrap:anywhere]">
|
|
749
|
+
{entry.hypothesis}
|
|
750
|
+
</span>
|
|
751
|
+
<button
|
|
752
|
+
type="button"
|
|
753
|
+
onClick={() => onReveal(entry.item)}
|
|
754
|
+
className="shrink-0 rounded text-accent-text hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
|
755
|
+
>
|
|
756
|
+
See {observation.title}
|
|
757
|
+
{entry.item.placement === "revision"
|
|
758
|
+
? " (earlier observation)"
|
|
759
|
+
: ""}
|
|
760
|
+
</button>
|
|
761
|
+
</li>
|
|
762
|
+
);
|
|
763
|
+
})}
|
|
764
|
+
</ul>
|
|
765
|
+
</section>
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
|
|
487
769
|
function AssessmentEvidenceQualification({
|
|
488
770
|
resolution,
|
|
489
771
|
onViewActivity,
|
|
@@ -925,14 +1207,25 @@ function CoverageGroupRow({
|
|
|
925
1207
|
function previousDifferentObservations(
|
|
926
1208
|
group: InvestigationEvidenceGroup,
|
|
927
1209
|
citedOrder?: number,
|
|
1210
|
+
boundOrders: ReadonlySet<number> = new Set(),
|
|
928
1211
|
) {
|
|
929
1212
|
const seen = new Set([evidenceDisplaySnapshot(group.latest)]);
|
|
1213
|
+
const pinned = (observation: InvestigationEvidenceObservation) =>
|
|
1214
|
+
observation.source.order === citedOrder ||
|
|
1215
|
+
boundOrders.has(observation.source.order);
|
|
930
1216
|
return [...group.observations].reverse().filter((observation) => {
|
|
1217
|
+
if (observation === group.latest) return false;
|
|
1218
|
+
// An observation bound to an agent item always keeps its own row: display
|
|
1219
|
+
// equivalence is a folding rule for unpinned rechecks, never a reason to
|
|
1220
|
+
// move a claim onto different content.
|
|
1221
|
+
if (boundOrders.has(observation.source.order)) return true;
|
|
1222
|
+
// A pinned observation wins over an unpinned twin with the same display
|
|
1223
|
+
// content.
|
|
931
1224
|
if (
|
|
932
|
-
observation
|
|
1225
|
+
!pinned(observation) &&
|
|
933
1226
|
group.observations.some(
|
|
934
1227
|
(other) =>
|
|
935
|
-
other
|
|
1228
|
+
pinned(other) &&
|
|
936
1229
|
evidenceDisplaySnapshot(other) ===
|
|
937
1230
|
evidenceDisplaySnapshot(observation),
|
|
938
1231
|
)
|
|
@@ -948,6 +1241,8 @@ function previousDifferentObservations(
|
|
|
948
1241
|
export function investigationEvidenceShouldRevealHistory(
|
|
949
1242
|
group: InvestigationEvidenceGroup,
|
|
950
1243
|
sourceId?: string,
|
|
1244
|
+
/** Sources whose observation carries an agent item and so always has a row. */
|
|
1245
|
+
boundSourceIds: ReadonlySet<string> = new Set(),
|
|
951
1246
|
): boolean {
|
|
952
1247
|
return (
|
|
953
1248
|
Boolean(sourceId) &&
|
|
@@ -955,8 +1250,9 @@ export function investigationEvidenceShouldRevealHistory(
|
|
|
955
1250
|
group.observations.some(
|
|
956
1251
|
(observation) =>
|
|
957
1252
|
observation.source.id === sourceId &&
|
|
958
|
-
|
|
959
|
-
evidenceDisplaySnapshot(
|
|
1253
|
+
(boundSourceIds.has(observation.source.id) ||
|
|
1254
|
+
evidenceDisplaySnapshot(observation) !==
|
|
1255
|
+
evidenceDisplaySnapshot(group.latest)),
|
|
960
1256
|
)
|
|
961
1257
|
);
|
|
962
1258
|
}
|
|
@@ -989,6 +1285,9 @@ function EvidenceCard({
|
|
|
989
1285
|
citedOrderByGroup,
|
|
990
1286
|
expandedGroupIds,
|
|
991
1287
|
onGroupOpenChange,
|
|
1288
|
+
metricsMarkersBySource,
|
|
1289
|
+
caseByGroup,
|
|
1290
|
+
excludedByItem,
|
|
992
1291
|
} = useContext(EvidenceNavigationContext);
|
|
993
1292
|
const open = expandedGroupIds?.has(group.id) ?? false;
|
|
994
1293
|
const setOpen = useCallback(
|
|
@@ -1002,7 +1301,17 @@ function EvidenceCard({
|
|
|
1002
1301
|
? parseLogLine(observation.summary).content
|
|
1003
1302
|
: observation.summary;
|
|
1004
1303
|
const citedOrder = citedOrderByGroup?.get(group.id);
|
|
1005
|
-
const
|
|
1304
|
+
const caseItems = caseByGroup?.get(group.id) ?? [];
|
|
1305
|
+
const cardItems = caseItems.filter((item) => item.placement === "card");
|
|
1306
|
+
const revisionItems = caseItems.filter(
|
|
1307
|
+
(item) => item.placement === "revision",
|
|
1308
|
+
);
|
|
1309
|
+
const agentCause = cardItems.some((item) => item.role === "cause");
|
|
1310
|
+
const previousObservations = previousDifferentObservations(
|
|
1311
|
+
group,
|
|
1312
|
+
citedOrder,
|
|
1313
|
+
new Set(revisionItems.map((item) => item.observation!.source.order)),
|
|
1314
|
+
);
|
|
1006
1315
|
const meaningfulHistory = previousObservations.length > 0;
|
|
1007
1316
|
const citedObservation = group.observations.find(
|
|
1008
1317
|
(item) => item.source.order === citedOrder,
|
|
@@ -1020,6 +1329,7 @@ function EvidenceCard({
|
|
|
1020
1329
|
const revealHistory = investigationEvidenceShouldRevealHistory(
|
|
1021
1330
|
group,
|
|
1022
1331
|
revealSourceId,
|
|
1332
|
+
new Set(revisionItems.map((item) => item.observation!.source.id)),
|
|
1023
1333
|
);
|
|
1024
1334
|
useLayoutEffect(() => {
|
|
1025
1335
|
const destination = revealSourceId
|
|
@@ -1100,7 +1410,14 @@ function EvidenceCard({
|
|
|
1100
1410
|
aria-label={`${observation.title} evidence`}
|
|
1101
1411
|
className={clsx(
|
|
1102
1412
|
"@container/card scroll-mt-14 overflow-hidden outline-none focus:ring-2 focus:ring-accent/50 data-[source-related]:ring-2 data-[source-related]:ring-accent/35",
|
|
1103
|
-
"rounded-lg border
|
|
1413
|
+
"rounded-lg border",
|
|
1414
|
+
// The card the agent calls the cause sorts first and is often the
|
|
1415
|
+
// calmest thing on screen: a cause is frequently a Secret or a
|
|
1416
|
+
// ConfigMap that Radar has no reason to colour, while every card
|
|
1417
|
+
// echoing the failure below it carries red. Lifting the surface marks
|
|
1418
|
+
// it without borrowing a severity hue, and without touching the left
|
|
1419
|
+
// rule (Radar's severity) or the ring (the source you are viewing).
|
|
1420
|
+
agentCause ? "bg-theme-elevated shadow-theme-sm" : "bg-theme-surface",
|
|
1104
1421
|
toneBorder(observation.tone, observation.tier, prominence),
|
|
1105
1422
|
wide && "@min-[760px]/evidence:col-span-2",
|
|
1106
1423
|
animateArrival && "animate-transcript-enter",
|
|
@@ -1173,6 +1490,24 @@ function EvidenceCard({
|
|
|
1173
1490
|
) : null}
|
|
1174
1491
|
</div>
|
|
1175
1492
|
</div>
|
|
1493
|
+
{cardItems.some((item) => item.claim || item.role) ? (
|
|
1494
|
+
<div
|
|
1495
|
+
className={clsx(
|
|
1496
|
+
"space-y-1.5",
|
|
1497
|
+
prominence === "primary" ? "px-3 pb-2.5" : "px-2.5 pb-2",
|
|
1498
|
+
)}
|
|
1499
|
+
>
|
|
1500
|
+
{cardItems.map((item) => (
|
|
1501
|
+
<AgentClaimNote
|
|
1502
|
+
key={item.index}
|
|
1503
|
+
claim={item.claim}
|
|
1504
|
+
role={item.role}
|
|
1505
|
+
excludes={excludedByItem?.get(investigationCaseItemKey(item))}
|
|
1506
|
+
className="pt-1.5"
|
|
1507
|
+
/>
|
|
1508
|
+
))}
|
|
1509
|
+
</div>
|
|
1510
|
+
) : null}
|
|
1176
1511
|
{canExpand ? (
|
|
1177
1512
|
<div id={bodyId}>
|
|
1178
1513
|
<Collapse open={open}>
|
|
@@ -1186,12 +1521,16 @@ function EvidenceCard({
|
|
|
1186
1521
|
<EvidenceBody
|
|
1187
1522
|
data={observation.data}
|
|
1188
1523
|
cardSummary={observation.summary}
|
|
1524
|
+
changeCoverage={metricsMarkersBySource?.get(
|
|
1525
|
+
observation.source.id,
|
|
1526
|
+
)}
|
|
1189
1527
|
/>
|
|
1190
1528
|
) : null}
|
|
1191
1529
|
{meaningfulHistory ? (
|
|
1192
1530
|
<RevisionHistory
|
|
1193
1531
|
observations={previousObservations}
|
|
1194
1532
|
citedOrder={citedOrder}
|
|
1533
|
+
caseItems={revisionItems}
|
|
1195
1534
|
reveal={revealHistory}
|
|
1196
1535
|
revealRequestId={revealRequestId}
|
|
1197
1536
|
onViewSource={onViewSource}
|
|
@@ -1293,9 +1632,12 @@ function uniquePrimarySources(
|
|
|
1293
1632
|
function EvidenceBody({
|
|
1294
1633
|
data,
|
|
1295
1634
|
cardSummary,
|
|
1635
|
+
changeCoverage,
|
|
1296
1636
|
}: {
|
|
1297
1637
|
data: InvestigationEvidenceData;
|
|
1298
1638
|
cardSummary?: string;
|
|
1639
|
+
/** Change markers for a metrics chart; derived by the pane, never by data. */
|
|
1640
|
+
changeCoverage?: MetricsChangeCoverage;
|
|
1299
1641
|
}) {
|
|
1300
1642
|
switch (data.type) {
|
|
1301
1643
|
case "issue":
|
|
@@ -1323,15 +1665,20 @@ function EvidenceBody({
|
|
|
1323
1665
|
case "inventory":
|
|
1324
1666
|
return <InventoryBody data={data} />;
|
|
1325
1667
|
case "receipt":
|
|
1326
|
-
|
|
1668
|
+
// The title and the scope above it are the answer. A body appears only
|
|
1669
|
+
// when it adds the reason or the limit, so an absent one renders nothing
|
|
1670
|
+
// rather than an empty line.
|
|
1671
|
+
return data.message ? (
|
|
1327
1672
|
<p className="text-xs text-theme-text-secondary">{data.message}</p>
|
|
1328
|
-
);
|
|
1673
|
+
) : null;
|
|
1329
1674
|
case "alerts":
|
|
1330
1675
|
return <AlertsBody data={data} />;
|
|
1331
1676
|
case "helm":
|
|
1332
1677
|
return <HelmBody data={data} />;
|
|
1333
1678
|
case "permissions":
|
|
1334
1679
|
return <PermissionsBody data={data} />;
|
|
1680
|
+
case "metrics":
|
|
1681
|
+
return <MetricsBody data={data} changeCoverage={changeCoverage} />;
|
|
1335
1682
|
}
|
|
1336
1683
|
}
|
|
1337
1684
|
|
|
@@ -1360,6 +1707,7 @@ function evidenceHasDetails(
|
|
|
1360
1707
|
investigationResourceEvidenceHasDetails(data.resource) ||
|
|
1361
1708
|
replicas?.desired !== undefined ||
|
|
1362
1709
|
data.resourceContext?.statusSummary?.conditions?.length ||
|
|
1710
|
+
diagnosedScalers(data.resourceContext).length ||
|
|
1363
1711
|
data.gitOpsDiagnosis ||
|
|
1364
1712
|
data.warnings.length,
|
|
1365
1713
|
);
|
|
@@ -1495,6 +1843,7 @@ function ResourceBody({ data }: { data: EvidenceDataOf<"resource"> }) {
|
|
|
1495
1843
|
const ready = replicas ? (replicas.ready ?? 0) : undefined;
|
|
1496
1844
|
const shortfall =
|
|
1497
1845
|
desired !== undefined && ready !== undefined && ready < desired;
|
|
1846
|
+
const scalers = diagnosedScalers(data.resourceContext);
|
|
1498
1847
|
return (
|
|
1499
1848
|
<div className="space-y-3">
|
|
1500
1849
|
<InvestigationResourceEvidence resource={data.resource} />
|
|
@@ -1550,6 +1899,12 @@ function ResourceBody({ data }: { data: EvidenceDataOf<"resource"> }) {
|
|
|
1550
1899
|
</div>
|
|
1551
1900
|
</div>
|
|
1552
1901
|
) : null}
|
|
1902
|
+
{scalers.length > 0 ? (
|
|
1903
|
+
<ScaledBySection
|
|
1904
|
+
scalers={scalers}
|
|
1905
|
+
allScalers={data.resourceContext?.scaledBy ?? []}
|
|
1906
|
+
/>
|
|
1907
|
+
) : null}
|
|
1553
1908
|
{data.warnings.length > 0 ? (
|
|
1554
1909
|
<ul className="space-y-1 text-xs text-theme-text-secondary">
|
|
1555
1910
|
{data.warnings.map((warning) => (
|
|
@@ -1564,6 +1919,75 @@ function ResourceBody({ data }: { data: EvidenceDataOf<"resource"> }) {
|
|
|
1564
1919
|
);
|
|
1565
1920
|
}
|
|
1566
1921
|
|
|
1922
|
+
type DiagnosedScaler = DiagnosisScalerRef &
|
|
1923
|
+
Required<Pick<DiagnosisScalerRef, "hpaSummary">>;
|
|
1924
|
+
|
|
1925
|
+
function diagnosedScalers(
|
|
1926
|
+
context: DiagnosisResourceContext | undefined,
|
|
1927
|
+
): DiagnosedScaler[] {
|
|
1928
|
+
return (context?.scaledBy ?? []).filter(
|
|
1929
|
+
(scaler): scaler is DiagnosedScaler => scaler.hpaSummary !== undefined,
|
|
1930
|
+
);
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
function ScaledBySection({
|
|
1934
|
+
scalers,
|
|
1935
|
+
allScalers,
|
|
1936
|
+
}: {
|
|
1937
|
+
scalers: DiagnosedScaler[];
|
|
1938
|
+
allScalers: DiagnosisScalerRef[];
|
|
1939
|
+
}) {
|
|
1940
|
+
const { onOpenResource } = useContext(EvidenceNavigationContext);
|
|
1941
|
+
// The ScaledObject is only a link when Radar listed it as a scaler too; a
|
|
1942
|
+
// name read off the HPA's owner reference alone is not a resource we hold.
|
|
1943
|
+
const listedScaler = (ref: DiagnosisResourceRef) =>
|
|
1944
|
+
allScalers.some(
|
|
1945
|
+
(scaler) =>
|
|
1946
|
+
scaler.kind === ref.kind &&
|
|
1947
|
+
scaler.name === ref.name &&
|
|
1948
|
+
(scaler.namespace ?? "") === (ref.namespace ?? ""),
|
|
1949
|
+
);
|
|
1950
|
+
return (
|
|
1951
|
+
<div>
|
|
1952
|
+
<div className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
1953
|
+
Scaled by
|
|
1954
|
+
</div>
|
|
1955
|
+
<div className="space-y-2">
|
|
1956
|
+
{scalers.map((scaler) => (
|
|
1957
|
+
<HPADiagnosisSummary
|
|
1958
|
+
key={`${scaler.namespace ?? ""}/${scaler.name}`}
|
|
1959
|
+
diagnosis={scaler.hpaSummary}
|
|
1960
|
+
variant="inline"
|
|
1961
|
+
header={
|
|
1962
|
+
<>
|
|
1963
|
+
<span className="font-medium text-theme-text-primary">
|
|
1964
|
+
{displayKind(scaler.kind)} {scaler.name}
|
|
1965
|
+
</span>
|
|
1966
|
+
{scaler.managedBy ? (
|
|
1967
|
+
<span className="text-theme-text-tertiary">
|
|
1968
|
+
managed by KEDA {displayKind(scaler.managedBy.kind)}{" "}
|
|
1969
|
+
<ResourceLink
|
|
1970
|
+
name={scaler.managedBy.name}
|
|
1971
|
+
kind={scaler.managedBy.kind}
|
|
1972
|
+
namespace={scaler.managedBy.namespace ?? ""}
|
|
1973
|
+
group={scaler.managedBy.group}
|
|
1974
|
+
onNavigate={
|
|
1975
|
+
onOpenResource && listedScaler(scaler.managedBy)
|
|
1976
|
+
? (ref) => onOpenResource(ref)
|
|
1977
|
+
: undefined
|
|
1978
|
+
}
|
|
1979
|
+
/>
|
|
1980
|
+
</span>
|
|
1981
|
+
) : null}
|
|
1982
|
+
</>
|
|
1983
|
+
}
|
|
1984
|
+
/>
|
|
1985
|
+
))}
|
|
1986
|
+
</div>
|
|
1987
|
+
</div>
|
|
1988
|
+
);
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1567
1991
|
function GitOpsStatusBody({
|
|
1568
1992
|
status,
|
|
1569
1993
|
}: {
|
|
@@ -2114,6 +2538,222 @@ function TopologyStat({ label, value }: { label: string; value: number }) {
|
|
|
2114
2538
|
);
|
|
2115
2539
|
}
|
|
2116
2540
|
|
|
2541
|
+
const METRICS_AXIS_LABEL_MAX_CHARS = 72;
|
|
2542
|
+
|
|
2543
|
+
function finiteSamples(series: TimeSeries): TimeSeries["dataPoints"] {
|
|
2544
|
+
return series.dataPoints.filter((point) => typeof point.value === "number");
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
function MetricsValueTable({
|
|
2548
|
+
series,
|
|
2549
|
+
labels,
|
|
2550
|
+
unit,
|
|
2551
|
+
withTime,
|
|
2552
|
+
}: {
|
|
2553
|
+
series: TimeSeries[];
|
|
2554
|
+
/** Display name per series, derived from the complete result. */
|
|
2555
|
+
labels: string[];
|
|
2556
|
+
unit: string;
|
|
2557
|
+
withTime: boolean;
|
|
2558
|
+
}) {
|
|
2559
|
+
return (
|
|
2560
|
+
<table className="w-full text-xs">
|
|
2561
|
+
<tbody>
|
|
2562
|
+
{series.map((item, index) => {
|
|
2563
|
+
const sample = finiteSamples(item).at(-1);
|
|
2564
|
+
return (
|
|
2565
|
+
<tr
|
|
2566
|
+
key={`${labels[index]}-${index}`}
|
|
2567
|
+
className="border-b border-theme-border/60 last:border-b-0"
|
|
2568
|
+
>
|
|
2569
|
+
<td className="py-1 pr-3 font-mono text-theme-text-secondary [overflow-wrap:anywhere]">
|
|
2570
|
+
{labels[index]}
|
|
2571
|
+
</td>
|
|
2572
|
+
{withTime ? (
|
|
2573
|
+
<td className="py-1 pr-3 text-right text-theme-text-tertiary tabular-nums">
|
|
2574
|
+
{sample
|
|
2575
|
+
? new Date(sample.timestamp * 1000).toLocaleTimeString()
|
|
2576
|
+
: ""}
|
|
2577
|
+
</td>
|
|
2578
|
+
) : null}
|
|
2579
|
+
<td className="py-1 text-right font-mono tabular-nums text-theme-text-primary">
|
|
2580
|
+
{sample && typeof sample.value === "number"
|
|
2581
|
+
? formatMetricValue(sample.value, unit)
|
|
2582
|
+
: "no value"}
|
|
2583
|
+
</td>
|
|
2584
|
+
</tr>
|
|
2585
|
+
);
|
|
2586
|
+
})}
|
|
2587
|
+
</tbody>
|
|
2588
|
+
</table>
|
|
2589
|
+
);
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
function MetricsBody({
|
|
2593
|
+
data,
|
|
2594
|
+
changeCoverage,
|
|
2595
|
+
}: {
|
|
2596
|
+
data: EvidenceDataOf<"metrics">;
|
|
2597
|
+
changeCoverage?: MetricsChangeCoverage;
|
|
2598
|
+
}) {
|
|
2599
|
+
const annotations = changeCoverage?.markers;
|
|
2600
|
+
const unit = data.unit ?? "";
|
|
2601
|
+
const axisLabel = data.label ?? data.query;
|
|
2602
|
+
const axisTruncated = axisLabel.length > METRICS_AXIS_LABEL_MAX_CHARS;
|
|
2603
|
+
const axisText = axisTruncated
|
|
2604
|
+
? `${axisLabel.slice(0, METRICS_AXIS_LABEL_MAX_CHARS - 1)}…`
|
|
2605
|
+
: axisLabel;
|
|
2606
|
+
const domain = metricsDomain(data);
|
|
2607
|
+
const windowText = domain
|
|
2608
|
+
? `${new Date(domain.start * 1000).toLocaleString()} to ${new Date(domain.end * 1000).toLocaleString()}`
|
|
2609
|
+
: undefined;
|
|
2610
|
+
if (data.series.length === 0) {
|
|
2611
|
+
return (
|
|
2612
|
+
<div className="space-y-2">
|
|
2613
|
+
<p className="text-xs text-theme-text-secondary">
|
|
2614
|
+
No series matched this query
|
|
2615
|
+
{data.mode === "range" ? " in the window" : ""}.
|
|
2616
|
+
</p>
|
|
2617
|
+
<pre className="whitespace-pre-wrap break-all rounded-md border border-theme-border/70 bg-theme-base/30 p-2 font-mono text-xs text-theme-text-secondary">
|
|
2618
|
+
{data.query}
|
|
2619
|
+
</pre>
|
|
2620
|
+
{data.note ? (
|
|
2621
|
+
<p className="text-xs text-theme-text-tertiary">{data.note}</p>
|
|
2622
|
+
) : null}
|
|
2623
|
+
</div>
|
|
2624
|
+
);
|
|
2625
|
+
}
|
|
2626
|
+
if (data.mode === "instant") {
|
|
2627
|
+
return (
|
|
2628
|
+
<div className="space-y-2">
|
|
2629
|
+
<MetricsValueTable
|
|
2630
|
+
series={data.series}
|
|
2631
|
+
labels={seriesDisplayLabels(data.series)}
|
|
2632
|
+
unit={unit}
|
|
2633
|
+
withTime={false}
|
|
2634
|
+
/>
|
|
2635
|
+
<pre className="whitespace-pre-wrap break-all rounded-md border border-theme-border/70 bg-theme-base/30 p-2 font-mono text-xs text-theme-text-secondary">
|
|
2636
|
+
{data.query}
|
|
2637
|
+
</pre>
|
|
2638
|
+
{data.note ? (
|
|
2639
|
+
<p className="text-xs text-theme-text-tertiary">{data.note}</p>
|
|
2640
|
+
) : null}
|
|
2641
|
+
</div>
|
|
2642
|
+
);
|
|
2643
|
+
}
|
|
2644
|
+
// Names come from the whole result so two series that differ only by a
|
|
2645
|
+
// label the chart would hide stay distinguishable wherever they are listed.
|
|
2646
|
+
const labels = seriesDisplayLabels(data.series);
|
|
2647
|
+
const indexed = data.series.map((item, index) => ({
|
|
2648
|
+
item,
|
|
2649
|
+
label: labels[index],
|
|
2650
|
+
finite: finiteSamples(item).length,
|
|
2651
|
+
}));
|
|
2652
|
+
// A series with one finite sample has no line to draw, and one with none
|
|
2653
|
+
// (Prometheus serializes NaN and infinities as gaps) has nothing to show.
|
|
2654
|
+
// Listing both keeps what was captured readable and states what was not.
|
|
2655
|
+
const charted = indexed.filter((entry) => entry.finite >= 2);
|
|
2656
|
+
const single = indexed.filter((entry) => entry.finite === 1);
|
|
2657
|
+
const empty = indexed.filter((entry) => entry.finite === 0);
|
|
2658
|
+
return (
|
|
2659
|
+
<div className="space-y-2">
|
|
2660
|
+
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5 text-xs">
|
|
2661
|
+
<Tooltip
|
|
2662
|
+
content={axisLabel}
|
|
2663
|
+
delay={200}
|
|
2664
|
+
position="top"
|
|
2665
|
+
disabled={!axisTruncated}
|
|
2666
|
+
>
|
|
2667
|
+
<span
|
|
2668
|
+
className="min-w-0 truncate font-mono text-theme-text-secondary"
|
|
2669
|
+
data-testid="investigation-metrics-axis-label"
|
|
2670
|
+
>
|
|
2671
|
+
{axisText}
|
|
2672
|
+
</span>
|
|
2673
|
+
</Tooltip>
|
|
2674
|
+
{unit ? (
|
|
2675
|
+
<span className="text-theme-text-tertiary">({unit})</span>
|
|
2676
|
+
) : null}
|
|
2677
|
+
{windowText ? (
|
|
2678
|
+
<span className="ml-auto text-theme-text-tertiary">{windowText}</span>
|
|
2679
|
+
) : null}
|
|
2680
|
+
</div>
|
|
2681
|
+
{charted.length > 0 ? (
|
|
2682
|
+
<div className="space-y-1.5 rounded-md border border-theme-border/70 bg-theme-base/30 p-2">
|
|
2683
|
+
<AreaChart
|
|
2684
|
+
series={charted.map((entry) => entry.item)}
|
|
2685
|
+
seriesLabels={charted.map((entry) => entry.label)}
|
|
2686
|
+
color={SERIES_COLORS[0]}
|
|
2687
|
+
fillColor={seriesFill(0, SERIES_COLORS[0])}
|
|
2688
|
+
unit={unit}
|
|
2689
|
+
annotations={annotations}
|
|
2690
|
+
domain={domain}
|
|
2691
|
+
layout="auto"
|
|
2692
|
+
/>
|
|
2693
|
+
{charted.length > 1 ? (
|
|
2694
|
+
<SeriesLegend
|
|
2695
|
+
series={charted.map((entry) => entry.item)}
|
|
2696
|
+
seriesLabels={charted.map((entry) => entry.label)}
|
|
2697
|
+
color={SERIES_COLORS[0]}
|
|
2698
|
+
/>
|
|
2699
|
+
) : null}
|
|
2700
|
+
</div>
|
|
2701
|
+
) : null}
|
|
2702
|
+
{single.length > 0 ? (
|
|
2703
|
+
<div
|
|
2704
|
+
className="space-y-1"
|
|
2705
|
+
data-testid="investigation-metrics-sparse-series"
|
|
2706
|
+
>
|
|
2707
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
2708
|
+
{single.length === 1
|
|
2709
|
+
? "1 series has a single sample in this window, listed with its time:"
|
|
2710
|
+
: `${single.length} series have a single sample in this window, listed with their times:`}
|
|
2711
|
+
</p>
|
|
2712
|
+
<MetricsValueTable
|
|
2713
|
+
series={single.map((entry) => entry.item)}
|
|
2714
|
+
labels={single.map((entry) => entry.label)}
|
|
2715
|
+
unit={unit}
|
|
2716
|
+
withTime
|
|
2717
|
+
/>
|
|
2718
|
+
</div>
|
|
2719
|
+
) : null}
|
|
2720
|
+
{empty.length > 0 ? (
|
|
2721
|
+
<p
|
|
2722
|
+
className="text-xs text-theme-text-tertiary [overflow-wrap:anywhere]"
|
|
2723
|
+
data-testid="investigation-metrics-empty-series"
|
|
2724
|
+
>
|
|
2725
|
+
{empty.length === 1
|
|
2726
|
+
? "1 series had no usable samples in this window: "
|
|
2727
|
+
: `${empty.length} series had no usable samples in this window: `}
|
|
2728
|
+
<span className="font-mono">
|
|
2729
|
+
{empty.map((entry) => entry.label).join("; ")}
|
|
2730
|
+
</span>
|
|
2731
|
+
</p>
|
|
2732
|
+
) : null}
|
|
2733
|
+
{changeCoverage?.checked && charted.length > 0 ? (
|
|
2734
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
2735
|
+
{annotations?.length === 1
|
|
2736
|
+
? "1 change recorded in this window is marked on the chart."
|
|
2737
|
+
: annotations?.length
|
|
2738
|
+
? `${annotations.length} changes recorded in this window are marked on the chart.`
|
|
2739
|
+
: // Only that the changes Radar read miss this window — not that
|
|
2740
|
+
// the window was fully covered. The change lookup has its own
|
|
2741
|
+
// window, which need not span the chart's.
|
|
2742
|
+
"None of the changes Radar read fall in this window."}
|
|
2743
|
+
</p>
|
|
2744
|
+
) : null}
|
|
2745
|
+
{axisTruncated ? (
|
|
2746
|
+
<pre className="whitespace-pre-wrap break-all rounded-md border border-theme-border/70 bg-theme-base/30 p-2 font-mono text-xs text-theme-text-secondary">
|
|
2747
|
+
{data.query}
|
|
2748
|
+
</pre>
|
|
2749
|
+
) : null}
|
|
2750
|
+
{data.note ? (
|
|
2751
|
+
<p className="text-xs text-theme-text-tertiary">{data.note}</p>
|
|
2752
|
+
) : null}
|
|
2753
|
+
</div>
|
|
2754
|
+
);
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2117
2757
|
function InventoryBody({ data }: { data: EvidenceDataOf<"inventory"> }) {
|
|
2118
2758
|
return (
|
|
2119
2759
|
<div className="max-h-72 overflow-y-auto rounded-md border border-theme-border">
|
|
@@ -2600,12 +3240,15 @@ function PermissionsBody({ data }: { data: EvidenceDataOf<"permissions"> }) {
|
|
|
2600
3240
|
function RevisionHistory({
|
|
2601
3241
|
observations,
|
|
2602
3242
|
citedOrder,
|
|
3243
|
+
caseItems = [],
|
|
2603
3244
|
reveal,
|
|
2604
3245
|
revealRequestId,
|
|
2605
3246
|
onViewSource,
|
|
2606
3247
|
}: {
|
|
2607
3248
|
observations: InvestigationEvidenceObservation[];
|
|
2608
3249
|
citedOrder?: number;
|
|
3250
|
+
/** Agent items bound to a superseded observation of this group. */
|
|
3251
|
+
caseItems?: InvestigationCaseItem[];
|
|
2609
3252
|
reveal: boolean;
|
|
2610
3253
|
revealRequestId?: number;
|
|
2611
3254
|
onViewSource: (
|
|
@@ -2615,6 +3258,7 @@ function RevisionHistory({
|
|
|
2615
3258
|
}) {
|
|
2616
3259
|
const [open, setOpen] = useState(false);
|
|
2617
3260
|
const regionId = useId();
|
|
3261
|
+
const { metricsMarkersBySource } = useContext(EvidenceNavigationContext);
|
|
2618
3262
|
const { elementRef, revealAfterToggle } =
|
|
2619
3263
|
useDisclosureReveal<HTMLDivElement>();
|
|
2620
3264
|
useLayoutEffect(() => {
|
|
@@ -2638,41 +3282,73 @@ function RevisionHistory({
|
|
|
2638
3282
|
<div id={regionId} ref={elementRef}>
|
|
2639
3283
|
<Collapse open={open}>
|
|
2640
3284
|
<ol className="space-y-3 pt-2">
|
|
2641
|
-
{observations.map((observation) =>
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
3285
|
+
{observations.map((observation) => {
|
|
3286
|
+
const key = investigationCaseObservationKey(observation);
|
|
3287
|
+
const rowItems = caseItems.filter(
|
|
3288
|
+
(item) =>
|
|
3289
|
+
item.observation &&
|
|
3290
|
+
investigationCaseObservationKey(item.observation) === key,
|
|
3291
|
+
);
|
|
3292
|
+
const rowRoles = [...new Set(rowItems.map((item) => item.role))];
|
|
3293
|
+
return (
|
|
3294
|
+
<li
|
|
3295
|
+
key={`${observation.source.id}-${observation.revision}`}
|
|
3296
|
+
data-case-observation={rowItems.length > 0 ? key : undefined}
|
|
3297
|
+
className="flex min-w-0 items-start gap-2 text-xs"
|
|
3298
|
+
>
|
|
3299
|
+
<span className="mt-0.5 shrink-0 text-theme-text-tertiary">
|
|
3300
|
+
<span className="font-medium text-theme-text-secondary">
|
|
3301
|
+
{observation.source.order === citedOrder
|
|
3302
|
+
? "Used for assessment"
|
|
3303
|
+
: phaseLabel(observation.source.phase)}
|
|
3304
|
+
</span>
|
|
2651
3305
|
</span>
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
3306
|
+
<div className="min-w-0 flex-1 space-y-1 text-theme-text-secondary">
|
|
3307
|
+
{rowRoles.length > 0 ? (
|
|
3308
|
+
<p className="flex flex-wrap items-center gap-1.5">
|
|
3309
|
+
<span>{observation.summary || observation.title}</span>
|
|
3310
|
+
{rowRoles.map((role) => (
|
|
3311
|
+
<AgentRoleChip key={role} role={role} />
|
|
3312
|
+
))}
|
|
3313
|
+
</p>
|
|
3314
|
+
) : (
|
|
3315
|
+
<p>{observation.summary || observation.title}</p>
|
|
3316
|
+
)}
|
|
3317
|
+
{rowItems
|
|
3318
|
+
.filter((item) => item.claim)
|
|
3319
|
+
.map((item) => (
|
|
3320
|
+
<AgentClaimNote
|
|
3321
|
+
key={item.index}
|
|
3322
|
+
claim={item.claim}
|
|
3323
|
+
role={rowRoles.length > 1 ? item.role : undefined}
|
|
3324
|
+
className="pt-1"
|
|
3325
|
+
/>
|
|
3326
|
+
))}
|
|
3327
|
+
{evidenceHasDetails(
|
|
3328
|
+
observation.data,
|
|
3329
|
+
observation.summary,
|
|
3330
|
+
) && (
|
|
3331
|
+
<EvidenceBody
|
|
3332
|
+
data={observation.data}
|
|
3333
|
+
cardSummary={observation.summary}
|
|
3334
|
+
changeCoverage={metricsMarkersBySource?.get(
|
|
3335
|
+
observation.source.id,
|
|
3336
|
+
)}
|
|
3337
|
+
/>
|
|
3338
|
+
)}
|
|
3339
|
+
</div>
|
|
3340
|
+
<SourceButton
|
|
3341
|
+
ariaLabel={`View source for ${phaseLabel(observation.source.phase).toLowerCase()} observation of ${observation.title}`}
|
|
3342
|
+
onClick={() =>
|
|
3343
|
+
onViewSource(
|
|
3344
|
+
observation.source.id,
|
|
3345
|
+
evidenceSourceExcerpt(observation.data),
|
|
3346
|
+
)
|
|
3347
|
+
}
|
|
3348
|
+
/>
|
|
3349
|
+
</li>
|
|
3350
|
+
);
|
|
3351
|
+
})}
|
|
2676
3352
|
</ol>
|
|
2677
3353
|
</Collapse>
|
|
2678
3354
|
</div>
|
|
@@ -2681,11 +3357,9 @@ function RevisionHistory({
|
|
|
2681
3357
|
}
|
|
2682
3358
|
|
|
2683
3359
|
function EvidenceCaveat({ data }: { data: InvestigationEvidenceData }) {
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
"Events support the timeline; proximity alone does not establish cause.";
|
|
2688
|
-
} else if (data.type === "changes") {
|
|
3360
|
+
// Changes get a tooltip rather than a sentence because the wording depends
|
|
3361
|
+
// on whether the reported age was collected with the change.
|
|
3362
|
+
if (data.type === "changes") {
|
|
2689
3363
|
return (
|
|
2690
3364
|
<Tooltip
|
|
2691
3365
|
content={`A change alone does not establish the cause.${data.changeContext?.when ? " The reported age is as of collection." : ""}`}
|
|
@@ -2702,16 +3376,8 @@ function EvidenceCaveat({ data }: { data: InvestigationEvidenceData }) {
|
|
|
2702
3376
|
</button>
|
|
2703
3377
|
</Tooltip>
|
|
2704
3378
|
);
|
|
2705
|
-
} else if (data.type === "relationships" || data.type === "topology") {
|
|
2706
|
-
text =
|
|
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.";
|
|
2714
3379
|
}
|
|
3380
|
+
const text = EVIDENCE_KIND_TRAITS[data.type].caveat;
|
|
2715
3381
|
if (!text) return null;
|
|
2716
3382
|
return (
|
|
2717
3383
|
<p className="flex items-start gap-1.5 text-xs leading-relaxed text-theme-text-tertiary">
|
|
@@ -2784,39 +3450,7 @@ function SourceButton({
|
|
|
2784
3450
|
}
|
|
2785
3451
|
|
|
2786
3452
|
function evidenceIcon(type: InvestigationEvidenceData["type"]) {
|
|
2787
|
-
|
|
2788
|
-
case "issue":
|
|
2789
|
-
return CircleAlert;
|
|
2790
|
-
case "startup":
|
|
2791
|
-
return ShieldAlert;
|
|
2792
|
-
case "crash":
|
|
2793
|
-
return Bug;
|
|
2794
|
-
case "resource":
|
|
2795
|
-
return Boxes;
|
|
2796
|
-
case "logs":
|
|
2797
|
-
return ScrollText;
|
|
2798
|
-
case "events":
|
|
2799
|
-
return Clock3;
|
|
2800
|
-
case "changes":
|
|
2801
|
-
return FileClock;
|
|
2802
|
-
case "dns":
|
|
2803
|
-
return Activity;
|
|
2804
|
-
case "network":
|
|
2805
|
-
return Network;
|
|
2806
|
-
case "relationships":
|
|
2807
|
-
case "topology":
|
|
2808
|
-
return Network;
|
|
2809
|
-
case "inventory":
|
|
2810
|
-
return ListTree;
|
|
2811
|
-
case "receipt":
|
|
2812
|
-
return CheckCircle2;
|
|
2813
|
-
case "alerts":
|
|
2814
|
-
return BellRing;
|
|
2815
|
-
case "helm":
|
|
2816
|
-
return Package;
|
|
2817
|
-
case "permissions":
|
|
2818
|
-
return KeyRound;
|
|
2819
|
-
}
|
|
3453
|
+
return EVIDENCE_KIND_TRAITS[type].icon;
|
|
2820
3454
|
}
|
|
2821
3455
|
|
|
2822
3456
|
function severityBadge(value: string) {
|
|
@@ -2850,6 +3484,13 @@ function toneBorder(
|
|
|
2850
3484
|
prominence: "primary" | "supporting" | "secondary",
|
|
2851
3485
|
): string {
|
|
2852
3486
|
if (prominence !== "primary") return "border-theme-border/70";
|
|
3487
|
+
// A supporting adverse card is what the healthy-conflict banner points at,
|
|
3488
|
+
// so it must be tellable from its neutral neighbours: a thin rule, lighter
|
|
3489
|
+
// than the Key tier's.
|
|
3490
|
+
if (tier === "supporting" && tone === "error")
|
|
3491
|
+
return "border-l-2 border-l-semantic-error border-theme-border";
|
|
3492
|
+
if (tier === "supporting" && (tone === "warning" || tone === "alert"))
|
|
3493
|
+
return "border-l-2 border-l-semantic-warning border-theme-border";
|
|
2853
3494
|
if (tier !== "key") return "border-theme-border";
|
|
2854
3495
|
if (tone === "error")
|
|
2855
3496
|
return "border-l-[3px] border-l-red-500 border-theme-border";
|