@skyhook-io/radar-app 1.16.3 → 1.17.1
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/README.md +1 -1
- package/package.json +3 -3
- package/src/App.tsx +138 -93
- package/src/RadarApp.tsx +27 -17
- package/src/api/actions.errorCode.test.ts +40 -0
- package/src/api/actions.test.ts +48 -0
- package/src/api/actions.ts +79 -0
- package/src/api/apiResources.navigation.test.tsx +232 -0
- package/src/api/apiResources.ts +46 -3
- package/src/api/client.goneResource.test.ts +49 -0
- package/src/api/client.helmSource.test.ts +39 -0
- package/src/api/client.namespaceSwitch.hook.test.tsx +54 -0
- package/src/api/client.namespaceSwitch.test.ts +107 -0
- package/src/api/client.ts +381 -47
- package/src/api/cnpg-ha.live.test.ts +140 -0
- package/src/api/cnpg-ha.test.ts +78 -0
- package/src/api/cnpg-ha.ts +19 -0
- package/src/api/cnpg-history.ts +123 -0
- package/src/api/cnpg-inspect.ts +102 -0
- package/src/api/cnpg-protection.ts +112 -0
- package/src/api/cnpg-recovery.test.tsx +60 -0
- package/src/api/cnpg-recovery.ts +262 -0
- package/src/api/cnpg-sessions.ts +175 -0
- package/src/api/cnpg-storage.ts +138 -0
- package/src/api/cnpg.legacy-reads.test.ts +43 -0
- package/src/api/cnpg.pooler-runtime.test.ts +15 -0
- package/src/api/cnpg.ts +428 -10
- package/src/api/diagnose.test.ts +40 -0
- package/src/api/diagnose.ts +12 -1
- package/src/api/radarFeatures.render.test.tsx +65 -3
- package/src/api/radarFeatures.ts +6 -0
- package/src/api/reviewedActionMutation.test.tsx +164 -0
- package/src/api/reviewedActionMutation.ts +66 -0
- package/src/api/timelineSource.test.ts +9 -0
- package/src/api/timelineSource.ts +8 -0
- package/src/api/traffic.sources.test.ts +55 -0
- package/src/api/traffic.ts +173 -14
- package/src/components/applications/ApplicationsView.tsx +3 -0
- package/src/components/audit/AuditView.tsx +9 -0
- package/src/components/capacity/CapacityActivity.tsx +3 -31
- package/src/components/capacity/CapacityDemand.tsx +4 -36
- package/src/components/capacity/CapacityOverview.tsx +217 -56
- package/src/components/capacity/CapacityPoolDetail.tsx +24 -62
- package/src/components/capacity/CapacityView.test.tsx +354 -1
- package/src/components/capacity/ClusterSchedulingCard.tsx +2 -10
- package/src/components/capacity/certaintyGlyph.test.tsx +2 -9
- package/src/components/capacity/routes.ts +42 -0
- package/src/components/capacity/shared.tsx +23 -150
- package/src/components/capacity/workspaceRoute.ts +18 -0
- package/src/components/cnpg/CNPGActionHost.compatibility.test.tsx +30 -0
- package/src/components/cnpg/CNPGArchivingRepair.tsx +191 -0
- package/src/components/cnpg/CNPGBlockingSessions.test.tsx +50 -0
- package/src/components/cnpg/CNPGBlockingSessions.tsx +333 -0
- package/src/components/cnpg/CNPGClusterActivity.test.tsx +11 -0
- package/src/components/cnpg/CNPGClusterActivity.tsx +18 -9
- package/src/components/cnpg/CNPGClusterLogs.compatibility.test.tsx +130 -0
- package/src/components/cnpg/CNPGClusterLogs.empty.test.tsx +29 -0
- package/src/components/cnpg/CNPGClusterLogs.test.ts +9 -0
- package/src/components/cnpg/CNPGClusterLogs.tsx +81 -24
- package/src/components/cnpg/CNPGClusterTabs.test.tsx +152 -0
- package/src/components/cnpg/CNPGClusterTabs.tsx +187 -0
- package/src/components/cnpg/CNPGClustersKindView.tsx +46 -0
- package/src/components/cnpg/CNPGConfiguration.test.tsx +223 -0
- package/src/components/cnpg/CNPGConfiguration.tsx +190 -0
- package/src/components/cnpg/CNPGCreateClusterDialog.test.tsx +113 -0
- package/src/components/cnpg/CNPGCreateClusterDialog.tsx +74 -0
- package/src/components/cnpg/CNPGDeclarations.test.ts +107 -0
- package/src/components/cnpg/CNPGDeclarations.tsx +236 -78
- package/src/components/cnpg/CNPGDetailPage.test.tsx +62 -0
- package/src/components/cnpg/CNPGDetailPage.tsx +159 -98
- package/src/components/cnpg/CNPGDrawerTrail.tsx +2 -2
- package/src/components/cnpg/CNPGInvestigationAction.test.tsx +30 -0
- package/src/components/cnpg/CNPGOperator.test.tsx +34 -0
- package/src/components/cnpg/CNPGOperator.tsx +78 -30
- package/src/components/cnpg/CNPGOperatorBanner.tsx +39 -0
- package/src/components/cnpg/CNPGOperatorConditions.test.tsx +45 -0
- package/src/components/cnpg/CNPGOperatorConditions.tsx +16 -0
- package/src/components/cnpg/CNPGOperatorConfig.test.tsx +27 -0
- package/src/components/cnpg/CNPGOperatorDiagnosis.tsx +247 -0
- package/src/components/cnpg/CNPGOverview.test.tsx +171 -0
- package/src/components/cnpg/CNPGOverview.tsx +238 -63
- package/src/components/cnpg/CNPGParametersInEffect.test.tsx +51 -0
- package/src/components/cnpg/CNPGParametersInEffect.tsx +120 -0
- package/src/components/cnpg/CNPGPerformance.prerequisite.test.tsx +48 -0
- package/src/components/cnpg/CNPGPerformance.test.tsx +73 -0
- package/src/components/cnpg/CNPGPerformance.tsx +477 -0
- package/src/components/cnpg/CNPGPooling.test.tsx +89 -0
- package/src/components/cnpg/CNPGPooling.tsx +97 -40
- package/src/components/cnpg/CNPGProtection.test.tsx +145 -0
- package/src/components/cnpg/CNPGProtection.tsx +230 -124
- package/src/components/cnpg/CNPGReplicationTab.test.tsx +87 -0
- package/src/components/cnpg/CNPGReplicationTab.tsx +212 -0
- package/src/components/cnpg/CNPGReplicationView.tsx +264 -0
- package/src/components/cnpg/CNPGStorage.test.tsx +140 -0
- package/src/components/cnpg/CNPGStorage.tsx +619 -0
- package/src/components/cnpg/CNPGSummaryHost.overview.test.tsx +44 -0
- package/src/components/cnpg/CNPGSummaryHost.test.tsx +23 -0
- package/src/components/cnpg/CNPGSummaryHost.tsx +228 -54
- package/src/components/cnpg/CNPGTrends.denied.test.tsx +33 -0
- package/src/components/cnpg/CNPGTrends.missing.test.tsx +69 -0
- package/src/components/cnpg/CNPGTrends.test.ts +38 -0
- package/src/components/cnpg/CNPGTrends.tsx +702 -0
- package/src/components/cnpg/CNPGView.tsx +34 -21
- package/src/components/cnpg/CNPGWorkspace.compatibility.test.tsx +90 -0
- package/src/components/cnpg/actions/CNPGClusterActions.dialog.test.tsx +79 -0
- package/src/components/cnpg/actions/CNPGClusterActions.test.tsx +142 -0
- package/src/components/cnpg/actions/CNPGClusterActions.tsx +853 -0
- package/src/components/cnpg/actions/CNPGConnectButton.read.test.tsx +40 -0
- package/src/components/cnpg/actions/CNPGConnectButton.test.ts +25 -0
- package/src/components/cnpg/actions/CNPGConnectButton.tsx +131 -0
- package/src/components/cnpg/actions/CNPGDestroyInstanceDialog.tsx +185 -0
- package/src/components/cnpg/actions/CNPGInstanceActions.test.tsx +38 -0
- package/src/components/cnpg/actions/CNPGInstanceActions.tsx +74 -0
- package/src/components/cnpg/actions/CNPGMaintenanceBanner.tsx +53 -0
- package/src/components/cnpg/actions/CNPGPoolerActions.test.tsx +35 -0
- package/src/components/cnpg/actions/CNPGPoolerActions.tsx +79 -0
- package/src/components/cnpg/actions/CNPGReportDialog.test.tsx +12 -0
- package/src/components/cnpg/actions/CNPGReportDialog.tsx +91 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.run.test.tsx +58 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.test.tsx +54 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.tsx +208 -0
- package/src/components/cnpg/actions/actionModel.test.ts +111 -0
- package/src/components/cnpg/actions/actionModel.ts +127 -0
- package/src/components/cnpg/actions/renderCNPGHeaderActions.tsx +60 -0
- package/src/components/cnpg/actions/useCNPGWriteGuard.tsx +58 -0
- package/src/components/cnpg/actions/useOpenCNPGPsql.ts +22 -0
- package/src/components/cnpg/archivingRepair.test.ts +104 -0
- package/src/components/cnpg/archivingRepair.ts +145 -0
- package/src/components/cnpg/baseBackup.test.ts +36 -0
- package/src/components/cnpg/baseBackup.ts +36 -0
- package/src/components/cnpg/blocking.test.ts +59 -0
- package/src/components/cnpg/blocking.ts +131 -0
- package/src/components/cnpg/coverageText.test.ts +27 -0
- package/src/components/cnpg/creation/CNPGTargetFields.tsx +110 -0
- package/src/components/cnpg/creation/targetModel.test.ts +137 -0
- package/src/components/cnpg/creation/targetModel.ts +149 -0
- package/src/components/cnpg/fleetStatus.test.ts +46 -0
- package/src/components/cnpg/fleetStatus.ts +43 -0
- package/src/components/cnpg/formFields.ts +2 -0
- package/src/components/cnpg/grantText.test.tsx +11 -0
- package/src/components/cnpg/host.test.tsx +24 -0
- package/src/components/cnpg/host.tsx +101 -0
- package/src/components/cnpg/inspectModel.test.ts +120 -0
- package/src/components/cnpg/inspectModel.ts +140 -0
- package/src/components/cnpg/logicalSlots.test.ts +30 -0
- package/src/components/cnpg/logicalSlots.ts +29 -0
- package/src/components/cnpg/lsn.test.ts +40 -0
- package/src/components/cnpg/lsn.ts +43 -0
- package/src/components/cnpg/operations/CNPGFleetOperation.tsx +30 -0
- package/src/components/cnpg/operations/CNPGOperationTracker.test.tsx +247 -0
- package/src/components/cnpg/operations/CNPGOperationTracker.tsx +255 -0
- package/src/components/cnpg/operations/model.test.ts +510 -0
- package/src/components/cnpg/operations/model.ts +499 -0
- package/src/components/cnpg/operations/presentation.test.ts +80 -0
- package/src/components/cnpg/operations/presentation.ts +84 -0
- package/src/components/cnpg/operations/store.ts +98 -0
- package/src/components/cnpg/operatorStatus.test.ts +134 -0
- package/src/components/cnpg/operatorStatus.ts +194 -0
- package/src/components/cnpg/paths.test.ts +54 -0
- package/src/components/cnpg/paths.ts +47 -4
- package/src/components/cnpg/protection/CNPGAttachArchiveDialog.tsx +185 -0
- package/src/components/cnpg/protection/CNPGCreateScheduleDialog.tsx +196 -0
- package/src/components/cnpg/protection/CNPGProtectionSetup.test.tsx +121 -0
- package/src/components/cnpg/protection/CNPGProtectionSetup.tsx +452 -0
- package/src/components/cnpg/protection/CNPGScheduleInput.tsx +115 -0
- package/src/components/cnpg/protection/CNPGScheduleRepairButton.tsx +120 -0
- package/src/components/cnpg/protection/scheduleModel.test.ts +19 -0
- package/src/components/cnpg/protection/scheduleModel.ts +16 -0
- package/src/components/cnpg/recovery/CNPGRecoveryPoint.tsx +220 -0
- package/src/components/cnpg/recovery/CNPGRestoreButton.test.tsx +29 -0
- package/src/components/cnpg/recovery/CNPGRestoreButton.tsx +26 -0
- package/src/components/cnpg/recovery/CNPGRestoreChecks.test.tsx +23 -0
- package/src/components/cnpg/recovery/CNPGRestoreChecks.tsx +153 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.back.test.tsx +149 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.test.tsx +46 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.tsx +333 -0
- package/src/components/cnpg/recovery/CNPGRestoreProgress.tsx +182 -0
- package/src/components/cnpg/recovery/CNPGRestoreValidation.tsx +216 -0
- package/src/components/cnpg/recovery/restoreModel.test.ts +709 -0
- package/src/components/cnpg/recovery/restoreModel.ts +685 -0
- package/src/components/cnpg/recovery/restoreOperation.ts +56 -0
- package/src/components/cnpg/refresh.test.ts +38 -0
- package/src/components/cnpg/refresh.ts +17 -0
- package/src/components/cnpg/refreshNotice.test.tsx +29 -0
- package/src/components/cnpg/routes.test.ts +34 -3
- package/src/components/cnpg/routes.ts +60 -48
- package/src/components/cnpg/runtimeAssessment.ts +198 -0
- package/src/components/cnpg/runtimeModel.test.ts +175 -0
- package/src/components/cnpg/runtimeModel.ts +196 -0
- package/src/components/cnpg/runtimeParts.test.tsx +17 -0
- package/src/components/cnpg/runtimeParts.tsx +79 -0
- package/src/components/cnpg/shared.test.tsx +59 -0
- package/src/components/cnpg/shared.tsx +100 -182
- package/src/components/cnpg/storageModel.test.ts +107 -0
- package/src/components/cnpg/storageModel.ts +101 -0
- package/src/components/cnpg/trendSamples.test.ts +148 -0
- package/src/components/cnpg/trendSamples.ts +222 -0
- package/src/components/cnpg/useCNPGClusterAssessment.ts +30 -0
- package/src/components/cnpg/useCNPGKubectlContext.test.tsx +45 -0
- package/src/components/cnpg/useCNPGKubectlContext.ts +13 -0
- package/src/components/cnpg/useCNPGNavigate.test.tsx +58 -0
- package/src/components/cnpg/useCNPGNavigate.ts +21 -0
- package/src/components/cnpg/useCNPGPoolerLive.ts +46 -0
- package/src/components/cnpg/useCNPGScreenParams.ts +24 -0
- package/src/components/cnpg/useCNPGSidebarWorkspace.ts +66 -16
- package/src/components/cnpg/workspaceRoute.ts +23 -0
- package/src/components/cost/ApplicationCostTab.test.ts +7 -0
- package/src/components/cost/ApplicationCostTab.tsx +7 -7
- package/src/components/cost/CostView.test.ts +2 -1
- package/src/components/cost/CostView.tsx +0 -2
- package/src/components/cost/WorkloadCostTab.tsx +2 -4
- package/src/components/cost/errors.ts +3 -1
- package/src/components/cost/source.test.ts +10 -0
- package/src/components/cost/source.ts +5 -1
- package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
- package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
- package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
- package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
- package/src/components/diagnose/ApplyDialog.tsx +26 -28
- package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
- package/src/components/diagnose/DiagnoseContext.tsx +136 -54
- package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
- package/src/components/diagnose/InvestigationView.tsx +39 -2
- package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
- package/src/components/diagnose/agentCatalog.ts +2 -2
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
- package/src/components/diagnose/investigationEvidence/adapters/issues.ts +8 -0
- package/src/components/diagnose/investigationEvidence/projection.test.ts +16 -0
- package/src/components/dock/BottomDock.tsx +2 -0
- package/src/components/dock/TerminalTab.test.tsx +17 -0
- package/src/components/dock/TerminalTab.tsx +7 -3
- package/src/components/dock/TrafficFlowListTab.tsx +27 -6
- package/src/components/execution/BatchExecutionView.tsx +11 -11
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +4 -2
- package/src/components/gitops/GitOpsView.tsx +16 -23
- package/src/components/helm/ChartBrowser.tsx +3 -3
- package/src/components/helm/HelmCompareRoute.test.tsx +59 -0
- package/src/components/helm/HelmCompareRoute.tsx +57 -26
- package/src/components/helm/HelmReleaseDrawer.test.ts +55 -3
- package/src/components/helm/HelmReleaseDrawer.tsx +50 -12
- package/src/components/helm/RevisionHistory.tsx +3 -2
- package/src/components/helm/TrackChartSourceDialog.render.test.tsx +120 -0
- package/src/components/helm/TrackChartSourceDialog.test.ts +50 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +153 -176
- package/src/components/helm/ValuesViewer.test.tsx +301 -0
- package/src/components/helm/ValuesViewer.tsx +169 -78
- package/src/components/home/ActivitySummary.tsx +2 -1
- package/src/components/home/ClusterHealthCard.tsx +7 -0
- package/src/components/home/HomeView.tsx +20 -9
- package/src/components/home/ProblemsPanel.test.tsx +81 -0
- package/src/components/home/TopologyPreview.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +1 -1
- package/src/components/issues/IssuesPane.render.test.tsx +122 -0
- package/src/components/issues/IssuesPane.test.ts +65 -1
- package/src/components/issues/IssuesPane.tsx +188 -25
- package/src/components/issues/uncheckedSources.ts +21 -0
- package/src/components/resource/PVCUsageBar.render.test.tsx +32 -0
- package/src/components/resource/PVCUsageBar.test.tsx +24 -0
- package/src/components/resource/PVCUsageBar.tsx +15 -6
- package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +3 -1
- package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
- package/src/components/resources/CompositeRenderer.tsx +4 -4
- package/src/components/resources/ResourceDetailDrawer.tsx +2 -2
- package/src/components/resources/ResourcesView.tsx +26 -12
- package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
- package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
- package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
- package/src/components/settings/LocalIntegrationStatus.test.tsx +52 -0
- package/src/components/settings/LocalIntegrationStatus.tsx +3 -4
- package/src/components/settings/SettingsDialog.tsx +150 -34
- package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
- package/src/components/timeline/TimelineList.tsx +25 -24
- package/src/components/timeline/TimelineLoadError.test.tsx +62 -0
- package/src/components/timeline/TimelineLoadError.tsx +96 -0
- package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
- package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
- package/src/components/timeline/TimelineView.tsx +25 -45
- package/src/components/timeline/ViewModeToggle.tsx +20 -0
- package/src/components/timeline/useStickyError.test.tsx +47 -0
- package/src/components/timeline/useStickyError.ts +15 -0
- package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
- package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
- package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
- package/src/components/traffic/TrafficGraph.tsx +109 -26
- package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
- package/src/components/traffic/TrafficView.tsx +582 -149
- package/src/components/traffic/trafficFilters.test.ts +398 -1
- package/src/components/traffic/trafficFilters.ts +408 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +34 -2
- package/src/components/ui/Markdown.test.tsx +65 -0
- package/src/components/ui/Markdown.tsx +144 -127
- package/src/components/whats-new/releaseNotes.ts +63 -1
- package/src/components/workload/WorkloadView.test.ts +11 -1
- package/src/components/workload/WorkloadView.tsx +162 -529
- package/src/components/workspace/layout.test.tsx +29 -0
- package/src/components/workspace/layout.tsx +259 -0
- package/src/components/workspace/table.ts +7 -0
- package/src/context/TimelineCustomization.tsx +15 -0
- package/src/contexts/CapabilitiesContext.tsx +1 -1
- package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
- package/src/hooks/useGitOpsWriteGuard.ts +193 -0
- package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
- package/src/integrations/resourceHost.test.tsx +55 -0
- package/src/integrations/resourceHost.ts +99 -0
- package/src/integrations/resourceHosts.test.tsx +141 -0
- package/src/integrations/resourceHosts.tsx +119 -0
- package/src/integrations/workspaceRoute.ts +20 -0
- package/src/integrations/workspaceRoutes.test.ts +40 -0
- package/src/integrations/workspaceRoutes.ts +22 -0
- package/src/integrations/workspaceScreens.tsx +14 -0
- package/src/utils/drawer-trail.ts +35 -0
- package/src/utils/kindFilterStore.test.ts +147 -0
- package/src/utils/kindFilterStore.ts +91 -0
- package/src/utils/navigation.test.ts +13 -1
- package/src/utils/navigation.ts +39 -2
- package/src/utils/page-links.ts +21 -0
- package/src/utils/tabStorage.ts +40 -0
- package/src/utils/viewMemory.test.ts +78 -0
- package/src/utils/viewMemory.ts +54 -0
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { AggregatedFlow, TrafficFlow } from '../../types'
|
|
2
|
+
import type { TrafficEndpointPair, TrafficEndpointRef, TrafficMatch } from '../../api/traffic'
|
|
3
|
+
import type { TrafficGraphSelection } from './TrafficGraph'
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Does a flow fall into one of the selected HTTP status ranges?
|
|
@@ -136,6 +138,19 @@ export function mergeFlowVolume(into: AggregatedFlow, flow: AggregatedFlow): voi
|
|
|
136
138
|
if (flow.errorCount) into.errorCount = (into.errorCount || 0) + flow.errorCount
|
|
137
139
|
if (flow.requestRate) into.requestRate = (into.requestRate || 0) + flow.requestRate
|
|
138
140
|
if (flow.errorRate) into.errorRate = (into.errorRate || 0) + flow.errorRate
|
|
141
|
+
// New objects rather than in-place sums: a merged edge starts as a shallow
|
|
142
|
+
// copy of the server's, so its maps are still the server's.
|
|
143
|
+
into.httpStatusCounts = sumCounts(into.httpStatusCounts, flow.httpStatusCounts)
|
|
144
|
+
into.verdictCounts = sumCounts(into.verdictCounts, flow.verdictCounts)
|
|
145
|
+
into.dropReasons = sumCounts(into.dropReasons, flow.dropReasons)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function sumCounts(a: Record<string, number> | undefined, b: Record<string, number> | undefined): Record<string, number> | undefined {
|
|
149
|
+
if (!b) return a
|
|
150
|
+
if (!a) return { ...b }
|
|
151
|
+
const sum = { ...a }
|
|
152
|
+
for (const [k, v] of Object.entries(b)) sum[k] = (sum[k] ?? 0) + v
|
|
153
|
+
return sum
|
|
139
154
|
}
|
|
140
155
|
|
|
141
156
|
/**
|
|
@@ -287,3 +302,396 @@ export function dedupeHTTPPairs(flows: TrafficFlow[], callerOriented: boolean):
|
|
|
287
302
|
}
|
|
288
303
|
return flows.filter(f => !(isHTTP(f, 'REQUEST') && answered.has(call(f.source.name, f.destination.name, f))))
|
|
289
304
|
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* A graph edge, carrying the server edges merged into it. The graph renames and
|
|
308
|
+
* merges endpoints (external services, the Internet node, the addon group), so
|
|
309
|
+
* these are what a selection is traced back through to ask the server for its
|
|
310
|
+
* records.
|
|
311
|
+
*/
|
|
312
|
+
export type GraphFlow = AggregatedFlow & { rawPairs?: TrafficEndpointPair[] }
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The port an edge merged from several shows: its lowest real one, whatever
|
|
316
|
+
* order the parts arrived in (0 means none was reported, so it shows only
|
|
317
|
+
* when no part has a port). The Internet node groups by it and selections
|
|
318
|
+
* find the edge by it, so it must not change from one refresh to the next.
|
|
319
|
+
*/
|
|
320
|
+
export function mergedEdgePort(a: number, b: number): number {
|
|
321
|
+
if (a === 0) return b
|
|
322
|
+
if (b === 0) return a
|
|
323
|
+
return Math.min(a, b)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Whether an edge carries a port: its own, or one of the edges merged into it.
|
|
328
|
+
* A merged edge shows one of its ports, so a selection made on another must
|
|
329
|
+
* still find it.
|
|
330
|
+
*/
|
|
331
|
+
export function edgeCarriesPort(flow: GraphFlow, port: number | undefined): boolean {
|
|
332
|
+
return port === undefined || flow.port === port || !!flow.rawPairs?.some(p => p.port === port)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Of the edges between a selection's endpoints, the ones it means: those on
|
|
337
|
+
* its exact port when any are, else those with the port merged in. Two edges
|
|
338
|
+
* can join the same endpoints (the Internet node keeps ports apart), and the
|
|
339
|
+
* one shown on a port must win over one that merely carries it.
|
|
340
|
+
*/
|
|
341
|
+
export function edgesOnPort<T>(candidates: T[], flowOf: (c: T) => GraphFlow | undefined, port: number | undefined): T[] {
|
|
342
|
+
if (port === undefined) return candidates
|
|
343
|
+
const exact = candidates.filter(c => flowOf(c)?.port === port)
|
|
344
|
+
if (exact.length > 0) return exact
|
|
345
|
+
return candidates.filter(c => {
|
|
346
|
+
const flow = flowOf(c)
|
|
347
|
+
return !!flow && edgeCarriesPort(flow, port)
|
|
348
|
+
})
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function endpointPair(flow: AggregatedFlow): TrafficEndpointPair {
|
|
352
|
+
const ref = (e: AggregatedFlow['source']) => ({
|
|
353
|
+
namespace: e.namespace || undefined,
|
|
354
|
+
name: e.name,
|
|
355
|
+
kind: e.kind,
|
|
356
|
+
...(e.workloadKind && { workloadKind: e.workloadKind }),
|
|
357
|
+
})
|
|
358
|
+
return {
|
|
359
|
+
source: ref(flow.source),
|
|
360
|
+
destination: ref(flow.destination),
|
|
361
|
+
port: flow.port,
|
|
362
|
+
...(flow.directionUnknown && { directionUnknown: true }),
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Adds a merged edge's pairs to the edge it was merged into. The target owns
|
|
367
|
+
* its array, so merging appends in place: copying it on every merge is
|
|
368
|
+
* quadratic, and an ingress with tens of thousands of distinct clients
|
|
369
|
+
* collapses into a single Internet edge. */
|
|
370
|
+
export function mergeRawPairs(into: GraphFlow, from: GraphFlow): void {
|
|
371
|
+
const target = into.rawPairs ?? (into.rawPairs = [])
|
|
372
|
+
for (const pair of from.rawPairs ?? []) target.push(pair)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// The renderer draws the addon group's virtual endpoints as one group node.
|
|
376
|
+
function selectableId(e: { namespace?: string; name: string; kind?: string }): string {
|
|
377
|
+
if (e.kind === 'AddonGroupTarget' || e.kind === 'AddonGroupSource') return 'addon-group'
|
|
378
|
+
return graphEndpointId(e)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* A record's endpoint as the graph names it: a pod with a known workload is
|
|
383
|
+
* drawn as that workload. Mirrors the server's graphEndpoint (pkg/traffic),
|
|
384
|
+
* which builds the aggregation the graph is drawn from — the two have to agree
|
|
385
|
+
* for a record to be found under its node.
|
|
386
|
+
*/
|
|
387
|
+
export function graphEndpoint(e: TrafficFlow['source']): { namespace?: string; name: string } {
|
|
388
|
+
if (e.kind === 'Pod' && e.workload && e.namespace && e.workload !== e.name) return { namespace: e.namespace, name: e.workload }
|
|
389
|
+
return e
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function graphEndpointId(e: { namespace?: string; name: string }): string {
|
|
393
|
+
return e.namespace ? `${e.namespace}/${e.name}` : e.name
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function pairKey(source: { namespace?: string; name: string }, destination: { namespace?: string; name: string }, port: number, directionUnknown?: boolean): string {
|
|
397
|
+
return `${graphEndpointId(source)}->${graphEndpointId(destination)}:${port}${directionUnknown ? ':u' : ''}`
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Whether a record belongs to the selected edges, in the grouping the graph
|
|
402
|
+
* draws (`drawn`). Without `responsesCallerOriented` the server sends an HTTP
|
|
403
|
+
* response as it was observed, server to client on the client's port, so a
|
|
404
|
+
* response is then also matched to an edge running the other way. Its port says nothing about which of the
|
|
405
|
+
* caller's edges it answers, so it is matched to each of them; other traffic
|
|
406
|
+
* between the same endpoints is not, being on edges of its own.
|
|
407
|
+
*/
|
|
408
|
+
export function selectedEdgeMatcher(
|
|
409
|
+
pairs: TrafficEndpointPair[] | null,
|
|
410
|
+
responsesCallerOriented: boolean,
|
|
411
|
+
drawn: (e: TrafficFlow['source']) => { namespace?: string; name: string },
|
|
412
|
+
): (f: TrafficFlow) => boolean {
|
|
413
|
+
if (!pairs) return () => true
|
|
414
|
+
const keys = new Set(pairs.map(p => pairKey(p.source, p.destination, p.port, p.directionUnknown)))
|
|
415
|
+
const callers = responsesCallerOriented ? null : new Set(pairs.map(p => `${graphEndpointId(p.source)}->${graphEndpointId(p.destination)}`))
|
|
416
|
+
return f => {
|
|
417
|
+
const src = drawn(f.source)
|
|
418
|
+
const dst = drawn(f.destination)
|
|
419
|
+
if (keys.has(pairKey(src, dst, f.port, f.directionUnknown))) return true
|
|
420
|
+
// The response travels callee to caller: reversed, it is the edge's.
|
|
421
|
+
return !!callers && f.l7Protocol === 'HTTP' && f.l7Type === 'RESPONSE' &&
|
|
422
|
+
callers.has(`${graphEndpointId(dst)}->${graphEndpointId(src)}`)
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* The server edges behind the selected node or edge, deduplicated and in a
|
|
428
|
+
* stable order — the server returns its aggregation in no particular order,
|
|
429
|
+
* and the result keys the records query. Null when the selection traces back
|
|
430
|
+
* to none. `inAddonGroup` says which endpoints the addon group node holds
|
|
431
|
+
* while addons are grouped, so selecting the group selects their traffic too.
|
|
432
|
+
*/
|
|
433
|
+
export function selectionRawPairs(
|
|
434
|
+
flows: GraphFlow[],
|
|
435
|
+
selection: TrafficGraphSelection | null,
|
|
436
|
+
inAddonGroup?: (e: { namespace?: string; name: string }) => boolean,
|
|
437
|
+
): TrafficEndpointPair[] | null {
|
|
438
|
+
if (!selection) return null
|
|
439
|
+
const pairs = new Map<string, TrafficEndpointPair>()
|
|
440
|
+
const groupId = (e: GraphFlow['source']) =>
|
|
441
|
+
inAddonGroup && selection.nodeId === 'addon-group' && inAddonGroup(e) ? 'addon-group' : selectableId(e)
|
|
442
|
+
const touching = flows.filter(flow => {
|
|
443
|
+
const sourceId = groupId(flow.source)
|
|
444
|
+
const destId = groupId(flow.destination)
|
|
445
|
+
return selection.type === 'node'
|
|
446
|
+
? sourceId === selection.nodeId || destId === selection.nodeId
|
|
447
|
+
: sourceId === selection.sourceId && destId === selection.destId &&
|
|
448
|
+
!!flow.directionUnknown === !!selection.directionUnknown
|
|
449
|
+
})
|
|
450
|
+
// An edge stands for its port; an edge merged from several ports shows one
|
|
451
|
+
// of them and carries the rest (edgesOnPort).
|
|
452
|
+
const selected = selection.type === 'node' ? touching : edgesOnPort(touching, f => f, selection.port)
|
|
453
|
+
for (const flow of selected) {
|
|
454
|
+
for (const pair of flow.rawPairs ?? []) {
|
|
455
|
+
pairs.set(pairKey(pair.source, pair.destination, pair.port, pair.directionUnknown), pair)
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (pairs.size === 0) return null
|
|
459
|
+
return Array.from(pairs.entries()).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([, pair]) => pair)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* What to ask the server for, for the records behind a selection traced to
|
|
464
|
+
* `pairs` (selectionRawPairs). A node that is one server endpoint is sent as
|
|
465
|
+
* that endpoint: a busy node has too many edges to name each within a URL.
|
|
466
|
+
* The answer then holds its hidden edges too, so the caller keeps the pairs
|
|
467
|
+
* to narrow it. Anything else — an edge, a node merged from several
|
|
468
|
+
* endpoints — is sent as its edges.
|
|
469
|
+
*/
|
|
470
|
+
export function selectionMatch(
|
|
471
|
+
pairs: TrafficEndpointPair[] | null,
|
|
472
|
+
selection: TrafficGraphSelection | null,
|
|
473
|
+
): TrafficMatch | null {
|
|
474
|
+
if (!pairs) return null
|
|
475
|
+
if (selection?.type !== 'node') return { pairs }
|
|
476
|
+
const refKey = (r: TrafficEndpointRef) => `${graphEndpointId(r)}|${r.kind ?? ''}`
|
|
477
|
+
// Only pods can be named to the source; any other endpoint sent alone would
|
|
478
|
+
// fetch everything, while its edges still name the pods at their far ends.
|
|
479
|
+
const nameable = (r: TrafficEndpointRef) => r.kind === 'Pod' || r.kind === 'Workload'
|
|
480
|
+
let only: TrafficEndpointRef | undefined
|
|
481
|
+
for (const p of pairs) {
|
|
482
|
+
const side = graphEndpointId(p.source) === selection.nodeId ? p.source
|
|
483
|
+
: graphEndpointId(p.destination) === selection.nodeId ? p.destination
|
|
484
|
+
: undefined
|
|
485
|
+
if (!side || !nameable(side) || (only && refKey(side) !== refKey(only))) return { pairs }
|
|
486
|
+
only = side
|
|
487
|
+
}
|
|
488
|
+
return only ? { endpoints: [only] } : { pairs }
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* How costly the graph is to draw. The layout runs on the main thread and its
|
|
493
|
+
* cost grows faster than linearly, edges more than nodes, so edges count
|
|
494
|
+
* double, as Weave Scope weighs them. Laying out whole namespaces of a dense
|
|
495
|
+
* service graph took 0.55s at a score of 493, 2s at 728, 4.5s at 1,433, 13s
|
|
496
|
+
* at 1,883 and 32s at 2,329; larger graphs overflowed ELK's stack.
|
|
497
|
+
*/
|
|
498
|
+
export function graphSize(flows: AggregatedFlow[]): { nodes: number; edges: number; score: number } {
|
|
499
|
+
const nodes = new Set<string>()
|
|
500
|
+
for (const flow of flows) {
|
|
501
|
+
nodes.add(graphEndpointId(flow.source))
|
|
502
|
+
nodes.add(graphEndpointId(flow.destination))
|
|
503
|
+
}
|
|
504
|
+
return { nodes: nodes.size, edges: flows.length, score: nodes.size + 2 * flows.length }
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Above this the graph is not drawn until the view is narrowed (about 1.5s of layout). */
|
|
508
|
+
export const GRAPH_DRAW_BUDGET = 600
|
|
509
|
+
/** Above this it is not drawn at all (about 5s of layout, freezing the tab). */
|
|
510
|
+
export const GRAPH_DRAW_CEILING = 1500
|
|
511
|
+
|
|
512
|
+
/** 5xx responses on an edge: counted where the source observes responses,
|
|
513
|
+
* a per-second rate where it measures one. */
|
|
514
|
+
export function flowErrors(flow: AggregatedFlow): number {
|
|
515
|
+
return flow.httpStatusCounts?.['5xx'] || errorRateOf(flow)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export function flowDrops(flow: AggregatedFlow): number {
|
|
519
|
+
return flow.verdictCounts?.dropped ?? 0
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Whether this edge carries HTTP responses, so its 5xx count is a measured
|
|
523
|
+
* zero rather than unknown; plain TCP has none. Zero rates and counts are left
|
|
524
|
+
* out of the response, so a healthy HTTP edge shows only by its requests or
|
|
525
|
+
* protocol. */
|
|
526
|
+
export function hasStatusData(flow: AggregatedFlow): boolean {
|
|
527
|
+
return (!!flow.httpStatusCounts && Object.keys(flow.httpStatusCounts).length > 0) ||
|
|
528
|
+
!!flow.requestRate || !!flow.requestCount || !!flow.errorRate || !!flow.errorCount ||
|
|
529
|
+
flow.l7Protocol === 'HTTP' || flow.l7Protocol === 'gRPC'
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* A workload (or any endpoint) the view is narrowed to: it and everything it
|
|
534
|
+
* talks to. Identified by namespace and name, the way the graph names nodes,
|
|
535
|
+
* so it means the same thing whether pods are grouped or not — ungrouped, it
|
|
536
|
+
* is the pods the workload owns.
|
|
537
|
+
*/
|
|
538
|
+
export interface TrafficFocus {
|
|
539
|
+
namespace?: string
|
|
540
|
+
name: string
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** The focus as the URL carries it: "ns/name", or "/name" for an endpoint
|
|
544
|
+
* with no namespace, whose name can itself hold a slash (a CIDR). */
|
|
545
|
+
export function focusParam(focus: TrafficFocus): string {
|
|
546
|
+
return `${focus.namespace ?? ''}/${focus.name}`
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export function parseFocus(value: string | null): TrafficFocus | null {
|
|
550
|
+
if (!value) return null
|
|
551
|
+
const slash = value.indexOf('/')
|
|
552
|
+
if (slash < 0) return null
|
|
553
|
+
const namespace = value.slice(0, slash)
|
|
554
|
+
const name = value.slice(slash + 1)
|
|
555
|
+
if (!name) return null
|
|
556
|
+
return namespace ? { namespace, name } : { name }
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** The graph's id for the focused node. */
|
|
560
|
+
export function focusId(focus: TrafficFocus): string {
|
|
561
|
+
return graphEndpointId(focus)
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export function touchesFocus(e: AggregatedFlow['source'], focus: TrafficFocus): boolean {
|
|
565
|
+
if ((e.namespace || undefined) !== focus.namespace) return false
|
|
566
|
+
return e.name === focus.name || (e.kind === 'Pod' && e.workload === focus.name)
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** The edges with the focus at either end. */
|
|
570
|
+
export function focusNeighborhood<T extends AggregatedFlow>(flows: T[], focus: TrafficFocus): T[] {
|
|
571
|
+
return flows.filter(f => touchesFocus(f.source, focus) || touchesFocus(f.destination, focus))
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
export interface EndpointSummary {
|
|
575
|
+
id: string
|
|
576
|
+
name: string
|
|
577
|
+
namespace?: string
|
|
578
|
+
kind: string
|
|
579
|
+
workloadKind?: string
|
|
580
|
+
volume: number
|
|
581
|
+
errors: number
|
|
582
|
+
drops: number
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Every endpoint drawn from these flows, with the traffic on its edges. The
|
|
586
|
+
* options a focus can be chosen from, so each one has something to show.
|
|
587
|
+
* Pods drawn ungrouped also offer their workload, which a focus covers whole. */
|
|
588
|
+
export function endpointSummaries(flows: AggregatedFlow[]): EndpointSummary[] {
|
|
589
|
+
const byId = new Map<string, EndpointSummary>()
|
|
590
|
+
const add = (e: { namespace?: string; name: string; kind: string; workloadKind?: string }, flow: AggregatedFlow, seen: Set<string>) => {
|
|
591
|
+
const id = graphEndpointId(e)
|
|
592
|
+
if (seen.has(id)) return
|
|
593
|
+
seen.add(id)
|
|
594
|
+
let s = byId.get(id)
|
|
595
|
+
if (!s) {
|
|
596
|
+
s = { id, name: e.name, namespace: e.namespace || undefined, kind: e.kind, workloadKind: e.workloadKind, volume: 0, errors: 0, drops: 0 }
|
|
597
|
+
byId.set(id, s)
|
|
598
|
+
}
|
|
599
|
+
s.volume += flow.connections
|
|
600
|
+
s.errors += flowErrors(flow)
|
|
601
|
+
s.drops += flowDrops(flow)
|
|
602
|
+
}
|
|
603
|
+
for (const flow of flows) {
|
|
604
|
+
const seen = new Set<string>()
|
|
605
|
+
for (const e of [flow.source, flow.destination]) {
|
|
606
|
+
add(e, flow, seen)
|
|
607
|
+
if (e.kind === 'Pod' && e.namespace && e.workload && e.workload !== e.name) {
|
|
608
|
+
add({ namespace: e.namespace, name: e.workload, kind: 'Workload', workloadKind: e.workloadKind }, flow, seen)
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return Array.from(byId.values())
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Where the problems are first, then the busiest. */
|
|
616
|
+
export function byProblemsThenVolume(a: { errors: number; drops: number; volume: number }, b: { errors: number; drops: number; volume: number }): number {
|
|
617
|
+
const pa = a.errors > 0 || a.drops > 0 ? 1 : 0
|
|
618
|
+
const pb = b.errors > 0 || b.drops > 0 ? 1 : 0
|
|
619
|
+
if (pa !== pb) return pb - pa
|
|
620
|
+
if (a.drops + a.errors !== b.drops + b.errors) return b.drops + b.errors - (a.drops + a.errors)
|
|
621
|
+
return b.volume - a.volume
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Search results for a query: a name that starts with the query before one
|
|
626
|
+
* that only contains it, and within each, problems first, then volume.
|
|
627
|
+
*/
|
|
628
|
+
export function searchEndpoints(summaries: EndpointSummary[], query: string, limit = 50): EndpointSummary[] {
|
|
629
|
+
const q = query.trim().toLowerCase()
|
|
630
|
+
const rank = (s: EndpointSummary) => {
|
|
631
|
+
if (!q) return 1
|
|
632
|
+
const name = s.name.toLowerCase()
|
|
633
|
+
if (name === q) return 0
|
|
634
|
+
if (name.startsWith(q)) return 1
|
|
635
|
+
if (name.includes(q)) return 2
|
|
636
|
+
if (s.id.toLowerCase().includes(q)) return 3
|
|
637
|
+
return -1
|
|
638
|
+
}
|
|
639
|
+
return summaries
|
|
640
|
+
.map(s => ({ s, r: rank(s) }))
|
|
641
|
+
.filter(x => x.r >= 0)
|
|
642
|
+
.sort((a, b) => a.r - b.r || byProblemsThenVolume(a.s, b.s))
|
|
643
|
+
.slice(0, limit)
|
|
644
|
+
.map(x => x.s)
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export interface NamespaceSummary {
|
|
648
|
+
name: string
|
|
649
|
+
endpoints: number
|
|
650
|
+
connections: number
|
|
651
|
+
volume: number
|
|
652
|
+
errors: number
|
|
653
|
+
drops: number
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Per namespace, what narrowing to it would show. Narrowing keeps an edge
|
|
658
|
+
* when either end is in the namespace, as the server does, so a cross-namespace
|
|
659
|
+
* edge counts toward both of its namespaces, and its far end toward the
|
|
660
|
+
* namespace's endpoints.
|
|
661
|
+
*/
|
|
662
|
+
/** Whether the namespace's own view — its edges, with either end in it — would
|
|
663
|
+
* be over the draw budget, judged from the traffic already fetched. Picking it
|
|
664
|
+
* fetches that namespace on its own, which can bring in more. */
|
|
665
|
+
export function namespaceOverBudget(ns: NamespaceSummary): boolean {
|
|
666
|
+
return ns.endpoints + 2 * ns.connections > GRAPH_DRAW_BUDGET
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
export function namespaceSummaries(flows: AggregatedFlow[]): NamespaceSummary[] {
|
|
670
|
+
const byNs = new Map<string, { endpoints: Set<string>; connections: number; volume: number; errors: number; drops: number }>()
|
|
671
|
+
for (const flow of flows) {
|
|
672
|
+
const namespaces = new Set([flow.source.namespace, flow.destination.namespace].filter(Boolean))
|
|
673
|
+
for (const ns of namespaces) {
|
|
674
|
+
let s = byNs.get(ns)
|
|
675
|
+
if (!s) {
|
|
676
|
+
s = { endpoints: new Set(), connections: 0, volume: 0, errors: 0, drops: 0 }
|
|
677
|
+
byNs.set(ns, s)
|
|
678
|
+
}
|
|
679
|
+
s.endpoints.add(graphEndpointId(flow.source))
|
|
680
|
+
s.endpoints.add(graphEndpointId(flow.destination))
|
|
681
|
+
s.connections += 1
|
|
682
|
+
s.volume += flow.connections
|
|
683
|
+
s.errors += flowErrors(flow)
|
|
684
|
+
s.drops += flowDrops(flow)
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
return Array.from(byNs.entries())
|
|
688
|
+
.map(([name, s]) => ({ name, endpoints: s.endpoints.size, connections: s.connections, volume: s.volume, errors: s.errors, drops: s.drops }))
|
|
689
|
+
.sort((a, b) => byProblemsThenVolume(a, b) || a.name.localeCompare(b.name))
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** The edges as table rows, problems first, then volume. */
|
|
693
|
+
export function connectionRows<T extends AggregatedFlow>(flows: T[]): T[] {
|
|
694
|
+
const keyed = flows.map(flow => ({ flow, errors: flowErrors(flow), drops: flowDrops(flow), volume: flow.connections }))
|
|
695
|
+
keyed.sort(byProblemsThenVolume)
|
|
696
|
+
return keyed.map(k => k.flow)
|
|
697
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useState, useCallback } from 'react'
|
|
1
|
+
import { useEffect, useState, useCallback, useRef } from 'react'
|
|
2
2
|
import { X, Copy, Check, ExternalLink } from 'lucide-react'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
4
|
import { TRANSITION_BACKDROP, TRANSITION_PANEL, overlayTransitionStyle } from '../../utils/animation'
|
|
@@ -7,15 +7,29 @@ import { useDiagnostics } from '../../api/client'
|
|
|
7
7
|
import type { DiagnosticsSnapshot, DiagEnvVar, DiagMetricsSourceHealth, DiagDropRecord, DiagErrorEntry, DiagCacheSyncStatus, DiagInformerSyncStatus, DiagSyncPhase, DiagSampleWindow } from '../../api/client'
|
|
8
8
|
import { getK8sUIPerfSnapshot, type K8sUIPerfSnapshot } from '@skyhook-io/k8s-ui'
|
|
9
9
|
|
|
10
|
+
export type DiagnosticsFocus = 'timeline'
|
|
11
|
+
|
|
10
12
|
interface DiagnosticsOverlayProps {
|
|
11
13
|
onClose: () => void
|
|
12
14
|
isOpen?: boolean
|
|
15
|
+
/** Section to scroll to on open, for callers linking to one specific problem. */
|
|
16
|
+
focus?: DiagnosticsFocus
|
|
13
17
|
}
|
|
14
18
|
|
|
15
|
-
export function DiagnosticsOverlay({ onClose, isOpen = true }: DiagnosticsOverlayProps) {
|
|
19
|
+
export function DiagnosticsOverlay({ onClose, isOpen = true, focus }: DiagnosticsOverlayProps) {
|
|
16
20
|
const { data, isLoading, error } = useDiagnostics(true)
|
|
17
21
|
const [copied, setCopied] = useState<'json' | 'formatted' | null>(null)
|
|
18
22
|
const [reportOpened, setReportOpened] = useState(false)
|
|
23
|
+
const timelineRef = useRef<HTMLDivElement>(null)
|
|
24
|
+
const hasData = data != null
|
|
25
|
+
|
|
26
|
+
// Once, when the snapshot first renders: the overlay polls, and re-scrolling
|
|
27
|
+
// on every refresh would yank the reader back while they read other sections.
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (isOpen && hasData && focus === 'timeline') {
|
|
30
|
+
timelineRef.current?.scrollIntoView({ block: 'start' })
|
|
31
|
+
}
|
|
32
|
+
}, [isOpen, hasData, focus])
|
|
19
33
|
|
|
20
34
|
// Close on Escape (capture phase) — only while logically open: the overlay
|
|
21
35
|
// stays mounted through its exit.
|
|
@@ -119,6 +133,7 @@ export function DiagnosticsOverlay({ onClose, isOpen = true }: DiagnosticsOverla
|
|
|
119
133
|
<CacheSection data={data} />
|
|
120
134
|
<MetricsSection data={data} />
|
|
121
135
|
<EventPipelineSection data={data} />
|
|
136
|
+
<div ref={timelineRef} className="scroll-mt-4"><TimelineSection data={data} /></div>
|
|
122
137
|
<InformersSection data={data} />
|
|
123
138
|
<PrometheusSection data={data} />
|
|
124
139
|
<TrafficSection data={data} />
|
|
@@ -330,6 +345,20 @@ function EventPipelineSection({ data }: { data: DiagnosticsSnapshot }) {
|
|
|
330
345
|
)
|
|
331
346
|
}
|
|
332
347
|
|
|
348
|
+
function TimelineSection({ data }: { data: DiagnosticsSnapshot }) {
|
|
349
|
+
if (!data.timeline) return null
|
|
350
|
+
const t = data.timeline
|
|
351
|
+
return (
|
|
352
|
+
<Section title="Timeline" warn={t.degraded}>
|
|
353
|
+
<Row label="Storage" value={t.storageType} warn={t.degraded} />
|
|
354
|
+
{t.degraded && t.degradedReason && <Row label="Unavailable" value={t.degradedReason} warn />}
|
|
355
|
+
{/* An unreachable database has no count to report; 0 would read as empty. */}
|
|
356
|
+
{(!t.degraded || t.storageType === 'memory') && <Row label="Events" value={t.totalEvents.toLocaleString()} />}
|
|
357
|
+
<Row label="Dropped" value={t.totalDrops.toLocaleString()} warn={t.totalDrops > 0} />
|
|
358
|
+
</Section>
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
|
|
333
362
|
function InformersSection({ data }: { data: DiagnosticsSnapshot }) {
|
|
334
363
|
if (!data.informers) return null
|
|
335
364
|
const inf = data.informers
|
|
@@ -702,6 +731,9 @@ export function formatForGitHub(data: DiagnosticsSnapshot, frontendPerf?: K8sUIP
|
|
|
702
731
|
const t = data.timeline
|
|
703
732
|
lines.push(`### Timeline`)
|
|
704
733
|
lines.push(`- Storage: \`${t.storageType}\` | Events: ${t.totalEvents.toLocaleString()} | Errors: ${t.storeErrors} | Drops: ${t.totalDrops}`)
|
|
734
|
+
if (t.degraded && t.degradedReason) {
|
|
735
|
+
lines.push(`- Unavailable: ${t.degradedReason}`)
|
|
736
|
+
}
|
|
705
737
|
lines.push(``)
|
|
706
738
|
}
|
|
707
739
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import { Markdown } from './Markdown'
|
|
6
|
+
|
|
7
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
8
|
+
|
|
9
|
+
const TEXT = 'First paragraph.\n\nRun `kubectl get pods` then open [the docs](https://example.com).\n\n```\nkubectl describe pod x\n```'
|
|
10
|
+
|
|
11
|
+
let container: HTMLDivElement
|
|
12
|
+
let root: Root
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
container = document.createElement('div')
|
|
16
|
+
document.body.appendChild(container)
|
|
17
|
+
root = createRoot(container)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
act(() => root.unmount())
|
|
22
|
+
container.remove()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
// Callers pass fresh inline callbacks on every render, as the investigation
|
|
26
|
+
// views do, so the rendered elements must survive a re-render with new ones.
|
|
27
|
+
function render(label: string) {
|
|
28
|
+
act(() => {
|
|
29
|
+
root.render(
|
|
30
|
+
<Markdown
|
|
31
|
+
codeActions={(code) => <button type="button">{`${label}:${code}`}</button>}
|
|
32
|
+
linkRenderer={(href, children) => (href?.includes('example') ? <a data-custom={label}>{children}</a> : null)}
|
|
33
|
+
>
|
|
34
|
+
{TEXT}
|
|
35
|
+
</Markdown>,
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('Markdown', () => {
|
|
41
|
+
it('keeps its rendered elements across parent re-renders', () => {
|
|
42
|
+
render('a')
|
|
43
|
+
const paragraph = container.querySelector('p')
|
|
44
|
+
const pre = container.querySelector('pre')
|
|
45
|
+
expect(paragraph).not.toBeNull()
|
|
46
|
+
expect(pre).not.toBeNull()
|
|
47
|
+
|
|
48
|
+
render('b')
|
|
49
|
+
|
|
50
|
+
expect(container.querySelector('p')).toBe(paragraph)
|
|
51
|
+
expect(container.querySelector('pre')).toBe(pre)
|
|
52
|
+
expect(paragraph?.isConnected).toBe(true)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('applies the latest link and code hooks', () => {
|
|
56
|
+
render('a')
|
|
57
|
+
render('b')
|
|
58
|
+
|
|
59
|
+
expect(container.querySelector('a')?.getAttribute('data-custom')).toBe('b')
|
|
60
|
+
const labels = Array.from(container.querySelectorAll('button')).map((b) => b.textContent?.trim())
|
|
61
|
+
expect(labels).toContain('b:kubectl get pods')
|
|
62
|
+
expect(labels).toContain('b:kubectl describe pod x')
|
|
63
|
+
expect(labels.some((l) => l?.startsWith('a:'))).toBe(false)
|
|
64
|
+
})
|
|
65
|
+
})
|