@skyhook-io/k8s-ui 1.16.3 → 1.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -0
- package/package.json +3 -3
- package/src/components/applications/ApplicationDetail.test.tsx +15 -0
- package/src/components/applications/ApplicationDetail.tsx +17 -15
- package/src/components/charts/AreaChart.interaction.test.tsx +118 -0
- package/src/components/charts/AreaChart.test.tsx +25 -0
- package/src/components/charts/AreaChart.tsx +141 -12
- package/src/components/charts/index.ts +1 -0
- package/src/components/checks/ChecksView.tsx +18 -1
- package/src/components/checks/types.ts +2 -13
- package/src/components/cnpg/CNPGBackupSummary.tsx +57 -9
- package/src/components/cnpg/CNPGClusterHASection.test.tsx +90 -0
- package/src/components/cnpg/CNPGClusterHASection.tsx +331 -0
- package/src/components/cnpg/CNPGClusterSummary.test.tsx +275 -0
- package/src/components/cnpg/CNPGClusterSummary.tsx +310 -96
- package/src/components/cnpg/CNPGConnectSection.test.tsx +64 -0
- package/src/components/cnpg/CNPGConnectSection.tsx +178 -0
- package/src/components/cnpg/CNPGDeclarativeSummary.tsx +157 -26
- package/src/components/cnpg/CNPGImageCatalogSummary.tsx +7 -4
- package/src/components/cnpg/CNPGLogicalPath.test.tsx +22 -0
- package/src/components/cnpg/CNPGLogicalPath.tsx +150 -0
- package/src/components/cnpg/CNPGObjectStoreSummary.tsx +9 -6
- package/src/components/cnpg/CNPGObjectSummary.test.tsx +193 -2
- package/src/components/cnpg/CNPGPoolerScheduling.test.tsx +36 -0
- package/src/components/cnpg/CNPGPoolerSummary.tsx +241 -14
- package/src/components/cnpg/CNPGSharedSummary.tsx +15 -8
- package/src/components/cnpg/CNPGWALArchivingFact.test.tsx +39 -0
- package/src/components/cnpg/backupRuns.test.ts +17 -0
- package/src/components/cnpg/backupRuns.ts +19 -0
- package/src/components/cnpg/connect.test.ts +124 -0
- package/src/components/cnpg/connect.ts +155 -0
- package/src/components/cnpg/databaseRole.test.ts +38 -0
- package/src/components/cnpg/databaseRole.ts +80 -0
- package/src/components/cnpg/ha.test.ts +446 -0
- package/src/components/cnpg/ha.ts +621 -0
- package/src/components/cnpg/index.ts +18 -0
- package/src/components/cnpg/logicalReplication.test.ts +121 -0
- package/src/components/cnpg/logicalReplication.ts +252 -0
- package/src/components/cnpg/pooler.test.ts +84 -0
- package/src/components/cnpg/pooler.ts +225 -0
- package/src/components/cnpg/primitives.tsx +21 -117
- package/src/components/cnpg/problems.ts +12 -0
- package/src/components/cnpg/relations.test.ts +63 -13
- package/src/components/cnpg/relations.ts +61 -52
- package/src/components/cnpg/schedule.ts +35 -0
- package/src/components/cnpg/workspace-disk.test.ts +97 -0
- package/src/components/cnpg/workspace-fleet-metrics.test.ts +250 -0
- package/src/components/cnpg/workspace-problems.test.ts +175 -0
- package/src/components/cnpg/workspace.test.ts +380 -9
- package/src/components/cnpg/workspace.ts +1137 -136
- package/src/components/dock/DockContext.test.tsx +32 -0
- package/src/components/dock/DockContext.tsx +18 -4
- package/src/components/dock/LocalTerminalTab.test.tsx +40 -0
- package/src/components/dock/LocalTerminalTab.tsx +4 -3
- package/src/components/dock/TerminalTab.tsx +20 -8
- package/src/components/facts/certainty.tsx +36 -0
- package/src/components/facts/facts.test.tsx +24 -0
- package/src/components/facts/facts.tsx +62 -0
- package/src/components/facts/index.ts +6 -0
- package/src/components/facts/managed-by.test.tsx +17 -0
- package/src/components/facts/managed-by.tsx +36 -0
- package/src/components/facts/public-exports.test.tsx +35 -0
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +6 -2
- package/src/components/gitops/GitOpsHealthSourceNotice.test.tsx +38 -0
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +13 -3
- package/src/components/gitops/GitOpsTableView.tsx +9 -24
- package/src/components/gitops/detail-helpers.ts +1 -1
- package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +115 -0
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +22 -5
- package/src/components/gitops/insights/DriftPanel.tsx +62 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +29 -83
- package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +26 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +40 -60
- package/src/components/gitops/tree/layout.test.ts +111 -0
- package/src/components/gitops/tree/layout.ts +128 -0
- package/src/components/gitops/tree/tree-helpers.test.ts +17 -0
- package/src/components/gitops/tree/tree-helpers.ts +7 -1
- package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
- package/src/components/issues/IssuesView.tsx +40 -5
- package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
- package/src/components/issues/ResourceIssuesSection.tsx +3 -0
- package/src/components/issues/index.ts +3 -1
- package/src/components/issues/issue-timing.ts +14 -3
- package/src/components/issues/issues.test.ts +50 -2
- package/src/components/issues/severity.ts +21 -0
- package/src/components/issues/types.ts +23 -0
- package/src/components/logs/LogCore.theme.test.tsx +81 -3
- package/src/components/logs/LogCore.tsx +25 -17
- package/src/components/logs/LogToolbarSelects.tsx +3 -1
- package/src/components/logs/LogsViewer.test.tsx +291 -0
- package/src/components/logs/LogsViewer.tsx +72 -11
- package/src/components/logs/StructuredLogLine.test.tsx +76 -0
- package/src/components/logs/StructuredLogLine.tsx +22 -30
- package/src/components/logs/WorkloadLogsViewer.notice.test.tsx +59 -0
- package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +44 -10
- package/src/components/logs/useLogBuffer.test.ts +8 -0
- package/src/components/logs/useLogStream.ts +28 -1
- package/src/components/problems/index.ts +1 -0
- package/src/components/problems/problems.test.tsx +63 -0
- package/src/components/problems/problems.tsx +165 -0
- package/src/components/resources/ActiveFilterBar.test.tsx +78 -0
- package/src/components/resources/ActiveFilterBar.tsx +86 -0
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
- package/src/components/resources/ResourcesSidebar.tsx +92 -41
- package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -0
- package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
- package/src/components/resources/ResourcesView.tsx +309 -117
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/renderers/AnalysisRunRenderer.tsx +3 -3
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +6 -5
- package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +5 -4
- package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
- package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachineRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +2 -2
- package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +7 -8
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +9 -17
- package/src/components/resources/renderers/CNPGShared.tsx +2 -2
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
- package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
- package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
- package/src/components/resources/renderers/CronJobRenderer.tsx +6 -4
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +3 -2
- package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +62 -0
- package/src/components/resources/renderers/EndpointSliceRenderer.tsx +15 -14
- package/src/components/resources/renderers/EventRenderer.tsx +3 -3
- package/src/components/resources/renderers/ExperimentRenderer.tsx +2 -2
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +2 -2
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -4
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +11 -24
- package/src/components/resources/renderers/GatewayRouteReferences.test.tsx +63 -0
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -3
- package/src/components/resources/renderers/HPARenderer.tsx +57 -17
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +12 -25
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -3
- package/src/components/resources/renderers/IngressClassReferences.test.tsx +52 -0
- package/src/components/resources/renderers/IngressClassRenderer.tsx +10 -3
- package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
- package/src/components/resources/renderers/IngressRenderer.tsx +36 -5
- package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
- package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -14
- package/src/components/resources/renderers/KedaCredentials.test.tsx +85 -0
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +15 -13
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +6 -3
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
- package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
- package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
- package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
- package/src/components/resources/renderers/KustomizationRenderer.tsx +2 -2
- package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +3 -2
- package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +4 -3
- package/src/components/resources/renderers/LeaseRenderer.tsx +3 -15
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
- package/src/components/resources/renderers/NamespaceRenderer.test.ts +69 -2
- package/src/components/resources/renderers/NamespaceRenderer.tsx +6 -1
- package/src/components/resources/renderers/NodeRenderer.test.tsx +81 -1
- package/src/components/resources/renderers/NodeRenderer.tsx +56 -15
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -3
- package/src/components/resources/renderers/PodClassReferences.test.tsx +42 -0
- package/src/components/resources/renderers/PodNomination.test.tsx +57 -0
- package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/PodRenderer.tsx +62 -36
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
- package/src/components/resources/renderers/RolloutRenderer.tsx +3 -2
- package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/SecretRenderer.test.tsx +88 -0
- package/src/components/resources/renderers/SecretRenderer.tsx +34 -5
- package/src/components/resources/renderers/ServiceRenderer.test.tsx +43 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +11 -3
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +9 -22
- package/src/components/resources/renderers/VPARenderer.tsx +17 -11
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +5 -7
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +3 -3
- package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +2 -2
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +3 -3
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +2 -2
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkflowRenderer.tsx +4 -3
- package/src/components/resources/renderers/kyverno-modern-cells.tsx +1 -1
- package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +2 -2
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
- package/src/components/resources/resource-utils-cnpg.ts +51 -24
- package/src/components/resources/resource-utils-contour.test.ts +44 -0
- package/src/components/resources/resource-utils-contour.ts +16 -0
- package/src/components/resources/resource-utils-istio.ts +12 -8
- package/src/components/resources/resource-utils-keda.ts +1 -7
- package/src/components/resources/resource-utils-knative.ts +18 -0
- package/src/components/resources/resource-utils-kyverno.ts +1 -7
- package/src/components/resources/resource-utils-velero.ts +0 -12
- package/src/components/resources/resource-utils.ts +99 -39
- package/src/components/resources/rules-column-width.test.ts +1 -1
- package/src/components/resources/summarize-scheduler-message.test.ts +30 -0
- package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
- package/src/components/shared/ActionConfirmDialog.tsx +246 -0
- package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
- package/src/components/shared/CreateResourceDialog.tsx +126 -80
- package/src/components/shared/DetailShell.test.tsx +80 -0
- package/src/components/shared/DetailShell.tsx +85 -10
- package/src/components/shared/DrainPlanDialog.tsx +1 -0
- package/src/components/shared/EditableYamlView.test.tsx +7 -0
- package/src/components/shared/EditableYamlView.tsx +1 -1
- package/src/components/shared/GitOpsWriteWarning.test.tsx +86 -0
- package/src/components/shared/GitOpsWriteWarning.tsx +135 -0
- package/src/components/shared/ResourceActionsBar.tsx +20 -24
- package/src/components/shared/ResourceRendererDispatch.tsx +13 -10
- package/src/components/shared/SetImageDialog.test.ts +75 -6
- package/src/components/shared/SetImageDialog.tsx +92 -84
- package/src/components/shared/index.ts +4 -1
- package/src/components/timeline/TimelineList.tsx +2 -15
- package/src/components/timeline/TimelineSwimlanes.tsx +3 -11
- package/src/components/timeline/timeline-filters.ts +1 -0
- package/src/components/topology/K8sResourceNode.test.ts +18 -1
- package/src/components/topology/K8sResourceNode.tsx +44 -2
- package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
- package/src/components/topology/TopologyGraph.tsx +8 -74
- package/src/components/topology/TopologySearch.tsx +1 -0
- package/src/components/topology/internet-groups.test.ts +35 -0
- package/src/components/topology/internet-groups.ts +55 -0
- package/src/components/topology/layout.ts +1 -1
- package/src/components/ui/Badge.tsx +1 -0
- package/src/components/ui/FoldSection.tsx +68 -0
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
- package/src/components/ui/RefLink.tsx +20 -0
- package/src/components/ui/RowActionMenu.tsx +8 -3
- package/src/components/ui/ScheduleValue.test.tsx +5 -0
- package/src/components/ui/ScheduleValue.tsx +3 -2
- package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
- package/src/components/ui/drawer-components.test.tsx +175 -1
- package/src/components/ui/drawer-components.tsx +103 -36
- package/src/components/ui/index.ts +3 -1
- package/src/components/ui/monacoEditorFeatures.ts +77 -0
- package/src/components/ui/monacoRuntime.ts +14 -19
- package/src/components/ui/status-tone.test.ts +10 -1
- package/src/components/ui/status-tone.tsx +29 -0
- package/src/components/workload/WorkloadView.deleted.test.tsx +101 -0
- package/src/components/workload/WorkloadView.deletedDialog.test.tsx +45 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
- package/src/components/workload/WorkloadView.secretSave.test.tsx +62 -0
- package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
- package/src/components/workload/WorkloadView.tsx +297 -104
- package/src/index.ts +14 -1
- package/src/monaco-deep.d.ts +19 -12
- package/src/theme/components.css +13 -0
- package/src/types/capacity.ts +21 -2
- package/src/types/core.ts +60 -2
- package/src/types/gitops-flux-status.test.ts +32 -1
- package/src/types/gitops-insights.ts +19 -2
- package/src/types/gitops-tree.ts +10 -0
- package/src/types/gitops.ts +26 -6
- package/src/utils/api-resources.ts +7 -0
- package/src/utils/application-history.ts +1 -0
- package/src/utils/cnpg-backup.test.ts +26 -0
- package/src/utils/cnpg-backup.ts +70 -0
- package/src/utils/format.test.ts +49 -2
- package/src/utils/format.ts +9 -1
- package/src/utils/gateway-references.test.ts +17 -0
- package/src/utils/gateway-references.ts +29 -0
- package/src/utils/gitops-owner.ts +3 -2
- package/src/utils/gitops-write-guard.test.ts +401 -0
- package/src/utils/gitops-write-guard.ts +509 -0
- package/src/utils/grant.test.ts +19 -0
- package/src/utils/grant.ts +35 -0
- package/src/utils/index.ts +6 -0
- package/src/utils/ingress-class-references.ts +22 -0
- package/src/utils/k8s-errors.ts +7 -7
- package/src/utils/log-format.test.ts +41 -0
- package/src/utils/log-format.ts +71 -15
- package/src/utils/log-level.ts +12 -8
- package/src/utils/navigation.test.ts +70 -0
- package/src/utils/navigation.ts +91 -8
- package/src/utils/node-lifecycle.test.ts +36 -0
- package/src/utils/node-lifecycle.ts +77 -0
- package/src/utils/object-reference-navigation.test.ts +43 -0
- package/src/utils/resource-hierarchy.test.ts +69 -0
- package/src/utils/resource-hierarchy.ts +41 -7
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/resource-refs.test.ts +23 -0
- package/src/utils/resource-refs.ts +30 -0
- package/src/utils/skeleton-yaml.test.ts +21 -0
- package/src/utils/skeleton-yaml.ts +16 -0
- package/src/utils/url-path.test.ts +35 -0
- package/src/utils/url-path.ts +21 -0
- package/src/utils/validators.test.ts +4 -13
- package/src/utils/validators.ts +5 -19
- package/src/utils/view-transition.test.ts +35 -1
- package/src/utils/view-transition.ts +16 -1
|
@@ -28,12 +28,16 @@ export interface ApplyResult {
|
|
|
28
28
|
namespace: string
|
|
29
29
|
kind: string
|
|
30
30
|
apiVersion?: string
|
|
31
|
+
uid?: string
|
|
31
32
|
created: boolean
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
export interface CreateResourceDialogProps {
|
|
35
36
|
open: boolean
|
|
36
37
|
onClose: () => void
|
|
38
|
+
/** Return the current draft to a parent flow without closing that flow. */
|
|
39
|
+
onBack?: (yaml: string) => void
|
|
40
|
+
backLabel?: string
|
|
37
41
|
initialYaml?: string
|
|
38
42
|
title?: string
|
|
39
43
|
onApply: (params: {
|
|
@@ -53,12 +57,19 @@ export interface CreateResourceDialogProps {
|
|
|
53
57
|
isPreviewing?: boolean
|
|
54
58
|
previewError?: string | null
|
|
55
59
|
schemaLoader?: YamlSchemaLoader
|
|
56
|
-
|
|
60
|
+
/** Receives the first result and the exact submitted manifest, after a successful write. */
|
|
61
|
+
onCreated?: (result: ApplyResult, submittedYaml: string) => void
|
|
62
|
+
/** Start in strict create mode (e.g. a prefilled manifest that must not update an existing object). */
|
|
63
|
+
initialMode?: 'apply' | 'create'
|
|
64
|
+
/** Stay in `initialMode` with the Apply/Create choice and Force hidden, even after a partial create (e.g. a new object that must only be created). */
|
|
65
|
+
lockMode?: boolean
|
|
57
66
|
}
|
|
58
67
|
|
|
59
68
|
export function CreateResourceDialog({
|
|
60
69
|
open,
|
|
61
70
|
onClose,
|
|
71
|
+
onBack,
|
|
72
|
+
backLabel = 'Back',
|
|
62
73
|
initialYaml = '',
|
|
63
74
|
title,
|
|
64
75
|
onApply,
|
|
@@ -68,15 +79,18 @@ export function CreateResourceDialog({
|
|
|
68
79
|
previewError,
|
|
69
80
|
schemaLoader,
|
|
70
81
|
onCreated,
|
|
82
|
+
initialMode = 'apply',
|
|
83
|
+
lockMode = false,
|
|
71
84
|
}: CreateResourceDialogProps) {
|
|
72
85
|
const titleId = useId()
|
|
73
86
|
const [yaml, setYaml] = useState(initialYaml)
|
|
74
|
-
const [mode, setMode] = useState<'apply' | 'create'>(
|
|
87
|
+
const [mode, setMode] = useState<'apply' | 'create'>(initialMode)
|
|
75
88
|
const [dryRun, setDryRun] = useState(false)
|
|
76
89
|
const [force, setForce] = useState(false)
|
|
77
90
|
const [yamlValid, setYamlValid] = useState(true)
|
|
78
91
|
const [error, setError] = useState<string | null>(null)
|
|
79
92
|
const [success, setSuccess] = useState<string | null>(null)
|
|
93
|
+
const [partialCreates, setPartialCreates] = useState<ApplyResult[]>([])
|
|
80
94
|
const [preview, setPreview] = useState<{
|
|
81
95
|
yaml: string
|
|
82
96
|
mode: 'apply' | 'create'
|
|
@@ -85,6 +99,7 @@ export function CreateResourceDialog({
|
|
|
85
99
|
nonAtomic: boolean
|
|
86
100
|
context?: string
|
|
87
101
|
} | null>(null)
|
|
102
|
+
const closeTimer = useRef<number | undefined>(undefined)
|
|
88
103
|
const [drag, setDrag] = useState<FileDragState>('none')
|
|
89
104
|
const [pendingImport, setPendingImport] = useState<{ fileName: string; yaml: string } | null>(null)
|
|
90
105
|
const [importing, setImporting] = useState<string | null>(null)
|
|
@@ -110,7 +125,11 @@ export function CreateResourceDialog({
|
|
|
110
125
|
scheduledFrames.current.clear()
|
|
111
126
|
}, [])
|
|
112
127
|
|
|
113
|
-
|
|
128
|
+
const cancelScheduledClose = useCallback(() => {
|
|
129
|
+
window.clearTimeout(closeTimer.current)
|
|
130
|
+
closeTimer.current = undefined
|
|
131
|
+
}, [])
|
|
132
|
+
useEffect(() => () => { retireDeferredImports(); cancelScheduledClose() }, [retireDeferredImports, cancelScheduledClose])
|
|
114
133
|
|
|
115
134
|
const scheduleFrame = useCallback((generation: number, run: () => void) => {
|
|
116
135
|
const id = requestAnimationFrame(() => {
|
|
@@ -123,12 +142,14 @@ export function CreateResourceDialog({
|
|
|
123
142
|
|
|
124
143
|
useEffect(() => {
|
|
125
144
|
if (!open) return
|
|
145
|
+
cancelScheduledClose()
|
|
126
146
|
setYaml(initialYaml)
|
|
127
|
-
setMode(
|
|
147
|
+
setMode(initialMode)
|
|
128
148
|
setDryRun(false)
|
|
129
149
|
setForce(false)
|
|
130
150
|
setYamlValid(true)
|
|
131
151
|
setError(null)
|
|
152
|
+
setPartialCreates([])
|
|
132
153
|
setSuccess(null)
|
|
133
154
|
setPreview(null)
|
|
134
155
|
setDrag('none')
|
|
@@ -136,19 +157,21 @@ export function CreateResourceDialog({
|
|
|
136
157
|
setImporting(null)
|
|
137
158
|
dragDepth.current = 0
|
|
138
159
|
retireDeferredImports()
|
|
139
|
-
}, [open, initialYaml, retireDeferredImports])
|
|
160
|
+
}, [open, initialYaml, initialMode, retireDeferredImports, cancelScheduledClose])
|
|
140
161
|
|
|
141
162
|
const pending = isApplying || isPreviewing
|
|
142
163
|
const closeNow = useCallback(() => {
|
|
164
|
+
cancelScheduledClose()
|
|
143
165
|
retireDeferredImports()
|
|
144
166
|
setError(null)
|
|
167
|
+
setPartialCreates([])
|
|
145
168
|
setSuccess(null)
|
|
146
169
|
setPreview(null)
|
|
147
170
|
setPendingImport(null)
|
|
148
171
|
setImporting(null)
|
|
149
172
|
setYaml('')
|
|
150
173
|
onClose()
|
|
151
|
-
}, [onClose, retireDeferredImports])
|
|
174
|
+
}, [onClose, retireDeferredImports, cancelScheduledClose])
|
|
152
175
|
const handleClose = useCallback(() => {
|
|
153
176
|
if (!pending) closeNow()
|
|
154
177
|
}, [closeNow, pending])
|
|
@@ -158,7 +181,7 @@ export function CreateResourceDialog({
|
|
|
158
181
|
}, [])
|
|
159
182
|
|
|
160
183
|
const finishApply = useCallback(
|
|
161
|
-
(results: ApplyResult[], appliedMode: 'apply' | 'create', wasDryRun: boolean) => {
|
|
184
|
+
(results: ApplyResult[], appliedMode: 'apply' | 'create', wasDryRun: boolean, submittedYaml: string) => {
|
|
162
185
|
const action = appliedMode === 'create' ? 'Created' : 'Applied'
|
|
163
186
|
const dryRunLabel = wasDryRun ? ' (dry run)' : ''
|
|
164
187
|
if (results.length === 1) {
|
|
@@ -170,11 +193,12 @@ export function CreateResourceDialog({
|
|
|
170
193
|
setSuccess(`${action} ${results.length} resources${dryRunLabel}`)
|
|
171
194
|
}
|
|
172
195
|
if (wasDryRun) return
|
|
196
|
+
setPartialCreates([])
|
|
173
197
|
if (onCreated && results.length > 0) {
|
|
174
198
|
closeNow()
|
|
175
|
-
onCreated(results[0])
|
|
199
|
+
onCreated(results[0], submittedYaml)
|
|
176
200
|
} else {
|
|
177
|
-
window.setTimeout(closeNow, 1200)
|
|
201
|
+
closeTimer.current = window.setTimeout(closeNow, 1200)
|
|
178
202
|
}
|
|
179
203
|
},
|
|
180
204
|
[closeNow, onCreated],
|
|
@@ -274,6 +298,17 @@ export function CreateResourceDialog({
|
|
|
274
298
|
[loadFiles],
|
|
275
299
|
)
|
|
276
300
|
|
|
301
|
+
const recoverPartialCreate = useCallback((caught: unknown, submittedYaml: string, appliedMode: 'apply' | 'create', wasDryRun: boolean) => {
|
|
302
|
+
const appliedResults = caught instanceof Error && 'appliedResults' in caught && Array.isArray(caught.appliedResults) ? caught.appliedResults : []
|
|
303
|
+
const createdResults = appliedMode === 'create' ? appliedResults : appliedResults.filter(result => result.created)
|
|
304
|
+
if (wasDryRun || createdResults.length === 0) return false
|
|
305
|
+
setYaml(submittedYaml)
|
|
306
|
+
if (!lockMode && onPreview) setMode('apply')
|
|
307
|
+
setPreview(null)
|
|
308
|
+
setPartialCreates(previous => [...previous, ...createdResults])
|
|
309
|
+
return true
|
|
310
|
+
}, [lockMode, onPreview])
|
|
311
|
+
|
|
277
312
|
const handleSubmit = useCallback(async () => {
|
|
278
313
|
if (!yaml.trim()) {
|
|
279
314
|
setError('YAML content is required')
|
|
@@ -301,11 +336,12 @@ export function CreateResourceDialog({
|
|
|
301
336
|
dryRun,
|
|
302
337
|
force: mode === 'apply' && force,
|
|
303
338
|
})
|
|
304
|
-
finishApply(results, mode, dryRun)
|
|
339
|
+
finishApply(results, mode, dryRun, yaml)
|
|
305
340
|
} catch (caught) {
|
|
306
341
|
setError(caught instanceof Error ? caught.message : 'Unknown error')
|
|
342
|
+
if (!onPreview) recoverPartialCreate(caught, yaml, mode, dryRun)
|
|
307
343
|
}
|
|
308
|
-
}, [yaml, mode, dryRun, force, onApply, onPreview, finishApply])
|
|
344
|
+
}, [yaml, mode, dryRun, force, onApply, onPreview, finishApply, recoverPartialCreate])
|
|
309
345
|
|
|
310
346
|
const handleApplyReviewed = useCallback(async () => {
|
|
311
347
|
if (!preview) return
|
|
@@ -320,25 +356,11 @@ export function CreateResourceDialog({
|
|
|
320
356
|
reviewedResourceVersions,
|
|
321
357
|
reviewedContext: preview.context,
|
|
322
358
|
})
|
|
323
|
-
finishApply(results, preview.mode, false)
|
|
359
|
+
finishApply(results, preview.mode, false, preview.yaml)
|
|
324
360
|
} catch (caught) {
|
|
325
361
|
const message = caught instanceof Error ? caught.message : 'Unknown error'
|
|
326
|
-
const appliedResults =
|
|
327
|
-
caught instanceof Error &&
|
|
328
|
-
'appliedResults' in caught &&
|
|
329
|
-
Array.isArray(caught.appliedResults)
|
|
330
|
-
? caught.appliedResults
|
|
331
|
-
: []
|
|
332
|
-
if (preview.mode === 'create' && appliedResults.length > 0) {
|
|
333
|
-
setYaml(preview.yaml)
|
|
334
|
-
setMode('apply')
|
|
335
|
-
setPreview(null)
|
|
336
|
-
setError(
|
|
337
|
-
`${message} Radar switched to Apply mode so you can review and continue from the current cluster state.`,
|
|
338
|
-
)
|
|
339
|
-
return
|
|
340
|
-
}
|
|
341
362
|
setError(message)
|
|
363
|
+
if (recoverPartialCreate(caught, preview.yaml, preview.mode, false)) return
|
|
342
364
|
if (onPreview) {
|
|
343
365
|
try {
|
|
344
366
|
const refreshed = await onPreview({
|
|
@@ -357,9 +379,10 @@ export function CreateResourceDialog({
|
|
|
357
379
|
}
|
|
358
380
|
}
|
|
359
381
|
}
|
|
360
|
-
}, [preview, onApply, onPreview, finishApply])
|
|
382
|
+
}, [preview, onApply, onPreview, finishApply, recoverPartialCreate])
|
|
361
383
|
|
|
362
384
|
const dialogTitle = title || 'Create Resource'
|
|
385
|
+
const showModeControls = !lockMode
|
|
363
386
|
const submitLabel = onPreview ? 'Review' : mode === 'create' ? 'Create' : 'Apply'
|
|
364
387
|
|
|
365
388
|
return (
|
|
@@ -409,8 +432,8 @@ export function CreateResourceDialog({
|
|
|
409
432
|
</Tooltip>
|
|
410
433
|
</div>
|
|
411
434
|
|
|
412
|
-
<div className="min-h-0 flex-1 px-5 py-3">
|
|
413
|
-
<div className="mb-2 flex items-center gap-2">
|
|
435
|
+
<div className="flex min-h-0 flex-1 flex-col px-5 py-3">
|
|
436
|
+
<div className="mb-2 flex shrink-0 items-center gap-2">
|
|
414
437
|
<button
|
|
415
438
|
type="button"
|
|
416
439
|
onClick={() => fileInputRef.current?.click()}
|
|
@@ -437,7 +460,7 @@ export function CreateResourceDialog({
|
|
|
437
460
|
</div>
|
|
438
461
|
|
|
439
462
|
<div
|
|
440
|
-
className="relative h-[400px]"
|
|
463
|
+
className="relative h-[400px] min-h-0"
|
|
441
464
|
onDragEnter={handleDragEnter}
|
|
442
465
|
onDragOver={handleDragOver}
|
|
443
466
|
onDragLeave={handleDragLeave}
|
|
@@ -446,7 +469,7 @@ export function CreateResourceDialog({
|
|
|
446
469
|
<YamlEditor
|
|
447
470
|
value={yaml}
|
|
448
471
|
onChange={setYaml}
|
|
449
|
-
height="
|
|
472
|
+
height="100%"
|
|
450
473
|
onValidate={handleValidate}
|
|
451
474
|
schemaLoader={schemaLoader}
|
|
452
475
|
/>
|
|
@@ -470,8 +493,8 @@ export function CreateResourceDialog({
|
|
|
470
493
|
</div>
|
|
471
494
|
</div>
|
|
472
495
|
|
|
473
|
-
{(error || previewError) && (
|
|
474
|
-
<ApplyErrorBanner error={error || previewError || '
|
|
496
|
+
{(error || previewError || partialCreates.length > 0) && (
|
|
497
|
+
<ApplyErrorBanner error={error || previewError || 'Resources were created before the previous attempt stopped.'} partialCreates={partialCreates} strictCreate={lockMode && mode === 'create'} hasPreview={Boolean(onPreview)} />
|
|
475
498
|
)}
|
|
476
499
|
{success && (
|
|
477
500
|
<div className="mx-5 mb-2 flex items-center gap-2 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-600 dark:text-emerald-400">
|
|
@@ -482,33 +505,35 @@ export function CreateResourceDialog({
|
|
|
482
505
|
|
|
483
506
|
<div className="flex shrink-0 items-center justify-between border-t border-theme-border px-5 py-3">
|
|
484
507
|
<div className="flex items-center gap-3">
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
<div
|
|
490
|
-
className="flex items-center rounded-md border border-theme-border bg-theme-base p-0.5"
|
|
491
|
-
role="radiogroup"
|
|
492
|
-
aria-label="Apply mode"
|
|
508
|
+
{showModeControls && (
|
|
509
|
+
<Tooltip
|
|
510
|
+
content="Apply: create or update (idempotent). Create: fail if exists."
|
|
511
|
+
position="bottom"
|
|
493
512
|
>
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
513
|
+
<div
|
|
514
|
+
className="flex items-center rounded-md border border-theme-border bg-theme-base p-0.5"
|
|
515
|
+
role="radiogroup"
|
|
516
|
+
aria-label="Apply mode"
|
|
517
|
+
>
|
|
518
|
+
{(['apply', 'create'] as const).map((option) => (
|
|
519
|
+
<button
|
|
520
|
+
type="button"
|
|
521
|
+
key={option}
|
|
522
|
+
onClick={() => setMode(option)}
|
|
523
|
+
role="radio"
|
|
524
|
+
aria-checked={mode === option}
|
|
525
|
+
className={`rounded px-2.5 py-1 text-xs font-medium capitalize transition-colors ${
|
|
526
|
+
mode === option
|
|
527
|
+
? 'bg-theme-elevated text-theme-text-primary shadow-theme-sm'
|
|
528
|
+
: 'text-theme-text-tertiary hover:text-theme-text-secondary'
|
|
529
|
+
}`}
|
|
530
|
+
>
|
|
531
|
+
{option}
|
|
532
|
+
</button>
|
|
533
|
+
))}
|
|
534
|
+
</div>
|
|
535
|
+
</Tooltip>
|
|
536
|
+
)}
|
|
512
537
|
|
|
513
538
|
{!onPreview && (
|
|
514
539
|
<Tooltip
|
|
@@ -527,26 +552,42 @@ export function CreateResourceDialog({
|
|
|
527
552
|
</Tooltip>
|
|
528
553
|
)}
|
|
529
554
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
<label
|
|
535
|
-
className={`flex items-center gap-1.5 text-xs ${mode === 'apply' ? 'cursor-pointer text-theme-text-secondary' : 'cursor-not-allowed text-theme-text-tertiary'}`}
|
|
555
|
+
{showModeControls && (
|
|
556
|
+
<Tooltip
|
|
557
|
+
content="Override field ownership conflicts. An active controller may reconcile those fields back."
|
|
558
|
+
position="bottom"
|
|
536
559
|
>
|
|
537
|
-
<
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
560
|
+
<label
|
|
561
|
+
className={`flex items-center gap-1.5 text-xs ${mode === 'apply' ? 'cursor-pointer text-theme-text-secondary' : 'cursor-not-allowed text-theme-text-tertiary'}`}
|
|
562
|
+
>
|
|
563
|
+
<input
|
|
564
|
+
type="checkbox"
|
|
565
|
+
checked={mode === 'apply' && force}
|
|
566
|
+
disabled={mode !== 'apply'}
|
|
567
|
+
onChange={(event) => setForce(event.target.checked)}
|
|
568
|
+
className="h-3.5 w-3.5 rounded border-theme-border bg-theme-base"
|
|
569
|
+
/>
|
|
570
|
+
Force
|
|
571
|
+
</label>
|
|
572
|
+
</Tooltip>
|
|
573
|
+
)}
|
|
547
574
|
</div>
|
|
548
575
|
|
|
549
576
|
<div className="flex items-center gap-2">
|
|
577
|
+
{onBack && (
|
|
578
|
+
<button
|
|
579
|
+
type="button"
|
|
580
|
+
onClick={() => {
|
|
581
|
+
cancelScheduledClose()
|
|
582
|
+
retireDeferredImports()
|
|
583
|
+
onBack(yamlRef.current)
|
|
584
|
+
}}
|
|
585
|
+
disabled={pending || importing !== null}
|
|
586
|
+
className="btn-secondary rounded-lg px-3 py-1.5 text-xs"
|
|
587
|
+
>
|
|
588
|
+
{backLabel}
|
|
589
|
+
</button>
|
|
590
|
+
)}
|
|
550
591
|
<button
|
|
551
592
|
type="button"
|
|
552
593
|
onClick={handleClose}
|
|
@@ -591,18 +632,23 @@ export function CreateResourceDialog({
|
|
|
591
632
|
)
|
|
592
633
|
}
|
|
593
634
|
|
|
594
|
-
function ApplyErrorBanner({ error }: { error: string }) {
|
|
635
|
+
function ApplyErrorBanner({ error, partialCreates, strictCreate, hasPreview }: { error: string; partialCreates: ApplyResult[]; strictCreate: boolean; hasPreview: boolean }) {
|
|
595
636
|
const [expanded, setExpanded] = useState(false)
|
|
596
637
|
const { panelId, buttonProps } = useDisclosure(expanded)
|
|
597
|
-
const parsed = formatApplyError(error)
|
|
638
|
+
const parsed = formatApplyError(error, { allowApply: !strictCreate && partialCreates.length === 0 })
|
|
598
639
|
const hasFriendly = Boolean(parsed.suggestion)
|
|
640
|
+
const recoveringWithReview = partialCreates.length > 0 && !strictCreate && hasPreview
|
|
599
641
|
return (
|
|
600
|
-
<div className="mx-5 mb-2 rounded-md border border-red-500/30 bg-red-500/10 text-xs">
|
|
642
|
+
<div className="mx-5 mb-2 max-h-[30vh] shrink-0 overflow-y-auto rounded-md border border-red-500/30 bg-red-500/10 text-xs">
|
|
601
643
|
<div className="flex items-start gap-2 px-3 py-2 text-red-600 dark:text-red-400">
|
|
602
644
|
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
603
645
|
<div className="min-w-0 flex-1">
|
|
604
646
|
<span className="font-medium">{parsed.summary}</span>
|
|
605
|
-
{
|
|
647
|
+
{partialCreates.length > 0 && <p className="mt-1">
|
|
648
|
+
Created before it stopped: {partialCreates.map(result => `${result.kind} ${result.namespace ? `${result.namespace}/` : ''}${result.name}`).join(', ')}.
|
|
649
|
+
{' '}{recoveringWithReview ? 'Radar switched to Apply mode so you can review and continue from the current cluster state.' : `${strictCreate ? 'This dialog only creates. ' : ''}Remove the created objects from the manifest before retrying Create.`}
|
|
650
|
+
</p>}
|
|
651
|
+
{parsed.suggestion && !recoveringWithReview && (
|
|
606
652
|
<p className="mt-1 text-red-500/80 dark:text-red-400/80">{parsed.suggestion}</p>
|
|
607
653
|
)}
|
|
608
654
|
</div>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, useState } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { afterEach, expect, it, vi } from 'vitest'
|
|
5
|
+
import { DetailShell } from './DetailShell'
|
|
6
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
7
|
+
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
|
|
8
|
+
it('re-measures asynchronously loaded tab badges and keeps the active tab visible', () => {
|
|
9
|
+
const observers = new Set<{ callback: () => void; elements: Element[] }>()
|
|
10
|
+
vi.stubGlobal('ResizeObserver', class {
|
|
11
|
+
record: { callback: () => void; elements: Element[] }
|
|
12
|
+
constructor(callback: () => void) { this.record = { callback, elements: [] }; observers.add(this.record) }
|
|
13
|
+
observe(element: Element) { this.record.elements.push(element) }
|
|
14
|
+
disconnect() { observers.delete(this.record) }
|
|
15
|
+
})
|
|
16
|
+
const width = (el: HTMLElement): number => el.getAttribute('role') === 'tab' ? (el.classList.contains('px-3') ? 100 : 70) + (el.textContent?.includes('mark') ? 40 : 0) : 260
|
|
17
|
+
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function(this: HTMLElement) { return { width: width(this), height: 20, top: 0, bottom: 20, left: 0, right: width(this), x: 0, y: 0, toJSON: () => ({}) } })
|
|
18
|
+
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 260)
|
|
19
|
+
vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function(this: HTMLElement) { return [...this.children].reduce((sum, child) => sum + width(child as HTMLElement), 0) })
|
|
20
|
+
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function(this: HTMLElement) { return width(this) })
|
|
21
|
+
vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockImplementation(function(this: HTMLElement) { return this.getAttribute('role') === 'tab' ? [...this.parentElement!.children].slice(0, [...this.parentElement!.children].indexOf(this)).reduce((sum, child) => sum + width(child as HTMLElement), 0) : 0 })
|
|
22
|
+
let setBadge!: (value: boolean) => void
|
|
23
|
+
function Badge() { const [loaded, set] = useState(false); setBadge = set; return loaded ? <span>mark</span> : null }
|
|
24
|
+
const tabs = [{ id: 'overview', label: 'Overview', icon: <span>icon</span>, badge: <Badge /> }, { id: 'replication', label: 'Replication', icon: <span>icon</span> }, { id: 'yaml', label: 'YAML', icon: <span>icon</span> }]
|
|
25
|
+
const host = document.createElement('div'); const root = createRoot(host)
|
|
26
|
+
act(() => root.render(<DetailShell identity="Cluster" tabs={tabs} activeTab="yaml" onTabChange={() => {}}>content</DetailShell>))
|
|
27
|
+
const strip = host.querySelector<HTMLElement>('[role="tablist"]')!
|
|
28
|
+
expect(host.textContent).not.toContain('icon')
|
|
29
|
+
expect([...observers].some((o) => o.elements.includes(strip.children[0]))).toBe(true)
|
|
30
|
+
act(() => setBadge(true))
|
|
31
|
+
act(() => { for (const observer of [...observers]) observer.callback() })
|
|
32
|
+
// The compact strip grows from 210 to 250 px, still fitting 260 px.
|
|
33
|
+
expect(host.textContent).not.toContain('icon')
|
|
34
|
+
// A second mark makes the compact strip overflow; the active YAML tab remains in view.
|
|
35
|
+
act(() => { strip.children[1].appendChild(document.createTextNode('mark')); for (const observer of [...observers]) observer.callback() })
|
|
36
|
+
const active = strip.querySelector<HTMLElement>('[aria-selected="true"]')!
|
|
37
|
+
expect(strip.scrollLeft).toBeGreaterThan(0)
|
|
38
|
+
expect(active.offsetLeft + active.offsetWidth).toBeLessThanOrEqual(strip.scrollLeft + strip.clientWidth)
|
|
39
|
+
strip.scrollLeft = 0
|
|
40
|
+
strip.dispatchEvent(new Event('scroll'))
|
|
41
|
+
act(() => root.render(<DetailShell identity="Cluster" tabs={[...tabs]} activeTab="yaml" onTabChange={() => {}}>refreshed content</DetailShell>))
|
|
42
|
+
expect(strip.scrollLeft).toBe(0)
|
|
43
|
+
act(() => { for (const observer of [...observers]) observer.callback() })
|
|
44
|
+
expect(strip.scrollLeft).toBe(0)
|
|
45
|
+
act(() => root.unmount())
|
|
46
|
+
})
|
|
47
|
+
it('switches to compact spacing when badge content grows after the initial fit', () => {
|
|
48
|
+
let callback = () => {}; let badgeLoaded = false
|
|
49
|
+
vi.stubGlobal('ResizeObserver', class { constructor(cb: () => void) { callback = cb } observe() {} disconnect() {} })
|
|
50
|
+
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 220)
|
|
51
|
+
vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(() => badgeLoaded ? 250 : 200)
|
|
52
|
+
const host = document.createElement('div'); const root = createRoot(host)
|
|
53
|
+
const tabs = [{ id: 'overview', label: 'Overview', icon: <span>icon</span> }, { id: 'yaml', label: 'YAML', icon: <span>icon</span>, badge: <span /> }]
|
|
54
|
+
act(() => root.render(<DetailShell identity="Cluster" tabs={tabs} activeTab="yaml" onTabChange={() => {}}>content</DetailShell>))
|
|
55
|
+
expect(host.textContent).toContain('icon')
|
|
56
|
+
act(() => { badgeLoaded = true; callback() })
|
|
57
|
+
expect(host.textContent).not.toContain('icon')
|
|
58
|
+
act(() => root.unmount())
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
it('lets the actions shrink and wrap while tab focus stays inside the scroll strip', () => {
|
|
63
|
+
const host = document.createElement('div'); const root = createRoot(host)
|
|
64
|
+
act(() => root.render(<DetailShell identity="Deployment" tabs={[{ id: 'overview', label: 'Overview' }, { id: 'yaml', label: 'YAML' }]} activeTab="overview" onTabChange={() => {}} tabStripEnd={<div data-actions>Set image Restart Rollback Port-forward Compare Delete</div>}>content</DetailShell>))
|
|
65
|
+
const actions = host.querySelector('[data-actions]')!.parentElement!
|
|
66
|
+
expect(actions.classList.contains('shrink-0')).toBe(false)
|
|
67
|
+
expect(actions.classList.contains('min-w-0')).toBe(true)
|
|
68
|
+
expect(actions.classList.contains('flex-wrap')).toBe(true)
|
|
69
|
+
const strip = host.querySelector('[role="tablist"]')!
|
|
70
|
+
expect(strip.classList.contains('min-w-0')).toBe(true)
|
|
71
|
+
expect(strip.classList.contains('flex-auto')).toBe(true)
|
|
72
|
+
expect(strip.classList.contains('flex-1')).toBe(false)
|
|
73
|
+
for (const tab of strip.querySelectorAll('[role="tab"]')) {
|
|
74
|
+
expect(tab.classList.contains('focus-visible:outline-none')).toBe(true)
|
|
75
|
+
expect(tab.classList.contains('focus-visible:ring-2')).toBe(true)
|
|
76
|
+
expect(tab.classList.contains('focus-visible:ring-inset')).toBe(true)
|
|
77
|
+
expect(tab.classList.contains('focus-visible:ring-accent')).toBe(true)
|
|
78
|
+
}
|
|
79
|
+
act(() => root.unmount())
|
|
80
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
3
|
|
|
4
4
|
// Shared tabbed detail chrome. Hosts provide all data-aware pieces; this owns
|
|
@@ -24,6 +24,8 @@ export interface DetailShellProps<TId extends string = string> {
|
|
|
24
24
|
nav?: ReactNode
|
|
25
25
|
identity: ReactNode
|
|
26
26
|
headerActions?: ReactNode
|
|
27
|
+
/** A line under the identity row, above the tabs — e.g. a status summary that holds on every tab. */
|
|
28
|
+
subheader?: ReactNode
|
|
27
29
|
scopeControls?: ReactNode
|
|
28
30
|
tabs: DetailShellTab<TId>[]
|
|
29
31
|
activeTab: TId
|
|
@@ -32,6 +34,11 @@ export interface DetailShellProps<TId extends string = string> {
|
|
|
32
34
|
overlay?: ReactNode
|
|
33
35
|
/** Hide breadcrumb/identity/header actions when a host page already owns that chrome. */
|
|
34
36
|
compactHeader?: boolean
|
|
37
|
+
/**
|
|
38
|
+
* Let the header actions wrap below the identity, as one group, when both
|
|
39
|
+
* do not fit — for an identity that packs a lot onto its title line.
|
|
40
|
+
*/
|
|
41
|
+
wrapHeader?: boolean
|
|
35
42
|
children: ReactNode
|
|
36
43
|
}
|
|
37
44
|
|
|
@@ -40,6 +47,7 @@ export function DetailShell<TId extends string = string>({
|
|
|
40
47
|
nav,
|
|
41
48
|
identity,
|
|
42
49
|
headerActions,
|
|
50
|
+
subheader,
|
|
43
51
|
scopeControls,
|
|
44
52
|
tabs,
|
|
45
53
|
activeTab,
|
|
@@ -47,9 +55,21 @@ export function DetailShell<TId extends string = string>({
|
|
|
47
55
|
tabStripEnd,
|
|
48
56
|
overlay,
|
|
49
57
|
compactHeader = false,
|
|
58
|
+
wrapHeader = false,
|
|
50
59
|
children,
|
|
51
60
|
}: DetailShellProps<TId>) {
|
|
52
61
|
const visibleTabs = tabs.filter((t) => !t.hidden)
|
|
62
|
+
const { stripRef, compact } = useCompactTabs(visibleTabs.map((t) => `${t.id}:${t.label}:${!!t.badge}`).join('|'))
|
|
63
|
+
|
|
64
|
+
useLayoutEffect(() => {
|
|
65
|
+
const strip = stripRef.current
|
|
66
|
+
const active = strip?.querySelector<HTMLElement>('[aria-selected="true"]')
|
|
67
|
+
if (strip && active) {
|
|
68
|
+
const left = active.offsetLeft - strip.offsetLeft
|
|
69
|
+
if (left < strip.scrollLeft) strip.scrollLeft = left
|
|
70
|
+
else if (left + active.offsetWidth > strip.scrollLeft + strip.clientWidth) strip.scrollLeft = left + active.offsetWidth - strip.clientWidth
|
|
71
|
+
}
|
|
72
|
+
}, [activeTab, compact])
|
|
53
73
|
|
|
54
74
|
return (
|
|
55
75
|
<div className="flex flex-col h-full w-full bg-theme-base">
|
|
@@ -58,27 +78,31 @@ export function DetailShell<TId extends string = string>({
|
|
|
58
78
|
{!compactHeader && (
|
|
59
79
|
<>
|
|
60
80
|
{breadcrumb && <div className="px-6 pt-2.5">{breadcrumb}</div>}
|
|
61
|
-
<div className={clsx('px-6 flex items-start gap-4', breadcrumb ? 'pb-3 pt-1.5' : 'py-3')}>
|
|
81
|
+
<div className={clsx('px-6 flex items-start gap-4', wrapHeader && 'flex-wrap gap-y-2', breadcrumb ? 'pb-3 pt-1.5' : 'py-3')}>
|
|
62
82
|
{nav}
|
|
63
|
-
|
|
64
|
-
|
|
83
|
+
{/* Wrapping on, the identity's basis is its one-line width, so the
|
|
84
|
+
actions move below whenever the whole title line would not fit
|
|
85
|
+
beside them — never squeezing the name to keep them up. */}
|
|
86
|
+
<div className={clsx('min-w-0', wrapHeader ? 'flex-auto' : 'flex-1')}>{identity}</div>
|
|
87
|
+
{wrapHeader ? <div className="ml-auto flex shrink-0 items-start gap-4">{headerActions}</div> : headerActions}
|
|
65
88
|
</div>
|
|
89
|
+
{subheader && <div className="-mt-1 px-6 pb-3">{subheader}</div>}
|
|
66
90
|
</>
|
|
67
91
|
)}
|
|
68
92
|
|
|
69
93
|
{/* Tabs (left) + scope controls / actions (right) */}
|
|
70
94
|
<div className={clsx('flex items-center', compactHeader ? 'px-0' : 'border-t border-theme-border px-6')}>
|
|
71
|
-
<div className=
|
|
95
|
+
<div ref={stripRef} className={clsx('flex min-w-0 flex-auto overflow-x-auto', compact ? 'gap-0' : 'gap-1')} role="tablist">
|
|
72
96
|
{visibleTabs.map((t) => (
|
|
73
|
-
<DetailShellTabButton key={t.id} active={activeTab === t.id} onClick={() => onTabChange(t.id)}>
|
|
74
|
-
{t.icon}
|
|
97
|
+
<DetailShellTabButton key={t.id} active={activeTab === t.id} compact={compact} onClick={() => onTabChange(t.id)}>
|
|
98
|
+
{!compact && t.icon}
|
|
75
99
|
{t.label}
|
|
76
100
|
{t.badge}
|
|
77
101
|
</DetailShellTabButton>
|
|
78
102
|
))}
|
|
79
103
|
</div>
|
|
80
104
|
{(scopeControls || tabStripEnd) && (
|
|
81
|
-
<div className="ml-auto flex items-center gap-2">
|
|
105
|
+
<div className="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-2 pl-2">
|
|
82
106
|
{scopeControls}
|
|
83
107
|
{tabStripEnd}
|
|
84
108
|
</div>
|
|
@@ -94,7 +118,57 @@ export function DetailShell<TId extends string = string>({
|
|
|
94
118
|
)
|
|
95
119
|
}
|
|
96
120
|
|
|
97
|
-
|
|
121
|
+
/**
|
|
122
|
+
* Drops the tab icons and tightens spacing only while the full strip does not
|
|
123
|
+
* fit, so a page with many tabs stays on one line at laptop widths and others
|
|
124
|
+
* keep their look. The width the strip needs with icons is measured while it
|
|
125
|
+
* shows them, so toggling cannot oscillate.
|
|
126
|
+
*/
|
|
127
|
+
function useCompactTabs(tabsKey: string) {
|
|
128
|
+
const stripRef = useRef<HTMLDivElement>(null)
|
|
129
|
+
const needed = useRef(0)
|
|
130
|
+
const [compact, setCompact] = useState(false)
|
|
131
|
+
useLayoutEffect(() => {
|
|
132
|
+
needed.current = 0
|
|
133
|
+
setCompact(false)
|
|
134
|
+
}, [tabsKey])
|
|
135
|
+
useLayoutEffect(() => {
|
|
136
|
+
const el = stripRef.current
|
|
137
|
+
if (!el || typeof ResizeObserver === 'undefined') return
|
|
138
|
+
const childWidth = () => [...el.children].reduce((sum, child) => sum + child.getBoundingClientRect().width, 0)
|
|
139
|
+
let previousWidth = childWidth()
|
|
140
|
+
let followActive = true
|
|
141
|
+
const onScroll = () => {
|
|
142
|
+
const active = el.querySelector<HTMLElement>('[aria-selected="true"]')
|
|
143
|
+
if (!active) return
|
|
144
|
+
const left = active.offsetLeft - el.offsetLeft
|
|
145
|
+
followActive = left >= el.scrollLeft && left + active.offsetWidth <= el.scrollLeft + el.clientWidth
|
|
146
|
+
}
|
|
147
|
+
const measure = () => {
|
|
148
|
+
const width = childWidth()
|
|
149
|
+
if (compact) needed.current += width - previousWidth
|
|
150
|
+
previousWidth = width
|
|
151
|
+
if (!compact) needed.current = el.scrollWidth
|
|
152
|
+
const next = needed.current > el.clientWidth + 1
|
|
153
|
+
setCompact((prev) => (prev === next ? prev : next))
|
|
154
|
+
const active = el.querySelector<HTMLElement>('[aria-selected="true"]')
|
|
155
|
+
if (active && followActive) {
|
|
156
|
+
const left = active.offsetLeft - el.offsetLeft
|
|
157
|
+
if (left < el.scrollLeft) el.scrollLeft = left
|
|
158
|
+
else if (left + active.offsetWidth > el.scrollLeft + el.clientWidth) el.scrollLeft = left + active.offsetWidth - el.clientWidth
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
measure()
|
|
162
|
+
const ro = new ResizeObserver(measure)
|
|
163
|
+
ro.observe(el)
|
|
164
|
+
for (const child of el.children) ro.observe(child)
|
|
165
|
+
el.addEventListener('scroll', onScroll)
|
|
166
|
+
return () => { ro.disconnect(); el.removeEventListener('scroll', onScroll) }
|
|
167
|
+
}, [compact, tabsKey])
|
|
168
|
+
return { stripRef, compact }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function DetailShellTabButton({ active, compact, onClick, children }: { active: boolean; compact: boolean; onClick: () => void; children: ReactNode }) {
|
|
98
172
|
return (
|
|
99
173
|
<button
|
|
100
174
|
type="button"
|
|
@@ -102,7 +176,8 @@ function DetailShellTabButton({ active, onClick, children }: { active: boolean;
|
|
|
102
176
|
aria-selected={active}
|
|
103
177
|
onClick={onClick}
|
|
104
178
|
className={clsx(
|
|
105
|
-
'flex items-center gap-1.5
|
|
179
|
+
'flex shrink-0 items-center gap-1.5 whitespace-nowrap py-2 text-sm font-medium border-b-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent',
|
|
180
|
+
compact ? 'px-2' : 'px-3',
|
|
106
181
|
active
|
|
107
182
|
? 'text-theme-text-primary border-skyhook-500'
|
|
108
183
|
: 'text-theme-text-secondary border-transparent hover:text-theme-text-primary hover:border-theme-border-light',
|
|
@@ -266,3 +266,10 @@ it("drops a refresh from an apply made before going back to a newer review", asy
|
|
|
266
266
|
expect.objectContaining({ reviewedResourceVersion: "3" }),
|
|
267
267
|
);
|
|
268
268
|
});
|
|
269
|
+
|
|
270
|
+
it("uses the accent token for a generic ConfigMap Edit control", () => {
|
|
271
|
+
render({ onSave: async () => {}, onPreview: async () => reviewed("1") });
|
|
272
|
+
const edit = [...host.querySelectorAll("button")].find((b) => b.textContent?.trim() === "Edit")!;
|
|
273
|
+
expect(edit.classList.contains("text-accent-text")).toBe(true);
|
|
274
|
+
expect(edit.className).not.toContain("text-blue-");
|
|
275
|
+
});
|