@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
|
@@ -0,0 +1,954 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type HPADiagnosisState,
|
|
3
|
+
type Issue,
|
|
4
|
+
type IssueRecentChange,
|
|
5
|
+
type Topology,
|
|
6
|
+
defaultConditionTone,
|
|
7
|
+
displayKind,
|
|
8
|
+
hpaStateLabel,
|
|
9
|
+
hpaStateLevel,
|
|
10
|
+
stripAnsi,
|
|
11
|
+
} from "@skyhook-io/k8s-ui";
|
|
12
|
+
import {
|
|
13
|
+
type DiagnosisChangeContext,
|
|
14
|
+
type DiagnosisEvidenceLimitationBase,
|
|
15
|
+
type DiagnosisPodLogEntry,
|
|
16
|
+
diagnosisSeverityTone,
|
|
17
|
+
} from "../diagnoseEvidenceTypes";
|
|
18
|
+
import { investigationResourceEvidenceSummary } from "../investigationResourceEvidenceModel";
|
|
19
|
+
import { kindToPluralWithGroup } from "../../../utils/navigation";
|
|
20
|
+
import { stableHash } from "./identity";
|
|
21
|
+
import { nonEmptyString, parseJSON, record, stringArray } from "./parse";
|
|
22
|
+
import {
|
|
23
|
+
type InvestigationEventEvidence,
|
|
24
|
+
type InvestigationEvidenceGroup,
|
|
25
|
+
type InvestigationEvidenceKind,
|
|
26
|
+
type InvestigationEvidenceLimitation,
|
|
27
|
+
type InvestigationEvidenceObservation,
|
|
28
|
+
type InvestigationEvidenceRelevance,
|
|
29
|
+
type InvestigationEvidenceSource,
|
|
30
|
+
type InvestigationEvidenceTarget,
|
|
31
|
+
type InvestigationEvidenceTier,
|
|
32
|
+
type InvestigationGitOpsDiagnosis,
|
|
33
|
+
type InvestigationKubernetesResource,
|
|
34
|
+
type InvestigationResourceContext,
|
|
35
|
+
type InvestigationSemanticDomain,
|
|
36
|
+
} from "./types";
|
|
37
|
+
|
|
38
|
+
type TopologyPartiality = {
|
|
39
|
+
warnings: string[];
|
|
40
|
+
largeCluster: boolean;
|
|
41
|
+
hiddenKinds: string[];
|
|
42
|
+
requiresNamespaceFilter: boolean;
|
|
43
|
+
crdDiscoveryStatus?: NonNullable<Topology["crdDiscoveryStatus"]>;
|
|
44
|
+
estimatedNodes?: number;
|
|
45
|
+
summaryMode: boolean;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Both get_topology wire shapes carry the same completeness metadata. Keep the
|
|
49
|
+
* adapter strict: silently dropping a malformed flag would make a partial graph
|
|
50
|
+
* look complete in Evidence.
|
|
51
|
+
*/
|
|
52
|
+
export function topologyPartiality(
|
|
53
|
+
value: Record<string, unknown>,
|
|
54
|
+
): TopologyPartiality | undefined {
|
|
55
|
+
const warnings =
|
|
56
|
+
value.warnings === undefined ? [] : stringArray(value.warnings);
|
|
57
|
+
const hiddenKinds =
|
|
58
|
+
value.hiddenKinds === undefined ? [] : stringArray(value.hiddenKinds);
|
|
59
|
+
const discovery = value.crdDiscoveryStatus;
|
|
60
|
+
const estimatedNodes = value.estimatedNodes;
|
|
61
|
+
if (
|
|
62
|
+
!warnings ||
|
|
63
|
+
!hiddenKinds ||
|
|
64
|
+
(value.largeCluster !== undefined &&
|
|
65
|
+
typeof value.largeCluster !== "boolean") ||
|
|
66
|
+
(value.requiresNamespaceFilter !== undefined &&
|
|
67
|
+
typeof value.requiresNamespaceFilter !== "boolean") ||
|
|
68
|
+
(value.summaryMode !== undefined &&
|
|
69
|
+
typeof value.summaryMode !== "boolean") ||
|
|
70
|
+
(discovery !== undefined &&
|
|
71
|
+
discovery !== "idle" &&
|
|
72
|
+
discovery !== "discovering" &&
|
|
73
|
+
discovery !== "ready") ||
|
|
74
|
+
(estimatedNodes !== undefined &&
|
|
75
|
+
(typeof estimatedNodes !== "number" ||
|
|
76
|
+
!Number.isSafeInteger(estimatedNodes) ||
|
|
77
|
+
estimatedNodes < 0))
|
|
78
|
+
) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
warnings,
|
|
83
|
+
largeCluster: value.largeCluster === true,
|
|
84
|
+
hiddenKinds,
|
|
85
|
+
requiresNamespaceFilter: value.requiresNamespaceFilter === true,
|
|
86
|
+
crdDiscoveryStatus: discovery as
|
|
87
|
+
NonNullable<Topology["crdDiscoveryStatus"]> | undefined,
|
|
88
|
+
estimatedNodes,
|
|
89
|
+
summaryMode: value.summaryMode === true,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
export function addTopologyLimitations(
|
|
93
|
+
builder: ProjectionBuilder,
|
|
94
|
+
source: InvestigationEvidenceSource,
|
|
95
|
+
partiality: TopologyPartiality,
|
|
96
|
+
): void {
|
|
97
|
+
for (const warning of partiality.warnings) {
|
|
98
|
+
const normalized = warning.toLowerCase();
|
|
99
|
+
builder.limit(
|
|
100
|
+
source,
|
|
101
|
+
"Topology coverage",
|
|
102
|
+
warning,
|
|
103
|
+
normalized.includes("large graph") || normalized.includes("too large")
|
|
104
|
+
? "truncated"
|
|
105
|
+
: "unknown",
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const scaleDetails: string[] = [];
|
|
110
|
+
const estimate = partiality.estimatedNodes
|
|
111
|
+
? ` (about ${partiality.estimatedNodes} estimated nodes)`
|
|
112
|
+
: "";
|
|
113
|
+
if (partiality.requiresNamespaceFilter) {
|
|
114
|
+
scaleDetails.push(
|
|
115
|
+
`The all-namespace topology was not built because the cluster is too large${estimate}; run a namespace-scoped topology search to collect a smaller graph.`,
|
|
116
|
+
);
|
|
117
|
+
} else if (partiality.largeCluster) {
|
|
118
|
+
scaleDetails.push(
|
|
119
|
+
`Large-cluster optimizations were active${estimate}; high-cardinality detail may be grouped.`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (partiality.hiddenKinds.length > 0) {
|
|
123
|
+
scaleDetails.push(
|
|
124
|
+
`Resource kinds omitted by the large-cluster optimization: ${partiality.hiddenKinds.join(", ")}.`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (partiality.summaryMode) {
|
|
128
|
+
scaleDetails.push(
|
|
129
|
+
"Summary mode collapsed individual Pods into workload or Service counts.",
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (scaleDetails.length > 0) {
|
|
133
|
+
builder.limit(
|
|
134
|
+
source,
|
|
135
|
+
"Topology scale",
|
|
136
|
+
scaleDetails.join(" "),
|
|
137
|
+
"truncated",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (
|
|
142
|
+
partiality.crdDiscoveryStatus === "idle" ||
|
|
143
|
+
partiality.crdDiscoveryStatus === "discovering"
|
|
144
|
+
) {
|
|
145
|
+
builder.limit(
|
|
146
|
+
source,
|
|
147
|
+
"Custom Resource topology",
|
|
148
|
+
partiality.crdDiscoveryStatus === "idle"
|
|
149
|
+
? "Custom Resource discovery had not started when this topology was captured; Custom Resource nodes and relationships may be missing."
|
|
150
|
+
: "Custom Resource discovery was still in progress when this topology was captured; Custom Resource nodes and relationships may be missing.",
|
|
151
|
+
"unknown",
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
export function scopeFromArgs(source: InvestigationEvidenceSource): string {
|
|
156
|
+
if (!source.args) return "requested scope";
|
|
157
|
+
const args = record(parseJSON(source.args));
|
|
158
|
+
if (!args) return "requested scope";
|
|
159
|
+
const kind = nonEmptyString(args.kind) ? displayKind(args.kind) : undefined;
|
|
160
|
+
const namespace = nonEmptyString(args.namespace) ? args.namespace : undefined;
|
|
161
|
+
const name = nonEmptyString(args.name) ? args.name : undefined;
|
|
162
|
+
if (kind && name) {
|
|
163
|
+
return kind + " " + (namespace ? namespace + "/" : "") + name;
|
|
164
|
+
}
|
|
165
|
+
if (kind && namespace) return kind + " resources in " + namespace;
|
|
166
|
+
if (kind) return kind + " resources";
|
|
167
|
+
if (namespace && name) return namespace + "/" + name;
|
|
168
|
+
if (namespace) return "namespace " + namespace;
|
|
169
|
+
if (name) return name;
|
|
170
|
+
return "requested scope";
|
|
171
|
+
}
|
|
172
|
+
const INVESTIGATION_RESULT_LABELS: Readonly<Record<string, string>> = {
|
|
173
|
+
diagnose: "Workload diagnosis",
|
|
174
|
+
issues: "Issue scan",
|
|
175
|
+
get_resource: "Resource details",
|
|
176
|
+
list_resources: "Resource inventory",
|
|
177
|
+
get_events: "Kubernetes events",
|
|
178
|
+
get_pod_logs: "Container logs",
|
|
179
|
+
get_changes: "Recent changes",
|
|
180
|
+
get_neighborhood: "Relationships",
|
|
181
|
+
get_topology: "Topology",
|
|
182
|
+
get_workload_logs: "Workload logs",
|
|
183
|
+
get_prometheus_rules: "Alert rules",
|
|
184
|
+
get_helm_release: "Helm release",
|
|
185
|
+
get_subject_permissions: "Permissions",
|
|
186
|
+
query_prometheus: "Prometheus query",
|
|
187
|
+
};
|
|
188
|
+
export function investigationResultLabel(
|
|
189
|
+
source: InvestigationEvidenceSource,
|
|
190
|
+
): string {
|
|
191
|
+
return INVESTIGATION_RESULT_LABELS[source.tool] ?? "Investigation result";
|
|
192
|
+
}
|
|
193
|
+
export function resourceMatchesTarget(
|
|
194
|
+
target: InvestigationEvidenceTarget,
|
|
195
|
+
resource: {
|
|
196
|
+
kind: string;
|
|
197
|
+
group?: string;
|
|
198
|
+
namespace?: string;
|
|
199
|
+
name: string;
|
|
200
|
+
},
|
|
201
|
+
): boolean {
|
|
202
|
+
const group = (resource.group ?? "").toLowerCase();
|
|
203
|
+
return (
|
|
204
|
+
group === target.group.toLowerCase() &&
|
|
205
|
+
kindToPluralWithGroup(resource.kind, group) ===
|
|
206
|
+
kindToPluralWithGroup(target.kind, group) &&
|
|
207
|
+
(resource.namespace ?? "") === (target.namespace ?? "") &&
|
|
208
|
+
resource.name === target.name
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
export function relevanceForResource(
|
|
212
|
+
builder: ProjectionBuilder,
|
|
213
|
+
resource: {
|
|
214
|
+
kind: string;
|
|
215
|
+
group?: string;
|
|
216
|
+
namespace?: string;
|
|
217
|
+
name: string;
|
|
218
|
+
},
|
|
219
|
+
): InvestigationEvidenceRelevance {
|
|
220
|
+
return resourceMatchesTarget(builder.target, resource) ? "target" : "broader";
|
|
221
|
+
}
|
|
222
|
+
export function sourceArgsRelevance(
|
|
223
|
+
builder: ProjectionBuilder,
|
|
224
|
+
source: InvestigationEvidenceSource,
|
|
225
|
+
impliedKind?: string,
|
|
226
|
+
): InvestigationEvidenceRelevance {
|
|
227
|
+
const args = record(source.args ? parseJSON(source.args) : undefined);
|
|
228
|
+
const kind = nonEmptyString(args?.kind) ? args.kind : impliedKind;
|
|
229
|
+
if (!kind || !nonEmptyString(args?.name)) return "broader";
|
|
230
|
+
return relevanceForResource(builder, {
|
|
231
|
+
kind,
|
|
232
|
+
// get_events/get_changes/issues cannot express an API group today. An
|
|
233
|
+
// omitted group is therefore unspecified, not proof that the caller meant
|
|
234
|
+
// the core API group. Use the known investigation target for that missing
|
|
235
|
+
// dimension; if a producer does provide a group, exact matching still
|
|
236
|
+
// applies (including an explicitly empty core group).
|
|
237
|
+
group: typeof args?.group === "string" ? args.group : builder.target.group,
|
|
238
|
+
namespace: nonEmptyString(args?.namespace) ? args.namespace : undefined,
|
|
239
|
+
name: args.name,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
export function evidenceTierForRelevance(
|
|
243
|
+
intended: InvestigationEvidenceTier,
|
|
244
|
+
relevance: InvestigationEvidenceRelevance,
|
|
245
|
+
): InvestigationEvidenceTier {
|
|
246
|
+
return relevance === "broader" ? "context" : intended;
|
|
247
|
+
}
|
|
248
|
+
const DIAGNOSABLE_WORKLOAD_KINDS = new Set([
|
|
249
|
+
"pod",
|
|
250
|
+
"deployment",
|
|
251
|
+
"statefulset",
|
|
252
|
+
"daemonset",
|
|
253
|
+
"rollout",
|
|
254
|
+
]);
|
|
255
|
+
export function isDiagnosableWorkloadKind(kind: string): boolean {
|
|
256
|
+
return DIAGNOSABLE_WORKLOAD_KINDS.has(kind.toLowerCase());
|
|
257
|
+
}
|
|
258
|
+
export function previousFromArgs(source: InvestigationEvidenceSource): boolean {
|
|
259
|
+
return (
|
|
260
|
+
record(source.args ? parseJSON(source.args) : undefined)?.previous === true
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
export class ProjectionBuilder {
|
|
264
|
+
readonly groups: InvestigationEvidenceGroup[] = [];
|
|
265
|
+
readonly sources: InvestigationEvidenceSource[] = [];
|
|
266
|
+
readonly limitations: InvestigationEvidenceLimitation[] = [];
|
|
267
|
+
readonly projectedSources = new Set<string>();
|
|
268
|
+
readonly checkedSources = new Set<string>();
|
|
269
|
+
readonly limitedSources = new Set<string>();
|
|
270
|
+
readonly semanticCoverageBySource = new Map<
|
|
271
|
+
string,
|
|
272
|
+
Set<InvestigationSemanticDomain>
|
|
273
|
+
>();
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Pods a target-scoped diagnose bundle listed as the workload's own, by
|
|
277
|
+
* controller ownership. Membership for an agent's pod-level Prometheus
|
|
278
|
+
* query is proved against this set, never inferred from a name.
|
|
279
|
+
*/
|
|
280
|
+
readonly establishedTargetPods = new Set<string>();
|
|
281
|
+
|
|
282
|
+
private readonly groupByIdentity = new Map<
|
|
283
|
+
string,
|
|
284
|
+
InvestigationEvidenceGroup
|
|
285
|
+
>();
|
|
286
|
+
private readonly limitationByIdentity = new Map<
|
|
287
|
+
string,
|
|
288
|
+
InvestigationEvidenceLimitation
|
|
289
|
+
>();
|
|
290
|
+
|
|
291
|
+
constructor(readonly target: InvestigationEvidenceTarget) {}
|
|
292
|
+
|
|
293
|
+
addSource(source: InvestigationEvidenceSource): void {
|
|
294
|
+
this.sources.push(source);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
coverSemantic(
|
|
298
|
+
source: InvestigationEvidenceSource,
|
|
299
|
+
domain: InvestigationSemanticDomain,
|
|
300
|
+
): void {
|
|
301
|
+
const covered = this.semanticCoverageBySource.get(source.id);
|
|
302
|
+
if (covered) {
|
|
303
|
+
covered.add(domain);
|
|
304
|
+
} else {
|
|
305
|
+
this.semanticCoverageBySource.set(source.id, new Set([domain]));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
observe(
|
|
310
|
+
identity: string,
|
|
311
|
+
kind: InvestigationEvidenceKind,
|
|
312
|
+
source: InvestigationEvidenceSource,
|
|
313
|
+
observation: Omit<
|
|
314
|
+
InvestigationEvidenceObservation,
|
|
315
|
+
"source" | "revision" | "historical" | "changedFromPrevious" | "relevance"
|
|
316
|
+
> & { relevance: InvestigationEvidenceRelevance },
|
|
317
|
+
): void {
|
|
318
|
+
// Logs and synthesized startup/crash evidence do not carry a stable object
|
|
319
|
+
// UID (and logs do not carry namespace in the producer row). Keep different
|
|
320
|
+
// proof scopes separate so a same-named sibling can never inherit stronger
|
|
321
|
+
// target provenance merely by colliding on its display identity.
|
|
322
|
+
const partitionByRelevance =
|
|
323
|
+
kind === "logs" ||
|
|
324
|
+
kind === "startup" ||
|
|
325
|
+
kind === "crash" ||
|
|
326
|
+
(kind === "receipt" && identity.startsWith("previous-log-absence:"));
|
|
327
|
+
const mapKey = groupKey(
|
|
328
|
+
kind,
|
|
329
|
+
identity,
|
|
330
|
+
partitionByRelevance
|
|
331
|
+
? `${observation.relevance}\u0000${scopeFromArgs(source)}`
|
|
332
|
+
: undefined,
|
|
333
|
+
);
|
|
334
|
+
let group = this.groupByIdentity.get(mapKey);
|
|
335
|
+
const previousObservation = group?.observations.at(-1);
|
|
336
|
+
const changedFromPrevious = previousObservation
|
|
337
|
+
? evidenceSemanticSnapshot(previousObservation) !==
|
|
338
|
+
evidenceSemanticSnapshot(observation)
|
|
339
|
+
: false;
|
|
340
|
+
const next: InvestigationEvidenceObservation = {
|
|
341
|
+
...observation,
|
|
342
|
+
source,
|
|
343
|
+
revision: group ? group.observations.length + 1 : 1,
|
|
344
|
+
historical: false,
|
|
345
|
+
changedFromPrevious,
|
|
346
|
+
};
|
|
347
|
+
if (!group) {
|
|
348
|
+
group = {
|
|
349
|
+
id: `evidence-${kind}-${stableHash(mapKey)}`,
|
|
350
|
+
identity,
|
|
351
|
+
kind,
|
|
352
|
+
historical: false,
|
|
353
|
+
firstOrder: source.order,
|
|
354
|
+
observations: [],
|
|
355
|
+
latest: next,
|
|
356
|
+
chronologicalLatest: next,
|
|
357
|
+
};
|
|
358
|
+
this.groupByIdentity.set(mapKey, group);
|
|
359
|
+
this.groups.push(group);
|
|
360
|
+
}
|
|
361
|
+
group.observations.push(next);
|
|
362
|
+
group.chronologicalLatest = next;
|
|
363
|
+
const relevanceRank: Record<InvestigationEvidenceRelevance, number> = {
|
|
364
|
+
target: 0,
|
|
365
|
+
"producer-related": 1,
|
|
366
|
+
broader: 2,
|
|
367
|
+
};
|
|
368
|
+
// A broad inventory/read can re-observe an item whose relationship to the
|
|
369
|
+
// target was already established by a scoped producer. Keep the broad read
|
|
370
|
+
// in revision history, but never let weaker provenance replace the card's
|
|
371
|
+
// authoritative observation. Equal provenance still advances normally.
|
|
372
|
+
// An alert rule's relevance is derived from its live instances, so a later
|
|
373
|
+
// read of the same rule is a state transition, not weaker provenance.
|
|
374
|
+
if (
|
|
375
|
+
kind === "alerts" ||
|
|
376
|
+
relevanceRank[next.relevance] <= relevanceRank[group.latest.relevance]
|
|
377
|
+
) {
|
|
378
|
+
group.latest = next;
|
|
379
|
+
}
|
|
380
|
+
this.projectedSources.add(source.id);
|
|
381
|
+
if (next.tier === "checked") this.checkedSources.add(source.id);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Relevance of the current card for an unpartitioned identity, if observed. */
|
|
385
|
+
latestRelevance(
|
|
386
|
+
kind: InvestigationEvidenceKind,
|
|
387
|
+
identity: string,
|
|
388
|
+
): InvestigationEvidenceRelevance | undefined {
|
|
389
|
+
return this.groupByIdentity.get(groupKey(kind, identity))?.latest.relevance;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
limit(
|
|
393
|
+
source: InvestigationEvidenceSource,
|
|
394
|
+
label: string,
|
|
395
|
+
message: string | undefined,
|
|
396
|
+
kind: DiagnosisEvidenceLimitationBase["kind"],
|
|
397
|
+
presentation?: InvestigationEvidenceLimitation["presentation"],
|
|
398
|
+
): void {
|
|
399
|
+
if (!message?.trim()) return;
|
|
400
|
+
const normalized = message.trim();
|
|
401
|
+
const key = `${kind}\u0000${presentation ?? ""}\u0000${label}\u0000${normalized}`;
|
|
402
|
+
const existing = this.limitationByIdentity.get(key);
|
|
403
|
+
if (existing) {
|
|
404
|
+
if (!existing.sources.some((item) => item.id === source.id)) {
|
|
405
|
+
existing.sources.push(source);
|
|
406
|
+
}
|
|
407
|
+
} else {
|
|
408
|
+
const limitation: InvestigationEvidenceLimitation = {
|
|
409
|
+
source: label,
|
|
410
|
+
message: normalized,
|
|
411
|
+
kind,
|
|
412
|
+
...(presentation ? { presentation } : {}),
|
|
413
|
+
firstOrder: source.order,
|
|
414
|
+
sources: [source],
|
|
415
|
+
};
|
|
416
|
+
this.limitationByIdentity.set(key, limitation);
|
|
417
|
+
this.limitations.push(limitation);
|
|
418
|
+
}
|
|
419
|
+
this.limitedSources.add(source.id);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* The one place a group's map key is built. NUL separates the parts so no
|
|
424
|
+
* kind or identity text can collide with another kind's key.
|
|
425
|
+
*/
|
|
426
|
+
function groupKey(
|
|
427
|
+
kind: InvestigationEvidenceKind,
|
|
428
|
+
identity: string,
|
|
429
|
+
partition?: string,
|
|
430
|
+
): string {
|
|
431
|
+
return `${kind}\u0000${identity}${partition === undefined ? "" : `\u0000${partition}`}`;
|
|
432
|
+
}
|
|
433
|
+
export function evidenceSemanticSnapshot(
|
|
434
|
+
observation: Pick<
|
|
435
|
+
InvestigationEvidenceObservation,
|
|
436
|
+
"tone" | "title" | "summary" | "data"
|
|
437
|
+
>,
|
|
438
|
+
): string {
|
|
439
|
+
if (observation.data.type === "resource") {
|
|
440
|
+
// Tool-specific context can change the summary/tone without changing the
|
|
441
|
+
// object. Keep that context in history, not in the resource-change signal.
|
|
442
|
+
return JSON.stringify({
|
|
443
|
+
type: "resource",
|
|
444
|
+
resource: observation.data.resource,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
const data =
|
|
448
|
+
observation.data.type === "alerts"
|
|
449
|
+
? {
|
|
450
|
+
type: observation.data.type,
|
|
451
|
+
// An instance's sampled value and activation time move on every
|
|
452
|
+
// evaluation; the finding is which instances exist and their state.
|
|
453
|
+
rule: observation.data.rule,
|
|
454
|
+
instances: observation.data.instances.map((instance) => ({
|
|
455
|
+
state: instance.state,
|
|
456
|
+
labels: instance.labels,
|
|
457
|
+
})),
|
|
458
|
+
annotations: observation.data.annotations,
|
|
459
|
+
}
|
|
460
|
+
: observation.data.type === "issue"
|
|
461
|
+
? {
|
|
462
|
+
type: observation.data.type,
|
|
463
|
+
// These are the finding and details shown in the evidence card.
|
|
464
|
+
// Collection timestamps and detector bookkeeping do not change it.
|
|
465
|
+
issue: {
|
|
466
|
+
kind: observation.data.issue.kind,
|
|
467
|
+
group: observation.data.issue.group,
|
|
468
|
+
namespace: observation.data.issue.namespace,
|
|
469
|
+
name: observation.data.issue.name,
|
|
470
|
+
reason: observation.data.issue.reason,
|
|
471
|
+
severity: observation.data.issue.severity,
|
|
472
|
+
cause: observation.data.issue.cause,
|
|
473
|
+
message: observation.data.issue.message,
|
|
474
|
+
},
|
|
475
|
+
}
|
|
476
|
+
: observation.data;
|
|
477
|
+
return JSON.stringify({
|
|
478
|
+
tone: observation.tone,
|
|
479
|
+
title: observation.title,
|
|
480
|
+
summary: observation.summary,
|
|
481
|
+
data,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
export function contextFrom(
|
|
485
|
+
value: unknown,
|
|
486
|
+
): InvestigationResourceContext | undefined {
|
|
487
|
+
const candidate = record(value);
|
|
488
|
+
if (!candidate || !nonEmptyString(candidate.tier)) return undefined;
|
|
489
|
+
return candidate as unknown as InvestigationResourceContext;
|
|
490
|
+
}
|
|
491
|
+
export function addNarrowHint(
|
|
492
|
+
builder: ProjectionBuilder,
|
|
493
|
+
source: InvestigationEvidenceSource,
|
|
494
|
+
value: Record<string, unknown>,
|
|
495
|
+
): void {
|
|
496
|
+
if (nonEmptyString(value.narrowHint)) {
|
|
497
|
+
const label = investigationResultLabel(source);
|
|
498
|
+
builder.limit(
|
|
499
|
+
source,
|
|
500
|
+
label,
|
|
501
|
+
label +
|
|
502
|
+
" returned part of the matching results to keep this investigation fast. More may exist.",
|
|
503
|
+
"truncated",
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
function addResourceContextLimitations(
|
|
508
|
+
builder: ProjectionBuilder,
|
|
509
|
+
source: InvestigationEvidenceSource,
|
|
510
|
+
context: InvestigationResourceContext | undefined,
|
|
511
|
+
): void {
|
|
512
|
+
if (!context) return;
|
|
513
|
+
for (const value of Array.isArray(context.omitted) ? context.omitted : []) {
|
|
514
|
+
const omitted = record(value);
|
|
515
|
+
if (
|
|
516
|
+
!omitted ||
|
|
517
|
+
!nonEmptyString(omitted.field) ||
|
|
518
|
+
!nonEmptyString(omitted.reason)
|
|
519
|
+
)
|
|
520
|
+
continue;
|
|
521
|
+
builder.limit(
|
|
522
|
+
source,
|
|
523
|
+
omitted.field,
|
|
524
|
+
`Radar left out part of the resource context (${omitted.reason.replaceAll("_", " ")}).`,
|
|
525
|
+
omitted.reason === "budget_exceeded" ? "truncated" : "unknown",
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
if (context.referencedBy?.truncated) {
|
|
529
|
+
const shown = context.referencedBy.items?.length ?? 0;
|
|
530
|
+
builder.limit(
|
|
531
|
+
source,
|
|
532
|
+
"Relationships",
|
|
533
|
+
`Radar returned ${shown} of ${context.referencedBy.total} referenced-by relationships.`,
|
|
534
|
+
"truncated",
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
if (context.appReferences?.staleSecretEnvTruncated) {
|
|
538
|
+
builder.limit(
|
|
539
|
+
source,
|
|
540
|
+
"Application references",
|
|
541
|
+
"Radar returned part of the stale Secret references.",
|
|
542
|
+
"truncated",
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
function addIssueLimitations(
|
|
547
|
+
builder: ProjectionBuilder,
|
|
548
|
+
source: InvestigationEvidenceSource,
|
|
549
|
+
value: Issue,
|
|
550
|
+
): void {
|
|
551
|
+
if (value.members_truncated) {
|
|
552
|
+
builder.limit(
|
|
553
|
+
source,
|
|
554
|
+
`Radar Issue ${value.id}`,
|
|
555
|
+
"Radar returned part of the affected-resource list.",
|
|
556
|
+
"truncated",
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
function resourceObservationSummary(
|
|
561
|
+
resource: InvestigationKubernetesResource,
|
|
562
|
+
context: InvestigationResourceContext | undefined,
|
|
563
|
+
warnings: string[],
|
|
564
|
+
gitOps?: InvestigationGitOpsDiagnosis,
|
|
565
|
+
detailedIssueShown = false,
|
|
566
|
+
): string | undefined {
|
|
567
|
+
if (!detailedIssueShown && context?.issueSummary?.topReason) {
|
|
568
|
+
return context.issueSummary.topReason;
|
|
569
|
+
}
|
|
570
|
+
if (gitOps?.health) return `Health ${gitOps.health}`;
|
|
571
|
+
if (gitOps?.ready) return `Ready ${gitOps.ready}`;
|
|
572
|
+
if (gitOps?.sync) return `Sync ${gitOps.sync}`;
|
|
573
|
+
if (gitOps?.suspended) return "Reconciliation suspended";
|
|
574
|
+
const replicas = context?.workloadSummary?.replicas;
|
|
575
|
+
const adverseScaler = adverseScalerStates(context)[0];
|
|
576
|
+
if (replicas?.desired !== undefined) {
|
|
577
|
+
const desired = replicas.desired;
|
|
578
|
+
const ready = replicas.ready ?? 0;
|
|
579
|
+
const readiness = `${ready}/${desired} replicas ready`;
|
|
580
|
+
return adverseScaler
|
|
581
|
+
? `${readiness} · HPA: ${hpaStateLabel(adverseScaler)}`
|
|
582
|
+
: readiness;
|
|
583
|
+
}
|
|
584
|
+
if (adverseScaler) return `HPA: ${hpaStateLabel(adverseScaler)}`;
|
|
585
|
+
if (context?.statusSummary?.phase) return context.statusSummary.phase;
|
|
586
|
+
if (context?.issueSummary?.topReason) return context.issueSummary.topReason;
|
|
587
|
+
return (
|
|
588
|
+
investigationResourceEvidenceSummary(resource) ||
|
|
589
|
+
warnings[0] ||
|
|
590
|
+
resource.metadata.namespace
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
// A scaler that cannot act (no metrics, cannot read the target) or is pinned
|
|
594
|
+
// at its ceiling is a captured Radar fact about the workload, so it lifts the
|
|
595
|
+
// card the way an adverse condition does. Ordinary scale-ups and scale-downs
|
|
596
|
+
// are the autoscaler working and stay as detail.
|
|
597
|
+
function adverseScalerStates(
|
|
598
|
+
context: InvestigationResourceContext | undefined,
|
|
599
|
+
): HPADiagnosisState[] {
|
|
600
|
+
return (context?.scaledBy ?? []).flatMap((scaler) => {
|
|
601
|
+
const state = scaler.hpaSummary?.state;
|
|
602
|
+
if (!state) return [];
|
|
603
|
+
return hpaStateLevel(state) === "unhealthy" || state === "limited_max"
|
|
604
|
+
? [state]
|
|
605
|
+
: [];
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
export function addResourceObservation(
|
|
609
|
+
builder: ProjectionBuilder,
|
|
610
|
+
source: InvestigationEvidenceSource,
|
|
611
|
+
resource: InvestigationKubernetesResource,
|
|
612
|
+
context: InvestigationResourceContext | undefined,
|
|
613
|
+
warnings: string[],
|
|
614
|
+
gitOpsDiagnosis: InvestigationGitOpsDiagnosis | undefined,
|
|
615
|
+
hasDetailedCriticalIssue: boolean,
|
|
616
|
+
relevance: InvestigationEvidenceRelevance,
|
|
617
|
+
): void {
|
|
618
|
+
const issueSummary = context?.issueSummary;
|
|
619
|
+
const severity = issueSummary?.highestSeverity ?? "";
|
|
620
|
+
const critical = severity.toLowerCase() === "critical";
|
|
621
|
+
const hasLiveIssue = (issueSummary?.count ?? 0) > 0;
|
|
622
|
+
const replicas = context?.workloadSummary?.replicas;
|
|
623
|
+
const desired = replicas?.desired;
|
|
624
|
+
const ready = replicas ? (replicas.ready ?? 0) : undefined;
|
|
625
|
+
const available = replicas ? (replicas.available ?? 0) : undefined;
|
|
626
|
+
const replicaShortfall =
|
|
627
|
+
desired !== undefined &&
|
|
628
|
+
desired > 0 &&
|
|
629
|
+
((ready !== undefined && ready < desired) ||
|
|
630
|
+
(available !== undefined && available < desired) ||
|
|
631
|
+
(replicas?.unavailable ?? 0) > 0);
|
|
632
|
+
const adverseCondition = (context?.statusSummary?.conditions ?? []).some(
|
|
633
|
+
(condition) => defaultConditionTone(condition) === "fail",
|
|
634
|
+
);
|
|
635
|
+
const gitOpsAdverse = Boolean(
|
|
636
|
+
gitOpsDiagnosis &&
|
|
637
|
+
(gitOpsDiagnosis.health?.toLowerCase() === "degraded" ||
|
|
638
|
+
gitOpsDiagnosis.health?.toLowerCase() === "missing" ||
|
|
639
|
+
gitOpsDiagnosis.sync?.toLowerCase() === "outofsync" ||
|
|
640
|
+
gitOpsDiagnosis.ready?.toLowerCase().startsWith("false") ||
|
|
641
|
+
["failed", "error"].includes(
|
|
642
|
+
gitOpsDiagnosis.operationPhase?.toLowerCase() ?? "",
|
|
643
|
+
)),
|
|
644
|
+
);
|
|
645
|
+
const hasAdverseState =
|
|
646
|
+
replicaShortfall ||
|
|
647
|
+
adverseCondition ||
|
|
648
|
+
gitOpsAdverse ||
|
|
649
|
+
adverseScalerStates(context).length > 0;
|
|
650
|
+
const intendedTier: InvestigationEvidenceTier =
|
|
651
|
+
critical && !hasDetailedCriticalIssue
|
|
652
|
+
? "key"
|
|
653
|
+
: hasLiveIssue
|
|
654
|
+
? "supporting"
|
|
655
|
+
: hasAdverseState
|
|
656
|
+
? "supporting"
|
|
657
|
+
: "context";
|
|
658
|
+
const tier = evidenceTierForRelevance(intendedTier, relevance);
|
|
659
|
+
const tone = hasLiveIssue
|
|
660
|
+
? diagnosisSeverityTone(severity)
|
|
661
|
+
: hasAdverseState
|
|
662
|
+
? "warning"
|
|
663
|
+
: warnings.length > 0
|
|
664
|
+
? "warning"
|
|
665
|
+
: "neutral";
|
|
666
|
+
const namespace = resource.metadata.namespace;
|
|
667
|
+
const identity = `${resource.apiVersion}:${resource.kind}:${namespace ?? ""}:${resource.metadata.name}`;
|
|
668
|
+
builder.observe(identity, "resource", source, {
|
|
669
|
+
tier,
|
|
670
|
+
relevance,
|
|
671
|
+
tone,
|
|
672
|
+
title: `${resource.kind} ${namespace ? `${namespace}/` : ""}${resource.metadata.name}`,
|
|
673
|
+
summary: resourceObservationSummary(
|
|
674
|
+
resource,
|
|
675
|
+
context,
|
|
676
|
+
warnings,
|
|
677
|
+
gitOpsDiagnosis,
|
|
678
|
+
hasDetailedCriticalIssue,
|
|
679
|
+
),
|
|
680
|
+
data: {
|
|
681
|
+
type: "resource",
|
|
682
|
+
resource,
|
|
683
|
+
resourceContext: context,
|
|
684
|
+
warnings,
|
|
685
|
+
gitOpsDiagnosis,
|
|
686
|
+
},
|
|
687
|
+
});
|
|
688
|
+
addResourceContextLimitations(builder, source, context);
|
|
689
|
+
}
|
|
690
|
+
export function addIssueObservation(
|
|
691
|
+
builder: ProjectionBuilder,
|
|
692
|
+
source: InvestigationEvidenceSource,
|
|
693
|
+
value: Issue,
|
|
694
|
+
producerRelevance: InvestigationEvidenceRelevance = "broader",
|
|
695
|
+
pods?: string[],
|
|
696
|
+
): void {
|
|
697
|
+
const matchesTarget = resourceMatchesTarget(builder.target, {
|
|
698
|
+
kind: value.kind,
|
|
699
|
+
group: value.group ?? "",
|
|
700
|
+
namespace: value.namespace,
|
|
701
|
+
name: value.name,
|
|
702
|
+
});
|
|
703
|
+
const relevance = matchesTarget ? "target" : producerRelevance;
|
|
704
|
+
builder.observe(`issue:${value.id}`, "issue", source, {
|
|
705
|
+
tier:
|
|
706
|
+
relevance === "broader"
|
|
707
|
+
? "context"
|
|
708
|
+
: value.severity === "critical"
|
|
709
|
+
? "key"
|
|
710
|
+
: "supporting",
|
|
711
|
+
tone: diagnosisSeverityTone(value.severity),
|
|
712
|
+
relevance,
|
|
713
|
+
title: value.reason,
|
|
714
|
+
summary: value.cause || value.message,
|
|
715
|
+
data: {
|
|
716
|
+
type: "issue",
|
|
717
|
+
issue: value,
|
|
718
|
+
relevance,
|
|
719
|
+
...(pods && pods.length > 0 ? { pods } : {}),
|
|
720
|
+
},
|
|
721
|
+
});
|
|
722
|
+
addIssueLimitations(builder, source, value);
|
|
723
|
+
}
|
|
724
|
+
export function addEvents(
|
|
725
|
+
builder: ProjectionBuilder,
|
|
726
|
+
source: InvestigationEvidenceSource,
|
|
727
|
+
values: InvestigationEventEvidence[],
|
|
728
|
+
identity: string,
|
|
729
|
+
complete = true,
|
|
730
|
+
emptyIsAuthoritative = false,
|
|
731
|
+
relevance: InvestigationEvidenceRelevance = "broader",
|
|
732
|
+
emptyReceipt: { title: string; message?: string } = {
|
|
733
|
+
title: "No warning events",
|
|
734
|
+
},
|
|
735
|
+
): void {
|
|
736
|
+
const scope = scopeFromArgs(source);
|
|
737
|
+
if (values.length === 0) {
|
|
738
|
+
if (!source.confirmedSuccess || !complete) return;
|
|
739
|
+
if (!emptyIsAuthoritative) {
|
|
740
|
+
builder.limit(
|
|
741
|
+
source,
|
|
742
|
+
"Events",
|
|
743
|
+
"No events were returned. This result does not establish that no events occurred.",
|
|
744
|
+
"unknown",
|
|
745
|
+
);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
builder.observe(identity, "receipt", source, {
|
|
749
|
+
tier: evidenceTierForRelevance("checked", relevance),
|
|
750
|
+
relevance,
|
|
751
|
+
tone: "neutral",
|
|
752
|
+
title: emptyReceipt.title,
|
|
753
|
+
summary: scope,
|
|
754
|
+
data: {
|
|
755
|
+
type: "receipt",
|
|
756
|
+
checked: "events",
|
|
757
|
+
scope,
|
|
758
|
+
message: emptyReceipt.message,
|
|
759
|
+
},
|
|
760
|
+
});
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
const lead = leadEvent(values);
|
|
764
|
+
builder.observe(identity, "events", source, {
|
|
765
|
+
tier: evidenceTierForRelevance(
|
|
766
|
+
values.some((item) => item.type.toLowerCase() === "warning")
|
|
767
|
+
? "supporting"
|
|
768
|
+
: "context",
|
|
769
|
+
relevance,
|
|
770
|
+
),
|
|
771
|
+
relevance,
|
|
772
|
+
tone: values.some((item) => item.type.toLowerCase() === "warning")
|
|
773
|
+
? "warning"
|
|
774
|
+
: "info",
|
|
775
|
+
title: "Kubernetes events",
|
|
776
|
+
summary: `${lead.reason}: ${lead.message}${
|
|
777
|
+
values.length > 1 ? ` · ${values.length} event groups` : ""
|
|
778
|
+
} · ${scope}`,
|
|
779
|
+
data: { type: "events", events: values, scope },
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* The newest warning, else the newest event. The card lead must be what
|
|
784
|
+
* happened, not how many groups the producer returned.
|
|
785
|
+
*/
|
|
786
|
+
function leadEvent(
|
|
787
|
+
values: InvestigationEventEvidence[],
|
|
788
|
+
): InvestigationEventEvidence {
|
|
789
|
+
const newest = (items: InvestigationEventEvidence[]) =>
|
|
790
|
+
items.reduce((best, item) =>
|
|
791
|
+
Date.parse(item.lastTimestamp) > Date.parse(best.lastTimestamp)
|
|
792
|
+
? item
|
|
793
|
+
: best,
|
|
794
|
+
);
|
|
795
|
+
const warnings = values.filter(
|
|
796
|
+
(item) => item.type.toLowerCase() === "warning",
|
|
797
|
+
);
|
|
798
|
+
return newest(warnings.length > 0 ? warnings : values);
|
|
799
|
+
}
|
|
800
|
+
export function addChanges(
|
|
801
|
+
builder: ProjectionBuilder,
|
|
802
|
+
source: InvestigationEvidenceSource,
|
|
803
|
+
values: IssueRecentChange[],
|
|
804
|
+
identity: string,
|
|
805
|
+
changeContext?: DiagnosisChangeContext,
|
|
806
|
+
complete = true,
|
|
807
|
+
emptyIsAuthoritative = false,
|
|
808
|
+
relevance: InvestigationEvidenceRelevance = "broader",
|
|
809
|
+
subject?: { kind?: string; namespace?: string; name: string },
|
|
810
|
+
window?: string,
|
|
811
|
+
): void {
|
|
812
|
+
const scope = scopeFromArgs(source);
|
|
813
|
+
if (values.length === 0 && !changeContext?.changed) {
|
|
814
|
+
if (!source.confirmedSuccess || !complete) return;
|
|
815
|
+
if (!emptyIsAuthoritative) {
|
|
816
|
+
builder.limit(
|
|
817
|
+
source,
|
|
818
|
+
"Recent changes",
|
|
819
|
+
`No changes were returned for ${scope}. This result does not establish a complete change history.`,
|
|
820
|
+
"unknown",
|
|
821
|
+
"history",
|
|
822
|
+
);
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
builder.observe(identity, "receipt", source, {
|
|
826
|
+
tier: evidenceTierForRelevance("checked", relevance),
|
|
827
|
+
relevance,
|
|
828
|
+
tone: "neutral",
|
|
829
|
+
title: "No recorded changes in this window",
|
|
830
|
+
summary: scope,
|
|
831
|
+
data: {
|
|
832
|
+
type: "receipt",
|
|
833
|
+
checked: "changes",
|
|
834
|
+
scope,
|
|
835
|
+
},
|
|
836
|
+
});
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
builder.observe(identity, "changes", source, {
|
|
840
|
+
// A producer-correlated change supports the diagnosis. A merely recent
|
|
841
|
+
// edit is chronology/context and must not imply causality by proximity.
|
|
842
|
+
tier: evidenceTierForRelevance(
|
|
843
|
+
changeContext?.changed ? "supporting" : "context",
|
|
844
|
+
relevance,
|
|
845
|
+
),
|
|
846
|
+
relevance,
|
|
847
|
+
tone: "info",
|
|
848
|
+
title: `Recent changes${window ? ` · last ${window}` : ""}`,
|
|
849
|
+
summary: changeContext?.changed
|
|
850
|
+
? changeContext.what === "The workload's Pod template changed" &&
|
|
851
|
+
changeContext.when
|
|
852
|
+
? `Pod template changed; newest ReplicaSet created ${changeContext.when} ago`
|
|
853
|
+
: `${changeContext.what || "A workload change was observed"}${changeContext.when ? ` · ${changeContext.when} ago` : ""}`
|
|
854
|
+
: `${values.length} change${values.length === 1 ? "" : "s"} · ${scope}`,
|
|
855
|
+
data: {
|
|
856
|
+
type: "changes",
|
|
857
|
+
changes: values,
|
|
858
|
+
scope,
|
|
859
|
+
changeContext,
|
|
860
|
+
subject,
|
|
861
|
+
},
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
export function changesSubjectFromArgs(
|
|
865
|
+
source: InvestigationEvidenceSource,
|
|
866
|
+
): { kind?: string; namespace?: string; name: string } | undefined {
|
|
867
|
+
const args = record(source.args ? parseJSON(source.args) : undefined);
|
|
868
|
+
if (!nonEmptyString(args?.name)) return undefined;
|
|
869
|
+
return {
|
|
870
|
+
...(nonEmptyString(args.kind) ? { kind: args.kind } : {}),
|
|
871
|
+
...(nonEmptyString(args.namespace) ? { namespace: args.namespace } : {}),
|
|
872
|
+
name: args.name,
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
export function addLogs(
|
|
876
|
+
builder: ProjectionBuilder,
|
|
877
|
+
source: InvestigationEvidenceSource,
|
|
878
|
+
value: DiagnosisPodLogEntry,
|
|
879
|
+
previous: boolean,
|
|
880
|
+
warnings: string[] = [],
|
|
881
|
+
relevance: InvestigationEvidenceRelevance = "broader",
|
|
882
|
+
namespace?: string,
|
|
883
|
+
): void {
|
|
884
|
+
const lines = (value.logs?.lines ?? []).map((line) => stripAnsi(line));
|
|
885
|
+
const normalizedWarnings = warnings.map((warning) => stripAnsi(warning));
|
|
886
|
+
const normalizedError = value.error ? stripAnsi(value.error) : undefined;
|
|
887
|
+
if (lines.length === 0) {
|
|
888
|
+
builder.limit(
|
|
889
|
+
source,
|
|
890
|
+
`${value.pod} / ${value.container}`,
|
|
891
|
+
value.error ||
|
|
892
|
+
"No log lines were available. This does not mean the container is healthy.",
|
|
893
|
+
value.error ? "error" : "unknown",
|
|
894
|
+
);
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
// A producer-filtered excerpt is a candidate, not proof that its contents are
|
|
898
|
+
// adverse. Query strings and routine request logs can contain words such as
|
|
899
|
+
// "warning" or "critical" and still be successful traffic. Promote only an
|
|
900
|
+
// explicit failure/error signature; keep benign excerpts available in Context.
|
|
901
|
+
const diagnosticSignal = [...lines, ...normalizedWarnings].some((line) =>
|
|
902
|
+
/(?:\b(?:error|exception|failed|failure|fatal|panic|crash|denied|refused|timeout|timed out|unhealthy|oomkill|back-?off)\b|\s5\d\d(?:\s|$))/i.test(
|
|
903
|
+
line,
|
|
904
|
+
),
|
|
905
|
+
);
|
|
906
|
+
const selectedEvidence = value.logs?.fallback !== true && diagnosticSignal;
|
|
907
|
+
const identity = `logs:${previous ? "previous" : "current"}:${value.pod}:${value.container}`;
|
|
908
|
+
builder.observe(identity, "logs", source, {
|
|
909
|
+
// FilterLogs' raw-tail fallback is useful provenance, but the producer did
|
|
910
|
+
// not select it as diagnostic signal. Keep it in Context; only filtered
|
|
911
|
+
// excerpts are Supporting evidence.
|
|
912
|
+
tier: evidenceTierForRelevance(
|
|
913
|
+
selectedEvidence ? "supporting" : "context",
|
|
914
|
+
relevance,
|
|
915
|
+
),
|
|
916
|
+
relevance,
|
|
917
|
+
tone: selectedEvidence || normalizedError ? "warning" : "neutral",
|
|
918
|
+
title: `${previous ? "Previous" : "Current"} logs · ${value.pod} / ${value.container}`,
|
|
919
|
+
summary:
|
|
920
|
+
lines.length > 0
|
|
921
|
+
? `${lines.length} selected line${lines.length === 1 ? "" : "s"}`
|
|
922
|
+
: "No log lines available",
|
|
923
|
+
data: {
|
|
924
|
+
type: "logs",
|
|
925
|
+
pod: value.pod,
|
|
926
|
+
container: value.container,
|
|
927
|
+
namespace,
|
|
928
|
+
previous,
|
|
929
|
+
logs: value.logs ? { ...value.logs, lines } : undefined,
|
|
930
|
+
warnings: normalizedWarnings,
|
|
931
|
+
error: normalizedError,
|
|
932
|
+
},
|
|
933
|
+
});
|
|
934
|
+
if (normalizedError) {
|
|
935
|
+
builder.limit(
|
|
936
|
+
source,
|
|
937
|
+
`${value.pod} / ${value.container}`,
|
|
938
|
+
normalizedError,
|
|
939
|
+
"error",
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
export function invalidPayload(
|
|
944
|
+
builder: ProjectionBuilder,
|
|
945
|
+
source: InvestigationEvidenceSource,
|
|
946
|
+
section = investigationResultLabel(source),
|
|
947
|
+
): void {
|
|
948
|
+
builder.limit(
|
|
949
|
+
source,
|
|
950
|
+
section,
|
|
951
|
+
"Radar couldn't summarize this investigation step. Review it in Activity.",
|
|
952
|
+
"unknown",
|
|
953
|
+
);
|
|
954
|
+
}
|