@skyhook-io/k8s-ui 1.16.2 → 1.17.0
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/package.json +3 -3
- package/src/components/applications/ApplicationDetail.test.tsx +15 -0
- package/src/components/applications/ApplicationDetail.tsx +17 -15
- package/src/components/checks/types.ts +2 -13
- package/src/components/cnpg/CNPGBackupSummary.tsx +223 -0
- package/src/components/cnpg/CNPGClusterSummary.tsx +222 -0
- package/src/components/cnpg/CNPGDeclarativeSummary.tsx +264 -0
- package/src/components/cnpg/CNPGImageCatalogSummary.tsx +80 -0
- package/src/components/cnpg/CNPGObjectStoreSummary.tsx +176 -0
- package/src/components/cnpg/CNPGObjectSummary.test.tsx +236 -0
- package/src/components/cnpg/CNPGPoolerSummary.tsx +70 -0
- package/src/components/cnpg/CNPGSharedSummary.tsx +88 -0
- package/src/components/cnpg/index.ts +14 -0
- package/src/components/cnpg/primitives.tsx +134 -0
- package/src/components/cnpg/relations.test.ts +304 -0
- package/src/components/cnpg/relations.ts +417 -0
- package/src/components/cnpg/workspace.test.ts +285 -0
- package/src/components/cnpg/workspace.ts +700 -0
- package/src/components/gitops/GitOpsTableView.tsx +9 -24
- package/src/components/gitops/detail-helpers.ts +1 -1
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +5 -12
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +2 -5
- package/src/components/issues/IssuesView.tsx +10 -1
- package/src/components/issues/issue-timing.ts +2 -2
- package/src/components/issues/issues.test.ts +1 -1
- package/src/components/issues/types.ts +24 -0
- package/src/components/logs/WorkloadLogsViewer.notice.test.tsx +59 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +17 -3
- package/src/components/logs/useLogBuffer.test.ts +17 -0
- package/src/components/logs/useLogStream.ts +11 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +76 -0
- package/src/components/resources/ResourcesSidebar.tsx +183 -11
- package/src/components/resources/ResourcesView.tsx +12 -9
- package/src/components/resources/index.ts +1 -1
- package/src/components/resources/renderers/AnalysisRunRenderer.tsx +3 -3
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +6 -5
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +2 -2
- 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/CNPGClusterRenderer.tsx +2 -1
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +25 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +16 -7
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +2 -2
- package/src/components/resources/renderers/CNPGShared.tsx +2 -2
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -2
- 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 +24 -0
- package/src/components/resources/renderers/EndpointSliceRenderer.tsx +1 -1
- 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 +2 -2
- 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.tsx +6 -2
- 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 +2 -2
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +6 -3
- 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/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.tsx +21 -7
- 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 +8 -21
- 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-cnpg-objectstore.test.ts +1 -1
- package/src/components/resources/resource-utils-cnpg.ts +3 -7
- package/src/components/resources/resource-utils-keda.ts +1 -7
- 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 +17 -33
- package/src/components/resources/status-overclaim.test.ts +1 -1
- package/src/components/shared/DrainPlanDialog.tsx +1 -0
- package/src/components/shared/GitOpsWriteWarning.test.tsx +86 -0
- package/src/components/shared/GitOpsWriteWarning.tsx +135 -0
- package/src/components/shared/ResourceActionsBar.tsx +9 -20
- package/src/components/shared/ResourceRendererDispatch.tsx +1 -1
- package/src/components/shared/SetImageDialog.test.ts +75 -6
- package/src/components/shared/SetImageDialog.tsx +92 -84
- package/src/components/shared/index.ts +2 -1
- package/src/components/timeline/TimelineList.tsx +187 -38
- package/src/components/timeline/TimelineListFold.test.tsx +56 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +2 -10
- package/src/components/timeline/TimelineToolbarState.test.tsx +98 -0
- package/src/components/timeline/index.ts +1 -0
- package/src/components/timeline/timeline-filters.ts +7 -0
- package/src/components/topology/TopologyGraph.tsx +5 -3
- package/src/components/ui/ScheduleValue.test.tsx +5 -0
- package/src/components/ui/ScheduleValue.tsx +3 -2
- package/src/components/ui/drawer-components.test.tsx +66 -1
- package/src/components/ui/drawer-components.tsx +43 -29
- package/src/components/workload/WorkloadView.deleted.test.tsx +58 -0
- package/src/components/workload/WorkloadView.deletedDialog.test.tsx +45 -0
- package/src/components/workload/WorkloadView.secretSave.test.tsx +62 -0
- package/src/components/workload/WorkloadView.tsx +216 -48
- package/src/components/workload/index.ts +1 -0
- package/src/components/workload/workload-logs-availability.test.ts +6 -0
- package/src/index.ts +3 -0
- package/src/types/capacity.ts +10 -0
- package/src/types/core.ts +32 -1
- package/src/utils/format.test.ts +40 -2
- package/src/utils/format.ts +5 -1
- package/src/utils/gateway-references.test.ts +17 -0
- package/src/utils/gateway-references.ts +29 -0
- package/src/utils/gitops-write-guard.test.ts +401 -0
- package/src/utils/gitops-write-guard.ts +509 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/ingress-class-references.ts +22 -0
- package/src/utils/log-level.ts +14 -0
- package/src/utils/navigation.test.ts +13 -0
- package/src/utils/navigation.ts +5 -0
- package/src/utils/node-lifecycle.test.ts +36 -0
- package/src/utils/node-lifecycle.ts +77 -0
- package/src/utils/resource-hierarchy.test.ts +32 -0
- package/src/utils/resource-hierarchy.ts +28 -2
- package/src/utils/resource-refs.test.ts +23 -0
- package/src/utils/resource-refs.ts +30 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { renderToString } from 'react-dom/server'
|
|
2
|
+
import { renderToString, renderToStaticMarkup } from 'react-dom/server'
|
|
3
3
|
import { NodeRenderer } from './NodeRenderer'
|
|
4
4
|
|
|
5
5
|
const node = {
|
|
@@ -101,3 +101,83 @@ describe('NodeRenderer metrics', () => {
|
|
|
101
101
|
expect(html).toContain('CPU')
|
|
102
102
|
})
|
|
103
103
|
})
|
|
104
|
+
|
|
105
|
+
describe('NodeRenderer removal advice', () => {
|
|
106
|
+
const removal = {
|
|
107
|
+
...node,
|
|
108
|
+
spec: { unschedulable: true, taints: [{ key: 'ToBeDeletedByClusterAutoscaler', value: String(Math.floor(Date.now() / 1000) - 60), effect: 'NoSchedule' }] },
|
|
109
|
+
status: { ...node.status, conditions: [{ type: 'Ready', status: 'Unknown', lastTransitionTime: new Date(Date.now() - 1000).toISOString(), message: 'Kubelet stopped posting' }] },
|
|
110
|
+
}
|
|
111
|
+
it('does not give an autoscaler removal an error or uncordon advice', () => {
|
|
112
|
+
const html = renderToStaticMarkup(<NodeRenderer data={removal} />)
|
|
113
|
+
expect(html).toContain('Removing (cluster autoscaler)')
|
|
114
|
+
expect(html).not.toContain('Issues Detected')
|
|
115
|
+
expect(html).not.toContain('Uncordon')
|
|
116
|
+
expect(html).toContain('Pod and disruption-budget checks are not available')
|
|
117
|
+
})
|
|
118
|
+
it('does not assert who caused a bare cordon', () => {
|
|
119
|
+
const html = renderToStaticMarkup(<NodeRenderer data={{ ...node, spec: { unschedulable: true } }} />)
|
|
120
|
+
expect(html).toContain('Cordoned')
|
|
121
|
+
expect(html).toContain('who cordoned it is not recorded')
|
|
122
|
+
expect(html).not.toContain('Uncordon to resume')
|
|
123
|
+
})
|
|
124
|
+
it('keeps observed readiness False neutral when it follows the removal start', () => {
|
|
125
|
+
const html = renderToStaticMarkup(<NodeRenderer data={{ ...removal, status: { ...removal.status, conditions: [{ ...removal.status.conditions[0], status: 'False' }] } }} />)
|
|
126
|
+
expect(html).toContain('Removing (cluster autoscaler)')
|
|
127
|
+
expect(html).toContain('Kubelet stopped posting')
|
|
128
|
+
expect(html).not.toContain('1 failing')
|
|
129
|
+
expect(html).not.toContain('Issues Detected')
|
|
130
|
+
})
|
|
131
|
+
it('shows named possible PDB blockers and terminating pods without invented progress', () => {
|
|
132
|
+
const html = renderToStaticMarkup(<NodeRenderer data={removal} removalPlan={{
|
|
133
|
+
node: 'kind-worker', generatedAt: new Date().toISOString(), estimate: true,
|
|
134
|
+
options: { ignoreDaemonSets: true, deleteEmptyDirData: true, force: true },
|
|
135
|
+
summary: { evict: 1, skip: 0, mayBlock: 1 }, pdbsEvaluated: true,
|
|
136
|
+
pods: [
|
|
137
|
+
{ namespace: 'shop', name: 'web', outcome: 'may-block', reason: 'PodDisruptionBudget shop/web allows no disruptions', pdb: 'shop/web', pdbChecked: true, emptyDir: false, terminating: false },
|
|
138
|
+
{ namespace: 'shop', name: 'old', outcome: 'evict', reason: 'already terminating', pdbChecked: false, emptyDir: false, terminating: true },
|
|
139
|
+
],
|
|
140
|
+
}} />)
|
|
141
|
+
expect(html).toContain('2 pods in the current snapshot')
|
|
142
|
+
expect(html).toContain('1 terminating')
|
|
143
|
+
expect(html).toContain('shop/web')
|
|
144
|
+
expect(html).toContain('may block removal')
|
|
145
|
+
expect(html).not.toContain('evicted')
|
|
146
|
+
})
|
|
147
|
+
it('uses warning tone for independent network failure during removal', () => {
|
|
148
|
+
const html = renderToStaticMarkup(<NodeRenderer data={{ ...removal, status: { conditions: [
|
|
149
|
+
...removal.status.conditions,
|
|
150
|
+
{ type: 'NetworkUnavailable', status: 'True', lastTransitionTime: new Date(Date.now() - 180_000).toISOString() },
|
|
151
|
+
] } }} />)
|
|
152
|
+
expect(html).toContain('Removing (cluster autoscaler) · Network unavailable')
|
|
153
|
+
expect(html).toContain('bg-amber-50')
|
|
154
|
+
expect(html).not.toContain('rounded-lg bg-red-50')
|
|
155
|
+
expect(html).not.toContain('rounded-lg bg-sky-50')
|
|
156
|
+
})
|
|
157
|
+
it('describes skip outcomes as drain estimates', () => {
|
|
158
|
+
const html = renderToStaticMarkup(<NodeRenderer data={removal} removalPlan={{
|
|
159
|
+
node: 'kind-worker', generatedAt: new Date().toISOString(), estimate: true,
|
|
160
|
+
options: { ignoreDaemonSets: true, deleteEmptyDirData: true, force: true },
|
|
161
|
+
summary: { evict: 0, skip: 1, mayBlock: 0 }, pdbsEvaluated: true,
|
|
162
|
+
pods: [{ namespace: 'shop', name: 'daemon', outcome: 'skip', reason: 'DaemonSet', pdbChecked: false, emptyDir: false, terminating: false }],
|
|
163
|
+
}} />)
|
|
164
|
+
expect(html).toContain('Skipped by drain estimate')
|
|
165
|
+
expect(html).toContain('1 skipped by drain estimate')
|
|
166
|
+
expect(html).not.toContain('Retained')
|
|
167
|
+
})
|
|
168
|
+
it('reports unavailable drain evidence as unavailable', () => {
|
|
169
|
+
const html = renderToStaticMarkup(<NodeRenderer data={removal} removalPlanError="forbidden: cannot list pods" />)
|
|
170
|
+
expect(html).toContain('Drain details unavailable')
|
|
171
|
+
expect(html).toContain('forbidden: cannot list pods')
|
|
172
|
+
expect(html).not.toContain('0 pods')
|
|
173
|
+
})
|
|
174
|
+
it('does not present a cached snapshot as current after a refresh fails', () => {
|
|
175
|
+
const html = renderToStaticMarkup(<NodeRenderer data={removal} removalPlanError="forbidden: cannot list pods" removalPlan={{
|
|
176
|
+
node: 'kind-worker', generatedAt: new Date().toISOString(), estimate: true,
|
|
177
|
+
options: { ignoreDaemonSets: true, deleteEmptyDirData: true, force: true },
|
|
178
|
+
summary: { evict: 0, skip: 0, mayBlock: 0 }, pdbsEvaluated: true, pods: [],
|
|
179
|
+
}} />)
|
|
180
|
+
expect(html).toContain('Drain details unavailable')
|
|
181
|
+
expect(html).not.toContain('0 pods in the current snapshot')
|
|
182
|
+
})
|
|
183
|
+
})
|
|
@@ -4,6 +4,9 @@ import { Section, PropertyList, Property, ConditionsSection, AlertBanner } from
|
|
|
4
4
|
import { MetricsChart } from '../../ui/MetricsChart'
|
|
5
5
|
import { formatMemoryString } from '../../../utils/format'
|
|
6
6
|
import { getExtendedCapacityRows } from '../../../utils/extended-resources'
|
|
7
|
+
import { getNodeLifecycle } from '../../../utils/node-lifecycle'
|
|
8
|
+
import { formatRelativeAgeTime } from '../../../utils/format'
|
|
9
|
+
import type { DrainPlan } from '../../shared/DrainPlanDialog'
|
|
7
10
|
import type { MetricsDataPoint } from '../../../types/core'
|
|
8
11
|
import { MetricsUnavailableNotice } from './MetricsUnavailableNotice'
|
|
9
12
|
|
|
@@ -15,6 +18,9 @@ interface NodeRendererProps {
|
|
|
15
18
|
metricsHistory?: { dataPoints?: MetricsDataPoint[]; collectionError?: string; metricsUnavailableReason?: string; metricsUnavailableDiagnosis?: string }
|
|
16
19
|
metricsUnavailable?: boolean
|
|
17
20
|
hideMetricsServer?: boolean
|
|
21
|
+
removalPlan?: DrainPlan
|
|
22
|
+
removalPlanLoading?: boolean
|
|
23
|
+
removalPlanError?: string
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
// Helper to handle undefined values
|
|
@@ -31,18 +37,18 @@ function formatStorage(value: string | undefined): string {
|
|
|
31
37
|
// Extract genuine problems from node status. Cordoned (unschedulable) is
|
|
32
38
|
// deliberately NOT included here — it's an intentional operator action
|
|
33
39
|
// (cordon/drain), surfaced separately as a calm advisory, not a red error.
|
|
34
|
-
function getNodeProblems(data: any): string[] {
|
|
40
|
+
function getNodeProblems(data: any, lifecycle: ReturnType<typeof getNodeLifecycle>): string[] {
|
|
35
41
|
const problems: string[] = []
|
|
36
42
|
const conditions = data.status?.conditions || []
|
|
37
43
|
|
|
38
44
|
for (const cond of conditions) {
|
|
39
45
|
// NotReady is a problem when status is not True
|
|
40
|
-
if (cond.type === 'Ready' &&
|
|
46
|
+
if (cond.type === 'Ready' && lifecycle.readinessFailed) {
|
|
41
47
|
problems.push(`Node is NotReady${cond.message ? ': ' + cond.message : ''}`)
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
// These conditions are problems when True
|
|
45
|
-
if (cond.status === 'True') {
|
|
51
|
+
if (cond.status === 'True' && lifecycle.problems.includes(cond.type)) {
|
|
46
52
|
if (cond.type === 'DiskPressure') {
|
|
47
53
|
problems.push(`Disk pressure${cond.message ? ': ' + cond.message : ''}`)
|
|
48
54
|
}
|
|
@@ -61,7 +67,7 @@ function getNodeProblems(data: any): string[] {
|
|
|
61
67
|
return problems
|
|
62
68
|
}
|
|
63
69
|
|
|
64
|
-
export function NodeRenderer({ data, relationships, onViewPods, metrics, metricsHistory, metricsUnavailable, hideMetricsServer }: NodeRendererProps) {
|
|
70
|
+
export function NodeRenderer({ data, relationships, onViewPods, metrics, metricsHistory, metricsUnavailable, hideMetricsServer, removalPlan, removalPlanLoading, removalPlanError }: NodeRendererProps) {
|
|
65
71
|
const status = data.status || {}
|
|
66
72
|
const spec = data.spec || {}
|
|
67
73
|
const metadata = data.metadata || {}
|
|
@@ -73,9 +79,14 @@ export function NodeRenderer({ data, relationships, onViewPods, metrics, metrics
|
|
|
73
79
|
const taints = spec.taints || []
|
|
74
80
|
|
|
75
81
|
// Check for problems
|
|
76
|
-
const
|
|
82
|
+
const lifecycle = getNodeLifecycle(data)
|
|
83
|
+
const problems = getNodeProblems(data, lifecycle)
|
|
77
84
|
const hasProblems = problems.length > 0
|
|
78
85
|
const isCordoned = !!spec.unschedulable
|
|
86
|
+
const currentPlan = !removalPlanError && removalPlan?.node === metadata.name ? removalPlan : undefined
|
|
87
|
+
const possibleBlockers = currentPlan?.pods.filter(pod => pod.outcome === 'may-block') ?? []
|
|
88
|
+
const terminatingPods = currentPlan?.pods.filter(pod => pod.terminating).length
|
|
89
|
+
const terminationKnown = currentPlan?.pods.every(pod => pod.terminating !== undefined)
|
|
79
90
|
const showMetricsUnavailable = !!metricsUnavailable && !metricsHistory?.collectionError
|
|
80
91
|
const hasMetricsHistory = !!metricsHistory?.dataPoints?.length
|
|
81
92
|
const currentMetrics = metricsUnavailable ? undefined : metrics
|
|
@@ -92,20 +103,50 @@ export function NodeRenderer({ data, relationships, onViewPods, metrics, metrics
|
|
|
92
103
|
<>
|
|
93
104
|
{/* Problems alert - shown at top when there are genuine issues */}
|
|
94
105
|
{hasProblems && (
|
|
95
|
-
<AlertBanner variant=
|
|
106
|
+
<AlertBanner variant={lifecycle.level === 'unhealthy' ? 'error' : 'warning'} title="Issues Detected" items={problems} />
|
|
96
107
|
)}
|
|
97
108
|
|
|
98
|
-
{
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
109
|
+
{lifecycle.removing ? (
|
|
110
|
+
<AlertBanner
|
|
111
|
+
variant={lifecycle.level === 'unhealthy' ? 'error' : lifecycle.level === 'degraded' ? 'warning' : 'info'}
|
|
112
|
+
title={lifecycle.delayed ? `${lifecycle.label} — taking longer than expected` : lifecycle.label}
|
|
113
|
+
message={lifecycle.delayed
|
|
114
|
+
? 'Check remaining pods, disruption budgets and controller events. Do not uncordon during removal.'
|
|
115
|
+
: 'This node is marked for removal. New pods are kept off it while the controller prepares or completes removal.'}
|
|
116
|
+
>
|
|
117
|
+
<div className="mt-2 space-y-2 text-xs">
|
|
118
|
+
<p>{lifecycle.startedAt !== undefined ? `Marked for removal ${formatRelativeAgeTime(new Date(lifecycle.startedAt).toISOString())}.` : 'Removal start time is not reported.'}</p>
|
|
119
|
+
{removalPlanLoading && <p>Checking remaining pods and disruption budgets…</p>}
|
|
120
|
+
{removalPlanError && <p>Drain details unavailable: {removalPlanError}</p>}
|
|
121
|
+
{!removalPlanLoading && !removalPlanError && !currentPlan && <p>Pod and disruption-budget checks are not available from this host.</p>}
|
|
122
|
+
{currentPlan && (
|
|
123
|
+
<>
|
|
124
|
+
<p>{currentPlan.pods.length} pods in the current snapshot · {terminationKnown ? `${terminatingPods} terminating` : 'termination state unavailable from this Radar'} · {currentPlan.summary.skip} skipped by drain estimate (DaemonSet, static or completed).</p>
|
|
125
|
+
<p className="text-theme-text-secondary">Checked {formatRelativeAgeTime(currentPlan.generatedAt)}. This is a snapshot, not an eviction history. Disruption-budget checks estimate what the Eviction API would allow now.</p>
|
|
126
|
+
<p className="text-theme-text-secondary">Removal controllers can also hold pods marked karpenter.sh/do-not-disrupt or cluster-autoscaler.kubernetes.io/safe-to-evict=false. Those policies are not evaluated by this drain estimate.</p>
|
|
127
|
+
<ul className="max-h-48 overflow-y-auto space-y-1">
|
|
128
|
+
{currentPlan.pods.map(pod => (
|
|
129
|
+
<li key={`${pod.namespace}/${pod.name}`} className="flex justify-between gap-2">
|
|
130
|
+
<span className="break-all font-mono">{pod.namespace}/{pod.name}</span>
|
|
131
|
+
<span className="shrink-0 text-theme-text-secondary">{pod.terminating ? 'Terminating' : pod.outcome === 'may-block' ? 'Budget may block' : pod.outcome === 'skip' ? 'Skipped by drain estimate' : 'Remaining'}</span>
|
|
132
|
+
</li>
|
|
133
|
+
))}
|
|
134
|
+
</ul>
|
|
135
|
+
{!currentPlan.pdbsEvaluated && <p>Disruption-budget checks are incomplete: {currentPlan.pdbError}</p>}
|
|
136
|
+
{possibleBlockers.length > 0 && (
|
|
137
|
+
<AlertBanner variant="warning" title="Disruption budgets may block removal" items={possibleBlockers.map(pod => `${pod.namespace}/${pod.name}: ${pod.reason}`)} />
|
|
138
|
+
)}
|
|
139
|
+
</>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
</AlertBanner>
|
|
143
|
+
) : isCordoned ? (
|
|
103
144
|
<AlertBanner
|
|
104
145
|
variant="warning"
|
|
105
|
-
title="Cordoned
|
|
106
|
-
message="New pods won't be scheduled here.
|
|
146
|
+
title="Cordoned"
|
|
147
|
+
message="New pods won't be scheduled here. The node may be under maintenance; who cordoned it is not recorded. Resume scheduling only after confirming maintenance has finished."
|
|
107
148
|
/>
|
|
108
|
-
)}
|
|
149
|
+
) : null}
|
|
109
150
|
|
|
110
151
|
{/* Node Info */}
|
|
111
152
|
<Section title="Node Info" icon={Server}>
|
|
@@ -314,7 +355,7 @@ export function NodeRenderer({ data, relationships, onViewPods, metrics, metrics
|
|
|
314
355
|
)}
|
|
315
356
|
|
|
316
357
|
{/* Conditions */}
|
|
317
|
-
<ConditionsSection conditions={status.conditions} />
|
|
358
|
+
<ConditionsSection conditions={status.conditions} getConditionTone={condition => condition.type === 'Ready' && lifecycle.removing && !lifecycle.readinessFailed && condition.status !== 'True' ? 'unknown' : undefined} />
|
|
318
359
|
</>
|
|
319
360
|
)
|
|
320
361
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { Package, CheckCircle2 } from 'lucide-react'
|
|
2
2
|
import { Section, PropertyList, Property, ConditionsSection, ProblemAlerts } from '../../ui/drawer-components'
|
|
3
|
-
import { formatAge } from '../resource-utils'
|
|
4
3
|
import { formatBytes } from '../../../utils/format'
|
|
5
4
|
import { GitOpsStatusBadge, SyncCountdown } from '../../gitops'
|
|
6
5
|
import { fluxConditionsToGitOpsStatus, type FluxCondition } from '../../../types/gitops'
|
|
6
|
+
import { TimeValue } from '../../ui/ScheduleValue'
|
|
7
7
|
|
|
8
8
|
interface OCIRepositoryRendererProps {
|
|
9
9
|
data: any
|
|
@@ -80,7 +80,7 @@ export function OCIRepositoryRenderer({ data }: OCIRepositoryRendererProps) {
|
|
|
80
80
|
<Property label="Digest" value={artifact.digest} />
|
|
81
81
|
<Property
|
|
82
82
|
label="Last Updated"
|
|
83
|
-
value={
|
|
83
|
+
value={<TimeValue timestamp={artifact.lastUpdateTime} />}
|
|
84
84
|
/>
|
|
85
85
|
{artifact.size && (
|
|
86
86
|
<Property label="Size" value={formatBytes(artifact.size)} />
|
|
@@ -103,7 +103,7 @@ export function OCIRepositoryRenderer({ data }: OCIRepositoryRendererProps) {
|
|
|
103
103
|
{status.lastHandledReconcileAt && (
|
|
104
104
|
<Property
|
|
105
105
|
label="Last Reconciled"
|
|
106
|
-
value={
|
|
106
|
+
value={<TimeValue timestamp={status.lastHandledReconcileAt} />}
|
|
107
107
|
/>
|
|
108
108
|
)}
|
|
109
109
|
</PropertyList>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
6
|
+
import { PodRenderer } from './PodRenderer'
|
|
7
|
+
|
|
8
|
+
const data = {
|
|
9
|
+
metadata: { name: 'worker', namespace: 'app' },
|
|
10
|
+
spec: { containers: [], priority: 1000, priorityClassName: 'urgent', runtimeClassName: 'sandbox' },
|
|
11
|
+
status: { phase: 'Pending' },
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('PodRenderer class references', () => {
|
|
15
|
+
it('links explicit class names with their API groups and cluster scope', async () => {
|
|
16
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
17
|
+
const container = document.createElement('div')
|
|
18
|
+
const root = createRoot(container)
|
|
19
|
+
const onNavigate = vi.fn()
|
|
20
|
+
try {
|
|
21
|
+
await act(async () => root.render(<PodRenderer data={data} onCopy={() => {}} copied={null} onNavigate={onNavigate} />))
|
|
22
|
+
for (const [name, kind, group] of [
|
|
23
|
+
['urgent', 'priorityclasses', 'scheduling.k8s.io'],
|
|
24
|
+
['sandbox', 'runtimeclasses', 'node.k8s.io'],
|
|
25
|
+
]) {
|
|
26
|
+
const button = [...container.querySelectorAll('button')].find(button => button.textContent === name)
|
|
27
|
+
expect(button).toBeDefined()
|
|
28
|
+
await act(async () => button!.click())
|
|
29
|
+
expect(onNavigate).toHaveBeenLastCalledWith({ kind, group, namespace: '', name })
|
|
30
|
+
}
|
|
31
|
+
} finally {
|
|
32
|
+
await act(async () => root.unmount())
|
|
33
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: false })
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('does not infer a class from numeric priority alone', () => {
|
|
38
|
+
const html = renderToStaticMarkup(<PodRenderer data={{ ...data, spec: { containers: [], priority: 1000 } }} onCopy={() => {}} copied={null} />)
|
|
39
|
+
expect(html).not.toContain('Priority Class')
|
|
40
|
+
expect(html).not.toContain('Runtime Class')
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
6
|
+
import { PodRenderer } from './PodRenderer'
|
|
7
|
+
|
|
8
|
+
describe('PodRenderer nominated Node', () => {
|
|
9
|
+
it('links the nominated Node of an unbound Pod', async () => {
|
|
10
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
11
|
+
const container = document.createElement('div')
|
|
12
|
+
const root = createRoot(container)
|
|
13
|
+
const onNavigate = vi.fn()
|
|
14
|
+
try {
|
|
15
|
+
await act(async () => root.render(
|
|
16
|
+
<PodRenderer
|
|
17
|
+
data={{ metadata: { name: 'pending', namespace: 'app' }, spec: { containers: [] }, status: { phase: 'Pending', nominatedNodeName: 'candidate' } }}
|
|
18
|
+
onCopy={() => {}}
|
|
19
|
+
copied={null}
|
|
20
|
+
onNavigate={onNavigate}
|
|
21
|
+
/>,
|
|
22
|
+
))
|
|
23
|
+
expect(container.textContent).toContain('Nominated Node')
|
|
24
|
+
const button = [...container.querySelectorAll('button')].find(button => button.textContent === 'candidate')
|
|
25
|
+
expect(button).toBeDefined()
|
|
26
|
+
await act(async () => button!.click())
|
|
27
|
+
expect(onNavigate).toHaveBeenCalledWith({ kind: 'nodes', namespace: '', name: 'candidate', group: undefined })
|
|
28
|
+
} finally {
|
|
29
|
+
await act(async () => root.unmount())
|
|
30
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: false })
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('hides a nomination on a terminal or terminating unbound Pod', () => {
|
|
35
|
+
for (const data of [
|
|
36
|
+
{ metadata: { name: 'failed', namespace: 'app' }, spec: { containers: [] }, status: { phase: 'Failed', nominatedNodeName: 'stale' } },
|
|
37
|
+
{ metadata: { name: 'deleting', namespace: 'app', deletionTimestamp: '2026-10-07T00:00:00Z' }, spec: { containers: [] }, status: { phase: 'Pending', nominatedNodeName: 'stale' } },
|
|
38
|
+
]) {
|
|
39
|
+
const html = renderToStaticMarkup(<PodRenderer data={data} onCopy={() => {}} copied={null} />)
|
|
40
|
+
expect(html).not.toContain('Nominated Node')
|
|
41
|
+
expect(html).not.toContain('stale')
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('hides a nomination once the Pod is bound, even when it names another node', () => {
|
|
46
|
+
const html = renderToStaticMarkup(
|
|
47
|
+
<PodRenderer
|
|
48
|
+
data={{ metadata: { name: 'running', namespace: 'app' }, spec: { nodeName: 'assigned', containers: [] }, status: { phase: 'Running', nominatedNodeName: 'stale' } }}
|
|
49
|
+
onCopy={() => {}}
|
|
50
|
+
copied={null}
|
|
51
|
+
/>,
|
|
52
|
+
)
|
|
53
|
+
expect(html).not.toContain('Nominated Node')
|
|
54
|
+
expect(html).not.toContain('stale')
|
|
55
|
+
expect(html).toContain('assigned')
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -28,6 +28,7 @@ import { getRadarUpgradeRequirement, type RadarUpgradeRequirement } from '../../
|
|
|
28
28
|
import { MetricsChart } from '../../ui/MetricsChart'
|
|
29
29
|
import { MetricsUnavailableNotice } from './MetricsUnavailableNotice'
|
|
30
30
|
import { ContainerEnvironmentSection } from './ContainerEnvironmentSection'
|
|
31
|
+
import { formatRelativeAgeTime } from '../../../utils/format'
|
|
31
32
|
|
|
32
33
|
function parseValidDate(dateStr: string): Date | null {
|
|
33
34
|
const d = new Date(dateStr)
|
|
@@ -35,13 +36,7 @@ function parseValidDate(dateStr: string): Date | null {
|
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
function formatMsAgo(dateStr: string): string | null {
|
|
38
|
-
|
|
39
|
-
if (!d) return null
|
|
40
|
-
const ms = Date.now() - d.getTime()
|
|
41
|
-
if (ms < 60000) return 'just now'
|
|
42
|
-
if (ms < 3600000) return `${Math.floor(ms / 60000)}m ago`
|
|
43
|
-
if (ms < 86400000) return `${Math.floor(ms / 3600000)}h ago`
|
|
44
|
-
return `${Math.floor(ms / 86400000)}d ago`
|
|
39
|
+
return parseValidDate(dateStr) ? formatRelativeAgeTime(dateStr) : null
|
|
45
40
|
}
|
|
46
41
|
|
|
47
42
|
function getRestartRecency(finishedAt: string | undefined, restarts: number): { color: string; label: string | null } {
|
|
@@ -453,6 +448,25 @@ export function PodRenderer({
|
|
|
453
448
|
<Property label="Node" value={
|
|
454
449
|
data.spec?.nodeName ? <ResourceLink name={data.spec.nodeName} kind="nodes" onNavigate={onNavigate} /> : undefined
|
|
455
450
|
} copyable onCopy={onCopy} copied={copied} />
|
|
451
|
+
{/* A nomination outlives its meaning: the API server clears it on binding by
|
|
452
|
+
default only from Kubernetes 1.35, and pod GC fails a terminating unbound
|
|
453
|
+
Pod without clearing it. Only a Pending, unbound, live Pod can still use it. */}
|
|
454
|
+
{data.status?.nominatedNodeName && !data.spec?.nodeName && data.status?.phase === 'Pending' && !data.metadata?.deletionTimestamp && (
|
|
455
|
+
<Property
|
|
456
|
+
label={
|
|
457
|
+
<Tooltip content="The node this Pod is expected to run on, set by the scheduler or another component. It can change before the Pod is bound." position="right">
|
|
458
|
+
<span className="border-b border-dotted border-theme-text-tertiary cursor-help">Nominated Node</span>
|
|
459
|
+
</Tooltip>
|
|
460
|
+
}
|
|
461
|
+
value={<ResourceLink name={data.status.nominatedNodeName} kind="nodes" onNavigate={onNavigate} />}
|
|
462
|
+
/>
|
|
463
|
+
)}
|
|
464
|
+
{data.spec?.priorityClassName && <Property label="Priority Class" value={
|
|
465
|
+
<ResourceLink name={data.spec.priorityClassName} kind="priorityclasses" group="scheduling.k8s.io" onNavigate={onNavigate} />
|
|
466
|
+
} />}
|
|
467
|
+
{data.spec?.runtimeClassName && <Property label="Runtime Class" value={
|
|
468
|
+
<ResourceLink name={data.spec.runtimeClassName} kind="runtimeclasses" group="node.k8s.io" onNavigate={onNavigate} />
|
|
469
|
+
} />}
|
|
456
470
|
<Property label="Pod IP" value={data.status?.podIP} copyable onCopy={onCopy} copied={copied} />
|
|
457
471
|
<Property label="Host IP" value={data.status?.hostIP} />
|
|
458
472
|
<Property
|
|
@@ -269,7 +269,7 @@ describe('rolloutProblems', () => {
|
|
|
269
269
|
)
|
|
270
270
|
expect(problems).toHaveLength(1)
|
|
271
271
|
expect(problems[0].color).toBe('yellow')
|
|
272
|
-
expect(problems[0].message).toMatch(/^Rollout is paused: CanaryPauseStep \(
|
|
272
|
+
expect(problems[0].message).toMatch(/^Rollout is paused: CanaryPauseStep \(for \d+d\)$/)
|
|
273
273
|
})
|
|
274
274
|
|
|
275
275
|
it('does not report a paused rollout that is actually aborted', () => {
|
|
@@ -9,6 +9,7 @@ import { CanaryStepTimeline, BlueGreenTimeline } from './rollout/CanaryStepTimel
|
|
|
9
9
|
import { ReplicaSetProgression } from './rollout/ReplicaSetProgression'
|
|
10
10
|
import { LookupFailureNote } from './LookupFailureNote'
|
|
11
11
|
import type { WorkloadRevision, WorkloadPodInfo } from '../../../types'
|
|
12
|
+
import { formatRelativeAgeTime } from '../../../utils/format'
|
|
12
13
|
|
|
13
14
|
export type RolloutAction = 'abort' | 'retry' | 'promote' | 'promote-full' | 'skip-step'
|
|
14
15
|
|
|
@@ -214,7 +215,7 @@ export function rolloutProblems(data: any): RolloutProblem[] {
|
|
|
214
215
|
|
|
215
216
|
if (phase === 'Paused' && !status.abort) {
|
|
216
217
|
const reasons = (status.pauseConditions || []).map((pc: any) => {
|
|
217
|
-
const since = pc.startTime ? ` (
|
|
218
|
+
const since = pc.startTime ? ` (for ${formatAge(pc.startTime)})` : ''
|
|
218
219
|
return `${pc.reason || 'Unknown'}${since}`
|
|
219
220
|
})
|
|
220
221
|
problems.push({
|
|
@@ -741,7 +742,7 @@ export function RolloutRenderer({ data, onNavigate, capabilities, onAction, pend
|
|
|
741
742
|
</span>
|
|
742
743
|
)}
|
|
743
744
|
<span className={clsx('text-xs text-theme-text-tertiary', run.metricsTotal === 0 && 'ml-auto')}>
|
|
744
|
-
{
|
|
745
|
+
{formatRelativeAgeTime(run.createdAt)}
|
|
745
746
|
</span>
|
|
746
747
|
</div>
|
|
747
748
|
))}
|
|
@@ -3,8 +3,8 @@ import { Package, Search } from 'lucide-react'
|
|
|
3
3
|
import { Section, PropertyList, Property } from '../../ui/drawer-components'
|
|
4
4
|
import { Collapse, CollapseChevron, useDisclosure } from '../../ui/Collapse'
|
|
5
5
|
import { Input } from '../../ui/Input'
|
|
6
|
-
import { formatAge } from '../resource-utils'
|
|
7
6
|
import { formatTrivyImage } from './trivy-shared'
|
|
7
|
+
import { TimeValue } from '../../ui/ScheduleValue'
|
|
8
8
|
|
|
9
9
|
interface SbomReportRendererProps {
|
|
10
10
|
data: any
|
|
@@ -57,7 +57,7 @@ export function SbomReportRenderer({ data }: SbomReportRendererProps) {
|
|
|
57
57
|
<Property label="Components" value={String(componentsCount)} />
|
|
58
58
|
{depsCount > 0 && <Property label="Dependencies" value={String(depsCount)} />}
|
|
59
59
|
<Property label="Scanner" value={scanner.name ? `${scanner.name} ${scanner.version || ''}`.trim() : '-'} />
|
|
60
|
-
<Property label="Scanned" value={
|
|
60
|
+
<Property label="Scanned" value={<TimeValue timestamp={report.updateTimestamp} />} />
|
|
61
61
|
</PropertyList>
|
|
62
62
|
</Section>
|
|
63
63
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { SecretRenderer } from './SecretRenderer'
|
|
7
|
+
|
|
8
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
9
|
+
|
|
10
|
+
const secret = {
|
|
11
|
+
apiVersion: 'v1',
|
|
12
|
+
kind: 'Secret',
|
|
13
|
+
metadata: { name: 'db', namespace: 'shop', resourceVersion: '7' },
|
|
14
|
+
type: 'Opaque',
|
|
15
|
+
data: { password: btoa('old') },
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let element: HTMLDivElement
|
|
19
|
+
let root: Root
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
element = document.createElement('div')
|
|
23
|
+
document.body.append(element)
|
|
24
|
+
root = createRoot(element)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
afterEach(async () => {
|
|
28
|
+
await act(async () => root.unmount())
|
|
29
|
+
element.remove()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
async function render(onSaveSecretValue?: (yaml: string) => Promise<void>) {
|
|
33
|
+
await act(async () => root.render(<SecretRenderer data={secret} resourceData={secret} onSaveSecretValue={onSaveSecretValue} />))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function button(label: string): HTMLButtonElement {
|
|
37
|
+
const match = [...document.querySelectorAll('button')].find(b => b.textContent?.trim() === label || b.title === label)
|
|
38
|
+
if (!match) throw new Error(`no button "${label}"`)
|
|
39
|
+
return match
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function editAndSave(value: string) {
|
|
43
|
+
await act(async () => button('Reveal').click())
|
|
44
|
+
await act(async () => button('Edit value').click())
|
|
45
|
+
const textarea = element.querySelector('textarea')!
|
|
46
|
+
await act(async () => {
|
|
47
|
+
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call(textarea, value)
|
|
48
|
+
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
|
49
|
+
})
|
|
50
|
+
await act(async () => button('Save').click())
|
|
51
|
+
await act(async () => button('Update').click())
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const httpError = (status: number, message: string) => Object.assign(new Error(message), { status })
|
|
55
|
+
|
|
56
|
+
describe('SecretRenderer value edit', () => {
|
|
57
|
+
it('keeps the edit open and says why when the Secret changed since it loaded', async () => {
|
|
58
|
+
await render(vi.fn().mockRejectedValue(httpError(409, 'resource changed after review')))
|
|
59
|
+
await editAndSave('new')
|
|
60
|
+
expect(element.textContent).toContain('Not saved')
|
|
61
|
+
expect(element.textContent).toContain('This Secret changed after it loaded. Once the view reloads it, save again')
|
|
62
|
+
expect(element.querySelector('textarea')?.value).toBe('new')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('says the Secret is gone when the save finds it deleted', async () => {
|
|
66
|
+
await render(vi.fn().mockRejectedValue(httpError(404, 'secrets "db" not found')))
|
|
67
|
+
await editAndSave('new')
|
|
68
|
+
expect(element.textContent).toContain('Not saved')
|
|
69
|
+
expect(element.textContent).toContain('This Secret no longer exists in the cluster.')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('shows the server message for any other failure', async () => {
|
|
73
|
+
await render(vi.fn().mockRejectedValue(httpError(403, 'secrets "db" is forbidden')))
|
|
74
|
+
await editAndSave('new')
|
|
75
|
+
expect(element.textContent).toContain('Not saved')
|
|
76
|
+
expect(element.textContent).toContain('secrets "db" is forbidden')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('closes the editor once the save handler is taken away', async () => {
|
|
80
|
+
await render(vi.fn().mockResolvedValue(undefined))
|
|
81
|
+
await act(async () => button('Reveal').click())
|
|
82
|
+
await act(async () => button('Edit value').click())
|
|
83
|
+
expect(element.querySelector('textarea')).not.toBeNull()
|
|
84
|
+
await render(undefined)
|
|
85
|
+
expect(element.querySelector('textarea')).toBeNull()
|
|
86
|
+
expect(element.textContent).not.toContain('Save')
|
|
87
|
+
})
|
|
88
|
+
})
|