@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,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'
|
|
@@ -1329,25 +1330,8 @@ export function getHPAMetrics(hpa: any): { cpu?: number; memory?: number; custom
|
|
|
1329
1330
|
// ============================================================================
|
|
1330
1331
|
|
|
1331
1332
|
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' }
|
|
1333
|
+
const lifecycle = getNodeLifecycle(node)
|
|
1334
|
+
return { text: lifecycle.label, color: healthColors[lifecycle.level], level: lifecycle.level }
|
|
1351
1335
|
}
|
|
1352
1336
|
|
|
1353
1337
|
export function getNodeRoles(node: any): string {
|
|
@@ -1375,27 +1359,32 @@ export interface NodeCondition {
|
|
|
1375
1359
|
message?: string
|
|
1376
1360
|
}
|
|
1377
1361
|
|
|
1378
|
-
|
|
1379
|
-
const NODE_PROBLEM_CONDITIONS = ['DiskPressure', 'MemoryPressure', 'PIDPressure', 'NetworkUnavailable']
|
|
1380
|
-
|
|
1381
|
-
export function getNodeConditions(node: any): { problems: string[]; healthy: boolean } {
|
|
1362
|
+
export function getNodeConditions(node: any): { problems: string[]; healthy: boolean; readinessLabel: string } {
|
|
1382
1363
|
const conditions = node.status?.conditions || []
|
|
1383
1364
|
const problems: string[] = []
|
|
1365
|
+
const lifecycle = getNodeLifecycle(node)
|
|
1384
1366
|
|
|
1385
1367
|
for (const cond of conditions) {
|
|
1386
1368
|
// Ready=False is a problem
|
|
1387
|
-
if (cond.type === 'Ready' &&
|
|
1369
|
+
if (cond.type === 'Ready' && lifecycle.readinessFailed) {
|
|
1388
1370
|
problems.push('NotReady')
|
|
1389
1371
|
}
|
|
1390
1372
|
// Other conditions are problems when True
|
|
1391
|
-
if (
|
|
1373
|
+
if (lifecycle.problems.includes(cond.type) && cond.status === 'True') {
|
|
1392
1374
|
// Format: "DiskPressure" -> "Disk Pressure"
|
|
1393
1375
|
const formatted = cond.type.replace(/([A-Z])/g, ' $1').trim()
|
|
1394
1376
|
problems.push(formatted)
|
|
1395
1377
|
}
|
|
1396
1378
|
}
|
|
1397
1379
|
|
|
1398
|
-
|
|
1380
|
+
if (lifecycle.delayed) problems.push('Removal delayed')
|
|
1381
|
+
const ready = conditions.find((cond: any) => cond.type === 'Ready')
|
|
1382
|
+
const readinessLabel = lifecycle.removing && !lifecycle.readinessFailed && ready?.status === 'False'
|
|
1383
|
+
? 'Not ready (expected during removal)'
|
|
1384
|
+
: lifecycle.removing && !lifecycle.readinessFailed && ready?.status === 'Unknown'
|
|
1385
|
+
? 'Readiness unknown during removal'
|
|
1386
|
+
: 'Readiness unknown'
|
|
1387
|
+
return { problems, healthy: problems.length === 0 && ready?.status === 'True', readinessLabel }
|
|
1399
1388
|
}
|
|
1400
1389
|
|
|
1401
1390
|
export function getNodeTaints(node: any): { count: number; text: string } {
|
|
@@ -1414,11 +1403,7 @@ export function getNodeVersion(node: any): string {
|
|
|
1414
1403
|
// ============================================================================
|
|
1415
1404
|
|
|
1416
1405
|
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)
|
|
1406
|
+
return formatCompactAge(timestamp) || '-'
|
|
1422
1407
|
}
|
|
1423
1408
|
|
|
1424
1409
|
export function formatDuration(ms: number, detailed: boolean = false): string {
|
|
@@ -2691,7 +2676,6 @@ export {
|
|
|
2691
2676
|
getScaledObjectTriggers,
|
|
2692
2677
|
getScaledObjectTriggerCount,
|
|
2693
2678
|
getScaledObjectHpaName,
|
|
2694
|
-
getScaledObjectLastActiveTime,
|
|
2695
2679
|
getScaledObjectPollingInterval,
|
|
2696
2680
|
getScaledObjectCooldownPeriod,
|
|
2697
2681
|
getScaledJobStatus,
|
|
@@ -141,7 +141,7 @@ describe('getCNPGObjectStoreStatus', () => {
|
|
|
141
141
|
// Every timestamp on a RecoveryWindow is optional, so the server can be
|
|
142
142
|
// listed while holding nothing restorable.
|
|
143
143
|
expect(getCNPGObjectStoreStatus({ status: { serverRecoveryWindow: { pg: {} } } }))
|
|
144
|
-
.toMatchObject({ text: '
|
|
144
|
+
.toMatchObject({ text: 'Recovery point not reported', level: 'unknown' })
|
|
145
145
|
})
|
|
146
146
|
|
|
147
147
|
it('reports recoverable once a real point exists', () => {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { evaluateGitOpsWriteGuard, type GitOpsWriteGuardInput } from '../../utils/gitops-write-guard'
|
|
5
|
+
import { GitOpsWriteWarning } from './GitOpsWriteWarning'
|
|
6
|
+
|
|
7
|
+
const base: GitOpsWriteGuardInput = {
|
|
8
|
+
target: { kind: 'Deployment', group: 'apps', namespace: 'prod', name: 'api' },
|
|
9
|
+
owner: { tool: 'argocd', kind: 'applications', namespace: 'argocd', name: 'api' },
|
|
10
|
+
writes: [{ scope: 'spec', paths: ['spec.replicas'] }],
|
|
11
|
+
evidence: {
|
|
12
|
+
uid: 'u',
|
|
13
|
+
resourceVersion: '1',
|
|
14
|
+
owner: null,
|
|
15
|
+
policy: { tool: 'argocd', auto: true, selfHeal: true, prune: false, suspended: null },
|
|
16
|
+
paths: [{ path: 'spec.replicas', lastApplied: 'present', ownedBy: [], ownedByGitOps: false }],
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function render(input: GitOpsWriteGuardInput, onOpenOwner?: () => void) {
|
|
21
|
+
return renderToStaticMarkup(
|
|
22
|
+
<GitOpsWriteWarning
|
|
23
|
+
guard={evaluateGitOpsWriteGuard(input)}
|
|
24
|
+
acknowledged={false}
|
|
25
|
+
onAcknowledgedChange={() => {}}
|
|
26
|
+
onOpenOwner={onOpenOwner}
|
|
27
|
+
/>,
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('GitOpsWriteWarning', () => {
|
|
32
|
+
it('renders the revert warning, owner link and required acknowledgment', () => {
|
|
33
|
+
const html = render(base, () => {})
|
|
34
|
+
expect(html).toContain('Argo CD Application argocd/api will revert this change.')
|
|
35
|
+
expect(html).toContain('overwritten at the next sync')
|
|
36
|
+
expect(html).toContain('change it in the GitOps source')
|
|
37
|
+
expect(html).toContain('View Argo CD Application')
|
|
38
|
+
expect(html).toContain('type="checkbox"')
|
|
39
|
+
expect(html).toContain('I understand Argo CD will revert this.')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('asks the acknowledgment in the verdict’s own terms', () => {
|
|
43
|
+
const html = render({
|
|
44
|
+
...base,
|
|
45
|
+
evidence: { ...base.evidence!, policy: { tool: 'argocd', auto: false, selfHeal: false, prune: false, suspended: null } },
|
|
46
|
+
})
|
|
47
|
+
expect(html).toContain('Argo CD Application argocd/api may revert this change.')
|
|
48
|
+
expect(html).toContain('I understand Argo CD may revert this.')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('shows a checking state while ownership resolves', () => {
|
|
52
|
+
const html = render({ ...base, ownerPending: true })
|
|
53
|
+
expect(html).toContain('Checking GitOps ownership…')
|
|
54
|
+
expect(html).not.toContain('type="checkbox"')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('renders a quiet note without acknowledgment at info level', () => {
|
|
58
|
+
const html = render({ ...base, writes: [{ scope: 'create-child' }] })
|
|
59
|
+
expect(html).toContain('Managed by Argo CD Application argocd/api.')
|
|
60
|
+
expect(html).not.toContain('type="checkbox"')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('leaves vertical spacing to the parent', () => {
|
|
64
|
+
for (const input of [base, { ...base, ownerPending: true }, { ...base, writes: [{ scope: 'create-child' as const }] }]) {
|
|
65
|
+
const root = render(input).match(/^<div class="([^"]*)"/)?.[1] ?? ''
|
|
66
|
+
expect(root).not.toMatch(/(^|\s)m[bt]-/)
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('renders nothing when unmanaged', () => {
|
|
71
|
+
expect(render({ ...base, owner: null })).toBe('')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('lists per-write reasons when they differ', () => {
|
|
75
|
+
const html = render({
|
|
76
|
+
...base,
|
|
77
|
+
writes: [
|
|
78
|
+
{ scope: 'spec', paths: ['spec.replicas'], description: 'Replicas' },
|
|
79
|
+
{ scope: 'delete', description: 'Delete' },
|
|
80
|
+
],
|
|
81
|
+
evidence: { ...base.evidence!, policy: { tool: 'argocd', auto: false, selfHeal: false, prune: false, suspended: null } },
|
|
82
|
+
})
|
|
83
|
+
expect(html).toContain('Replicas:')
|
|
84
|
+
expect(html).toContain('Delete:')
|
|
85
|
+
})
|
|
86
|
+
})
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { ExternalLink, GitBranch, Info, Loader2 } from 'lucide-react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
|
|
4
|
+
import { AlertBanner } from '../ui/drawer-components'
|
|
5
|
+
import {
|
|
6
|
+
canConfirmGitOpsWrite,
|
|
7
|
+
gitOpsWriteHeadline,
|
|
8
|
+
guardToolLabel,
|
|
9
|
+
type GitOpsWriteAssessment,
|
|
10
|
+
type GitOpsWriteGuard,
|
|
11
|
+
} from '../../utils/gitops-write-guard'
|
|
12
|
+
|
|
13
|
+
export { canConfirmGitOpsWrite }
|
|
14
|
+
|
|
15
|
+
export interface GitOpsWriteWarningProps {
|
|
16
|
+
guard: GitOpsWriteGuard
|
|
17
|
+
acknowledged: boolean
|
|
18
|
+
onAcknowledgedChange: (acknowledged: boolean) => void
|
|
19
|
+
/** Opens the GitOps owner (or Helm release) the guard names. */
|
|
20
|
+
onOpenOwner?: () => void
|
|
21
|
+
disabled?: boolean
|
|
22
|
+
className?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const OWNER_LINK_LABEL = {
|
|
26
|
+
applications: 'View Argo CD Application',
|
|
27
|
+
kustomizations: 'View Flux Kustomization',
|
|
28
|
+
helmreleases: 'View Flux HelmRelease',
|
|
29
|
+
} as const
|
|
30
|
+
|
|
31
|
+
function reasonLines(perWrite: GitOpsWriteAssessment[], levels: GitOpsWriteGuard['level'][]): string[] {
|
|
32
|
+
const relevant = perWrite.filter((entry) => levels.includes(entry.level) && entry.reason)
|
|
33
|
+
const distinct = new Set(relevant.map((entry) => entry.reason))
|
|
34
|
+
if (distinct.size <= 1) return [...distinct]
|
|
35
|
+
return relevant.map((entry) =>
|
|
36
|
+
entry.write.description ? `${entry.write.description}: ${entry.reason}` : entry.reason,
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function GitOpsWriteWarning({
|
|
41
|
+
guard,
|
|
42
|
+
acknowledged,
|
|
43
|
+
onAcknowledgedChange,
|
|
44
|
+
onOpenOwner,
|
|
45
|
+
disabled = false,
|
|
46
|
+
// Not undefined: AlertBanner would add its own mb-4, and a margin utility
|
|
47
|
+
// overrides the parent's space-y gap.
|
|
48
|
+
className = '',
|
|
49
|
+
}: GitOpsWriteWarningProps) {
|
|
50
|
+
if (guard.pending) {
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
className={clsx(
|
|
54
|
+
'flex items-center gap-2 rounded-lg border border-theme-border bg-theme-base px-3 py-2 text-xs text-theme-text-secondary',
|
|
55
|
+
className,
|
|
56
|
+
)}
|
|
57
|
+
>
|
|
58
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
59
|
+
Checking GitOps ownership…
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
if (guard.level === 'none') return null
|
|
64
|
+
|
|
65
|
+
const headline = gitOpsWriteHeadline(guard)
|
|
66
|
+
const ownerLink = onOpenOwner ? (
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
onClick={onOpenOwner}
|
|
70
|
+
className="inline-flex items-center gap-1 text-xs font-medium text-skyhook-600 hover:underline dark:text-skyhook-400"
|
|
71
|
+
>
|
|
72
|
+
{guard.owner ? OWNER_LINK_LABEL[guard.owner.kind] : 'View Helm release'}
|
|
73
|
+
<ExternalLink className="h-3 w-3" />
|
|
74
|
+
</button>
|
|
75
|
+
) : null
|
|
76
|
+
|
|
77
|
+
if (guard.level === 'info') {
|
|
78
|
+
const lines = reasonLines(guard.perWrite, ['info'])
|
|
79
|
+
return (
|
|
80
|
+
<div
|
|
81
|
+
className={clsx(
|
|
82
|
+
'flex items-start gap-2 rounded-lg border border-theme-border bg-theme-base px-3 py-2 text-xs text-theme-text-secondary',
|
|
83
|
+
className,
|
|
84
|
+
)}
|
|
85
|
+
>
|
|
86
|
+
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
|
|
87
|
+
<div className="min-w-0 space-y-1">
|
|
88
|
+
<p>
|
|
89
|
+
<span className="font-medium text-theme-text-primary">{headline}</span> {lines.join(' ')}
|
|
90
|
+
</p>
|
|
91
|
+
{ownerLink}
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const tool = guardToolLabel(guard)
|
|
98
|
+
const lines = reasonLines(guard.perWrite, ['will-revert', 'may-revert'])
|
|
99
|
+
const durableFix = guard.owner
|
|
100
|
+
? 'For a lasting change, change it in the GitOps source.'
|
|
101
|
+
: guard.helmRelease
|
|
102
|
+
? 'For a lasting change, change the release’s chart values.'
|
|
103
|
+
: null
|
|
104
|
+
return (
|
|
105
|
+
<AlertBanner
|
|
106
|
+
variant="warning"
|
|
107
|
+
icon={GitBranch}
|
|
108
|
+
title={headline}
|
|
109
|
+
className={className}
|
|
110
|
+
message={
|
|
111
|
+
lines.length === 1 ? (
|
|
112
|
+
<>
|
|
113
|
+
{lines[0]}
|
|
114
|
+
{durableFix ? ` ${durableFix}` : ''}
|
|
115
|
+
</>
|
|
116
|
+
) : durableFix ?? undefined
|
|
117
|
+
}
|
|
118
|
+
items={lines.length > 1 ? lines : undefined}
|
|
119
|
+
>
|
|
120
|
+
{ownerLink && <div className="mt-2">{ownerLink}</div>}
|
|
121
|
+
<label className="mt-2 flex cursor-pointer items-start gap-2 text-xs text-theme-text-primary">
|
|
122
|
+
<input
|
|
123
|
+
type="checkbox"
|
|
124
|
+
checked={acknowledged}
|
|
125
|
+
onChange={(event) => onAcknowledgedChange(event.target.checked)}
|
|
126
|
+
disabled={disabled}
|
|
127
|
+
className="mt-0.5 h-3.5 w-3.5 accent-skyhook-500"
|
|
128
|
+
/>
|
|
129
|
+
<span>
|
|
130
|
+
I understand {tool} {guard.level === 'will-revert' ? 'will' : 'may'} revert this.
|
|
131
|
+
</span>
|
|
132
|
+
</label>
|
|
133
|
+
</AlertBanner>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getNodeLifecycle } from '../../utils/node-lifecycle'
|
|
1
2
|
import { useEffect, useState, useRef, useId } from 'react'
|
|
2
3
|
import {
|
|
3
4
|
RefreshCw,
|
|
@@ -28,11 +29,12 @@ import type { SelectedResource, WorkloadRevision } from '../../types'
|
|
|
28
29
|
import type { RadarUpgradeRequirement } from '../../types/fetch-error'
|
|
29
30
|
import { displayKindName } from '../ui/drawer-components'
|
|
30
31
|
import { getDefaultContainerName } from '../resources/resource-utils'
|
|
31
|
-
import { SetImageDialog, type
|
|
32
|
+
import { SetImageDialog, type SetImageOwnership } from './SetImageDialog'
|
|
32
33
|
import { DrainPlanDialog, DEFAULT_DRAIN_DIALOG_OPTIONS, type DrainDialogOptions, type DrainPlan } from './DrainPlanDialog'
|
|
33
34
|
import type { WorkloadImageInventory, WorkloadImageUpdate } from '../../types/core'
|
|
34
35
|
import { isArgoRolloutResource } from '../../utils/workload-rollout'
|
|
35
36
|
import { isCoreBatchJob } from '../../utils/api-resources'
|
|
37
|
+
import { formatRelativeAgeTime } from '../../utils/format'
|
|
36
38
|
|
|
37
39
|
// ============================================================================
|
|
38
40
|
// ACTIONS BAR - Interactive buttons that change based on resource kind
|
|
@@ -86,7 +88,7 @@ interface ResourceActionsBarProps {
|
|
|
86
88
|
onLoadImages?: (params: { kind: string; namespace: string; name: string }) => Promise<WorkloadImageInventory>
|
|
87
89
|
onSetImages?: (params: { kind: string; namespace: string; name: string; updates: WorkloadImageUpdate[] }) => Promise<unknown>
|
|
88
90
|
isSettingImages?: boolean
|
|
89
|
-
|
|
91
|
+
imageOwnership?: SetImageOwnership
|
|
90
92
|
|
|
91
93
|
// Rollback
|
|
92
94
|
revisions?: WorkloadRevision[]
|
|
@@ -161,7 +163,7 @@ export function ResourceActionsBar({
|
|
|
161
163
|
renderPortForward,
|
|
162
164
|
onDelete, isDeleting, cascadeDependents, cascadeLoading, cascadeRootResolved,
|
|
163
165
|
onRestart, isRestarting,
|
|
164
|
-
onLoadImages, onSetImages, isSettingImages,
|
|
166
|
+
onLoadImages, onSetImages, isSettingImages, imageOwnership,
|
|
165
167
|
revisions: revisionsList, revisionsLoading, revisionsError, onRollback, isRollingBack,
|
|
166
168
|
onRolloutPromoteFull,
|
|
167
169
|
onTriggerCronJob, isTriggeringCronJob,
|
|
@@ -358,7 +360,7 @@ export function ResourceActionsBar({
|
|
|
358
360
|
</button>
|
|
359
361
|
)}
|
|
360
362
|
|
|
361
|
-
{canNodeWrite && (
|
|
363
|
+
{canNodeWrite && !getNodeLifecycle(data).removing && (
|
|
362
364
|
<>
|
|
363
365
|
{data?.spec?.unschedulable ? (
|
|
364
366
|
onUncordonNode && (
|
|
@@ -660,7 +662,7 @@ export function ResourceActionsBar({
|
|
|
660
662
|
</div>
|
|
661
663
|
)}
|
|
662
664
|
|
|
663
|
-
{onDelete && (
|
|
665
|
+
{onDelete && !((kind === 'node' || kind === 'nodes') && data?.metadata?.deletionTimestamp) && (
|
|
664
666
|
<Tooltip content="Delete resource">
|
|
665
667
|
<button
|
|
666
668
|
onClick={() => setShowDeleteConfirm(true)}
|
|
@@ -690,7 +692,7 @@ export function ResourceActionsBar({
|
|
|
690
692
|
workloadLabel={`${displayKindName(resource.kind, data?.kind)} ${resource.namespace}/${resource.name}`}
|
|
691
693
|
workloadName={resource.name}
|
|
692
694
|
workloadResource={resource.kind}
|
|
693
|
-
|
|
695
|
+
ownership={imageOwnership}
|
|
694
696
|
pending={isSettingImages}
|
|
695
697
|
onClose={() => setShowSetImage(false)}
|
|
696
698
|
onLoad={() => onLoadImages({ kind: resource.kind, namespace: resource.namespace, name: resource.name })}
|
|
@@ -1127,19 +1129,6 @@ export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, re
|
|
|
1127
1129
|
setPromoteError(outcome.error ?? { message: 'The promotion did not go through.', controllerLagging: false })
|
|
1128
1130
|
}
|
|
1129
1131
|
|
|
1130
|
-
function formatTimeAgo(dateStr: string): string {
|
|
1131
|
-
const date = new Date(dateStr)
|
|
1132
|
-
const now = new Date()
|
|
1133
|
-
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000)
|
|
1134
|
-
if (seconds < 60) return `${seconds}s ago`
|
|
1135
|
-
const minutes = Math.floor(seconds / 60)
|
|
1136
|
-
if (minutes < 60) return `${minutes}m ago`
|
|
1137
|
-
const hours = Math.floor(minutes / 60)
|
|
1138
|
-
if (hours < 24) return `${hours}h ago`
|
|
1139
|
-
const days = Math.floor(hours / 24)
|
|
1140
|
-
return `${days}d ago`
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
1132
|
function getImageTag(image: string): string {
|
|
1144
1133
|
if (!image) return '-'
|
|
1145
1134
|
const parts = image.split(':')
|
|
@@ -1231,7 +1220,7 @@ export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, re
|
|
|
1231
1220
|
<RevisionImage image={rev.image} displayImage={getImageTag(rev.image)} />
|
|
1232
1221
|
</td>
|
|
1233
1222
|
<td className="py-2 pr-3 text-theme-text-secondary whitespace-nowrap">
|
|
1234
|
-
{
|
|
1223
|
+
{formatRelativeAgeTime(rev.createdAt)}
|
|
1235
1224
|
</td>
|
|
1236
1225
|
<td className="py-2 text-right">
|
|
1237
1226
|
<div className="flex items-center gap-1 justify-end">
|
|
@@ -978,7 +978,7 @@ export function ResourceRendererDispatch({
|
|
|
978
978
|
{kind === 'authorizationpolicies' && <IstioAuthorizationPolicyRenderer data={data} />}
|
|
979
979
|
{kind === 'mutatingwebhookconfigurations' && <WebhookConfigRenderer data={data} isMutating />}
|
|
980
980
|
{kind === 'validatingwebhookconfigurations' && <WebhookConfigRenderer data={data} />}
|
|
981
|
-
{kind === 'ingressclasses' && <IngressClassRenderer data={data} />}
|
|
981
|
+
{kind === 'ingressclasses' && <IngressClassRenderer data={data} onNavigate={onNavigate} />}
|
|
982
982
|
{kind === 'priorityclasses' && <PriorityClassRenderer data={data} />}
|
|
983
983
|
{kind === 'runtimeclasses' && <RuntimeClassRenderer data={data} />}
|
|
984
984
|
{kind === 'leases' && <LeaseRenderer data={data} />}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
|
|
3
3
|
import type { WorkloadContainerImage } from '../../types/core'
|
|
4
|
+
import { canConfirmGitOpsWrite, evaluateGitOpsWriteGuard, gitOpsWriteGuardKey } from '../../utils/gitops-write-guard'
|
|
4
5
|
import {
|
|
6
|
+
SET_IMAGE_WRITES,
|
|
5
7
|
canSubmitImageUpdates,
|
|
6
8
|
changedImageUpdates,
|
|
7
9
|
describeImageUpdateBehavior,
|
|
8
10
|
reconcileRefreshedImageDrafts,
|
|
11
|
+
setImageWrites,
|
|
9
12
|
} from './SetImageDialog'
|
|
10
13
|
|
|
11
14
|
const containers: WorkloadContainerImage[] = [
|
|
@@ -104,26 +107,92 @@ describe('reconcileRefreshedImageDrafts', () => {
|
|
|
104
107
|
})
|
|
105
108
|
|
|
106
109
|
describe('canSubmitImageUpdates', () => {
|
|
110
|
+
const target = { kind: 'Deployment', group: 'apps', namespace: 'prod', name: 'api' }
|
|
111
|
+
const managedGuard = evaluateGitOpsWriteGuard({
|
|
112
|
+
target,
|
|
113
|
+
owner: { tool: 'argocd', kind: 'applications', namespace: 'argocd', name: 'api' },
|
|
114
|
+
writes: SET_IMAGE_WRITES,
|
|
115
|
+
})
|
|
116
|
+
const unmanagedGuard = evaluateGitOpsWriteGuard({ target, owner: null, writes: SET_IMAGE_WRITES })
|
|
117
|
+
const pendingGuard = evaluateGitOpsWriteGuard({ target, owner: null, ownerPending: true, writes: SET_IMAGE_WRITES })
|
|
107
118
|
const ready = {
|
|
108
119
|
updateCount: 1,
|
|
109
120
|
hasEmptyImage: false,
|
|
110
|
-
|
|
111
|
-
ownershipResolved: true,
|
|
112
|
-
acknowledged: false,
|
|
121
|
+
ownershipConfirmable: canConfirmGitOpsWrite(unmanagedGuard, false),
|
|
113
122
|
busy: false,
|
|
114
123
|
}
|
|
115
124
|
|
|
116
125
|
it('requires break-glass acknowledgement for managed resources', () => {
|
|
117
|
-
expect(
|
|
126
|
+
expect(managedGuard.requiresAck).toBe(true)
|
|
127
|
+
expect(
|
|
128
|
+
canSubmitImageUpdates({ ...ready, ownershipConfirmable: canConfirmGitOpsWrite(managedGuard, false) }),
|
|
129
|
+
).toBe(false)
|
|
118
130
|
expect(
|
|
119
|
-
canSubmitImageUpdates({ ...ready,
|
|
131
|
+
canSubmitImageUpdates({ ...ready, ownershipConfirmable: canConfirmGitOpsWrite(managedGuard, true) }),
|
|
120
132
|
).toBe(true)
|
|
121
133
|
})
|
|
122
134
|
|
|
135
|
+
it('gates until ownership resolves', () => {
|
|
136
|
+
expect(canConfirmGitOpsWrite(undefined, true)).toBe(false)
|
|
137
|
+
expect(canConfirmGitOpsWrite(pendingGuard, true)).toBe(false)
|
|
138
|
+
expect(canSubmitImageUpdates(ready)).toBe(true)
|
|
139
|
+
})
|
|
140
|
+
|
|
123
141
|
it('blocks empty images and concurrent submissions', () => {
|
|
124
142
|
expect(canSubmitImageUpdates({ ...ready, hasEmptyImage: true })).toBe(false)
|
|
125
143
|
expect(canSubmitImageUpdates({ ...ready, busy: true })).toBe(false)
|
|
126
144
|
expect(canSubmitImageUpdates({ ...ready, loadFailed: true })).toBe(false)
|
|
127
|
-
expect(canSubmitImageUpdates({ ...ready,
|
|
145
|
+
expect(canSubmitImageUpdates({ ...ready, ownershipConfirmable: false })).toBe(false)
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
describe('setImageWrites', () => {
|
|
150
|
+
it('names each container, so the verdict is for exactly the images being changed', () => {
|
|
151
|
+
expect(setImageWrites(containers).map((w) => w.paths)).toEqual([
|
|
152
|
+
['spec.template.spec.containers[name=app].image'],
|
|
153
|
+
['spec.template.spec.containers[name=sidecar].image'],
|
|
154
|
+
['spec.template.spec.initContainers[name=migrate].image'],
|
|
155
|
+
])
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it("asks again when the evidence doesn't cover a changed container", () => {
|
|
159
|
+
// Evidence read before the dialog listed the init container covers only
|
|
160
|
+
// the app image, which the owner ignores.
|
|
161
|
+
const guard = evaluateGitOpsWriteGuard({
|
|
162
|
+
target: { kind: 'Deployment', group: 'apps', namespace: 'prod', name: 'api' },
|
|
163
|
+
owner: { tool: 'argocd', kind: 'applications', namespace: 'argocd', name: 'api' },
|
|
164
|
+
writes: setImageWrites([{ type: 'initContainer', name: 'migrate' }]),
|
|
165
|
+
evidence: {
|
|
166
|
+
uid: 'u',
|
|
167
|
+
resourceVersion: '1',
|
|
168
|
+
owner: null,
|
|
169
|
+
policy: { tool: 'argocd', auto: true, selfHeal: true, prune: false, suspended: null, respectIgnoreDifferences: true },
|
|
170
|
+
paths: [{ path: 'spec.template.spec.containers[name=app].image', lastApplied: 'present', ownedBy: [], ownedByGitOps: false, ignored: 'effective', ignoredBy: 'spec.ignoreDifferences' }],
|
|
171
|
+
},
|
|
172
|
+
})
|
|
173
|
+
expect(guard.level).toBe('may-revert')
|
|
174
|
+
expect(canConfirmGitOpsWrite(guard, false)).toBe(false)
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
describe('acknowledgment scope', () => {
|
|
179
|
+
it("an acknowledgment for one container's image doesn't cover another's with the same verdict", () => {
|
|
180
|
+
const guardFor = (name: string) =>
|
|
181
|
+
evaluateGitOpsWriteGuard({
|
|
182
|
+
target: { kind: 'Deployment', group: 'apps', namespace: 'prod', name: 'api' },
|
|
183
|
+
owner: { tool: 'argocd', kind: 'applications', namespace: 'argocd', name: 'api' },
|
|
184
|
+
writes: setImageWrites([{ type: 'container', name }]),
|
|
185
|
+
evidence: {
|
|
186
|
+
uid: 'u',
|
|
187
|
+
resourceVersion: '1',
|
|
188
|
+
owner: null,
|
|
189
|
+
policy: { tool: 'argocd', auto: true, selfHeal: true, prune: false, suspended: null },
|
|
190
|
+
paths: ['app', 'sidecar'].map((n) => ({ path: `spec.template.spec.containers[name=${n}].image`, lastApplied: 'present' as const, ownedBy: [], ownedByGitOps: false })),
|
|
191
|
+
},
|
|
192
|
+
})
|
|
193
|
+
const sidecar = guardFor('sidecar')
|
|
194
|
+
const app = guardFor('app')
|
|
195
|
+
expect(app.level).toBe(sidecar.level)
|
|
196
|
+
expect(gitOpsWriteGuardKey(app)).not.toBe(gitOpsWriteGuardKey(sidecar))
|
|
128
197
|
})
|
|
129
198
|
})
|