@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
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Utility functions for resource display in tables
|
|
2
2
|
|
|
3
|
+
import { getNodeLifecycle } from '../../utils/node-lifecycle'
|
|
3
4
|
import { effectivePolicyTypeNames } from '../../utils/network-policy'
|
|
4
|
-
import { formatCPUString, formatMemoryString, formatBytes } from '../../utils/format'
|
|
5
|
+
import { formatCPUString, formatMemoryString, formatBytes, formatCompactAge } from '../../utils/format'
|
|
5
6
|
import { pluralize } from '../../utils/pluralize'
|
|
6
7
|
import type { WorkloadPodInfo } from '../../types/core'
|
|
7
8
|
import { getArgoRolloutStepNumber, getObservedGeneration, getWorkloadRolloutActivity, isArgoRolloutResource, isRolloutActivityVisible, rolloutActivityBadge, type WorkloadRolloutActivity } from '../../utils/workload-rollout'
|
|
@@ -85,12 +86,57 @@ export interface PodProblem {
|
|
|
85
86
|
* misses. Presentation-only; the backend `scheduling` issue source does the
|
|
86
87
|
* structured decomposition + node-label resolution (e.g. naming arm64).
|
|
87
88
|
*/
|
|
88
|
-
export function summarizeSchedulerMessage(message?: string): string {
|
|
89
|
+
export function summarizeSchedulerMessage(message?: string, options: { plain?: boolean } = {}): string {
|
|
89
90
|
if (!message) return ''
|
|
90
91
|
let m = message.split('. preemption:')[0].split(' preemption:')[0].trim()
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
if (options.plain) {
|
|
93
|
+
m = m.replace(/^(?:Pod cannot be scheduled:\s*)?(?:Unschedulable:\s*)?(?:0\/\d+ nodes are available:\s*)?/i, '')
|
|
94
|
+
// Schedulers with dynamic resource allocation append this after the node reasons.
|
|
95
|
+
m = m.replace(/no new claims to deallocate,?/i, '').trim()
|
|
96
|
+
} else {
|
|
97
|
+
const colon = m.indexOf(':')
|
|
98
|
+
if (colon >= 0) m = m.slice(colon + 1).trim()
|
|
99
|
+
}
|
|
100
|
+
m = m.replace(/\.\s*$/, '').trim()
|
|
101
|
+
if (!options.plain) return m
|
|
102
|
+
const total = /0\/(\d+) nodes (?:are )?available/.exec(message)?.[1]
|
|
103
|
+
const causes: [RegExp, string][] = [
|
|
104
|
+
[/^(\d+) (?:Too many pods|(?:node\(s\) )?insufficient pods)(?: \(0\/\d+ nodes available\))?$/i, 'have reached their Pod limit'],
|
|
105
|
+
[/^(\d+) (?:node\(s\) )?Insufficient cpu(?: \(0\/\d+ nodes available\))?$/i, 'do not have enough CPU'],
|
|
106
|
+
[/^(\d+) (?:node\(s\) )?Insufficient memory(?: \(0\/\d+ nodes available\))?$/i, 'do not have enough memory'],
|
|
107
|
+
[/^(\d+) node\(s\) (?:didn.t match Pod.s node affinity\/selector|node affinity\/selector mismatch)(?: \(0\/\d+ nodes available\))?$/i, 'do not match the Pod’s node affinity or selector'],
|
|
108
|
+
[/^(\d+) node\(s\) (?:had )?untolerated taint(?:\(s\))?(?: \{[^}]*\}| [^\s]+)?(?: \(0\/\d+ nodes available\))?$/i, 'have taints the Pod does not tolerate'],
|
|
109
|
+
[/^(\d+) node\(s\) (?:had )?volume node[- ]affinity conflict(?: \(0\/\d+ nodes available\))?$/i, 'do not match the volume’s node affinity'],
|
|
110
|
+
]
|
|
111
|
+
return splitSchedulerClauses(m).filter((part) => part.trim()).map((part) => {
|
|
112
|
+
const clause = part.trim()
|
|
113
|
+
for (const [pattern, cause] of causes) {
|
|
114
|
+
const count = pattern.exec(clause)?.[1]
|
|
115
|
+
if (count) return `${count === '1' ? '1 node' : count === total && count === '2' ? 'both nodes' : count === total ? 'all nodes' : `${count} nodes`} ${count === '1' ? cause.replace(/^have /, 'has ').replace(/^do /, 'does ') : cause}`
|
|
116
|
+
}
|
|
117
|
+
if (/^(?:pod has )?unbound (?:immediate )?PersistentVolumeClaims?(?:\s*\(0\/\d+ nodes available\))?$/i.test(clause)) return 'a required volume claim is not bound'
|
|
118
|
+
return clause
|
|
119
|
+
}).join('; ')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Splits on commas and semicolons, except where the next bracket ahead closes:
|
|
123
|
+
// scheduler taints and resolved node labels can contain comma-separated values.
|
|
124
|
+
// One pass from the end keeps long messages linear.
|
|
125
|
+
function splitSchedulerClauses(m: string): string[] {
|
|
126
|
+
const parts: string[] = []
|
|
127
|
+
let end = m.length
|
|
128
|
+
let insideBrackets = false
|
|
129
|
+
for (let i = m.length - 1; i >= 0; i--) {
|
|
130
|
+
const c = m[i]
|
|
131
|
+
if (c === ']' || c === '}') insideBrackets = true
|
|
132
|
+
else if (c === '[' || c === '{') insideBrackets = false
|
|
133
|
+
else if ((c === ',' || c === ';') && !insideBrackets) {
|
|
134
|
+
parts.push(m.slice(i + 1, end))
|
|
135
|
+
end = i
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
parts.push(m.slice(0, end))
|
|
139
|
+
return parts.reverse()
|
|
94
140
|
}
|
|
95
141
|
|
|
96
142
|
/** Tailwind classes for severity dot indicators (used in tooltips and alert banners) */
|
|
@@ -1329,25 +1375,8 @@ export function getHPAMetrics(hpa: any): { cpu?: number; memory?: number; custom
|
|
|
1329
1375
|
// ============================================================================
|
|
1330
1376
|
|
|
1331
1377
|
export function getNodeStatus(node: any): StatusBadge {
|
|
1332
|
-
const
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
const isReady = readyCondition?.status === 'True'
|
|
1336
|
-
const isUnschedulable = node.spec?.unschedulable === true
|
|
1337
|
-
|
|
1338
|
-
if (isReady && isUnschedulable) {
|
|
1339
|
-
// Cordon is intentional but consequential — it's lost scheduling capacity, and
|
|
1340
|
-
// a forgotten cordon strands nodes — so it stays on the warning axis (matching
|
|
1341
|
-
// the backend Cordoned issue), unlike no-op intentional states (suspended/idle).
|
|
1342
|
-
return { text: 'Ready,SchedulingDisabled', color: healthColors.degraded, level: 'degraded' }
|
|
1343
|
-
}
|
|
1344
|
-
if (isReady) {
|
|
1345
|
-
return { text: 'Ready', color: healthColors.healthy, level: 'healthy' }
|
|
1346
|
-
}
|
|
1347
|
-
if (readyCondition?.status === 'False') {
|
|
1348
|
-
return { text: 'NotReady', color: healthColors.unhealthy, level: 'unhealthy' }
|
|
1349
|
-
}
|
|
1350
|
-
return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
|
|
1378
|
+
const lifecycle = getNodeLifecycle(node)
|
|
1379
|
+
return { text: lifecycle.label, color: healthColors[lifecycle.level], level: lifecycle.level }
|
|
1351
1380
|
}
|
|
1352
1381
|
|
|
1353
1382
|
export function getNodeRoles(node: any): string {
|
|
@@ -1375,27 +1404,32 @@ export interface NodeCondition {
|
|
|
1375
1404
|
message?: string
|
|
1376
1405
|
}
|
|
1377
1406
|
|
|
1378
|
-
|
|
1379
|
-
const NODE_PROBLEM_CONDITIONS = ['DiskPressure', 'MemoryPressure', 'PIDPressure', 'NetworkUnavailable']
|
|
1380
|
-
|
|
1381
|
-
export function getNodeConditions(node: any): { problems: string[]; healthy: boolean } {
|
|
1407
|
+
export function getNodeConditions(node: any): { problems: string[]; healthy: boolean; readinessLabel: string } {
|
|
1382
1408
|
const conditions = node.status?.conditions || []
|
|
1383
1409
|
const problems: string[] = []
|
|
1410
|
+
const lifecycle = getNodeLifecycle(node)
|
|
1384
1411
|
|
|
1385
1412
|
for (const cond of conditions) {
|
|
1386
1413
|
// Ready=False is a problem
|
|
1387
|
-
if (cond.type === 'Ready' &&
|
|
1414
|
+
if (cond.type === 'Ready' && lifecycle.readinessFailed) {
|
|
1388
1415
|
problems.push('NotReady')
|
|
1389
1416
|
}
|
|
1390
1417
|
// Other conditions are problems when True
|
|
1391
|
-
if (
|
|
1418
|
+
if (lifecycle.problems.includes(cond.type) && cond.status === 'True') {
|
|
1392
1419
|
// Format: "DiskPressure" -> "Disk Pressure"
|
|
1393
1420
|
const formatted = cond.type.replace(/([A-Z])/g, ' $1').trim()
|
|
1394
1421
|
problems.push(formatted)
|
|
1395
1422
|
}
|
|
1396
1423
|
}
|
|
1397
1424
|
|
|
1398
|
-
|
|
1425
|
+
if (lifecycle.delayed) problems.push('Removal delayed')
|
|
1426
|
+
const ready = conditions.find((cond: any) => cond.type === 'Ready')
|
|
1427
|
+
const readinessLabel = lifecycle.removing && !lifecycle.readinessFailed && ready?.status === 'False'
|
|
1428
|
+
? 'Not ready (expected during removal)'
|
|
1429
|
+
: lifecycle.removing && !lifecycle.readinessFailed && ready?.status === 'Unknown'
|
|
1430
|
+
? 'Readiness unknown during removal'
|
|
1431
|
+
: 'Readiness unknown'
|
|
1432
|
+
return { problems, healthy: problems.length === 0 && ready?.status === 'True', readinessLabel }
|
|
1399
1433
|
}
|
|
1400
1434
|
|
|
1401
1435
|
export function getNodeTaints(node: any): { count: number; text: string } {
|
|
@@ -1414,11 +1448,7 @@ export function getNodeVersion(node: any): string {
|
|
|
1414
1448
|
// ============================================================================
|
|
1415
1449
|
|
|
1416
1450
|
export function formatAge(timestamp: string): string {
|
|
1417
|
-
|
|
1418
|
-
const created = new Date(timestamp)
|
|
1419
|
-
const now = new Date()
|
|
1420
|
-
const diffMs = now.getTime() - created.getTime()
|
|
1421
|
-
return formatDuration(diffMs)
|
|
1451
|
+
return formatCompactAge(timestamp) || '-'
|
|
1422
1452
|
}
|
|
1423
1453
|
|
|
1424
1454
|
export function formatDuration(ms: number, detailed: boolean = false): string {
|
|
@@ -1869,7 +1899,7 @@ export function getGatewayClassDescription(gc: any): string {
|
|
|
1869
1899
|
}
|
|
1870
1900
|
|
|
1871
1901
|
// ============================================================================
|
|
1872
|
-
// GATEWAY API ROUTE UTILITIES (shared by HTTPRoute, GRPCRoute, TCPRoute, TLSRoute)
|
|
1902
|
+
// GATEWAY API ROUTE UTILITIES (shared by HTTPRoute, GRPCRoute, TCPRoute, TLSRoute, UDPRoute)
|
|
1873
1903
|
// ============================================================================
|
|
1874
1904
|
|
|
1875
1905
|
const GATEWAY_API_GROUP = 'gateway.networking.k8s.io'
|
|
@@ -2279,6 +2309,37 @@ export function parseColumnFilterExcludes(filtersParam: string | null): Record<s
|
|
|
2279
2309
|
return excludes
|
|
2280
2310
|
}
|
|
2281
2311
|
|
|
2312
|
+
/** The Resources list's own filter params in the URL (the kind is in the path, `apiGroup` beside it). */
|
|
2313
|
+
export const RESOURCE_LIST_FILTER_PARAMS = ['search', 'regex', 'filters', 'problems', 'labels', 'ownerKind', 'ownerName', 'showInactive'] as const
|
|
2314
|
+
|
|
2315
|
+
/** The per-kind filters in a query string: what a kind switch resets or restores. */
|
|
2316
|
+
export function kindFiltersFromSearch(search: string | undefined) {
|
|
2317
|
+
const params = new URLSearchParams(search ?? '')
|
|
2318
|
+
const filtersParam = params.get('filters')
|
|
2319
|
+
return {
|
|
2320
|
+
columnFilters: parseColumnFilters(filtersParam),
|
|
2321
|
+
columnFilterExcludes: parseColumnFilterExcludes(filtersParam),
|
|
2322
|
+
problemFilters: params.get('problems')?.split(',').filter(Boolean) || [],
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
/** The `filters` param value. Drops an exclude flag on a column with no active values. */
|
|
2327
|
+
export function columnFiltersParam(colFilters: Record<string, string[]>, colExcludes: Record<string, boolean>): string {
|
|
2328
|
+
const activeExcludes = Object.fromEntries(
|
|
2329
|
+
Object.entries(colExcludes).filter(([k, on]) => on && (colFilters[k]?.length ?? 0) > 0)
|
|
2330
|
+
)
|
|
2331
|
+
return serializeColumnFilters(colFilters, activeExcludes)
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
/** Inverse of kindFiltersFromSearch: the `filters` + `problems` query, '' when none. */
|
|
2335
|
+
export function kindFiltersToSearch(f: ReturnType<typeof kindFiltersFromSearch>): string {
|
|
2336
|
+
const params = new URLSearchParams()
|
|
2337
|
+
const filters = columnFiltersParam(f.columnFilters, f.columnFilterExcludes)
|
|
2338
|
+
if (filters) params.set('filters', filters)
|
|
2339
|
+
if (f.problemFilters.length > 0) params.set('problems', f.problemFilters.join(','))
|
|
2340
|
+
return params.toString()
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2282
2343
|
// Split a single "col[:operator]:values" pair into its decoded key, values, and
|
|
2283
2344
|
// whether the explicit exclude operator was present. The first literal colon
|
|
2284
2345
|
// delimits key from the rest (keys are encoded, so their own colons don't
|
|
@@ -2357,7 +2418,7 @@ export function getCellFilterValue(resource: any, column: string, kind: string):
|
|
|
2357
2418
|
if (kindLower === 'hpas' || kindLower === 'horizontalpodautoscalers') return getHPAStatus(resource).text
|
|
2358
2419
|
if (kindLower === 'gateways') return getGatewayStatus(resource).text
|
|
2359
2420
|
if (kindLower === 'gatewayclasses') return getGatewayClassStatus(resource).text
|
|
2360
|
-
if (['httproutes', 'grpcroutes', 'tcproutes', 'tlsroutes'].includes(kindLower)) return getRouteStatus(resource).text
|
|
2421
|
+
if (['httproutes', 'grpcroutes', 'tcproutes', 'tlsroutes', 'udproutes'].includes(kindLower)) return getRouteStatus(resource).text
|
|
2361
2422
|
if (kindLower === 'sealedsecrets') return getSealedSecretStatus(resource).text
|
|
2362
2423
|
if (kindLower === 'poddisruptionbudgets') return getPDBStatus(resource).text
|
|
2363
2424
|
if (kindLower === 'applications') return getArgoApplicationStatus(resource).text
|
|
@@ -2691,7 +2752,6 @@ export {
|
|
|
2691
2752
|
getScaledObjectTriggers,
|
|
2692
2753
|
getScaledObjectTriggerCount,
|
|
2693
2754
|
getScaledObjectHpaName,
|
|
2694
|
-
getScaledObjectLastActiveTime,
|
|
2695
2755
|
getScaledObjectPollingInterval,
|
|
2696
2756
|
getScaledObjectCooldownPeriod,
|
|
2697
2757
|
getScaledJobStatus,
|
|
@@ -27,7 +27,7 @@ function columnSetBody(key: string): string {
|
|
|
27
27
|
// in the sortable-key list, so neither affordance ever renders on it. w-20
|
|
28
28
|
// (80px) leaves 47px.
|
|
29
29
|
describe('Rules column width', () => {
|
|
30
|
-
const kinds = ['kyvernopolicies', 'clusterpolicies', 'httproutes', 'grpcroutes', 'tcproutes', 'tlsroutes']
|
|
30
|
+
const kinds = ['kyvernopolicies', 'clusterpolicies', 'httproutes', 'grpcroutes', 'tcproutes', 'tlsroutes', 'udproutes']
|
|
31
31
|
|
|
32
32
|
it.each(kinds)('%s Rules column is at least w-20', (kind) => {
|
|
33
33
|
const rules = columnSetBody(kind).match(/\{ key: 'rules'.*\}/)
|
|
@@ -28,3 +28,33 @@ describe('summarizeSchedulerMessage', () => {
|
|
|
28
28
|
expect(summarizeSchedulerMessage(undefined)).toBe('')
|
|
29
29
|
})
|
|
30
30
|
})
|
|
31
|
+
|
|
32
|
+
it.each([
|
|
33
|
+
['0/2 nodes are available: 2 Too many pods.', 'both nodes have reached their Pod limit'],
|
|
34
|
+
['Unschedulable: 0/2 nodes are available: 2 Too many pods.', 'both nodes have reached their Pod limit'],
|
|
35
|
+
['2 node(s) insufficient pods (0/2 nodes available)', 'both nodes have reached their Pod limit'],
|
|
36
|
+
['0/1 nodes are available: 1 Insufficient cpu.', '1 node does not have enough CPU'],
|
|
37
|
+
['0/3 nodes are available: 3 Insufficient cpu.', 'all nodes do not have enough CPU'],
|
|
38
|
+
['0/2 nodes are available: 2 Insufficient memory, 2 Too many pods.', 'both nodes do not have enough memory; both nodes have reached their Pod limit'],
|
|
39
|
+
["0/2 nodes are available: 2 node(s) didn’t match Pod’s node affinity/selector.", 'both nodes do not match the Pod’s node affinity or selector'],
|
|
40
|
+
['1 node(s) had untolerated taint {dedicated: db}.', '1 node has taints the Pod does not tolerate'],
|
|
41
|
+
['0/2 nodes are available: 1 node(s) had untolerated taint {dedicated: db}.', '1 node has taints the Pod does not tolerate'],
|
|
42
|
+
['0/2 nodes are available: pod has unbound immediate PersistentVolumeClaims.', 'a required volume claim is not bound'],
|
|
43
|
+
['1 node(s) untolerated taint dedicated=db, 1 node(s) insufficient cpu (0/2 nodes available)', '1 node has taints the Pod does not tolerate; 1 node does not have enough CPU'],
|
|
44
|
+
['2 node(s) node affinity/selector mismatch (0/2 nodes available)', 'both nodes do not match the Pod’s node affinity or selector'],
|
|
45
|
+
['unbound PersistentVolumeClaim, 1 node(s) volume node-affinity conflict (0/2 nodes available)', 'a required volume claim is not bound; 1 node does not match the volume’s node affinity'],
|
|
46
|
+
['0/2 nodes are available: 1 node(s) had volume node affinity conflict, 1 Insufficient memory.', '1 node does not match the volume’s node affinity; 1 node does not have enough memory'],
|
|
47
|
+
['no node has kubernetes.io/arch=arm64 — 2 node(s) carry kubernetes.io/arch: [amd64]; 1 node(s) insufficient cpu (0/3 nodes available)', 'no node has kubernetes.io/arch=arm64 — 2 node(s) carry kubernetes.io/arch: [amd64]; 1 node does not have enough CPU'],
|
|
48
|
+
['custom scheduler says 2 Insufficient cpu, 1 Insufficient memory', 'custom scheduler says 2 Insufficient cpu; 1 node does not have enough memory'],
|
|
49
|
+
['1 node(s) pod anti-affinity conflict, 1 node(s) insufficient memory, no node has zone=west — 2 node(s) carry zone: [east, north]', '1 node(s) pod anti-affinity conflict; 1 node does not have enough memory; no node has zone=west — 2 node(s) carry zone: [east, north]'],
|
|
50
|
+
["0/14 nodes are available: 6 node(s) had untolerated taint(s), 8 node(s) didn't match Pod's node affinity/selector. no new claims to deallocate, preemption: 0/14 nodes are available: 14 Preemption is not helpful for scheduling.", '6 nodes have taints the Pod does not tolerate; 8 nodes do not match the Pod’s node affinity or selector'],
|
|
51
|
+
])('offers a plain summary additively for %s', (raw, expected) => {
|
|
52
|
+
expect(summarizeSchedulerMessage(raw, { plain: true })).toBe(expected)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('summarizes a very long, separator-heavy message in linear time', () => {
|
|
56
|
+
const message = '0/2 nodes are available: ' + '1 node(s) custom reason, '.repeat(10_000) + '2 Insufficient cpu.'
|
|
57
|
+
const summary = summarizeSchedulerMessage(message, { plain: true })
|
|
58
|
+
expect(summary.split('; ')).toHaveLength(10_001)
|
|
59
|
+
expect(summary.endsWith('both nodes do not have enough CPU')).toBe(true)
|
|
60
|
+
})
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, useState } from 'react'
|
|
3
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { ActionConfirmDialog, type ActionConfirmDialogProps } from './ActionConfirmDialog'
|
|
6
|
+
|
|
7
|
+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
|
8
|
+
const roots: Root[] = []
|
|
9
|
+
const base: ActionConfirmDialogProps = {
|
|
10
|
+
open: true, onClose: () => {}, onConfirm: () => {}, title: 'Scale worker?',
|
|
11
|
+
subject: { kind: 'Deployment', namespace: 'jobs', name: 'worker' },
|
|
12
|
+
context: 'test-cluster', effect: 'Set the desired replicas to three.', confirmLabel: 'Scale',
|
|
13
|
+
}
|
|
14
|
+
function render(props: Partial<ActionConfirmDialogProps> = {}) {
|
|
15
|
+
const host = document.createElement('div')
|
|
16
|
+
document.body.appendChild(host)
|
|
17
|
+
const root = createRoot(host)
|
|
18
|
+
roots.push(root)
|
|
19
|
+
const update = (next: Partial<ActionConfirmDialogProps>) => act(() => root.render(<ActionConfirmDialog {...base} {...next} />))
|
|
20
|
+
update(props)
|
|
21
|
+
return update
|
|
22
|
+
}
|
|
23
|
+
function button(label: string) {
|
|
24
|
+
return [...document.querySelectorAll('button')].find(b => b.textContent?.trim() === label)!
|
|
25
|
+
}
|
|
26
|
+
function type(text: string) {
|
|
27
|
+
const input = document.querySelector('input')!
|
|
28
|
+
act(() => {
|
|
29
|
+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, text)
|
|
30
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
act(() => roots.splice(0).forEach(root => root.unmount()))
|
|
35
|
+
document.body.innerHTML = ''
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('ActionConfirmDialog', () => {
|
|
39
|
+
it('shows the target and context before a reviewed write', () => {
|
|
40
|
+
const confirm = vi.fn()
|
|
41
|
+
render({ onConfirm: confirm })
|
|
42
|
+
expect(document.body.textContent).toContain('Deployment jobs/worker · context test-cluster')
|
|
43
|
+
act(() => button('Scale').click())
|
|
44
|
+
expect(confirm).toHaveBeenCalledOnce()
|
|
45
|
+
})
|
|
46
|
+
it('shows an incomplete form as a quiet hint and a disabled confirm', () => {
|
|
47
|
+
render({ incompleteReason: 'The replica count is unchanged' })
|
|
48
|
+
expect(document.body.textContent).toContain('The replica count is unchanged')
|
|
49
|
+
expect(document.body.textContent).not.toContain('This action is not available')
|
|
50
|
+
expect(button('Scale').disabled).toBe(true)
|
|
51
|
+
})
|
|
52
|
+
it('keeps the alert for a real blocker', () => {
|
|
53
|
+
render({ disabledReason: 'Needs patch deployments in jobs' })
|
|
54
|
+
expect(document.body.textContent).toContain('This action is not available')
|
|
55
|
+
expect(button('Scale').disabled).toBe(true)
|
|
56
|
+
})
|
|
57
|
+
it('requires typed acknowledgement and clears it after closing', () => {
|
|
58
|
+
const update = render({ typedConfirmation: 'worker' })
|
|
59
|
+
expect(button('Scale').disabled).toBe(true)
|
|
60
|
+
type('other')
|
|
61
|
+
expect(button('Scale').disabled).toBe(true)
|
|
62
|
+
type('worker')
|
|
63
|
+
expect(button('Scale').disabled).toBe(false)
|
|
64
|
+
update({ typedConfirmation: 'worker', open: false })
|
|
65
|
+
update({ typedConfirmation: 'worker' })
|
|
66
|
+
expect(document.querySelector('input')?.value).toBe('')
|
|
67
|
+
expect(button('Scale').disabled).toBe(true)
|
|
68
|
+
})
|
|
69
|
+
it('requires the host write guard even with the correct acknowledgement', () => {
|
|
70
|
+
render({ typedConfirmation: 'worker', guardSatisfied: false })
|
|
71
|
+
type('worker')
|
|
72
|
+
expect(button('Scale').disabled).toBe(true)
|
|
73
|
+
})
|
|
74
|
+
it('locks resubmission for an unknown or partial result without claiming no write happened', () => {
|
|
75
|
+
render({ result: { state: 'unknown', message: 'Refresh the target before another attempt.' } })
|
|
76
|
+
expect(button('Scale').disabled).toBe(true)
|
|
77
|
+
expect(document.body.textContent).toContain('Radar could not tell whether this took effect')
|
|
78
|
+
expect(document.body.textContent).not.toContain('The write did not happen')
|
|
79
|
+
})
|
|
80
|
+
it('locks an unknown result even without an error string', () => {
|
|
81
|
+
render({ result: { state: 'unknown' } })
|
|
82
|
+
expect(button('Scale').disabled).toBe(true)
|
|
83
|
+
expect(document.body.textContent).toContain('Radar could not tell whether this took effect')
|
|
84
|
+
expect(document.body.textContent).toContain('Check the object’s current state')
|
|
85
|
+
})
|
|
86
|
+
it('allows retry after a known refusal, using neutral default error copy', () => {
|
|
87
|
+
render({ result: { state: 'error', message: 'The reviewed replica count changed.' } })
|
|
88
|
+
expect(button('Scale').disabled).toBe(false)
|
|
89
|
+
expect(document.body.textContent).toContain('The action could not be completed')
|
|
90
|
+
})
|
|
91
|
+
it('locks confirm, Back and dismissal while the request is pending', () => {
|
|
92
|
+
const close = vi.fn(), back = vi.fn()
|
|
93
|
+
render({ isLoading: true, onClose: close, onBack: back })
|
|
94
|
+
expect(button('Scale').disabled).toBe(true)
|
|
95
|
+
expect(button('Back').disabled).toBe(true)
|
|
96
|
+
expect(button('Cancel').disabled).toBe(true)
|
|
97
|
+
act(() => button('Cancel').click())
|
|
98
|
+
act(() => document.querySelector<HTMLButtonElement>('[aria-label="Close"]')!.click())
|
|
99
|
+
expect(close).not.toHaveBeenCalled()
|
|
100
|
+
expect(back).not.toHaveBeenCalled()
|
|
101
|
+
})
|
|
102
|
+
it('keeps Back and Cancel distinct', () => {
|
|
103
|
+
const close = vi.fn(), back = vi.fn()
|
|
104
|
+
render({ onClose: close, onBack: back })
|
|
105
|
+
act(() => button('Back').click())
|
|
106
|
+
expect(back).toHaveBeenCalledOnce()
|
|
107
|
+
expect(close).not.toHaveBeenCalled()
|
|
108
|
+
act(() => button('Cancel').click())
|
|
109
|
+
expect(close).toHaveBeenCalledOnce()
|
|
110
|
+
})
|
|
111
|
+
it('reveals the reviewed writes through an accessible disclosure', () => {
|
|
112
|
+
render({ writes: [{ summary: 'patch Deployment jobs/worker', detail: 'spec.replicas: 3' }] })
|
|
113
|
+
const toggle = button('The API write')
|
|
114
|
+
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
|
115
|
+
act(() => toggle.click())
|
|
116
|
+
expect(toggle.getAttribute('aria-expanded')).toBe('true')
|
|
117
|
+
expect(document.body.textContent).toContain('spec.replicas: 3')
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('retains a host-owned outcome lock across dialog dismissal and remount', () => {
|
|
122
|
+
function Host() {
|
|
123
|
+
const [open, setOpen] = useState(true)
|
|
124
|
+
const [unknown, setUnknown] = useState(true)
|
|
125
|
+
return <>
|
|
126
|
+
<button onClick={() => setOpen(true)}>Review action</button>
|
|
127
|
+
<button onClick={() => setUnknown(false)}>Current state reviewed</button>
|
|
128
|
+
{open && <ActionConfirmDialog {...base} result={unknown ? { state: 'unknown' } : undefined} onClose={() => setOpen(false)} />}
|
|
129
|
+
</>
|
|
130
|
+
}
|
|
131
|
+
const root = createRoot(document.body.appendChild(document.createElement('div')))
|
|
132
|
+
roots.push(root)
|
|
133
|
+
act(() => root.render(<Host />))
|
|
134
|
+
expect(button('Scale').disabled).toBe(true)
|
|
135
|
+
act(() => button('Cancel').click())
|
|
136
|
+
act(() => button('Review action').click())
|
|
137
|
+
expect(button('Scale').disabled).toBe(true)
|
|
138
|
+
act(() => button('Current state reviewed').click())
|
|
139
|
+
expect(button('Scale').disabled).toBe(false)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('locks a partial result and explains that changes completed', () => {
|
|
143
|
+
render({ result: { state: 'partial' } })
|
|
144
|
+
expect(button('Scale').disabled).toBe(true)
|
|
145
|
+
expect(document.body.textContent).toContain('Some changes completed')
|
|
146
|
+
expect(document.body.textContent).toContain('Check the object’s current state')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('keeps an empty known-refusal message distinct from an uncertain outcome', () => {
|
|
150
|
+
render({ result: { state: 'error', message: '' } })
|
|
151
|
+
expect(button('Scale').disabled).toBe(false)
|
|
152
|
+
expect(document.body.textContent).toContain('The action could not be completed')
|
|
153
|
+
expect(document.body.textContent).not.toContain('Check the object’s current state')
|
|
154
|
+
})
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { useEffect, useId, useState, type ReactNode } from 'react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
import { AlertTriangle, Info, X } from 'lucide-react'
|
|
4
|
+
import { DialogPortal } from '../ui/DialogPortal'
|
|
5
|
+
import { Collapse, CollapseChevron, useDisclosure } from '../ui/Collapse'
|
|
6
|
+
import { TONE_SOLID_CLASS } from '../ui/severity-tone'
|
|
7
|
+
import { AlertBanner } from '../ui/drawer-components'
|
|
8
|
+
import type { ResourceRef } from '../../types/core'
|
|
9
|
+
|
|
10
|
+
/** One API call the action will make, shown in the technical section. */
|
|
11
|
+
export interface ActionWrite {
|
|
12
|
+
/** Plain-language line, e.g. "patch Cluster payments/pg-orders status". */
|
|
13
|
+
summary: string
|
|
14
|
+
/** The literal body or field change, shown monospaced. */
|
|
15
|
+
detail?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type ActionConfirmResult =
|
|
19
|
+
| { state: 'error'; message: string; title?: string }
|
|
20
|
+
| { state: 'unknown' | 'partial'; message?: string; title?: string }
|
|
21
|
+
|
|
22
|
+
export interface ActionConfirmDialogProps {
|
|
23
|
+
open: boolean
|
|
24
|
+
onClose: () => void
|
|
25
|
+
onConfirm: () => void
|
|
26
|
+
onBack?: () => void
|
|
27
|
+
backLabel?: string
|
|
28
|
+
/** Imperative title: "Switch over pg-orders?" */
|
|
29
|
+
title: string
|
|
30
|
+
subject: Pick<ResourceRef, 'kind' | 'name'> & Partial<Pick<ResourceRef, 'namespace'>>
|
|
31
|
+
/** Kubernetes context the write goes to, always visible. */
|
|
32
|
+
context?: string
|
|
33
|
+
/** What happens, stated first. */
|
|
34
|
+
effect: ReactNode
|
|
35
|
+
/** Form fields (targets, options). */
|
|
36
|
+
children?: ReactNode
|
|
37
|
+
notes?: ReactNode[]
|
|
38
|
+
warnings?: ReactNode[]
|
|
39
|
+
/** Extra acknowledgement block, e.g. the GitOps write warning. */
|
|
40
|
+
guard?: ReactNode
|
|
41
|
+
/** Blocks confirm until the guard is satisfied. */
|
|
42
|
+
guardSatisfied?: boolean
|
|
43
|
+
writes?: ActionWrite[]
|
|
44
|
+
/** When set, the user must type this exact text to confirm. */
|
|
45
|
+
typedConfirmation?: string
|
|
46
|
+
confirmLabel: string
|
|
47
|
+
/** Disruptive actions use the danger styling. */
|
|
48
|
+
disruptive?: boolean
|
|
49
|
+
/** Why the action cannot run right now; disables confirm and is shown. */
|
|
50
|
+
disabledReason?: string
|
|
51
|
+
/** What the form still needs (an unchanged value, a missing input); disables confirm with a quiet hint beside it, not an alert. */
|
|
52
|
+
incompleteReason?: string
|
|
53
|
+
isLoading?: boolean
|
|
54
|
+
/**
|
|
55
|
+
* Unknown and partial results lock Confirm: repeating the action could apply
|
|
56
|
+
* it twice. The host must retain the result
|
|
57
|
+
* across dismissal/remount until it has reviewed the current target again.
|
|
58
|
+
*/
|
|
59
|
+
result?: ActionConfirmResult
|
|
60
|
+
/** `wide` for dialogs that show evidence beside their form fields. */
|
|
61
|
+
size?: 'default' | 'wide'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function ActionConfirmDialog({
|
|
65
|
+
open,
|
|
66
|
+
onClose,
|
|
67
|
+
onConfirm,
|
|
68
|
+
onBack,
|
|
69
|
+
backLabel = 'Back',
|
|
70
|
+
title,
|
|
71
|
+
subject,
|
|
72
|
+
context,
|
|
73
|
+
effect,
|
|
74
|
+
children,
|
|
75
|
+
notes = [],
|
|
76
|
+
warnings = [],
|
|
77
|
+
guard,
|
|
78
|
+
guardSatisfied = true,
|
|
79
|
+
writes = [],
|
|
80
|
+
typedConfirmation,
|
|
81
|
+
confirmLabel,
|
|
82
|
+
disruptive = false,
|
|
83
|
+
disabledReason,
|
|
84
|
+
incompleteReason,
|
|
85
|
+
isLoading = false,
|
|
86
|
+
result,
|
|
87
|
+
size = 'default',
|
|
88
|
+
}: ActionConfirmDialogProps) {
|
|
89
|
+
const titleId = useId()
|
|
90
|
+
const [typed, setTyped] = useState('')
|
|
91
|
+
const [showWrites, setShowWrites] = useState(false)
|
|
92
|
+
const writesDisclosure = useDisclosure(showWrites)
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (!open) {
|
|
96
|
+
setTyped('')
|
|
97
|
+
setShowWrites(false)
|
|
98
|
+
}
|
|
99
|
+
}, [open])
|
|
100
|
+
|
|
101
|
+
const typedOk = !typedConfirmation || typed.trim() === typedConfirmation
|
|
102
|
+
const outcomeLocked = result?.state === 'unknown' || result?.state === 'partial'
|
|
103
|
+
const canConfirm = !disabledReason && !incompleteReason && !outcomeLocked && typedOk && guardSatisfied && !isLoading
|
|
104
|
+
const where = [subject.namespace, subject.name].filter(Boolean).join('/')
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<DialogPortal open={open} onClose={onClose} closable={!isLoading} className={size === 'wide' ? 'w-full max-w-4xl' : 'w-full max-w-xl'} ariaLabelledBy={titleId}>
|
|
108
|
+
<div className="flex items-start gap-3 border-b border-theme-border p-4">
|
|
109
|
+
<div className="min-w-0 flex-1">
|
|
110
|
+
<h3 id={titleId} className="text-lg font-semibold text-theme-text-primary">{title}</h3>
|
|
111
|
+
<div className="mt-0.5 text-xs text-theme-text-tertiary">
|
|
112
|
+
{subject.kind} <span className="font-mono">{where}</span>
|
|
113
|
+
{context && (
|
|
114
|
+
<>
|
|
115
|
+
{' · context '}
|
|
116
|
+
<span className="font-mono">{context}</span>
|
|
117
|
+
</>
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
onClick={onClose}
|
|
124
|
+
disabled={isLoading}
|
|
125
|
+
aria-label="Close"
|
|
126
|
+
className="rounded p-1 text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary disabled:opacity-50"
|
|
127
|
+
>
|
|
128
|
+
<X className="h-5 w-5" />
|
|
129
|
+
</button>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<div className="max-h-[70vh] space-y-4 overflow-y-auto p-4 text-sm text-theme-text-primary">
|
|
133
|
+
<div className="text-theme-text-primary">{effect}</div>
|
|
134
|
+
|
|
135
|
+
{disabledReason && <AlertBanner variant="info" title="This action is not available" message={disabledReason} />}
|
|
136
|
+
|
|
137
|
+
{children}
|
|
138
|
+
|
|
139
|
+
{warnings.length > 0 && (
|
|
140
|
+
<div className="space-y-2">
|
|
141
|
+
{warnings.map((w, i) => (
|
|
142
|
+
<div key={i} className="flex items-start gap-2 text-sm text-theme-text-primary">
|
|
143
|
+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning-text" />
|
|
144
|
+
<div className="min-w-0">{w}</div>
|
|
145
|
+
</div>
|
|
146
|
+
))}
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
149
|
+
|
|
150
|
+
{notes.length > 0 && (
|
|
151
|
+
<div className="space-y-1.5">
|
|
152
|
+
{notes.map((n, i) => (
|
|
153
|
+
<div key={i} className="flex items-start gap-2 text-xs text-theme-text-secondary">
|
|
154
|
+
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
|
|
155
|
+
<div className="min-w-0">{n}</div>
|
|
156
|
+
</div>
|
|
157
|
+
))}
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
|
|
161
|
+
{guard}
|
|
162
|
+
|
|
163
|
+
{writes.length > 0 && (
|
|
164
|
+
<div>
|
|
165
|
+
<button
|
|
166
|
+
type="button"
|
|
167
|
+
{...writesDisclosure.buttonProps}
|
|
168
|
+
onClick={() => setShowWrites((v) => !v)}
|
|
169
|
+
className="flex items-center gap-1.5 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary"
|
|
170
|
+
>
|
|
171
|
+
<CollapseChevron open={showWrites} className="h-3.5 w-3.5" />
|
|
172
|
+
{writes.length === 1 ? 'The API write' : `The API writes, in order (${writes.length})`}
|
|
173
|
+
</button>
|
|
174
|
+
<Collapse open={showWrites} id={writesDisclosure.panelId}>
|
|
175
|
+
<ol className="mt-2 space-y-2 pl-5 text-xs">
|
|
176
|
+
{writes.map((w, i) => (
|
|
177
|
+
<li key={i} className="list-decimal text-theme-text-secondary">
|
|
178
|
+
<div>{w.summary}</div>
|
|
179
|
+
{w.detail && (
|
|
180
|
+
<pre className="mt-1 overflow-auto whitespace-pre-wrap rounded bg-theme-base/60 p-2 font-mono text-[11.5px] text-theme-text-primary">
|
|
181
|
+
{w.detail}
|
|
182
|
+
</pre>
|
|
183
|
+
)}
|
|
184
|
+
</li>
|
|
185
|
+
))}
|
|
186
|
+
</ol>
|
|
187
|
+
</Collapse>
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
|
|
191
|
+
{typedConfirmation && (
|
|
192
|
+
<label className="block">
|
|
193
|
+
<span className="text-xs text-theme-text-secondary">
|
|
194
|
+
Type <span className="font-mono font-semibold text-theme-text-primary">{typedConfirmation}</span> to confirm
|
|
195
|
+
</span>
|
|
196
|
+
<input
|
|
197
|
+
value={typed}
|
|
198
|
+
onChange={(e) => setTyped(e.target.value)}
|
|
199
|
+
autoComplete="off"
|
|
200
|
+
spellCheck={false}
|
|
201
|
+
className="mt-1 w-full rounded-lg border border-theme-border bg-theme-base px-3 py-1.5 font-mono text-sm text-theme-text-primary focus:outline-none focus:ring-2 focus:ring-skyhook-500"
|
|
202
|
+
/>
|
|
203
|
+
</label>
|
|
204
|
+
)}
|
|
205
|
+
|
|
206
|
+
{result && (
|
|
207
|
+
<AlertBanner
|
|
208
|
+
variant={outcomeLocked ? 'warning' : 'error'}
|
|
209
|
+
title={result.title ?? (result.state === 'partial' ? 'Some changes completed' : result.state === 'unknown' ? 'Radar could not tell whether this took effect' : 'The action could not be completed')}
|
|
210
|
+
message={result.message || (outcomeLocked ? 'Check the object’s current state before another attempt.' : undefined)}
|
|
211
|
+
/>
|
|
212
|
+
)}
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<div className="flex items-center justify-end gap-3 border-t border-theme-border p-4">
|
|
216
|
+
{onBack && <button type="button" onClick={onBack} disabled={isLoading} className="btn-secondary mr-auto px-4 py-2 text-sm disabled:opacity-50">{backLabel}</button>}
|
|
217
|
+
{incompleteReason && !disabledReason && <span className="mr-auto min-w-0 text-xs text-theme-text-tertiary">{incompleteReason}</span>}
|
|
218
|
+
<button
|
|
219
|
+
type="button"
|
|
220
|
+
onClick={onClose}
|
|
221
|
+
disabled={isLoading}
|
|
222
|
+
className="rounded-lg px-4 py-2 text-sm font-medium text-theme-text-secondary transition-colors hover:bg-theme-elevated hover:text-theme-text-primary disabled:opacity-50"
|
|
223
|
+
>
|
|
224
|
+
Cancel
|
|
225
|
+
</button>
|
|
226
|
+
<button
|
|
227
|
+
type="button"
|
|
228
|
+
onClick={onConfirm}
|
|
229
|
+
disabled={!canConfirm}
|
|
230
|
+
className={clsx(
|
|
231
|
+
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50',
|
|
232
|
+
disruptive ? [TONE_SOLID_CLASS.red, 'hover:opacity-90'] : 'btn-brand',
|
|
233
|
+
)}
|
|
234
|
+
>
|
|
235
|
+
{isLoading && (
|
|
236
|
+
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" aria-hidden>
|
|
237
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
|
238
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
239
|
+
</svg>
|
|
240
|
+
)}
|
|
241
|
+
{confirmLabel}
|
|
242
|
+
</button>
|
|
243
|
+
</div>
|
|
244
|
+
</DialogPortal>
|
|
245
|
+
)
|
|
246
|
+
}
|