@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
package/src/api/radarFeatures.ts
CHANGED
|
@@ -28,6 +28,7 @@ export interface RadarFeatureSpec {
|
|
|
28
28
|
|
|
29
29
|
export const RADAR_FEATURES = {
|
|
30
30
|
resourceIssues: { label: 'Operational issues', minimumVersion: 'v1.8.0', flag: 'resourceIssues' },
|
|
31
|
+
resourceIssueCoverage: { label: 'Resource-specific issue views', flag: 'resourceIssueCoverage', flagShippedWithEndpoint: true },
|
|
31
32
|
podEnvironment: { label: 'Values from ConfigMaps and Secrets', minimumVersion: 'v1.9.0', flag: 'podEnvironment' },
|
|
32
33
|
policyResource: { label: 'Policy results', minimumVersion: 'v1.10.0', flag: 'policyResource' },
|
|
33
34
|
capacity: { label: 'Capacity views', minimumVersion: 'v1.9.0' },
|
|
@@ -35,6 +36,11 @@ export const RADAR_FEATURES = {
|
|
|
35
36
|
drainPlan: { label: 'Drain plans', minimumVersion: 'v1.14.0' },
|
|
36
37
|
applications: { label: 'Applications', minimumVersion: 'v1.7.7' },
|
|
37
38
|
workloadHistory: { label: 'Workload history', flag: 'workloadHistory', flagShippedWithEndpoint: true },
|
|
39
|
+
cnpgWorkspace: { label: 'CloudNativePG views', flag: 'cnpgWorkspace', flagShippedWithEndpoint: true },
|
|
40
|
+
cnpgProtectionSetup: { label: 'CloudNativePG backup setup', flag: 'cnpgProtectionSetup', flagShippedWithEndpoint: true },
|
|
41
|
+
gitopsWriteEvidence: { label: 'GitOps revert warnings', flag: 'gitopsWriteEvidence', flagShippedWithEndpoint: true },
|
|
42
|
+
serviceEndpointSlices: { label: 'Service EndpointSlice inventories', flag: 'serviceEndpointSlices', flagShippedWithEndpoint: true },
|
|
43
|
+
trafficRecords: { label: 'Flow records for a graph selection', flag: 'trafficRecords', flagShippedWithEndpoint: true },
|
|
38
44
|
} as const satisfies Record<string, RadarFeatureSpec>
|
|
39
45
|
|
|
40
46
|
export type RadarFeature = keyof typeof RADAR_FEATURES
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
6
|
+
import { useReviewedActionMutation } from './reviewedActionMutation'
|
|
7
|
+
import { ApiError } from './client'
|
|
8
|
+
import { actionErrorCode, actionCompleted } from './actions'
|
|
9
|
+
import { RadarFeatureUnsupportedError } from './radarFeatures'
|
|
10
|
+
|
|
11
|
+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
|
12
|
+
|
|
13
|
+
describe('reviewed action lifetime', () => {
|
|
14
|
+
it.each([
|
|
15
|
+
new TypeError('Connection lost'),
|
|
16
|
+
new ApiError('One write completed', 409, { code: 'partial', completed: ['Pod removed'] }),
|
|
17
|
+
new ApiError('upstream connect error or disconnect/reset before headers', 503),
|
|
18
|
+
])('retains an ambiguous result through remount and prevents another request: %s', async (failure) => {
|
|
19
|
+
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
|
20
|
+
const host = document.body.appendChild(document.createElement('div'))
|
|
21
|
+
const root = createRoot(host)
|
|
22
|
+
const request = vi.fn(async () => { throw failure })
|
|
23
|
+
let current: ReturnType<typeof useReviewedActionMutation<null, { request: { reviewedContext: string } }>>
|
|
24
|
+
function Dialog({ target }: { target: string }) {
|
|
25
|
+
current = useReviewedActionMutation<null, { request: { reviewedContext: string } }>(target, 'ctx', { mutationFn: request })
|
|
26
|
+
return <div>{current.outcomeError?.message}</div>
|
|
27
|
+
}
|
|
28
|
+
const mount = (target?: string) => act(() => root.render(<QueryClientProvider client={client}>{target && <Dialog target={target} />}</QueryClientProvider>))
|
|
29
|
+
const attempt = () => act(async () => {
|
|
30
|
+
await current.mutateAsync({ request: { reviewedContext: 'ctx' } }).catch(() => {})
|
|
31
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
32
|
+
})
|
|
33
|
+
try {
|
|
34
|
+
mount('/api/cnpg/clusters/jobs/orders')
|
|
35
|
+
await attempt()
|
|
36
|
+
mount()
|
|
37
|
+
client.removeQueries()
|
|
38
|
+
client.clear()
|
|
39
|
+
mount('/api/cnpg/clusters/jobs/orders')
|
|
40
|
+
expect(current!.outcomeError?.message).toContain('reload this page')
|
|
41
|
+
expect(actionErrorCode(current!.outcomeError)).toBe(actionErrorCode(failure))
|
|
42
|
+
expect(actionCompleted(current!.outcomeError)).toEqual(actionCompleted(failure))
|
|
43
|
+
await attempt()
|
|
44
|
+
expect(request).toHaveBeenCalledTimes(1)
|
|
45
|
+
mount('/other-cluster/api/cnpg/clusters/jobs/orders')
|
|
46
|
+
expect(current!.outcomeError).toBeNull()
|
|
47
|
+
mount('/api/cnpg/scheduledbackups/jobs/orders')
|
|
48
|
+
expect(current!.outcomeError).toBeNull()
|
|
49
|
+
} finally {
|
|
50
|
+
act(() => root.unmount())
|
|
51
|
+
client.clear()
|
|
52
|
+
host.remove()
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
it.each([
|
|
56
|
+
{ nextTarget: '/api/target', nextContext: 'other-context' },
|
|
57
|
+
{ nextTarget: '/other-cluster/api/target', nextContext: 'ctx' },
|
|
58
|
+
])('keeps a late result on the original cluster binding: %s', async ({ nextTarget, nextContext }) => {
|
|
59
|
+
const client = new QueryClient()
|
|
60
|
+
const host = document.body.appendChild(document.createElement('div'))
|
|
61
|
+
const root = createRoot(host)
|
|
62
|
+
let fail!: (error: Error) => void
|
|
63
|
+
let current: ReturnType<typeof useReviewedActionMutation<null, { request: { reviewedContext: string } }>>
|
|
64
|
+
function Dialog({ target, context }: { target: string; context: string }) {
|
|
65
|
+
current = useReviewedActionMutation<null, { request: { reviewedContext: string } }>(target, context, {
|
|
66
|
+
mutationFn: () => new Promise<null>((_, reject) => { fail = reject }),
|
|
67
|
+
})
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
const mount = (target: string, context: string) => act(() => root.render(<QueryClientProvider client={client}><Dialog target={target} context={context} /></QueryClientProvider>))
|
|
71
|
+
try {
|
|
72
|
+
mount('/api/target', 'ctx')
|
|
73
|
+
let attempted!: Promise<unknown>
|
|
74
|
+
await act(async () => {
|
|
75
|
+
attempted = current.mutateAsync({ request: { reviewedContext: 'ctx' } }).catch(() => {})
|
|
76
|
+
await Promise.resolve()
|
|
77
|
+
})
|
|
78
|
+
mount(nextTarget, nextContext)
|
|
79
|
+
await act(async () => {
|
|
80
|
+
fail(new TypeError('Connection lost'))
|
|
81
|
+
await attempted
|
|
82
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
83
|
+
})
|
|
84
|
+
expect(current!.outcomeError).toBeNull()
|
|
85
|
+
mount('/api/target', 'ctx')
|
|
86
|
+
expect(current!.outcomeError?.message).toContain('Connection lost')
|
|
87
|
+
} finally {
|
|
88
|
+
act(() => root.unmount())
|
|
89
|
+
client.clear()
|
|
90
|
+
host.remove()
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
it.each([
|
|
94
|
+
new ApiError('Facts changed', 409, { code: 'changed' }),
|
|
95
|
+
new ApiError('The operator webhook did not answer', 503, { code: 'operator_webhook_unavailable' }),
|
|
96
|
+
new RadarFeatureUnsupportedError('cnpgWorkspace', {}),
|
|
97
|
+
])('keeps a known refusal retryable after remount: %s', async (failure) => {
|
|
98
|
+
const client = new QueryClient()
|
|
99
|
+
const host = document.body.appendChild(document.createElement('div'))
|
|
100
|
+
const root = createRoot(host)
|
|
101
|
+
let current: ReturnType<typeof useReviewedActionMutation<null, { request: { reviewedContext: string } }>>
|
|
102
|
+
const request = vi.fn(async () => { throw failure })
|
|
103
|
+
function Dialog() {
|
|
104
|
+
current = useReviewedActionMutation<null, { request: { reviewedContext: string } }>('/api/target', 'ctx', { mutationFn: request })
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
const render = (open: boolean) => act(() => root.render(<QueryClientProvider client={client}>{open && <Dialog />}</QueryClientProvider>))
|
|
108
|
+
try {
|
|
109
|
+
render(true)
|
|
110
|
+
await act(async () => { await current.mutateAsync({ request: { reviewedContext: 'ctx' } }).catch(() => {}) })
|
|
111
|
+
render(false)
|
|
112
|
+
render(true)
|
|
113
|
+
expect(current!.outcomeError).toBeNull()
|
|
114
|
+
await act(async () => { await current.mutateAsync({ request: { reviewedContext: 'ctx' } }).catch(() => {}) })
|
|
115
|
+
expect(request).toHaveBeenCalledTimes(2)
|
|
116
|
+
} finally {
|
|
117
|
+
act(() => root.unmount())
|
|
118
|
+
client.clear()
|
|
119
|
+
host.remove()
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('retains an in-flight request through remount and cache clearing without sending twice', async () => {
|
|
124
|
+
const client = new QueryClient()
|
|
125
|
+
const host = document.body.appendChild(document.createElement('div'))
|
|
126
|
+
const root = createRoot(host)
|
|
127
|
+
let complete!: (data: null) => void
|
|
128
|
+
const request = vi.fn(() => new Promise<null>(resolve => { complete = resolve }))
|
|
129
|
+
let current: ReturnType<typeof useReviewedActionMutation<null, { request: { reviewedContext: string } }>>
|
|
130
|
+
function Dialog() {
|
|
131
|
+
current = useReviewedActionMutation<null, { request: { reviewedContext: string } }>('/api/target', 'ctx', { mutationFn: request })
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
const render = (open: boolean) => act(() => root.render(<QueryClientProvider client={client}>{open && <Dialog />}</QueryClientProvider>))
|
|
135
|
+
try {
|
|
136
|
+
render(true)
|
|
137
|
+
let attempted!: Promise<null>
|
|
138
|
+
await act(async () => {
|
|
139
|
+
attempted = current.mutateAsync({ request: { reviewedContext: 'ctx' } })
|
|
140
|
+
await Promise.resolve()
|
|
141
|
+
})
|
|
142
|
+
render(false)
|
|
143
|
+
client.removeQueries()
|
|
144
|
+
client.clear()
|
|
145
|
+
render(true)
|
|
146
|
+
expect(current!.isPending).toBe(true)
|
|
147
|
+
await act(async () => {
|
|
148
|
+
await expect(current.mutateAsync({ request: { reviewedContext: 'ctx' } })).rejects.toThrow('still in progress')
|
|
149
|
+
})
|
|
150
|
+
expect(request).toHaveBeenCalledTimes(1)
|
|
151
|
+
await act(async () => {
|
|
152
|
+
complete(null)
|
|
153
|
+
await attempted
|
|
154
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
155
|
+
})
|
|
156
|
+
expect(current!.isPending).toBe(false)
|
|
157
|
+
expect(current!.outcomeError).toBeNull()
|
|
158
|
+
} finally {
|
|
159
|
+
act(() => root.unmount())
|
|
160
|
+
client.clear()
|
|
161
|
+
host.remove()
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { useCallback, useSyncExternalStore } from 'react'
|
|
2
|
+
import { useMutation, useQueryClient, type MutationFunction, type QueryClient, type UseMutationOptions } from '@tanstack/react-query'
|
|
3
|
+
import { actionOutcomeLocked } from './actions'
|
|
4
|
+
import { ApiError } from './client'
|
|
5
|
+
|
|
6
|
+
interface Outcome {
|
|
7
|
+
pending: boolean
|
|
8
|
+
error: Error | null
|
|
9
|
+
}
|
|
10
|
+
const EMPTY: Outcome = { pending: false, error: null }
|
|
11
|
+
const stores = new WeakMap<QueryClient, { entries: Map<string, Outcome>; listeners: Set<() => void> }>()
|
|
12
|
+
|
|
13
|
+
/** Result memory belongs to the host, so dismissing a dialog cannot unlock a second write. */
|
|
14
|
+
export function useReviewedActionMutation<Data, Variables extends { request: { reviewedContext: string } }>(
|
|
15
|
+
target: string,
|
|
16
|
+
reviewedContext: string | undefined,
|
|
17
|
+
options: Omit<UseMutationOptions<Data, Error, Variables>, 'mutationFn' | 'mutationKey'> & { mutationFn: MutationFunction<Data, Variables> },
|
|
18
|
+
) {
|
|
19
|
+
const client = useQueryClient()
|
|
20
|
+
let store = stores.get(client)
|
|
21
|
+
if (!store) {
|
|
22
|
+
store = { entries: new Map(), listeners: new Set() }
|
|
23
|
+
stores.set(client, store)
|
|
24
|
+
}
|
|
25
|
+
const outcomes = store
|
|
26
|
+
const keyFor = (context: string | undefined) => JSON.stringify([target, context])
|
|
27
|
+
const key = keyFor(reviewedContext)
|
|
28
|
+
const subscribe = useCallback((listener: () => void) => {
|
|
29
|
+
outcomes.listeners.add(listener)
|
|
30
|
+
return () => { outcomes.listeners.delete(listener) }
|
|
31
|
+
}, [outcomes])
|
|
32
|
+
const snapshot = useCallback(() => outcomes.entries.get(key) ?? EMPTY, [outcomes, key])
|
|
33
|
+
// Connection recovery purges query/mutation caches. It must not forget a
|
|
34
|
+
// request still in flight or a result that may already have changed the target.
|
|
35
|
+
const retained = useSyncExternalStore(subscribe, snapshot, () => EMPTY)
|
|
36
|
+
const publish = (key: string, value: Outcome) => {
|
|
37
|
+
if (value.pending || value.error) outcomes.entries.set(key, value)
|
|
38
|
+
else outcomes.entries.delete(key)
|
|
39
|
+
for (const listener of outcomes.listeners) listener()
|
|
40
|
+
}
|
|
41
|
+
const mutation = useMutation<Data, Error, Variables>({
|
|
42
|
+
...options,
|
|
43
|
+
mutationKey: ['reviewed-action', target, reviewedContext],
|
|
44
|
+
mutationFn: async (variables, context) => {
|
|
45
|
+
const boundKey = keyFor(variables.request.reviewedContext)
|
|
46
|
+
const previous = outcomes.entries.get(boundKey)
|
|
47
|
+
if (previous?.error) throw previous.error
|
|
48
|
+
if (previous?.pending) throw new ApiError('A write to this target is still in progress. Wait for its result.', 409, { code: 'blocked' })
|
|
49
|
+
publish(boundKey, { pending: true, error: null })
|
|
50
|
+
let retainedError: Error | null = null
|
|
51
|
+
try {
|
|
52
|
+
return await options.mutationFn(variables, context)
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (actionOutcomeLocked(error)) {
|
|
55
|
+
const failure = error as Error
|
|
56
|
+
const message = `${failure.message} Inspect the current state, then reload this page and review the action again.`
|
|
57
|
+
retainedError = failure instanceof ApiError ? new ApiError(message, failure.status, failure.data) : new Error(message)
|
|
58
|
+
}
|
|
59
|
+
throw error
|
|
60
|
+
} finally {
|
|
61
|
+
publish(boundKey, { pending: false, error: retainedError })
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
return { ...mutation, isPending: mutation.isPending || retained.pending, outcomeError: retained.error }
|
|
66
|
+
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
applyClientFilters,
|
|
20
20
|
localOverviewFromEvents,
|
|
21
21
|
needsWindowFetch,
|
|
22
|
+
frozenWindowRefetchInterval,
|
|
22
23
|
type RetainedRing,
|
|
23
24
|
} from './timelineSource'
|
|
24
25
|
import type { TimelineEvent } from '../types'
|
|
@@ -650,3 +651,11 @@ describe('retained ring fetch (the OSS-identical accumulate model)', () => {
|
|
|
650
651
|
// cursor is intact on the cached ring — nothing was committed
|
|
651
652
|
})
|
|
652
653
|
})
|
|
654
|
+
|
|
655
|
+
describe('frozenWindowRefetchInterval', () => {
|
|
656
|
+
it('polls a failed window until it loads, and leaves a loaded one alone', () => {
|
|
657
|
+
expect(frozenWindowRefetchInterval('error')).toBe(10_000)
|
|
658
|
+
expect(frozenWindowRefetchInterval('success')).toBe(false)
|
|
659
|
+
expect(frozenWindowRefetchInterval('pending')).toBe(false)
|
|
660
|
+
})
|
|
661
|
+
})
|
|
@@ -544,6 +544,13 @@ export function needsWindowFetch(
|
|
|
544
544
|
// refresh.
|
|
545
545
|
const FROZEN_WINDOW_STALE_MS = 10 * 60 * 1000
|
|
546
546
|
|
|
547
|
+
// A frozen window that failed to load does poll, at the ring's cadence, until
|
|
548
|
+
// it loads: the error page tells the user it recovers on its own once the
|
|
549
|
+
// timeline's database is back, and without a poll only "Try again" would.
|
|
550
|
+
export function frozenWindowRefetchInterval(status: 'pending' | 'error' | 'success'): number | false {
|
|
551
|
+
return status === 'error' ? RETAINED_POLL_MS : false
|
|
552
|
+
}
|
|
553
|
+
|
|
547
554
|
// Ring keys whose next fetch must be a full reload (manual refresh). A side
|
|
548
555
|
// flag, not a side cursor: consuming it only switches the PATH of the next
|
|
549
556
|
// fetch — all sync state that must stay consistent with the data lives inside
|
|
@@ -750,6 +757,7 @@ function createRingEventsHook(opts: {
|
|
|
750
757
|
},
|
|
751
758
|
enabled: windowNeeded,
|
|
752
759
|
staleTime: FROZEN_WINDOW_STALE_MS,
|
|
760
|
+
refetchInterval: (q) => frozenWindowRefetchInterval(q.state.status),
|
|
753
761
|
})
|
|
754
762
|
|
|
755
763
|
const kindsKey = query.kinds?.join(',')
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { QueryClient } from '@tanstack/react-query'
|
|
3
|
+
import { detectTrafficSourcesAgain, fetchTrafficSources } from './traffic'
|
|
4
|
+
|
|
5
|
+
const found = { detected: [{ name: 'hubble', status: 'available' }], notDetected: [] }
|
|
6
|
+
const none = { detected: [], notDetected: [{ name: 'hubble', status: 'not_found' }] }
|
|
7
|
+
|
|
8
|
+
function serve(responses: Record<string, unknown>, delays: Record<string, number> = {}) {
|
|
9
|
+
const calls: string[] = []
|
|
10
|
+
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
|
|
11
|
+
const path = url.slice(url.indexOf('/traffic/'))
|
|
12
|
+
calls.push(path)
|
|
13
|
+
if (delays[path]) {
|
|
14
|
+
await new Promise((resolve, reject) => {
|
|
15
|
+
const timer = setTimeout(resolve, delays[path])
|
|
16
|
+
init?.signal?.addEventListener('abort', () => { clearTimeout(timer); reject(new DOMException('aborted', 'AbortError')) })
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
return new Response(JSON.stringify(responses[path]), { status: 200 })
|
|
20
|
+
}))
|
|
21
|
+
return calls
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
afterEach(() => vi.unstubAllGlobals())
|
|
25
|
+
|
|
26
|
+
describe('fetchTrafficSources', () => {
|
|
27
|
+
it('reuses a recent detection that found a source', async () => {
|
|
28
|
+
const calls = serve({ '/traffic/sources?recent=1': found })
|
|
29
|
+
const sources = await fetchTrafficSources(new QueryClient(), false)
|
|
30
|
+
expect(sources).toEqual(found)
|
|
31
|
+
expect(calls).toEqual(['/traffic/sources?recent=1'])
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('detects afresh before showing a recent detection that found none', async () => {
|
|
35
|
+
const calls = serve({ '/traffic/sources?recent=1': none, '/traffic/sources': found })
|
|
36
|
+
const sources = await fetchTrafficSources(new QueryClient(), false)
|
|
37
|
+
expect(sources).toEqual(found)
|
|
38
|
+
expect(calls).toEqual(['/traffic/sources?recent=1', '/traffic/sources'])
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('detectTrafficSourcesAgain', () => {
|
|
43
|
+
it('detects afresh even while a recent detection is being fetched', async () => {
|
|
44
|
+
const calls = serve({ '/traffic/sources?recent=1': found, '/traffic/sources': none }, { '/traffic/sources?recent=1': 50 })
|
|
45
|
+
const client = new QueryClient()
|
|
46
|
+
void client.fetchQuery({ queryKey: ['traffic-sources'], queryFn: ({ signal }) => fetchTrafficSources(client, false, signal) }).catch(() => {})
|
|
47
|
+
const sources = await detectTrafficSourcesAgain(client)
|
|
48
|
+
expect(sources).toEqual(none)
|
|
49
|
+
expect(calls).toContain('/traffic/sources')
|
|
50
|
+
// The abandoned recent detection must not land afterwards.
|
|
51
|
+
await new Promise(resolve => setTimeout(resolve, 80))
|
|
52
|
+
expect(client.getQueryData(['traffic-sources'])).toEqual(none)
|
|
53
|
+
expect(client.getQueryData(['traffic-sources', 'recent'])).toEqual(none)
|
|
54
|
+
})
|
|
55
|
+
})
|
package/src/api/traffic.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
1
|
+
import { useCallback, useMemo } from 'react'
|
|
2
|
+
import { useQuery, useMutation, useQueryClient, type QueryClient } from '@tanstack/react-query'
|
|
3
|
+
import type { TrafficSourcesResponse, TrafficFlowsResponse, TrafficRecordsResponse } from '../types'
|
|
4
|
+
import { fetchJSON as fetchApiJSON, useRadarFeature } from './client'
|
|
5
|
+
import { shouldRetryRadarQuery } from './radarFeatures'
|
|
3
6
|
import { apiUrl, getAuthHeaders, getCredentialsMode } from './config'
|
|
4
7
|
import { readErrorBody } from './httpErrors'
|
|
5
8
|
|
|
6
|
-
async function fetchJSON<T>(path: string): Promise<T> {
|
|
9
|
+
async function fetchJSON<T>(path: string, signal?: AbortSignal): Promise<T> {
|
|
7
10
|
const response = await fetch(apiUrl(path), {
|
|
8
11
|
credentials: getCredentialsMode(),
|
|
9
12
|
headers: getAuthHeaders(),
|
|
13
|
+
signal,
|
|
10
14
|
})
|
|
11
15
|
if (!response.ok) {
|
|
12
16
|
const error = await readErrorBody(response)
|
|
@@ -26,34 +30,102 @@ export interface TrafficConnectionInfo {
|
|
|
26
30
|
error?: string
|
|
27
31
|
}
|
|
28
32
|
|
|
29
|
-
// Get available traffic sources and recommendations
|
|
33
|
+
// Get available traffic sources and recommendations. A detection up to a
|
|
34
|
+
// couple of minutes old is reused when it found a source: a full one probes
|
|
35
|
+
// every source in turn and takes seconds, and the page that linked here has
|
|
36
|
+
// usually just made one. One that found none is redone, since it is about to
|
|
37
|
+
// be shown as setup advice. detectAgain always detects afresh.
|
|
38
|
+
export async function fetchTrafficSources(queryClient: QueryClient, fresh: boolean, signal?: AbortSignal): Promise<TrafficSourcesResponse> {
|
|
39
|
+
let sources = await fetchJSON<TrafficSourcesResponse>(fresh ? '/traffic/sources' : '/traffic/sources?recent=1', signal)
|
|
40
|
+
if (!fresh && !sources.detected.some(s => s.status === 'available')) {
|
|
41
|
+
sources = await fetchJSON<TrafficSourcesResponse>('/traffic/sources', signal)
|
|
42
|
+
}
|
|
43
|
+
// A detection is also the newest answer to "is traffic available".
|
|
44
|
+
queryClient.setQueryData(['traffic-sources', 'recent'], sources)
|
|
45
|
+
return sources
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function detectTrafficSourcesAgain(queryClient: QueryClient): Promise<TrafficSourcesResponse> {
|
|
49
|
+
// A pending fetch of the same key would otherwise be returned in its place.
|
|
50
|
+
await queryClient.cancelQueries({ queryKey: ['traffic-sources'], exact: true })
|
|
51
|
+
return queryClient.fetchQuery({
|
|
52
|
+
queryKey: ['traffic-sources'],
|
|
53
|
+
queryFn: ({ signal }) => fetchTrafficSources(queryClient, true, signal),
|
|
54
|
+
staleTime: 0,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
30
58
|
export function useTrafficSources() {
|
|
31
|
-
|
|
59
|
+
const queryClient = useQueryClient()
|
|
60
|
+
const query = useQuery<TrafficSourcesResponse>({
|
|
32
61
|
queryKey: ['traffic-sources'],
|
|
33
|
-
queryFn: () =>
|
|
62
|
+
queryFn: ({ signal }) => fetchTrafficSources(queryClient, false, signal),
|
|
34
63
|
staleTime: 30000, // 30 seconds
|
|
35
64
|
retry: 1,
|
|
36
65
|
})
|
|
66
|
+
const detectAgain = useCallback(() => detectTrafficSourcesAgain(queryClient), [queryClient])
|
|
67
|
+
return { ...query, detectAgain }
|
|
37
68
|
}
|
|
38
69
|
|
|
39
|
-
//
|
|
40
|
-
|
|
70
|
+
// Whether this cluster has a traffic source: true only once one is detected
|
|
71
|
+
// as available, so a page offering traffic never offers it on a guess. Accepts
|
|
72
|
+
// a detection up to a couple of minutes old (?recent=1), so browsing workloads
|
|
73
|
+
// does not probe every source on each page.
|
|
74
|
+
export function useTrafficAvailable(enabled = true): boolean {
|
|
75
|
+
// A workload focus needs the server to name each pod's workload, which
|
|
76
|
+
// shipped with flow records. An older Radar leaves Hubble pods unnamed, so
|
|
77
|
+
// the focus it opened would match nothing.
|
|
78
|
+
const resolvesWorkloads = useRadarFeature('trafficRecords').support === 'supported'
|
|
79
|
+
const { data } = useQuery<TrafficSourcesResponse>({
|
|
80
|
+
queryKey: ['traffic-sources', 'recent'],
|
|
81
|
+
queryFn: () => fetchJSON('/traffic/sources?recent=1'),
|
|
82
|
+
staleTime: 2 * 60_000,
|
|
83
|
+
retry: false,
|
|
84
|
+
enabled: enabled && resolvesWorkloads,
|
|
85
|
+
})
|
|
86
|
+
return resolvesWorkloads && !!data?.detected?.some(s => s.status === 'available')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// What the flows and records queries share: the namespaces in view, the
|
|
90
|
+
// window, and the traffic the view hides. The hidden traffic is sent so the
|
|
91
|
+
// source can drop it before it counts against its limits; the view still
|
|
92
|
+
// filters it too.
|
|
93
|
+
export interface TrafficScope {
|
|
41
94
|
namespaces?: string[]
|
|
42
95
|
since?: string // Duration like "5m", "1h"
|
|
43
|
-
|
|
96
|
+
excludeNamespaces?: readonly string[]
|
|
97
|
+
excludeHost?: boolean
|
|
44
98
|
}
|
|
45
99
|
|
|
46
|
-
|
|
47
|
-
const { namespaces = [], since, enabled = true } = options
|
|
48
|
-
|
|
100
|
+
function trafficScopeParams({ namespaces = [], since, excludeNamespaces, excludeHost }: TrafficScope): URLSearchParams {
|
|
49
101
|
const params = new URLSearchParams()
|
|
50
|
-
//
|
|
102
|
+
// With several namespaces in view the server reads them from the saved pick.
|
|
51
103
|
if (namespaces.length === 1) params.set('namespace', namespaces[0])
|
|
52
104
|
if (since) params.set('since', since)
|
|
105
|
+
if (excludeNamespaces?.length) params.set('excludeNamespaces', excludeNamespaces.join(','))
|
|
106
|
+
if (excludeHost) params.set('excludeHost', 'true')
|
|
107
|
+
return params
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function trafficScopeKey({ namespaces = [], since, excludeNamespaces, excludeHost }: TrafficScope) {
|
|
111
|
+
return [namespaces, since, excludeNamespaces ?? [], !!excludeHost]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Get traffic flows
|
|
115
|
+
export interface UseTrafficFlowsOptions extends TrafficScope {
|
|
116
|
+
/** One graph node per pod instead of per workload. */
|
|
117
|
+
byPod?: boolean
|
|
118
|
+
enabled?: boolean
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function useTrafficFlows(options: UseTrafficFlowsOptions = {}) {
|
|
122
|
+
const { enabled = true, byPod = false } = options
|
|
123
|
+
const params = trafficScopeParams(options)
|
|
124
|
+
if (byPod) params.set('groupBy', 'pod')
|
|
53
125
|
const queryString = params.toString()
|
|
54
126
|
|
|
55
127
|
return useQuery<TrafficFlowsResponse>({
|
|
56
|
-
queryKey: ['traffic-flows',
|
|
128
|
+
queryKey: ['traffic-flows', ...trafficScopeKey(options), byPod],
|
|
57
129
|
queryFn: () => fetchJSON(`/traffic/flows${queryString ? `?${queryString}` : ''}`),
|
|
58
130
|
staleTime: 5000, // 5 seconds
|
|
59
131
|
enabled,
|
|
@@ -61,6 +133,93 @@ export function useTrafficFlows(options: UseTrafficFlowsOptions = {}) {
|
|
|
61
133
|
})
|
|
62
134
|
}
|
|
63
135
|
|
|
136
|
+
/** An endpoint as the server's aggregation keys it. */
|
|
137
|
+
export interface TrafficEndpointRef {
|
|
138
|
+
namespace?: string
|
|
139
|
+
name: string
|
|
140
|
+
kind?: string
|
|
141
|
+
workloadKind?: string
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** One edge of the server's aggregation, keyed as the server keys it. */
|
|
145
|
+
export interface TrafficEndpointPair {
|
|
146
|
+
source: TrafficEndpointRef
|
|
147
|
+
destination: TrafficEndpointRef
|
|
148
|
+
port: number
|
|
149
|
+
directionUnknown?: boolean
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The selection rides in the query string, URL-encoded. Proxies in front of
|
|
153
|
+
* Radar commonly refuse request lines over 8 KiB (ingress-nginx's default),
|
|
154
|
+
* so a larger selection is not sent at all. */
|
|
155
|
+
const MAX_TRAFFIC_MATCH_CHARS = 6_000
|
|
156
|
+
|
|
157
|
+
/** A selection as the server matches it: its edges, or a node as the one
|
|
158
|
+
* endpoint it is, which every one of its edges has at an end. */
|
|
159
|
+
export type TrafficMatch = { pairs: TrafficEndpointPair[] } | { endpoints: TrafficEndpointRef[] }
|
|
160
|
+
|
|
161
|
+
// Every pair encodes to more than this many characters, so a selection with
|
|
162
|
+
// more pairs than the request can hold is rejected without serializing it.
|
|
163
|
+
const MIN_MATCH_PAIR_CHARS = 40
|
|
164
|
+
|
|
165
|
+
/** The whole query string's limit: the selection plus the scope beside it,
|
|
166
|
+
* under the 8 KiB request line proxies commonly allow. */
|
|
167
|
+
const MAX_TRAFFIC_QUERY_CHARS = 7_000
|
|
168
|
+
|
|
169
|
+
function encodedMatch(match: TrafficMatch): string | null {
|
|
170
|
+
if ('pairs' in match && match.pairs.length * MIN_MATCH_PAIR_CHARS > MAX_TRAFFIC_MATCH_CHARS) return null
|
|
171
|
+
const json = JSON.stringify(match)
|
|
172
|
+
return encodeURIComponent(json).length > MAX_TRAFFIC_MATCH_CHARS ? null : json
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface UseTrafficRecordsOptions extends TrafficScope {
|
|
176
|
+
/** The selection, or null for no selection. */
|
|
177
|
+
match: TrafficMatch | null
|
|
178
|
+
/** Namespaces also excluded when the request has room for them; the list
|
|
179
|
+
* filters them out anyway. */
|
|
180
|
+
optionalExcludeNamespaces?: readonly string[]
|
|
181
|
+
enabled?: boolean
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// The newest records behind a graph selection. The flows response carries only
|
|
185
|
+
// a capped sample of records, so a selected edge is looked up on its own —
|
|
186
|
+
// otherwise a quiet edge on a busy cluster would show nothing.
|
|
187
|
+
export function useTrafficRecords(options: UseTrafficRecordsOptions) {
|
|
188
|
+
const { enabled = true } = options
|
|
189
|
+
const { guard, gatedKey, support } = useRadarFeature('trafficRecords')
|
|
190
|
+
// Serialized when the selection changes (each refresh), not on every
|
|
191
|
+
// render: a merged edge can carry tens of thousands of pairs.
|
|
192
|
+
const match = useMemo(() => (options.match ? encodedMatch(options.match) : null), [options.match])
|
|
193
|
+
const optional = options.optionalExcludeNamespaces ?? []
|
|
194
|
+
let scope: TrafficScope = optional.length > 0
|
|
195
|
+
? { ...options, excludeNamespaces: [...(options.excludeNamespaces ?? []), ...optional] }
|
|
196
|
+
: options
|
|
197
|
+
let params = trafficScopeParams(scope)
|
|
198
|
+
if (match) params.set('match', match)
|
|
199
|
+
if (optional.length > 0 && params.toString().length > MAX_TRAFFIC_QUERY_CHARS) {
|
|
200
|
+
scope = options
|
|
201
|
+
params = trafficScopeParams(scope)
|
|
202
|
+
if (match) params.set('match', match)
|
|
203
|
+
}
|
|
204
|
+
const tooLarge = options.match !== null && (match === null || params.toString().length > MAX_TRAFFIC_QUERY_CHARS)
|
|
205
|
+
// The view this answers, apart from the optional exclusions.
|
|
206
|
+
const view = JSON.stringify([...trafficScopeKey(options), match])
|
|
207
|
+
|
|
208
|
+
const query = useQuery<TrafficRecordsResponse>({
|
|
209
|
+
queryKey: ['traffic-flows', 'records', ...trafficScopeKey(scope), match, ...gatedKey],
|
|
210
|
+
queryFn: () => guard(() => fetchApiJSON<TrafficRecordsResponse>(`/traffic/flows/records?${params.toString()}`)),
|
|
211
|
+
staleTime: 5000,
|
|
212
|
+
enabled: enabled && match !== null && !tooLarge && support !== 'unsupported',
|
|
213
|
+
meta: { view },
|
|
214
|
+
// Kept while only the optional exclusions change (a namespace hidden in
|
|
215
|
+
// the sidebar, which the list filters out anyway). Not across another
|
|
216
|
+
// selection, window or scope: those records would show under this one.
|
|
217
|
+
placeholderData: (prev, prevQuery) => (prevQuery?.meta?.view === view ? prev : undefined),
|
|
218
|
+
retry: shouldRetryRadarQuery,
|
|
219
|
+
})
|
|
220
|
+
return { ...query, tooLarge, supported: support !== 'unsupported' }
|
|
221
|
+
}
|
|
222
|
+
|
|
64
223
|
// Get active traffic source
|
|
65
224
|
export function useActiveTrafficSource() {
|
|
66
225
|
return useQuery<{ active: string }>({
|
|
@@ -55,6 +55,7 @@ import { useTimelineSource } from "../../context/TimelineSource";
|
|
|
55
55
|
import { apiVersionToGroup, buildWorkloadPath, kindToPluralWithGroup } from "../../utils/navigation";
|
|
56
56
|
import { WorkloadView } from "../workload/WorkloadView";
|
|
57
57
|
import { ApplicationCostTab } from "../cost/ApplicationCostTab";
|
|
58
|
+
import { useTimelineRetentionNote } from "../../context/TimelineCustomization";
|
|
58
59
|
import { isOpenCostWorkloadKind } from "../cost/kinds";
|
|
59
60
|
|
|
60
61
|
type ApplicationRouteView = ApplicationView | "cost";
|
|
@@ -302,6 +303,7 @@ function AppDetailRoute({
|
|
|
302
303
|
: historyRangeOptions[0].value;
|
|
303
304
|
const historyTimelineEnabled =
|
|
304
305
|
!selectedWorkloadKey && selectedView === "history";
|
|
306
|
+
const retentionNote = useTimelineRetentionNote();
|
|
305
307
|
const historyTimelineQuery = timelineSource.useEvents({
|
|
306
308
|
namespaces: appHistoryNamespaces,
|
|
307
309
|
timeRange: historyRange,
|
|
@@ -665,6 +667,7 @@ function AppDetailRoute({
|
|
|
665
667
|
historyCoverageRecordCount={historyTimelineQuery.coverage?.length ?? 0}
|
|
666
668
|
historyRuntimeLimited={historyRuntimeLimited}
|
|
667
669
|
historyRingTruncated={historyTimelineQuery.truncated ?? false}
|
|
670
|
+
historyRetentionNote={retentionNote}
|
|
668
671
|
onHistoryRangeChange={setRetainedHistoryRange}
|
|
669
672
|
onOpenTimeline={openApplicationTimeline}
|
|
670
673
|
onOpenSource={openSource}
|
|
@@ -83,6 +83,9 @@ function BestPracticesView({ namespaces, onNavigateToResource }: AuditViewProps)
|
|
|
83
83
|
)
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
const annotationIgnored = Object.values(data.annotationIgnoredByNamespace ?? {})
|
|
87
|
+
.reduce((sum, byNS) => sum + Object.values(byNS).reduce((a, n) => a + n, 0), 0)
|
|
88
|
+
|
|
86
89
|
const onResourceClick = (ref: CheckResourceRef) =>
|
|
87
90
|
onNavigateToResource({ kind: ref.kind, namespace: ref.namespace, name: ref.name, group: ref.group })
|
|
88
91
|
|
|
@@ -108,6 +111,11 @@ function BestPracticesView({ namespaces, onNavigateToResource }: AuditViewProps)
|
|
|
108
111
|
{ignoredCount > 0 && (
|
|
109
112
|
<button onClick={() => setShowSettings(true)} className="text-xs text-theme-text-tertiary hover:text-theme-text-secondary transition-colors">{ignoredCount} {ignoredCount === 1 ? 'namespace' : 'namespaces'} hidden</button>
|
|
110
113
|
)}
|
|
114
|
+
{annotationIgnored > 0 && (
|
|
115
|
+
<Tooltip content={`Resources that opted out of a check with the ${data.ignoreChecksAnnotation} annotation`}>
|
|
116
|
+
<span className="text-xs text-theme-text-tertiary">{annotationIgnored} {annotationIgnored === 1 ? 'finding' : 'findings'} hidden by annotation</span>
|
|
117
|
+
</Tooltip>
|
|
118
|
+
)}
|
|
111
119
|
<Tooltip content="Checks settings">
|
|
112
120
|
<button
|
|
113
121
|
onClick={() => setShowSettings(true)}
|
|
@@ -129,6 +137,7 @@ function BestPracticesView({ namespaces, onNavigateToResource }: AuditViewProps)
|
|
|
129
137
|
onResourceClick={onResourceClick}
|
|
130
138
|
onHideCheck={canEdit ? hideCheck : undefined}
|
|
131
139
|
onHideCategory={canEdit ? hideCategory : undefined}
|
|
140
|
+
ignoreChecksAnnotation={data.ignoreChecksAnnotation}
|
|
132
141
|
/>
|
|
133
142
|
|
|
134
143
|
{showSettings && <AuditSettingsDialog namespaces={namespaces} onClose={() => setShowSettings(false)} />}
|