@skyhook-io/radar-app 1.17.0 → 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 +125 -87
- 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.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 +266 -39
- 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/radarFeatures.render.test.tsx +47 -3
- package/src/api/radarFeatures.ts +4 -1
- 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/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 +6 -55
- package/src/components/capacity/CapacityView.test.tsx +323 -0
- 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 +11 -129
- 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 +228 -122
- 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/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/gitops/ArgoResourceDiffLoader.tsx +4 -2
- package/src/components/gitops/GitOpsView.tsx +16 -23
- 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/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/HomeView.tsx +20 -9
- package/src/components/home/ProblemsPanel.test.tsx +81 -0
- package/src/components/home/TopologyPreview.tsx +2 -2
- 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/settings/LocalIntegrationStatus.test.tsx +52 -0
- package/src/components/settings/LocalIntegrationStatus.tsx +3 -4
- package/src/components/settings/SettingsDialog.tsx +28 -15
- package/src/components/timeline/TimelineList.tsx +22 -24
- package/src/components/timeline/TimelineLoadError.test.tsx +62 -0
- package/src/components/timeline/TimelineLoadError.tsx +96 -0
- package/src/components/timeline/TimelineView.tsx +18 -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/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 +10 -1
- package/src/components/workload/WorkloadView.tsx +27 -239
- 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/contexts/CapabilitiesContext.tsx +1 -1
- 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
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { MemoryRouter } from 'react-router-dom'
|
|
4
|
+
import type { IssuesResponse, SubjectIssuesResponse } from '../../api/client'
|
|
5
|
+
import { IssuesPane } from './IssuesPane'
|
|
6
|
+
|
|
7
|
+
const state = vi.hoisted(() => ({
|
|
8
|
+
fleet: { data: undefined as IssuesResponse | undefined, isLoading: false, error: null as Error | null, dataUpdatedAt: 0, refetch: vi.fn() },
|
|
9
|
+
subject: { data: undefined as SubjectIssuesResponse | undefined, isLoading: false, error: null as Error | null, dataUpdatedAt: 0, refetch: vi.fn() },
|
|
10
|
+
}))
|
|
11
|
+
vi.mock('../../api/client', () => ({ useIssues: () => state.fleet, useSubjectIssues: () => state.subject }))
|
|
12
|
+
vi.mock('../../api/apiResources', () => ({ useAPIResources: () => ({ data: undefined }), karpenterCapacityAvailable: () => false }))
|
|
13
|
+
vi.mock('../../contexts/CapabilitiesContext', () => ({ useCapabilitiesContext: () => ({}) }))
|
|
14
|
+
vi.mock('../../context/ConnectionContext', () => ({ useConnection: () => ({ connection: { state: 'connected' } }) }))
|
|
15
|
+
vi.mock('../diagnose/LocalDiagnoseAction', () => ({ IssueDiagnoseButton: () => null }))
|
|
16
|
+
|
|
17
|
+
const subjectUrl = '/issues?kind=Pod&resource=db%2Fpg-1'
|
|
18
|
+
const issue = {
|
|
19
|
+
id: 'pod-failed', severity: 'critical' as const, source: 'problem', category: 'workload', category_group: 'workload', grouping_scope: 'resource',
|
|
20
|
+
kind: 'Pod', group: '', namespace: 'db', name: 'pg-1', reason: 'CrashLoopBackOff', message: 'The selected Pod keeps crashing',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function render(url = subjectUrl, namespaces: string[] = []) {
|
|
24
|
+
return renderToString(<MemoryRouter initialEntries={[url]}><IssuesPane namespaces={namespaces} onNavigateToResource={() => {}} /></MemoryRouter>).replaceAll('<!-- -->', '')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
state.fleet = { data: undefined, isLoading: false, error: null, dataUpdatedAt: 0, refetch: vi.fn() }
|
|
29
|
+
state.subject = { data: { issues: [issue], coverage: 'ok' }, isLoading: false, error: null, dataUpdatedAt: 0, refetch: vi.fn() }
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('Issues page query ownership', () => {
|
|
33
|
+
it.each(['loading', 'failed'] as const)('renders subject results while the fleet query is %s', (fleetState) => {
|
|
34
|
+
state.fleet.isLoading = fleetState === 'loading'
|
|
35
|
+
state.fleet.error = fleetState === 'failed' ? new Error('Fleet unavailable') : null
|
|
36
|
+
const html = render()
|
|
37
|
+
expect(html).toContain('The selected Pod keeps crashing')
|
|
38
|
+
expect(html).toContain('Showing issues about Pod')
|
|
39
|
+
expect(html).not.toContain('Failed to load issues')
|
|
40
|
+
expect(html).not.toContain('Loading issues…')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('uses the subject visibility rather than stale fleet visibility', () => {
|
|
44
|
+
state.fleet.data = { issues: [], visibility: { impact: 'Fleet-only limitation' } }
|
|
45
|
+
state.subject.data!.visibility = { state: 'limited', impact: 'Subject evidence is incomplete.' }
|
|
46
|
+
const html = render()
|
|
47
|
+
expect(html).toContain('Subject evidence is incomplete.')
|
|
48
|
+
expect(html).not.toContain('Fleet-only limitation')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it.each(['loading', 'failed'] as const)('reports a %s subject lookup independently of fleet failure', (subjectState) => {
|
|
52
|
+
state.fleet.error = new Error('Fleet unavailable')
|
|
53
|
+
state.subject.data = undefined
|
|
54
|
+
state.subject.isLoading = subjectState === 'loading'
|
|
55
|
+
state.subject.error = subjectState === 'failed' ? new Error('Subject unavailable') : null
|
|
56
|
+
const html = render()
|
|
57
|
+
expect(html).toContain(subjectState === 'loading' ? 'Checking issues about Pod' : 'Subject unavailable')
|
|
58
|
+
expect(html).not.toContain('Failed to load issues')
|
|
59
|
+
expect(html).not.toContain('none now')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('explains a hidden subject even when the fleet query fails', () => {
|
|
63
|
+
state.fleet.error = new Error('Fleet unavailable')
|
|
64
|
+
const html = render(subjectUrl, ['other'])
|
|
65
|
+
expect(html).toContain('namespace filter hides')
|
|
66
|
+
expect(html).not.toContain('The selected Pod keeps crashing')
|
|
67
|
+
expect(html).not.toContain('Failed to load issues')
|
|
68
|
+
expect(html).not.toContain('Auto-updating')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it.each(['loading', 'failed'] as const)('retains the fleet %s state on the unfiltered page', (fleetState) => {
|
|
72
|
+
state.fleet.isLoading = fleetState === 'loading'
|
|
73
|
+
state.fleet.error = fleetState === 'failed' ? new Error('Fleet unavailable') : null
|
|
74
|
+
expect(render('/issues')).toContain(fleetState === 'loading' ? 'Loading issues…' : 'Failed to load issues')
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('Issues page when Radar could not read Helm releases', () => {
|
|
79
|
+
const line = "Helm releases couldn't be checked, so failed or stuck releases may be missing."
|
|
80
|
+
const text = (html: string) => html.replace(/'|'/g, "'")
|
|
81
|
+
|
|
82
|
+
it('shows no all-clear when nothing else was found', () => {
|
|
83
|
+
state.fleet.data = { issues: [], total: 0, total_matched: 0, unchecked_sources: [{ source: 'helm', reason: 'read_failed' }] }
|
|
84
|
+
const html = text(render('/issues'))
|
|
85
|
+
expect(html).toContain('No issues found')
|
|
86
|
+
expect(html).toContain(line)
|
|
87
|
+
expect(html).not.toContain('Nothing broken right now')
|
|
88
|
+
expect(html.split(line).length - 1).toBe(1)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('says so above the rows when there are other issues', () => {
|
|
92
|
+
state.fleet.data = { issues: [issue], total: 1, total_matched: 1, unchecked_sources: [{ source: 'helm', reason: 'read_failed' }] }
|
|
93
|
+
const html = text(render('/issues'))
|
|
94
|
+
expect(html).toContain(line)
|
|
95
|
+
expect(html).toContain('The selected Pod keeps crashing')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('shows no all-clear for a source this version does not know, with a generic line', () => {
|
|
99
|
+
state.fleet.data = { issues: [], total: 0, total_matched: 0, unchecked_sources: [{ source: 'some-new-source', reason: 'read_failed' }] }
|
|
100
|
+
const html = text(render('/issues'))
|
|
101
|
+
expect(html).toContain('No issues found')
|
|
102
|
+
expect(html).toContain("Some issue checks couldn't run, so issues may be missing.")
|
|
103
|
+
expect(html).not.toContain('some-new-source')
|
|
104
|
+
expect(html).not.toContain('Nothing broken right now')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('keeps the all-clear for a Radar that read Helm or predates the field', () => {
|
|
108
|
+
state.fleet.data = { issues: [], total: 0, total_matched: 0 }
|
|
109
|
+
const html = text(render('/issues'))
|
|
110
|
+
expect(html).toContain('Nothing broken right now')
|
|
111
|
+
expect(html).not.toContain('couldn')
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe('Helm notice in subject mode', () => {
|
|
116
|
+
it('is left out: the per-resource lookup never reads Helm storage', () => {
|
|
117
|
+
state.fleet.data = { issues: [], unchecked_sources: [{ source: 'helm', reason: 'read_failed' }] }
|
|
118
|
+
const html = render()
|
|
119
|
+
expect(html).toContain('The selected Pod keeps crashing')
|
|
120
|
+
expect(html).not.toContain('Helm releases couldn')
|
|
121
|
+
})
|
|
122
|
+
})
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import type { Issue } from "@skyhook-io/k8s-ui";
|
|
3
|
-
import {
|
|
3
|
+
import type { SubjectIssuesResponse } from "../../api/client";
|
|
4
|
+
import { writeViewParams } from "../../utils/navigation";
|
|
5
|
+
import { capacityHrefForIssue, issueSubjectFromParams, issueSubjectKindLabel, issueSubjectState } from "./IssuesPane";
|
|
4
6
|
|
|
5
7
|
function issue(partial: Partial<Issue>): Issue {
|
|
6
8
|
return {
|
|
@@ -140,3 +142,65 @@ describe("capacityHrefForIssue subject carry", () => {
|
|
|
140
142
|
).toBe("/capacity/demand");
|
|
141
143
|
});
|
|
142
144
|
});
|
|
145
|
+
|
|
146
|
+
describe('issue subject links', () => {
|
|
147
|
+
it('reads the subject a link narrows to, with its API group', () => {
|
|
148
|
+
expect(issueSubjectFromParams(new URLSearchParams('kind=Cluster&apiGroup=postgresql.cnpg.io&resource=pg%2Fpg-main'))).toEqual({ kind: 'Cluster', group: 'postgresql.cnpg.io', namespace: 'pg', name: 'pg-main' })
|
|
149
|
+
expect(issueSubjectFromParams(new URLSearchParams('kind=Pod&resource=pg%2Fpg-main-1'))).toEqual({ kind: 'Pod', group: '', namespace: 'pg', name: 'pg-main-1' })
|
|
150
|
+
expect(issueSubjectFromParams(new URLSearchParams('kind=ClusterImageCatalog&apiGroup=postgresql.cnpg.io&resource=pg'))).toEqual({ kind: 'ClusterImageCatalog', group: 'postgresql.cnpg.io', namespace: '', name: 'pg' })
|
|
151
|
+
expect(issueSubjectFromParams(new URLSearchParams('kind=Cluster'))).toBeNull()
|
|
152
|
+
expect(issueSubjectFromParams(new URLSearchParams('kind=Cluster&resource=pg%2F'))).toBeNull()
|
|
153
|
+
})
|
|
154
|
+
it('keeps the API group through App’s URL sync on the Issues view', () => {
|
|
155
|
+
for (const [search, subject] of [
|
|
156
|
+
['kind=Cluster&apiGroup=postgresql.cnpg.io&resource=pg%2Fpg-main', { kind: 'Cluster', group: 'postgresql.cnpg.io', namespace: 'pg', name: 'pg-main' }],
|
|
157
|
+
['kind=Service&apiGroup=serving.knative.dev&resource=web%2Fapi', { kind: 'Service', group: 'serving.knative.dev', namespace: 'web', name: 'api' }],
|
|
158
|
+
] as const) {
|
|
159
|
+
const params = new URLSearchParams(search)
|
|
160
|
+
writeViewParams(params, ['web'], null)
|
|
161
|
+
expect(issueSubjectFromParams(params)).toEqual(subject)
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
it('names the API group when the kind could be another resource', () => {
|
|
165
|
+
expect(issueSubjectKindLabel({ kind: 'Service', group: 'serving.knative.dev', namespace: 'web', name: 'api' })).toBe('Service (serving.knative.dev)')
|
|
166
|
+
expect(issueSubjectKindLabel({ kind: 'Cluster', group: 'postgresql.cnpg.io', namespace: 'pg', name: 'main' })).toBe('Cluster (postgresql.cnpg.io)')
|
|
167
|
+
expect(issueSubjectKindLabel({ kind: 'Service', group: '', namespace: 'web', name: 'api' })).toBe('Service')
|
|
168
|
+
expect(issueSubjectKindLabel({ kind: 'Deployment', group: 'apps', namespace: 'web', name: 'api' })).toBe('Deployment')
|
|
169
|
+
})
|
|
170
|
+
it('never reads the view filter as the subject namespace', () => {
|
|
171
|
+
expect(issueSubjectFromParams(new URLSearchParams('kind=Cluster&namespace=pg&name=pg-main'))).toBeNull()
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('issueSubjectState', () => {
|
|
176
|
+
const subject = { kind: 'Cluster', group: 'postgresql.cnpg.io', namespace: 'pg', name: 'main' }
|
|
177
|
+
const loaded = (data: Partial<SubjectIssuesResponse>) => ({ isLoading: false, error: null, data: { issues: [], coverage: 'ok' as const, ...data } })
|
|
178
|
+
it('says the view filter hides the subject rather than none', () => {
|
|
179
|
+
expect(issueSubjectState(subject, ['app'], loaded({}))).toEqual({ state: 'hidden' })
|
|
180
|
+
expect(issueSubjectState(subject, ['app', 'pg'], loaded({}))).toEqual({ state: 'none' })
|
|
181
|
+
expect(issueSubjectState(subject, [], loaded({}))).toEqual({ state: 'none' })
|
|
182
|
+
expect(issueSubjectState({ ...subject, kind: 'ClusterImageCatalog', namespace: '' }, ['app'], loaded({}))).toEqual({ state: 'none' })
|
|
183
|
+
})
|
|
184
|
+
it('reads an empty answer as unconfirmed when Radar does not read the kind', () => {
|
|
185
|
+
expect(issueSubjectState(subject, [], loaded({ coverage: 'notWatched' }))).toEqual({ state: 'unconfirmed', why: "Radar isn't watching Cluster" })
|
|
186
|
+
expect(issueSubjectState(subject, [], loaded({ coverage: 'syncing' }))).toEqual({ state: 'unconfirmed', why: 'Radar is still loading Cluster' })
|
|
187
|
+
})
|
|
188
|
+
it('says when the cluster has no such kind, as for a link made on another cluster', () => {
|
|
189
|
+
expect(issueSubjectState(subject, [], loaded({ coverage: 'unknownKind' }))).toEqual({ state: 'unconfirmed', why: 'this cluster has no such kind' })
|
|
190
|
+
})
|
|
191
|
+
it('reads an empty answer as unconfirmed when RBAC withheld issues about it', () => {
|
|
192
|
+
expect(issueSubjectState(subject, [], loaded({ withheld: { issues: 2 } }))).toEqual({ state: 'unconfirmed', why: 'your permissions withhold 2 issues about it' })
|
|
193
|
+
})
|
|
194
|
+
it('reads an empty answer under limited visibility as unconfirmed, never none', () => {
|
|
195
|
+
expect(issueSubjectState(subject, [], loaded({ visibility: { state: 'degraded', impact: 'Pods are not readable.' } }))).toEqual({ state: 'unconfirmed', why: "some evidence isn't readable (Pods are not readable)" })
|
|
196
|
+
expect(issueSubjectState(subject, [], loaded({ visibility: { state: 'limited' } }))).toEqual({ state: 'unconfirmed', why: "some evidence isn't readable" })
|
|
197
|
+
})
|
|
198
|
+
it('reports a failed lookup as unconfirmed and a pending one as checking', () => {
|
|
199
|
+
expect(issueSubjectState(subject, [], { isLoading: false, error: new Error('no access to Cluster main'), data: undefined })).toEqual({ state: 'unconfirmed', why: 'no access to Cluster main' })
|
|
200
|
+
expect(issueSubjectState(subject, [], { isLoading: true, error: null, data: undefined })).toEqual({ state: 'checking' })
|
|
201
|
+
})
|
|
202
|
+
it('takes the per-resource answer, which covers members past the inline cap', () => {
|
|
203
|
+
const grouped = issue({ kind: 'Cluster', group: 'postgresql.cnpg.io', namespace: 'pg', name: 'main', members: [], members_truncated: true })
|
|
204
|
+
expect(issueSubjectState(subject, [], loaded({ issues: [grouped], withheld: { issues: 1 } }))).toEqual({ state: 'found', issues: [grouped], withheld: 1 })
|
|
205
|
+
})
|
|
206
|
+
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useMemo, useState } from "react";
|
|
2
|
-
import { useNavigate } from "react-router-dom";
|
|
3
|
-
import { useIssues } from "../../api/client";
|
|
2
|
+
import { useNavigate, useSearchParams } from "react-router-dom";
|
|
3
|
+
import { useIssues, useSubjectIssues, type SubjectIssuesResponse } from "../../api/client";
|
|
4
4
|
import {
|
|
5
5
|
useAPIResources,
|
|
6
6
|
karpenterCapacityAvailable,
|
|
@@ -9,6 +9,7 @@ import { useCapabilitiesContext } from "../../contexts/CapabilitiesContext";
|
|
|
9
9
|
import { useConnection } from "../../context/ConnectionContext";
|
|
10
10
|
import type { SelectedResource } from "../../types";
|
|
11
11
|
import {
|
|
12
|
+
EmptyState,
|
|
12
13
|
IssuesView,
|
|
13
14
|
PaneLoader,
|
|
14
15
|
PageHeader,
|
|
@@ -21,8 +22,10 @@ import {
|
|
|
21
22
|
type IssueSeverity,
|
|
22
23
|
type SummaryTone,
|
|
23
24
|
} from "@skyhook-io/k8s-ui";
|
|
25
|
+
import { builtinGroupForKind } from "@skyhook-io/k8s-ui/utils/api-resources";
|
|
24
26
|
import { AlertTriangle } from "lucide-react";
|
|
25
27
|
import { IssueDiagnoseButton } from "../diagnose/LocalDiagnoseAction";
|
|
28
|
+
import { uncheckedSourceLines } from "./uncheckedSources";
|
|
26
29
|
|
|
27
30
|
// A capacity-relevant issue links to its Karpenter diagnosis. Karpenter is
|
|
28
31
|
// always single-cluster (unlike Argo hub-and-spoke), so the issue and the
|
|
@@ -75,6 +78,82 @@ export function capacityHrefForIssue(
|
|
|
75
78
|
return null;
|
|
76
79
|
}
|
|
77
80
|
|
|
81
|
+
/**
|
|
82
|
+
* The subject a link into Issues narrows to (?kind=&apiGroup=&resource=ns/name),
|
|
83
|
+
* or null. The group is `apiGroup`, as in resource links: App's URL sync owns
|
|
84
|
+
* `group` (topology grouping) and drops it on every other view.
|
|
85
|
+
*/
|
|
86
|
+
export function issueSubjectFromParams(params: URLSearchParams): IssueSubject | null {
|
|
87
|
+
const kind = params.get("kind");
|
|
88
|
+
const resource = params.get("resource");
|
|
89
|
+
if (!kind || !resource) return null;
|
|
90
|
+
const group = params.get("apiGroup") ?? "";
|
|
91
|
+
const slash = resource.indexOf("/");
|
|
92
|
+
if (slash < 0) return { kind, group, namespace: "", name: resource };
|
|
93
|
+
const name = resource.slice(slash + 1);
|
|
94
|
+
return name ? { kind, group, namespace: resource.slice(0, slash), name } : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface IssueSubject {
|
|
98
|
+
kind: string;
|
|
99
|
+
/** The API group; "" for the core group. Tells a CNPG Cluster from a CAPI one. */
|
|
100
|
+
group: string;
|
|
101
|
+
namespace: string;
|
|
102
|
+
name: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The subject's kind, with its API group when that isn't the built-in kind's (a Knative Service). */
|
|
106
|
+
export function issueSubjectKindLabel(subject: IssueSubject): string {
|
|
107
|
+
return subject.group && subject.group !== builtinGroupForKind(subject.kind)
|
|
108
|
+
? `${subject.kind} (${subject.group})`
|
|
109
|
+
: subject.kind;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type IssueSubjectState =
|
|
113
|
+
| { state: "hidden" }
|
|
114
|
+
| { state: "checking" }
|
|
115
|
+
| { state: "unconfirmed"; why: string }
|
|
116
|
+
| { state: "found"; issues: Issue[]; withheld: number }
|
|
117
|
+
| { state: "none" };
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* What can be said about the subject's issues. They come from the per-resource
|
|
121
|
+
* lookup, which matches every grouped member (the list's inline members are
|
|
122
|
+
* capped) by exact API group; an empty answer is "none" only when Radar reads
|
|
123
|
+
* the subject's kind, nothing kept it from the evidence, and RBAC withheld no
|
|
124
|
+
* issue about it.
|
|
125
|
+
*/
|
|
126
|
+
export function issueSubjectState(
|
|
127
|
+
subject: IssueSubject,
|
|
128
|
+
viewNamespaces: string[],
|
|
129
|
+
related: { isLoading: boolean; error: unknown; data: SubjectIssuesResponse | undefined },
|
|
130
|
+
): IssueSubjectState {
|
|
131
|
+
if (subject.namespace && viewNamespaces.length > 0 && !viewNamespaces.includes(subject.namespace)) {
|
|
132
|
+
return { state: "hidden" };
|
|
133
|
+
}
|
|
134
|
+
if (related.error) {
|
|
135
|
+
return { state: "unconfirmed", why: related.error instanceof Error ? related.error.message : String(related.error) };
|
|
136
|
+
}
|
|
137
|
+
const data = related.data;
|
|
138
|
+
if (related.isLoading || !data) return { state: "checking" };
|
|
139
|
+
if (data.issues.length > 0) return { state: "found", issues: data.issues, withheld: data.withheld?.issues ?? 0 };
|
|
140
|
+
if (data.coverage === "unknownKind") return { state: "unconfirmed", why: "this cluster has no such kind" };
|
|
141
|
+
if (data.coverage === "notWatched") return { state: "unconfirmed", why: `Radar isn't watching ${subject.kind}` };
|
|
142
|
+
if (data.coverage === "syncing") return { state: "unconfirmed", why: `Radar is still loading ${subject.kind}` };
|
|
143
|
+
const withheld = data.withheld?.issues ?? 0;
|
|
144
|
+
if (withheld > 0) {
|
|
145
|
+
return { state: "unconfirmed", why: `your permissions withhold ${withheld} ${withheld === 1 ? "issue" : "issues"} about it` };
|
|
146
|
+
}
|
|
147
|
+
const visibility = data.visibility;
|
|
148
|
+
if (visibility?.state === "degraded" || visibility?.state === "limited") {
|
|
149
|
+
return {
|
|
150
|
+
state: "unconfirmed",
|
|
151
|
+
why: `some evidence isn't readable${visibility.impact ? ` (${visibility.impact.replace(/\.$/, "")})` : ""}`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return { state: "none" };
|
|
155
|
+
}
|
|
156
|
+
|
|
78
157
|
const SEVERITY_TONE: Record<IssueSeverity, SummaryTone> = {
|
|
79
158
|
critical: "error",
|
|
80
159
|
warning: "warning",
|
|
@@ -83,6 +162,8 @@ const SEVERITY_TONE: Record<IssueSeverity, SummaryTone> = {
|
|
|
83
162
|
interface IssuesPaneProps {
|
|
84
163
|
namespaces: string[];
|
|
85
164
|
onNavigateToResource: (resource: SelectedResource) => void;
|
|
165
|
+
/** Brings a namespace into the view filter: added to it, or (where Radar's scope allows only one) switched to it. */
|
|
166
|
+
showNamespace?: { mode: "add" | "switch"; show: (namespace: string) => void };
|
|
86
167
|
}
|
|
87
168
|
|
|
88
169
|
// The per-cluster Issues surface. Renders the same shared triage queue
|
|
@@ -96,9 +177,12 @@ interface IssuesPaneProps {
|
|
|
96
177
|
export function IssuesPane({
|
|
97
178
|
namespaces,
|
|
98
179
|
onNavigateToResource,
|
|
180
|
+
showNamespace,
|
|
99
181
|
}: IssuesPaneProps) {
|
|
182
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
183
|
+
const subject = useMemo(() => issueSubjectFromParams(searchParams), [searchParams]);
|
|
100
184
|
const { data, isLoading, error, dataUpdatedAt, refetch } =
|
|
101
|
-
useIssues(namespaces);
|
|
185
|
+
useIssues(namespaces, !subject);
|
|
102
186
|
const { connection } = useConnection();
|
|
103
187
|
const navigate = useNavigate();
|
|
104
188
|
const apiResources = useAPIResources();
|
|
@@ -110,15 +194,37 @@ export function IssuesPane({
|
|
|
110
194
|
new Set(),
|
|
111
195
|
);
|
|
112
196
|
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
197
|
+
const subjectHidden =
|
|
198
|
+
!!subject?.namespace && namespaces.length > 0 && !namespaces.includes(subject.namespace);
|
|
199
|
+
const related = useSubjectIssues(subject, !subjectHidden);
|
|
200
|
+
const subjectState = subject
|
|
201
|
+
? issueSubjectState(subject, namespaces, related)
|
|
202
|
+
: null;
|
|
203
|
+
const visibility = subject
|
|
204
|
+
? subjectHidden ? undefined : related.data?.visibility
|
|
205
|
+
: data?.visibility;
|
|
206
|
+
const pageIssues = useMemo(() => data?.issues ?? [], [data]);
|
|
207
|
+
const uncheckedLines = subject ? [] : uncheckedSourceLines(data?.unchecked_sources);
|
|
208
|
+
// With a subject set, the tiles and the list both describe the subject.
|
|
209
|
+
const scopeIssues = subjectState
|
|
210
|
+
? subjectState.state === "found" ? subjectState.issues : []
|
|
211
|
+
: pageIssues;
|
|
212
|
+
const totals: Record<IssueSeverity, number> = { critical: 0, warning: 0 };
|
|
213
|
+
for (const i of scopeIssues) totals[i.severity] = (totals[i.severity] ?? 0) + 1;
|
|
119
214
|
const shown = severityFilter.size
|
|
120
|
-
?
|
|
121
|
-
:
|
|
215
|
+
? scopeIssues.filter((i) => severityFilter.has(i.severity))
|
|
216
|
+
: scopeIssues;
|
|
217
|
+
const clearSubject = () =>
|
|
218
|
+
setSearchParams(
|
|
219
|
+
(prev) => {
|
|
220
|
+
const next = new URLSearchParams(prev);
|
|
221
|
+
next.delete("kind");
|
|
222
|
+
next.delete("apiGroup");
|
|
223
|
+
next.delete("resource");
|
|
224
|
+
return next;
|
|
225
|
+
},
|
|
226
|
+
{ replace: true },
|
|
227
|
+
);
|
|
122
228
|
|
|
123
229
|
const toggleSeverity = (s: IssueSeverity) =>
|
|
124
230
|
setSeverityFilter((prev) => {
|
|
@@ -136,11 +242,11 @@ export function IssuesPane({
|
|
|
136
242
|
group: ref.group ?? "",
|
|
137
243
|
});
|
|
138
244
|
|
|
139
|
-
if (isLoading) {
|
|
245
|
+
if (!subject && isLoading) {
|
|
140
246
|
return <PaneLoader label="Loading issues…" className="flex-1" />;
|
|
141
247
|
}
|
|
142
248
|
|
|
143
|
-
if (error) {
|
|
249
|
+
if (!subject && error) {
|
|
144
250
|
return (
|
|
145
251
|
<div className="flex-1 flex items-center justify-center text-theme-text-secondary">
|
|
146
252
|
<p>Failed to load issues</p>
|
|
@@ -156,17 +262,20 @@ export function IssuesPane({
|
|
|
156
262
|
description="Live cluster problems — crashes, scheduling failures, bad references — grouped by the resource they affect."
|
|
157
263
|
actions={
|
|
158
264
|
<>
|
|
159
|
-
<FreshnessControl
|
|
265
|
+
{subjectHidden ? null : <FreshnessControl
|
|
160
266
|
mode="auto"
|
|
161
|
-
dataUpdatedAt={dataUpdatedAt}
|
|
162
|
-
onRefresh={() =>
|
|
267
|
+
dataUpdatedAt={subject ? related.dataUpdatedAt : dataUpdatedAt}
|
|
268
|
+
onRefresh={() => {
|
|
269
|
+
if (subject) related.refetch();
|
|
270
|
+
else refetch();
|
|
271
|
+
}}
|
|
163
272
|
connectionState={connection.state}
|
|
164
|
-
/>
|
|
165
|
-
{
|
|
273
|
+
/>}
|
|
274
|
+
{scopeIssues.length > 0 && (
|
|
166
275
|
<>
|
|
167
276
|
<SummaryTile
|
|
168
|
-
label={
|
|
169
|
-
value={
|
|
277
|
+
label={scopeIssues.length === 1 ? "issue" : "issues"}
|
|
278
|
+
value={scopeIssues.length}
|
|
170
279
|
/>
|
|
171
280
|
{ISSUE_SEVERITIES.map((s) =>
|
|
172
281
|
totals[s] > 0 || severityFilter.has(s) ? (
|
|
@@ -189,19 +298,29 @@ export function IssuesPane({
|
|
|
189
298
|
{/* Visibility honesty: when RBAC reads are incomplete, an empty queue may
|
|
190
299
|
mean "can't see" rather than "nothing broken" — say so up front so the
|
|
191
300
|
empty state isn't mistaken for a clean bill of health. */}
|
|
192
|
-
{
|
|
301
|
+
{visibility?.impact && (
|
|
193
302
|
<div className="flex items-start gap-2 rounded-lg border border-theme-border bg-theme-elevated px-3 py-2 text-xs text-theme-text-secondary">
|
|
194
303
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
|
|
195
304
|
<span>
|
|
196
|
-
Limited visibility — {
|
|
305
|
+
Limited visibility — {visibility.impact} Results may be
|
|
197
306
|
incomplete.
|
|
198
307
|
</span>
|
|
199
308
|
</div>
|
|
200
309
|
)}
|
|
201
310
|
|
|
311
|
+
{/* Unchecked-source honesty: the rows can't include issues from a
|
|
312
|
+
source Radar couldn't check. With no rows the empty state below says
|
|
313
|
+
it instead. */}
|
|
314
|
+
{scopeIssues.length > 0 && uncheckedLines.map((line) => (
|
|
315
|
+
<div key={line} className="flex items-start gap-2 rounded-lg border border-theme-border bg-theme-elevated px-3 py-2 text-xs text-theme-text-secondary">
|
|
316
|
+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
|
|
317
|
+
<span>{line}</span>
|
|
318
|
+
</div>
|
|
319
|
+
))}
|
|
320
|
+
|
|
202
321
|
{/* Truncation honesty: when more issues matched than were returned, say
|
|
203
322
|
so — don't present a capped list as the complete picture. */}
|
|
204
|
-
{data?.total_matched != null &&
|
|
323
|
+
{!subject && data?.total_matched != null &&
|
|
205
324
|
data.total_matched > (data.issues?.length ?? 0) && (
|
|
206
325
|
<p className="text-xs text-theme-text-tertiary">
|
|
207
326
|
Showing {data.issues?.length ?? 0} of {data.total_matched} issues
|
|
@@ -209,10 +328,52 @@ export function IssuesPane({
|
|
|
209
328
|
</p>
|
|
210
329
|
)}
|
|
211
330
|
|
|
331
|
+
{subject && subjectState && (
|
|
332
|
+
<div className="flex flex-wrap items-center gap-2 text-xs text-theme-text-secondary">
|
|
333
|
+
{subjectState.state === "hidden" ? (
|
|
334
|
+
<>
|
|
335
|
+
<span>
|
|
336
|
+
{issueSubjectKindLabel(subject)}{" "}
|
|
337
|
+
<span className="font-mono">{subject.namespace}/{subject.name}</span>{" "}
|
|
338
|
+
is in namespace <span className="font-mono">{subject.namespace}</span>, which your
|
|
339
|
+
namespace filter hides.
|
|
340
|
+
</span>
|
|
341
|
+
{showNamespace && (
|
|
342
|
+
<button
|
|
343
|
+
type="button"
|
|
344
|
+
onClick={() => showNamespace.show(subject.namespace)}
|
|
345
|
+
className="text-accent-text hover:underline"
|
|
346
|
+
>
|
|
347
|
+
{showNamespace.mode === "add" ? "Add" : "Switch to"}{" "}
|
|
348
|
+
<span className="font-mono">{subject.namespace}</span>
|
|
349
|
+
{showNamespace.mode === "add" ? " to the view" : ""}
|
|
350
|
+
</button>
|
|
351
|
+
)}
|
|
352
|
+
</>
|
|
353
|
+
) : (
|
|
354
|
+
<span>
|
|
355
|
+
{subjectState.state === "checking" ? "Checking issues about" : "Showing issues about"}{" "}
|
|
356
|
+
{issueSubjectKindLabel(subject)}{" "}
|
|
357
|
+
<span className="font-mono">
|
|
358
|
+
{subject.namespace ? `${subject.namespace}/` : ""}
|
|
359
|
+
{subject.name}
|
|
360
|
+
</span>
|
|
361
|
+
{subjectState.state === "none" && " — none now"}
|
|
362
|
+
{subjectState.state === "unconfirmed" && ` — can't confirm: ${subjectState.why}`}
|
|
363
|
+
{subjectState.state === "found" && subjectState.withheld > 0 &&
|
|
364
|
+
` — your permissions withhold ${subjectState.withheld} more`}
|
|
365
|
+
</span>
|
|
366
|
+
)}
|
|
367
|
+
<button type="button" onClick={clearSubject} className="text-accent-text hover:underline">
|
|
368
|
+
Show all issues
|
|
369
|
+
</button>
|
|
370
|
+
</div>
|
|
371
|
+
)}
|
|
372
|
+
|
|
212
373
|
{/* Filtered-empty is NOT the healthy empty state: when a severity filter
|
|
213
374
|
hides every row but issues still exist, say "no matches" rather than
|
|
214
375
|
letting IssuesView render its "nothing broken" terminal state. */}
|
|
215
|
-
{severityFilter.size > 0 &&
|
|
376
|
+
{subject && scopeIssues.length === 0 ? null : severityFilter.size > 0 && scopeIssues.length > 0 && shown.length === 0 ? (
|
|
216
377
|
<div className="flex flex-col items-center gap-2 py-12 text-center text-sm text-theme-text-secondary">
|
|
217
378
|
<p>No issues match the selected severity.</p>
|
|
218
379
|
<button
|
|
@@ -223,12 +384,14 @@ export function IssuesPane({
|
|
|
223
384
|
Clear filter
|
|
224
385
|
</button>
|
|
225
386
|
</div>
|
|
387
|
+
) : uncheckedLines.length > 0 && scopeIssues.length === 0 ? (
|
|
388
|
+
<EmptyState tone="neutral" variant="card" headline="No issues found" body={uncheckedLines.join(" ")} />
|
|
226
389
|
) : (
|
|
227
390
|
/* anyData = the query resolved, i.e. the cluster is reachable; an empty
|
|
228
391
|
list then means "nothing broken" rather than "not connected". */
|
|
229
392
|
<IssuesView
|
|
230
393
|
issues={shown}
|
|
231
|
-
anyData={!!data}
|
|
394
|
+
anyData={subject ? !!related.data : !!data}
|
|
232
395
|
onResourceClick={onResourceClick}
|
|
233
396
|
renderActions={({ issue }) => {
|
|
234
397
|
const capacityHref = capacityHrefForIssue(issue, hasKarpenter);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { IssuesResponse } from "../../api/client";
|
|
2
|
+
|
|
3
|
+
// The issue sources /api/issues can list in unchecked_sources, and the line
|
|
4
|
+
// each one gets wherever Radar's issues would otherwise read as complete.
|
|
5
|
+
const SOURCE_LINES: Record<string, string> = {
|
|
6
|
+
helm: "Helm releases couldn't be checked, so failed or stuck releases may be missing.",
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
// For a source this version doesn't know, sent by a newer Radar.
|
|
10
|
+
export const UNKNOWN_SOURCE_LINE = "Some issue checks couldn't run, so issues may be missing.";
|
|
11
|
+
|
|
12
|
+
/** One line per unchecked source, known sources first, without repeats. */
|
|
13
|
+
export function uncheckedSourceLines(sources: IssuesResponse["unchecked_sources"]): string[] {
|
|
14
|
+
const known = new Set<string>();
|
|
15
|
+
let unknown = false;
|
|
16
|
+
for (const { source } of sources ?? []) {
|
|
17
|
+
if (Object.hasOwn(SOURCE_LINES, source)) known.add(SOURCE_LINES[source]);
|
|
18
|
+
else unknown = true;
|
|
19
|
+
}
|
|
20
|
+
return unknown ? [...known, UNKNOWN_SOURCE_LINE] : [...known];
|
|
21
|
+
}
|
|
@@ -182,3 +182,35 @@ describe('PVC metrics next step', () => {
|
|
|
182
182
|
expect(render()).not.toContain('Ask your operator')
|
|
183
183
|
})
|
|
184
184
|
})
|
|
185
|
+
|
|
186
|
+
describe('PVC scope remedies', () => {
|
|
187
|
+
it.each(['ambiguous_scope', 'scope_mismatch'] as const)('shows operator scope controls for %s to owners and viewers', scope => {
|
|
188
|
+
statusResult = { data: { connected: true } }
|
|
189
|
+
usageResult = { data: { ...measured, hasData: false, status: scope } }
|
|
190
|
+
roleLoading = false
|
|
191
|
+
for (const owner of [true, false]) {
|
|
192
|
+
canConfigure = owner
|
|
193
|
+
const html = render()
|
|
194
|
+
expect(html).toContain('--prometheus-cluster-label')
|
|
195
|
+
expect(html).toContain('traffic.prometheusClusterLabels')
|
|
196
|
+
expect(html).toContain('not available in Settings or Desktop')
|
|
197
|
+
expect(html).not.toContain('Configure metrics')
|
|
198
|
+
expect(html).not.toContain('verified labels')
|
|
199
|
+
if (scope === 'scope_mismatch') {
|
|
200
|
+
expect(html).toContain('align cluster labels')
|
|
201
|
+
expect(html).toContain('affects all these charts')
|
|
202
|
+
expect(html).toContain('dedicated to this cluster')
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('keeps connection settings when a cached scope failure outlives the connection', () => {
|
|
208
|
+
canConfigure = true
|
|
209
|
+
roleLoading = false
|
|
210
|
+
statusResult = { data: { connected: false } }
|
|
211
|
+
usageResult = { data: { ...measured, hasData: false, status: 'ambiguous_scope' } }
|
|
212
|
+
const html = render()
|
|
213
|
+
expect(html).toContain('Configure metrics')
|
|
214
|
+
expect(html).not.toContain('--prometheus-cluster-label')
|
|
215
|
+
})
|
|
216
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { expect, it, vi } from 'vitest'
|
|
3
|
+
import { PVCUsageBar } from './PVCUsageBar'
|
|
4
|
+
|
|
5
|
+
const state = vi.hoisted(() => ({ status: 'ambiguous_scope' }))
|
|
6
|
+
vi.mock('../../api/client', () => ({
|
|
7
|
+
isForbiddenError: () => false,
|
|
8
|
+
useAutoPromConnect: () => {},
|
|
9
|
+
useCloudRole: () => ({ canAtLeast: () => false, isLoading: false }),
|
|
10
|
+
usePrometheusStatus: () => ({ data: { connected: true } }),
|
|
11
|
+
usePrometheusPVCUsage: () => ({ data: { status: state.status, hasData: false } }),
|
|
12
|
+
}))
|
|
13
|
+
vi.mock('../../context/NavCustomization', () => ({ useNavCustomization: () => ({ embedded: true }) }))
|
|
14
|
+
vi.mock('../../hooks/usePreviousIntegrationSettings', () => ({ usePreviousIntegrationSettings: () => ({}), previousSettingsNote: () => undefined }))
|
|
15
|
+
|
|
16
|
+
it('distinguishes mismatched volume-stat labels from multiple cluster identities', () => {
|
|
17
|
+
state.status = 'ambiguous_scope'
|
|
18
|
+
expect(renderToStaticMarkup(<PVCUsageBar namespace="demo" name="disk" />)).toContain('more than one cluster')
|
|
19
|
+
state.status = 'scope_mismatch'
|
|
20
|
+
const html = renderToStaticMarkup(<PVCUsageBar namespace="demo" name="disk" />)
|
|
21
|
+
expect(html).toContain('could not match this volume’s stats to this cluster')
|
|
22
|
+
expect(html).toContain('Used space is unknown')
|
|
23
|
+
expect(html).not.toContain('more than one cluster')
|
|
24
|
+
})
|
|
@@ -33,23 +33,32 @@ export function PVCUsageBar({ namespace, name }: { namespace: string; name: stri
|
|
|
33
33
|
unavailable = 'Loading usage measurements…'
|
|
34
34
|
} else if (usage.status === 'no_series') {
|
|
35
35
|
unavailable = 'No usage measurements reported for this volume.'
|
|
36
|
+
} else if (usage.status === 'ambiguous_scope') {
|
|
37
|
+
unavailable = 'Prometheus has volume stats with this name from more than one cluster. Radar cannot tell which belong to this cluster. Used space is unknown.'
|
|
38
|
+
} else if (usage.status === 'scope_mismatch') {
|
|
39
|
+
unavailable = 'Radar could not match this volume’s stats to this cluster. Used space is unknown.'
|
|
36
40
|
} else if (usage.status === 'invalid_data') {
|
|
37
41
|
unavailable = 'Volume usage measurements are invalid. Used space is unknown.'
|
|
38
42
|
} else if (!usage.hasData) {
|
|
39
43
|
unavailable = 'Usage measurements are unavailable.'
|
|
40
44
|
}
|
|
41
45
|
|
|
46
|
+
const scopeFailure = isConnected && !error && !statusError && (usage?.status === 'ambiguous_scope' || usage?.status === 'scope_mismatch')
|
|
42
47
|
const denied = isForbiddenError(error) || isForbiddenError(statusError)
|
|
43
48
|
const waiting = !denied && !statusError && (!status || status.discovering || (isConnected && !usage && !error))
|
|
44
49
|
const offers = usePreviousIntegrationSettings(canConfigure && !waiting && !denied && !statusError && !isConnected)
|
|
45
50
|
const previousNote = offers.metrics ? previousSettingsNote('metrics', offers.metrics) : undefined
|
|
46
51
|
const guidance = denied
|
|
47
52
|
? 'Ask your operator to review your metrics access.'
|
|
48
|
-
:
|
|
49
|
-
?
|
|
50
|
-
? '
|
|
51
|
-
: 'Ask your operator to
|
|
52
|
-
:
|
|
53
|
+
: scopeFailure
|
|
54
|
+
? usage.status === 'ambiguous_scope'
|
|
55
|
+
? 'An operator can set this cluster’s exact metrics labels with --prometheus-cluster-label, or traffic.prometheusClusterLabels in Helm. These scope controls are not available in Settings or Desktop.'
|
|
56
|
+
: 'Ask your operator to align cluster labels across kube-state-metrics, workload metrics and volume stats. --prometheus-cluster-label (Helm: traffic.prometheusClusterLabels) affects all these charts; its labels must exist on every affected source. For a backend dedicated to this cluster, an operator can use --prometheus-single-cluster instead. These scope controls are not available in Settings or Desktop.'
|
|
57
|
+
: !canConfigure && !roleLoading
|
|
58
|
+
? !isConnected || statusError
|
|
59
|
+
? 'Ask your operator to check the metrics connection for this cluster.'
|
|
60
|
+
: 'Ask your operator to check metrics availability for this volume.'
|
|
61
|
+
: undefined
|
|
53
62
|
|
|
54
63
|
if (unavailable || !usage) {
|
|
55
64
|
return (
|
|
@@ -58,7 +67,7 @@ export function PVCUsageBar({ namespace, name }: { namespace: string; name: stri
|
|
|
58
67
|
<p className="text-sm text-theme-text-tertiary">{unavailable}</p>
|
|
59
68
|
{previousNote && <p className="mt-2 text-xs text-theme-text-secondary">{previousNote}</p>}
|
|
60
69
|
{!waiting && (
|
|
61
|
-
canConfigure && !denied ? (
|
|
70
|
+
canConfigure && !denied && !scopeFailure ? (
|
|
62
71
|
<button
|
|
63
72
|
type="button"
|
|
64
73
|
className="mt-2 text-xs text-accent hover:underline"
|