@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,259 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from 'react'
|
|
2
|
+
import type { UseQueryResult } from '@tanstack/react-query'
|
|
3
|
+
import { clsx } from 'clsx'
|
|
4
|
+
import { AlertTriangle, X } from 'lucide-react'
|
|
5
|
+
import { Tooltip, formatUpdatedAgo, grantParts, toneTextClass, type Grant } from '@skyhook-io/k8s-ui'
|
|
6
|
+
import type { SelectedResource } from '../../types'
|
|
7
|
+
import { sameSelectedResource } from '../../utils/drawer-trail'
|
|
8
|
+
import { ROW_HOVER, TABLE_HEAD, TABLE_WRAP, TBODY, TD, TH } from './table'
|
|
9
|
+
|
|
10
|
+
export function Notice({ children }: { children: ReactNode }) {
|
|
11
|
+
return (
|
|
12
|
+
<div className="flex items-start gap-2 rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-sm text-theme-text-secondary">
|
|
13
|
+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary" />
|
|
14
|
+
<div>{children}</div>
|
|
15
|
+
</div>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A whole screen with nothing to show: not installed, unreadable, or empty. */
|
|
20
|
+
export function ScreenEmptyState({
|
|
21
|
+
icon: Icon,
|
|
22
|
+
title,
|
|
23
|
+
detail,
|
|
24
|
+
action,
|
|
25
|
+
}: {
|
|
26
|
+
icon: ComponentType<{ className?: string }>;
|
|
27
|
+
title: string;
|
|
28
|
+
detail: ReactNode;
|
|
29
|
+
action?: ReactNode;
|
|
30
|
+
}) {
|
|
31
|
+
return (
|
|
32
|
+
<div className="flex min-h-0 flex-1 items-center justify-center bg-theme-base p-6">
|
|
33
|
+
<div className="max-w-md text-center">
|
|
34
|
+
<Icon className="mx-auto h-9 w-9 text-theme-text-tertiary/50" />
|
|
35
|
+
<h2 className="mt-3 text-lg font-medium text-theme-text-primary">
|
|
36
|
+
{title}
|
|
37
|
+
</h2>
|
|
38
|
+
<p className="mt-1 text-sm text-theme-text-secondary">{detail}</p>
|
|
39
|
+
{action}
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function ScreenBody({ children }: { children: ReactNode }) {
|
|
46
|
+
return (
|
|
47
|
+
<div className="min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]">
|
|
48
|
+
<div className="space-y-5 px-5 pb-6 pt-3 xl:px-7">{children}</div>
|
|
49
|
+
</div>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function FilterChips({ chips }: { chips: { label: string; onClear: () => void }[] }) {
|
|
54
|
+
if (chips.length === 0) return null
|
|
55
|
+
return (
|
|
56
|
+
<div className="flex flex-wrap gap-1.5">
|
|
57
|
+
{chips.map((c) => (
|
|
58
|
+
<span key={c.label} className="inline-flex items-center gap-1 rounded-full bg-theme-elevated px-2.5 py-0.5 text-xs text-theme-text-secondary">
|
|
59
|
+
{c.label}
|
|
60
|
+
<button type="button" onClick={c.onClear} aria-label={`Remove ${c.label}`} className="rounded-full p-0.5 hover:bg-theme-hover">
|
|
61
|
+
<X className="h-3 w-3" />
|
|
62
|
+
</button>
|
|
63
|
+
</span>
|
|
64
|
+
))}
|
|
65
|
+
</div>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function namespaceChip(namespaces: string[], onClear: () => void) {
|
|
70
|
+
return namespaces.length > 0 ? [{ label: `Namespace: ${namespaces.join(', ')}`, onClear }] : []
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function Segments<T extends string>({
|
|
74
|
+
value,
|
|
75
|
+
options,
|
|
76
|
+
onChange,
|
|
77
|
+
label,
|
|
78
|
+
}: {
|
|
79
|
+
value: T
|
|
80
|
+
options: { id: T; label: string; count?: number | string }[]
|
|
81
|
+
onChange: (id: T) => void
|
|
82
|
+
label: string
|
|
83
|
+
}) {
|
|
84
|
+
return (
|
|
85
|
+
<div role="tablist" aria-label={label} className="inline-flex rounded-lg bg-theme-elevated p-0.5">
|
|
86
|
+
{options.map((o) => {
|
|
87
|
+
const on = o.id === value
|
|
88
|
+
return (
|
|
89
|
+
<button
|
|
90
|
+
key={o.id}
|
|
91
|
+
type="button"
|
|
92
|
+
role="tab"
|
|
93
|
+
aria-selected={on}
|
|
94
|
+
onClick={() => onChange(o.id)}
|
|
95
|
+
className={clsx(
|
|
96
|
+
'inline-flex items-center gap-1.5 rounded-md px-3 py-1 text-sm font-medium transition-colors',
|
|
97
|
+
on ? 'bg-theme-surface text-theme-text-primary shadow-theme-sm' : 'text-theme-text-secondary hover:text-theme-text-primary',
|
|
98
|
+
)}
|
|
99
|
+
>
|
|
100
|
+
{o.label}
|
|
101
|
+
{o.count !== undefined && <span className="font-mono text-xs text-theme-text-tertiary">{o.count}</span>}
|
|
102
|
+
</button>
|
|
103
|
+
)
|
|
104
|
+
})}
|
|
105
|
+
</div>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface TableColumn<T> {
|
|
110
|
+
header: ReactNode
|
|
111
|
+
width?: string
|
|
112
|
+
cell: (row: T) => ReactNode
|
|
113
|
+
className?: string
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A workspace table. Rows inspect in the drawer; the inspected row is highlighted. */
|
|
117
|
+
export function SectionTable<T>({
|
|
118
|
+
title,
|
|
119
|
+
subtitle,
|
|
120
|
+
columns,
|
|
121
|
+
rows,
|
|
122
|
+
rowKey,
|
|
123
|
+
rowResource,
|
|
124
|
+
onInspect,
|
|
125
|
+
inspected,
|
|
126
|
+
empty,
|
|
127
|
+
minWidth = 760,
|
|
128
|
+
footer,
|
|
129
|
+
}: {
|
|
130
|
+
title: ReactNode
|
|
131
|
+
subtitle?: ReactNode
|
|
132
|
+
columns: TableColumn<T>[]
|
|
133
|
+
rows: T[]
|
|
134
|
+
rowKey: (row: T) => string
|
|
135
|
+
rowResource?: (row: T) => SelectedResource | null
|
|
136
|
+
onInspect?: (resource: SelectedResource) => void
|
|
137
|
+
inspected?: SelectedResource | null
|
|
138
|
+
empty: ReactNode
|
|
139
|
+
minWidth?: number
|
|
140
|
+
footer?: ReactNode
|
|
141
|
+
}) {
|
|
142
|
+
return (
|
|
143
|
+
<section>
|
|
144
|
+
<div className="mb-2 flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
|
|
145
|
+
<h2 className="text-sm font-semibold text-theme-text-primary">{title}</h2>
|
|
146
|
+
{subtitle && <span className="text-xs text-theme-text-tertiary">{subtitle}</span>}
|
|
147
|
+
</div>
|
|
148
|
+
<div className="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm">
|
|
149
|
+
{rows.length === 0 ? (
|
|
150
|
+
<div className="px-4 py-5 text-sm text-theme-text-tertiary">{empty}</div>
|
|
151
|
+
) : (
|
|
152
|
+
<div className={TABLE_WRAP}>
|
|
153
|
+
<table className="w-full table-fixed" style={{ minWidth }}>
|
|
154
|
+
<colgroup>
|
|
155
|
+
{columns.map((c, i) => (
|
|
156
|
+
<col key={i} style={c.width ? { width: c.width } : undefined} />
|
|
157
|
+
))}
|
|
158
|
+
</colgroup>
|
|
159
|
+
<thead className={TABLE_HEAD}>
|
|
160
|
+
<tr>
|
|
161
|
+
{columns.map((c, i) => (
|
|
162
|
+
<th key={i} className={TH}>{c.header}</th>
|
|
163
|
+
))}
|
|
164
|
+
</tr>
|
|
165
|
+
</thead>
|
|
166
|
+
<tbody className={TBODY}>
|
|
167
|
+
{rows.map((row) => {
|
|
168
|
+
const res = rowResource?.(row) ?? null
|
|
169
|
+
const active = !!res && sameSelectedResource(inspected, res)
|
|
170
|
+
return (
|
|
171
|
+
<tr
|
|
172
|
+
key={rowKey(row)}
|
|
173
|
+
onClick={res && onInspect ? () => onInspect(res) : undefined}
|
|
174
|
+
className={clsx(res && onInspect && 'cursor-pointer', ROW_HOVER, active && 'selection')}
|
|
175
|
+
aria-selected={res ? active : undefined}
|
|
176
|
+
>
|
|
177
|
+
{columns.map((c, i) => (
|
|
178
|
+
<td key={i} className={clsx(TD, c.className)}>{c.cell(row)}</td>
|
|
179
|
+
))}
|
|
180
|
+
</tr>
|
|
181
|
+
)
|
|
182
|
+
})}
|
|
183
|
+
</tbody>
|
|
184
|
+
</table>
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
{footer && <div className="mt-1.5 text-xs text-theme-text-tertiary">{footer}</div>}
|
|
189
|
+
</section>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Text that wraps only after `after` ("/" for a path, "-" for a resource
|
|
195
|
+
* name), never mid-word; a single segment too long for its cell is cut with an
|
|
196
|
+
* ellipsis. The whole value shows on hover.
|
|
197
|
+
*/
|
|
198
|
+
export function BreakText({ value, after, className }: { value: string; after: '/' | '-'; className?: string }) {
|
|
199
|
+
const parts = value.split(after === '/' ? /(?<=\/)/ : /(?<=-)/)
|
|
200
|
+
return (
|
|
201
|
+
<Tooltip content={value} wrapperClassName="max-w-full">
|
|
202
|
+
<span className={clsx('block max-w-full', className)}>
|
|
203
|
+
{parts.map((p, i) => (
|
|
204
|
+
<span key={i} className="inline-block max-w-full truncate align-top">
|
|
205
|
+
{p}
|
|
206
|
+
</span>
|
|
207
|
+
))}
|
|
208
|
+
</span>
|
|
209
|
+
</Tooltip>
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** A URL or path; see BreakText. */
|
|
214
|
+
export function PathText({ value, className }: { value: string; className?: string }) {
|
|
215
|
+
return <BreakText value={value} after="/" className={clsx('font-mono text-[12.5px]', className)} />
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* A grant as one unit: the verb and resource in a code span that does not
|
|
220
|
+
* wrap, its scope ("cluster-wide", "in namespace pg") as plain text after it.
|
|
221
|
+
*/
|
|
222
|
+
export function GrantText({ grant }: { grant: Grant }) {
|
|
223
|
+
const { what, scope } = grantParts(grant)
|
|
224
|
+
return (
|
|
225
|
+
<>
|
|
226
|
+
<code className="whitespace-nowrap rounded bg-theme-elevated px-1 font-mono text-[12px]">{what}</code>
|
|
227
|
+
{scope}
|
|
228
|
+
</>
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function Mono({ children }: { children: ReactNode }) {
|
|
233
|
+
return <Tooltip content={children} wrapperClassName="min-w-0"><span className="font-mono text-[12.5px] break-normal [overflow-wrap:anywhere]">{children}</span></Tooltip>
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function Sub({ children }: { children: ReactNode }) {
|
|
237
|
+
return <div className="mt-0.5 text-xs text-theme-text-tertiary break-words">{children}</div>
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
type RefreshableQuery = Pick<UseQueryResult<unknown>, 'isRefetchError' | 'error' | 'dataUpdatedAt'>
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* A refetch failed while the last good answer stays on screen: say so, why,
|
|
244
|
+
* and how old that answer is, so cached values are not read as current.
|
|
245
|
+
*/
|
|
246
|
+
export function RefreshFailedNotice({ queries, className }: { queries: RefreshableQuery[]; className?: string }) {
|
|
247
|
+
const failed = queries.filter((q) => q.isRefetchError)
|
|
248
|
+
if (failed.length === 0) return null
|
|
249
|
+
const oldest = failed.reduce((a, b) => (b.dataUpdatedAt < a.dataUpdatedAt ? b : a))
|
|
250
|
+
const reason = oldest.error instanceof Error ? oldest.error.message : 'unknown error'
|
|
251
|
+
return (
|
|
252
|
+
<div role="status" className={clsx('flex items-start gap-1.5 text-xs text-theme-text-secondary', className)}>
|
|
253
|
+
<AlertTriangle className={clsx('mt-px h-3.5 w-3.5 shrink-0', toneTextClass('degraded'))} />
|
|
254
|
+
<span>
|
|
255
|
+
Last refresh failed: {reason.length > 160 ? `${reason.slice(0, 160)}…` : reason} · showing data from {formatUpdatedAgo(Date.now() - oldest.dataUpdatedAt)}
|
|
256
|
+
</span>
|
|
257
|
+
</div>
|
|
258
|
+
)
|
|
259
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const TABLE_WRAP = 'overflow-x-auto';
|
|
2
|
+
export const TABLE_HEAD =
|
|
3
|
+
'border-b border-theme-border bg-theme-base/60 text-[11px] uppercase tracking-wide text-theme-text-tertiary';
|
|
4
|
+
export const TH = 'px-3 py-2.5 text-left font-medium whitespace-nowrap';
|
|
5
|
+
export const TD = 'px-3 py-2.5 align-top text-sm text-theme-text-primary';
|
|
6
|
+
export const TBODY = 'table-divide-subtle';
|
|
7
|
+
export const ROW_HOVER = 'transition-colors hover:bg-theme-hover/50';
|
|
@@ -155,7 +155,7 @@ export function useNamespacedCapabilities(namespace: string | undefined) {
|
|
|
155
155
|
const { data: nsCaps, error, isPending } = useNamespaceCapabilities(namespace, globalCaps)
|
|
156
156
|
|
|
157
157
|
if (error) {
|
|
158
|
-
console.warn(
|
|
158
|
+
console.warn('Failed to fetch namespace capabilities, using global:', namespace, error)
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
return useMemo(() => ({
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import {
|
|
4
|
+
composeDetailSlots, composeResourceHosts, hostsForResource, resourceHostForSlot,
|
|
5
|
+
type ResourceHost,
|
|
6
|
+
} from './resourceHost'
|
|
7
|
+
|
|
8
|
+
const job = { kind: 'Job', group: 'batch', namespace: 'ml', name: 'run' }
|
|
9
|
+
const jobs = [{ name: 'jobs', group: 'batch' }]
|
|
10
|
+
const Admission = () => <span>admission</span>
|
|
11
|
+
|
|
12
|
+
describe('resource host composition', () => {
|
|
13
|
+
it('allows batch and admission to extend the same Job without hiding the destination owner', () => {
|
|
14
|
+
const hosts: ResourceHost[] = [
|
|
15
|
+
{ id: 'admission', resources: jobs, detailSlots: () => ({ renderHeaderActions: () => <Admission /> }) },
|
|
16
|
+
{ id: 'execution', resources: jobs, detailOwnership: ['destination'], expandedPath: () => '/execution/run', detailSlots: () => ({ renderHeaderActions: () => <span>execution</span>, extraTabs: [{ id: 'runs', label: 'Runs', render: () => null }] }) },
|
|
17
|
+
]
|
|
18
|
+
composeResourceHosts(hosts)
|
|
19
|
+
expect(hostsForResource(hosts, job).map(host => host.id)).toEqual(['admission', 'execution'])
|
|
20
|
+
expect(resourceHostForSlot(hosts, job, 'destination')?.expandedPath?.(job)).toBe('/execution/run')
|
|
21
|
+
const slots = composeDetailSlots(hosts.map(host => ({ id: host.id, slots: host.detailSlots?.(job) })), [{ id: 'history', label: 'History', render: () => null }])
|
|
22
|
+
const html = renderToStaticMarkup(<>{slots.renderHeaderActions?.({ resource: {}, context: 'drawer' })}</>)
|
|
23
|
+
expect(html).toBe('<span>admission</span><span>execution</span>')
|
|
24
|
+
expect(slots.extraTabs?.map(tab => tab.id)).toEqual(['history', 'runs'])
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('requires resolved group identity and normalizes Kind versus resource plural', () => {
|
|
28
|
+
const hosts: ResourceHost[] = [{ id: 'batch', resources: jobs }]
|
|
29
|
+
expect(hostsForResource(hosts, job)).toHaveLength(1)
|
|
30
|
+
expect(hostsForResource(hosts, { ...job, kind: 'jobs' })).toHaveLength(1)
|
|
31
|
+
expect(hostsForResource(hosts, { ...job, group: undefined })).toEqual([])
|
|
32
|
+
expect(hostsForResource(hosts, { ...job, group: '' })).toEqual([])
|
|
33
|
+
expect(hostsForResource(hosts, { ...job, group: 'batch.volcano.sh' })).toEqual([])
|
|
34
|
+
expect(hostsForResource([{ id: 'core', resources: [{ name: 'pods', group: '' }] }], { ...job, kind: 'Pod', group: '' })).toHaveLength(1)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('rejects competing exclusive ownership instead of letting registration order win', () => {
|
|
38
|
+
const owner: ResourceHost = { id: 'execution', resources: jobs, detailOwnership: ['destination'], expandedPath: () => '/execution' }
|
|
39
|
+
const other: ResourceHost = { ...owner, id: 'other' }
|
|
40
|
+
expect(() => composeResourceHosts([owner, other])).toThrow('ownership conflict')
|
|
41
|
+
expect(() => composeResourceHosts([{ id: 'missing', resources: jobs, logs: () => null }])).toThrow('must declare logs ownership')
|
|
42
|
+
expect(() => composeResourceHosts([{ id: 'missing', resources: jobs, expandedPath: () => '/' }])).toThrow('must declare destination ownership')
|
|
43
|
+
expect(() => composeResourceHosts([
|
|
44
|
+
{ id: 'one', renderers: { JobRenderer: Admission } },
|
|
45
|
+
{ id: 'two', renderers: { JobRenderer: Admission } },
|
|
46
|
+
])).toThrow('renderer:JobRenderer')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('catches summary and tab conflicts even if a contribution omitted its declaration', () => {
|
|
50
|
+
expect(() => composeDetailSlots(['one', 'two'].map(id => ({ id, slots: { renderSummary: () => null } })))).toThrow('summary ownership conflict')
|
|
51
|
+
const tab = { id: 'runs', label: 'Runs', render: () => null }
|
|
52
|
+
expect(composeDetailSlots([], [tab, tab]).extraTabs).toEqual([tab, tab])
|
|
53
|
+
expect(() => composeDetailSlots([{ id: 'execution', slots: { extraTabs: [tab] } }], [tab])).toThrow('tab ownership conflict: runs')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createElement, Fragment, type ComponentProps, type ComponentType, type ReactNode } from 'react'
|
|
2
|
+
import type { RendererOverrides, WorkloadExtraTab, WorkloadView } from '@skyhook-io/k8s-ui'
|
|
3
|
+
import type { RadarFeature, RadarFeatureSupport } from '../api/radarFeatures'
|
|
4
|
+
import type { RenderDiagnoseAction } from '../context/DiagnoseCustomization'
|
|
5
|
+
import type { SelectedResource } from '../types'
|
|
6
|
+
import { kindToPluralWithGroup } from '../utils/navigation'
|
|
7
|
+
|
|
8
|
+
export type HostResource = Pick<SelectedResource, 'kind' | 'group' | 'namespace' | 'name'>
|
|
9
|
+
export type DetailSlots = Pick<ComponentProps<typeof WorkloadView>, 'renderSummary' | 'renderHeaderActions' | 'extraTabs'>
|
|
10
|
+
export type HostFeatures = Partial<Record<RadarFeature, RadarFeatureSupport>>
|
|
11
|
+
export type KindListMode = 'table' | 'wait' | 'view'
|
|
12
|
+
export interface HostKind { name: string; kind: string; group: string }
|
|
13
|
+
export interface KindListProps {
|
|
14
|
+
namespaces: string[]
|
|
15
|
+
inspected: SelectedResource | null
|
|
16
|
+
onInspect: (resource: SelectedResource) => void
|
|
17
|
+
onClearNamespaces: () => void
|
|
18
|
+
onCreate: (kind: HostKind) => void
|
|
19
|
+
}
|
|
20
|
+
type ExclusiveDetailSlot = 'summary' | 'destination' | 'logs'
|
|
21
|
+
export interface ResourceHost {
|
|
22
|
+
id: string
|
|
23
|
+
feature?: RadarFeature
|
|
24
|
+
diagnoseAction?: (render: RenderDiagnoseAction | undefined) => RenderDiagnoseAction | undefined
|
|
25
|
+
kindLists?: readonly {
|
|
26
|
+
kind: HostKind
|
|
27
|
+
title: string
|
|
28
|
+
mode: (support: RadarFeatureSupport, pending: boolean) => KindListMode
|
|
29
|
+
Component: ComponentType<KindListProps>
|
|
30
|
+
}[]
|
|
31
|
+
renderers?: RendererOverrides
|
|
32
|
+
resources?: readonly { name: string; group: string }[]
|
|
33
|
+
expandedPath?: (resource: HostResource, context?: string, tab?: 'yaml') => string | null
|
|
34
|
+
detailRedirect?: (resource: HostResource, search: URLSearchParams) => string | null
|
|
35
|
+
detailSlots?: (resource: HostResource) => DetailSlots
|
|
36
|
+
detailOwnership?: readonly ExclusiveDetailSlot[]
|
|
37
|
+
logs?: (resource: HostResource, data: { apiVersion?: string } | null | undefined) => ReactNode
|
|
38
|
+
drawerNavigation?: {
|
|
39
|
+
ownsPath: (pathname: string) => boolean
|
|
40
|
+
Component: ComponentType<{ resource: SelectedResource }>
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function hostSupport(host: ResourceHost, features: HostFeatures): RadarFeatureSupport {
|
|
45
|
+
return host.feature ? features[host.feature] ?? 'unknown' : 'supported'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resourceKey(name: string, group: string): string { return `${group}\u0000${name}` }
|
|
49
|
+
|
|
50
|
+
export function hostsForResource(hosts: readonly ResourceHost[], resource: HostResource): ResourceHost[] {
|
|
51
|
+
// An unresolved group must not select a colliding CRD, or be treated as core.
|
|
52
|
+
if (resource.group === undefined) return []
|
|
53
|
+
const plural = kindToPluralWithGroup(resource.kind, resource.group)
|
|
54
|
+
return hosts.filter(host => host.resources?.some(kind => kind.name === plural && kind.group === resource.group))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function resourceHostForSlot(hosts: readonly ResourceHost[], resource: HostResource, slot: ExclusiveDetailSlot): ResourceHost | undefined {
|
|
58
|
+
return hostsForResource(hosts, resource).find(host => host.detailOwnership?.includes(slot))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function composeResourceHosts(hosts: readonly ResourceHost[]): RendererOverrides {
|
|
62
|
+
const renderers: RendererOverrides = {}
|
|
63
|
+
const owners = new Map<string, string>()
|
|
64
|
+
const claim = (key: string, id: string) => {
|
|
65
|
+
const previous = owners.get(key)
|
|
66
|
+
if (previous) throw new Error(`Resource host ownership conflict: ${previous} and ${id} own ${key}`)
|
|
67
|
+
owners.set(key, id)
|
|
68
|
+
}
|
|
69
|
+
for (const host of hosts) {
|
|
70
|
+
claim(`host:${host.id}`, host.id)
|
|
71
|
+
if ((host.expandedPath || host.detailRedirect) && !host.detailOwnership?.includes('destination')) throw new Error(`Resource host ${host.id} must declare destination ownership`)
|
|
72
|
+
if (host.logs && !host.detailOwnership?.includes('logs')) throw new Error(`Resource host ${host.id} must declare logs ownership`)
|
|
73
|
+
for (const name of Object.keys(host.renderers ?? {})) claim(`renderer:${name}`, host.id)
|
|
74
|
+
Object.assign(renderers, host.renderers)
|
|
75
|
+
for (const kind of host.resources ?? []) {
|
|
76
|
+
for (const slot of host.detailOwnership ?? []) claim(`${slot}:${resourceKey(kind.name, kind.group)}`, host.id)
|
|
77
|
+
}
|
|
78
|
+
for (const list of host.kindLists ?? []) claim(`kind-list:${resourceKey(list.kind.name, list.kind.group)}`, host.id)
|
|
79
|
+
}
|
|
80
|
+
return renderers
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function composeDetailSlots(contributions: readonly { id: string; slots: DetailSlots | undefined }[], inheritedTabs: WorkloadExtraTab[] = []): DetailSlots {
|
|
84
|
+
const summaries = contributions.filter(host => host.slots?.renderSummary)
|
|
85
|
+
if (summaries.length > 1) throw new Error(`Resource host summary ownership conflict: ${summaries.map(host => host.id).join(', ')}`)
|
|
86
|
+
const summary = summaries[0]?.slots?.renderSummary
|
|
87
|
+
const actions = contributions.filter(host => host.slots?.renderHeaderActions)
|
|
88
|
+
const tabs = [...inheritedTabs, ...contributions.flatMap(host => host.slots?.extraTabs ?? [])]
|
|
89
|
+
const tabIds = new Set(inheritedTabs.map(tab => tab.id))
|
|
90
|
+
for (const tab of contributions.flatMap(host => host.slots?.extraTabs ?? [])) {
|
|
91
|
+
if (tabIds.has(tab.id)) throw new Error(`Resource host tab ownership conflict: ${tab.id}`)
|
|
92
|
+
tabIds.add(tab.id)
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
...(summary ? { renderSummary: summary } : {}),
|
|
96
|
+
...(actions.length ? { renderHeaderActions: (props: Parameters<NonNullable<DetailSlots['renderHeaderActions']>>[0]) => actions.map(host => createElement(Fragment, { key: host.id }, host.slots!.renderHeaderActions!(props))) } : {}),
|
|
97
|
+
...(tabs.length ? { extraTabs: tabs } : {}),
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { isValidElement } from 'react'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
import { MemoryRouter } from 'react-router-dom'
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { ResourceRendererDispatch } from '@skyhook-io/k8s-ui'
|
|
6
|
+
import { encodeDrawerTrail } from '../utils/drawer-trail'
|
|
7
|
+
import { kindToPluralWithGroup } from '../utils/navigation'
|
|
8
|
+
import {
|
|
9
|
+
decorateResourceDiagnose, renderResourceKindList, resourceKindListMode, resourceKindListTitle, ResourceDrawerNavigation,
|
|
10
|
+
resourceDetailRedirect, resourceDetailSlots, resourceExpandedPath,
|
|
11
|
+
resourceLogs, resourceRendererOverrides,
|
|
12
|
+
} from './resourceHosts'
|
|
13
|
+
|
|
14
|
+
vi.mock('../api/client', () => ({
|
|
15
|
+
ApiError: class extends Error { status = 500 },
|
|
16
|
+
useKueueAdmission: () => ({ data: { uid: 'job-id', installed: true, workloads: [], total: 0 }, isLoading: false, refetch: () => {} }),
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
import type { HostFeatures } from './resourceHost'
|
|
20
|
+
|
|
21
|
+
const cluster = { kind: 'Cluster', group: 'postgresql.cnpg.io', namespace: 'db', name: 'pg' }
|
|
22
|
+
|
|
23
|
+
const cnpgKind = { name: 'clusters', group: cluster.group }
|
|
24
|
+
const supported: HostFeatures = { cnpgWorkspace: 'supported' }
|
|
25
|
+
const unsupported: HostFeatures = { cnpgWorkspace: 'unsupported' }
|
|
26
|
+
const unknown: HostFeatures = { cnpgWorkspace: 'unknown' }
|
|
27
|
+
const listProps = { namespaces: ['db'], inspected: null, onInspect: () => {}, onClearNamespaces: () => {}, onCreate: () => {} }
|
|
28
|
+
|
|
29
|
+
function renderResource(data: any) {
|
|
30
|
+
const group = data.apiVersion.split('/')[0]
|
|
31
|
+
return renderToStaticMarkup(<MemoryRouter><ResourceRendererDispatch
|
|
32
|
+
resource={{ kind: kindToPluralWithGroup(data.kind, group), namespace: 'ml', name: 'run', group }}
|
|
33
|
+
data={data} onCopy={() => {}} copied={null} showCommonSections={false}
|
|
34
|
+
rendererOverrides={resourceRendererOverrides}
|
|
35
|
+
/></MemoryRouter>)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('application resource hosts', () => {
|
|
39
|
+
it('preserves existing CNPG access and exact group collisions without a new feature flag', () => {
|
|
40
|
+
expect(resourceExpandedPath(cluster, 'kind-demo', 'yaml')).toBe('/cnpg/clusters/db/pg?ctx=kind-demo&tab=yaml')
|
|
41
|
+
expect(resourceDetailSlots(cluster).renderSummary).toBeDefined()
|
|
42
|
+
for (const group of [undefined, '', 'cluster.x-k8s.io']) {
|
|
43
|
+
expect(resourceExpandedPath({ ...cluster, group })).toBeNull()
|
|
44
|
+
expect(resourceDetailSlots({ ...cluster, group })).toEqual({})
|
|
45
|
+
}
|
|
46
|
+
expect(resourceExpandedPath({ kind: 'NodePool', group: 'karpenter.sh', namespace: '', name: 'pool' })).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('retains every existing renderer override at a stable module reference', () => {
|
|
50
|
+
expect(Object.keys(resourceRendererOverrides).sort()).toEqual([
|
|
51
|
+
'RayJobRenderer', 'JobRenderer', 'JobSetRenderer', 'RayServiceRenderer', 'RayClusterRenderer',
|
|
52
|
+
'KueueWorkloadRenderer', 'CAPIClusterRenderer', 'PodRenderer', 'KarpenterNodePoolRenderer',
|
|
53
|
+
'NodeRenderer', 'ServiceRenderer', 'WorkloadRenderer', 'CompositeRenderer', 'ServiceAccountRenderer',
|
|
54
|
+
'RoleRenderer', 'RoleBindingRenderer', 'NamespaceRenderer', 'HPARenderer', 'PVCRenderer',
|
|
55
|
+
'RolloutRenderer', 'KyvernoPolicyCoverage', 'KyvernoPolicyQueued', 'CNPGObjectStoreRenderer',
|
|
56
|
+
'VeleroBSLRenderer', 'VeleroBackupRenderer', 'VeleroRestoreRenderer', 'CNPGClusterRenderer',
|
|
57
|
+
'CNPGDatabaseRenderer', 'CNPGPublicationRenderer', 'CNPGSubscriptionRenderer', 'CNPGImageCatalogRenderer',
|
|
58
|
+
].sort())
|
|
59
|
+
const inherited = [{ id: 'local', label: 'Local', render: () => null }]
|
|
60
|
+
expect(resourceDetailSlots({ kind: 'Pod', group: '', namespace: 'db', name: 'pg-1' }, inherited).extraTabs).toEqual(inherited)
|
|
61
|
+
expect(resourceExpandedPath({ ...cluster, group: undefined })).toBeNull()
|
|
62
|
+
expect(resourceDetailRedirect({ ...cluster, group: '' }, new URLSearchParams('tab=events'))).toBeNull()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('keeps query, tab, context and drawer state when redirecting a generic detail link', () => {
|
|
66
|
+
const search = new URLSearchParams('apiGroup=postgresql.cnpg.io&ctx=kind-demo&tab=events&drawer=pods%3Apg-1&ai-run=run')
|
|
67
|
+
const path = resourceDetailRedirect({ ...cluster, kind: 'clusters' }, search)!
|
|
68
|
+
const url = new URL(path, 'http://radar.test')
|
|
69
|
+
expect(url.pathname).toBe('/cnpg/clusters/db/pg')
|
|
70
|
+
expect(Object.fromEntries(url.searchParams)).toEqual({ ctx: 'kind-demo', tab: 'activity', drawer: 'pods:pg-1', 'ai-run': 'run' })
|
|
71
|
+
expect(search.get('tab')).toBe('events')
|
|
72
|
+
expect(search.get('apiGroup')).toBe('postgresql.cnpg.io')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('keeps integration log selection confined to observed CNPG Clusters and falls back for other resources', () => {
|
|
76
|
+
expect(isValidElement(resourceLogs(cluster, { apiVersion: 'postgresql.cnpg.io/v1' }))).toBe(true)
|
|
77
|
+
expect(isValidElement(resourceLogs({ ...cluster, group: undefined }, { apiVersion: 'postgresql.cnpg.io/v1' }))).toBe(true)
|
|
78
|
+
expect(resourceLogs(cluster, { apiVersion: 'cluster.x-k8s.io/v1beta1' })).toBeNull()
|
|
79
|
+
expect(resourceLogs({ ...cluster, kind: 'Pooler' }, { apiVersion: 'postgresql.cnpg.io/v1' })).toBeNull()
|
|
80
|
+
expect(resourceLogs({ kind: 'Pod', group: '', namespace: 'db', name: 'pg-1' }, { apiVersion: 'v1' })).toBeNull()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('mounts drawer trail navigation for any inspected resource in its owning workspace, including embeds', () => {
|
|
84
|
+
const previous = { kind: 'backups', group: 'postgresql.cnpg.io', namespace: 'db', name: 'base' }
|
|
85
|
+
const pod = { kind: 'pods', group: '', namespace: 'db', name: 'pg-1' }
|
|
86
|
+
const search = `?drawer=${encodeURIComponent(encodeDrawerTrail([previous, pod]))}`
|
|
87
|
+
const html = renderToStaticMarkup(<MemoryRouter basename="/c/demo" initialEntries={[`/c/demo/cnpg/protection${search}`]}><ResourceDrawerNavigation resource={pod} /></MemoryRouter>)
|
|
88
|
+
expect(html).toContain('Backup base')
|
|
89
|
+
const ordinary = renderToStaticMarkup(<MemoryRouter initialEntries={[`/resources/pods${search}`]}><ResourceDrawerNavigation resource={pod} /></MemoryRouter>)
|
|
90
|
+
expect(ordinary).toBe('')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('preserves shared version dispatch and simultaneous batch/Kueue detail in the stable override map', () => {
|
|
94
|
+
const data = { kind: 'Job', apiVersion: 'batch/v1', metadata: { name: 'run', namespace: 'ml', uid: 'job-id', labels: { 'kueue.x-k8s.io/queue-name': 'queue' } }, spec: { completions: 1 }, status: { active: 1 } }
|
|
95
|
+
const job = renderResource(data)
|
|
96
|
+
expect(job).toContain('Completions')
|
|
97
|
+
expect(job).toContain('No controller-owned Kueue Workload observed')
|
|
98
|
+
const jobSet = { ...data, kind: 'JobSet', apiVersion: 'jobset.x-k8s.io/v1alpha2', spec: { replicatedJobs: [] } }
|
|
99
|
+
const current = renderResource(jobSet)
|
|
100
|
+
expect(current).toContain('JobSet status')
|
|
101
|
+
expect(current).toContain('No controller-owned Kueue Workload observed')
|
|
102
|
+
for (const apiVersion of ['jobset.x-k8s.io/v1beta1', 'example.io/v1alpha2']) {
|
|
103
|
+
const generic = renderResource({ ...jobSet, apiVersion })
|
|
104
|
+
expect(generic).not.toContain('JobSet status')
|
|
105
|
+
expect(generic).not.toContain('No controller-owned Kueue Workload observed')
|
|
106
|
+
}
|
|
107
|
+
const foreignJob = renderResource({ ...data, apiVersion: 'batch.volcano.sh/v1alpha1' })
|
|
108
|
+
expect(foreignJob).toContain('Specification')
|
|
109
|
+
expect(foreignJob).not.toContain('No controller-owned Kueue Workload observed')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('makes the same kind-list decision for fetching and rendering across feature states', () => {
|
|
113
|
+
for (const [features, pending, mode] of [
|
|
114
|
+
[supported, true, 'view'], [supported, false, 'view'],
|
|
115
|
+
[unknown, true, 'wait'], [unknown, false, 'table'],
|
|
116
|
+
[unsupported, true, 'table'], [unsupported, false, 'table'],
|
|
117
|
+
] as const) {
|
|
118
|
+
expect(resourceKindListMode(cnpgKind, features, pending)).toBe(mode)
|
|
119
|
+
const view = renderResourceKindList(cnpgKind, features, pending, listProps)
|
|
120
|
+
expect(view !== null).toBe(mode !== 'table')
|
|
121
|
+
if (mode === 'wait') expect(renderToStaticMarkup(view)).toContain('Loading…')
|
|
122
|
+
}
|
|
123
|
+
for (const group of ['', 'cluster.x-k8s.io']) {
|
|
124
|
+
expect(resourceKindListMode({ ...cnpgKind, group }, supported, true)).toBe('table')
|
|
125
|
+
expect(renderResourceKindList({ ...cnpgKind, group }, supported, true, listProps)).toBeNull()
|
|
126
|
+
expect(resourceKindListTitle({ ...cnpgKind, group })).toBeNull()
|
|
127
|
+
}
|
|
128
|
+
expect(resourceKindListTitle(cnpgKind)).toBe('CloudNativePG Clusters')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('decorates Diagnose only for the integration-owned resource context', () => {
|
|
132
|
+
const render = vi.fn(() => <span>diagnose</span>)
|
|
133
|
+
const decorated = decorateResourceDiagnose(render)!
|
|
134
|
+
const context = { ...cluster, compact: false }
|
|
135
|
+
expect(isValidElement(decorated(context))).toBe(true)
|
|
136
|
+
expect(render).not.toHaveBeenCalled()
|
|
137
|
+
decorated({ ...context, group: 'cluster.x-k8s.io' })
|
|
138
|
+
expect(render).toHaveBeenCalledOnce()
|
|
139
|
+
expect(decorateResourceDiagnose(undefined)).toBeUndefined()
|
|
140
|
+
})
|
|
141
|
+
})
|