@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.
Files changed (152) hide show
  1. package/package.json +3 -3
  2. package/src/components/applications/ApplicationDetail.test.tsx +15 -0
  3. package/src/components/applications/ApplicationDetail.tsx +17 -15
  4. package/src/components/checks/types.ts +2 -13
  5. package/src/components/cnpg/CNPGBackupSummary.tsx +223 -0
  6. package/src/components/cnpg/CNPGClusterSummary.tsx +222 -0
  7. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +264 -0
  8. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +80 -0
  9. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +176 -0
  10. package/src/components/cnpg/CNPGObjectSummary.test.tsx +236 -0
  11. package/src/components/cnpg/CNPGPoolerSummary.tsx +70 -0
  12. package/src/components/cnpg/CNPGSharedSummary.tsx +88 -0
  13. package/src/components/cnpg/index.ts +14 -0
  14. package/src/components/cnpg/primitives.tsx +134 -0
  15. package/src/components/cnpg/relations.test.ts +304 -0
  16. package/src/components/cnpg/relations.ts +417 -0
  17. package/src/components/cnpg/workspace.test.ts +285 -0
  18. package/src/components/cnpg/workspace.ts +700 -0
  19. package/src/components/gitops/GitOpsTableView.tsx +9 -24
  20. package/src/components/gitops/detail-helpers.ts +1 -1
  21. package/src/components/gitops/insights/GitOpsInsightViews.tsx +5 -12
  22. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +2 -5
  23. package/src/components/issues/IssuesView.tsx +10 -1
  24. package/src/components/issues/issue-timing.ts +2 -2
  25. package/src/components/issues/issues.test.ts +1 -1
  26. package/src/components/issues/types.ts +24 -0
  27. package/src/components/logs/WorkloadLogsViewer.notice.test.tsx +59 -0
  28. package/src/components/logs/WorkloadLogsViewer.tsx +17 -3
  29. package/src/components/logs/useLogBuffer.test.ts +17 -0
  30. package/src/components/logs/useLogStream.ts +11 -0
  31. package/src/components/resources/ResourcesSidebar.test.tsx +76 -0
  32. package/src/components/resources/ResourcesSidebar.tsx +183 -11
  33. package/src/components/resources/ResourcesView.tsx +12 -9
  34. package/src/components/resources/index.ts +1 -1
  35. package/src/components/resources/renderers/AnalysisRunRenderer.tsx +3 -3
  36. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +6 -5
  37. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +2 -2
  38. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +2 -2
  39. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +2 -2
  40. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +2 -2
  41. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +2 -2
  42. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +2 -2
  43. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +2 -1
  44. package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +25 -0
  45. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +16 -7
  46. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +2 -2
  47. package/src/components/resources/renderers/CNPGShared.tsx +2 -2
  48. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  49. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -2
  50. package/src/components/resources/renderers/CronJobRenderer.tsx +6 -4
  51. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +3 -2
  52. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +24 -0
  53. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +1 -1
  54. package/src/components/resources/renderers/EventRenderer.tsx +3 -3
  55. package/src/components/resources/renderers/ExperimentRenderer.tsx +2 -2
  56. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -2
  57. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +2 -2
  58. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -4
  59. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +11 -24
  60. package/src/components/resources/renderers/GatewayRouteReferences.test.tsx +63 -0
  61. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -3
  62. package/src/components/resources/renderers/HPARenderer.tsx +2 -2
  63. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +12 -25
  64. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -3
  65. package/src/components/resources/renderers/IngressClassReferences.test.tsx +52 -0
  66. package/src/components/resources/renderers/IngressClassRenderer.tsx +10 -3
  67. package/src/components/resources/renderers/IngressRenderer.tsx +6 -2
  68. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -14
  69. package/src/components/resources/renderers/KedaCredentials.test.tsx +85 -0
  70. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -2
  71. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +6 -3
  72. package/src/components/resources/renderers/KustomizationRenderer.tsx +2 -2
  73. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +3 -2
  74. package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +4 -3
  75. package/src/components/resources/renderers/LeaseRenderer.tsx +3 -15
  76. package/src/components/resources/renderers/NamespaceRenderer.test.ts +69 -2
  77. package/src/components/resources/renderers/NamespaceRenderer.tsx +6 -1
  78. package/src/components/resources/renderers/NodeRenderer.test.tsx +81 -1
  79. package/src/components/resources/renderers/NodeRenderer.tsx +56 -15
  80. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -3
  81. package/src/components/resources/renderers/PodClassReferences.test.tsx +42 -0
  82. package/src/components/resources/renderers/PodNomination.test.tsx +57 -0
  83. package/src/components/resources/renderers/PodRenderer.tsx +21 -7
  84. package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
  85. package/src/components/resources/renderers/RolloutRenderer.tsx +3 -2
  86. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  87. package/src/components/resources/renderers/SecretRenderer.test.tsx +88 -0
  88. package/src/components/resources/renderers/SecretRenderer.tsx +34 -5
  89. package/src/components/resources/renderers/ServiceRenderer.test.tsx +43 -0
  90. package/src/components/resources/renderers/ServiceRenderer.tsx +11 -3
  91. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +8 -21
  92. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +5 -7
  93. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +3 -3
  94. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +2 -2
  95. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +3 -3
  96. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +2 -2
  97. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  98. package/src/components/resources/renderers/WorkflowRenderer.tsx +4 -3
  99. package/src/components/resources/renderers/kyverno-modern-cells.tsx +1 -1
  100. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +2 -2
  101. package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +1 -1
  102. package/src/components/resources/resource-utils-cnpg.ts +3 -7
  103. package/src/components/resources/resource-utils-keda.ts +1 -7
  104. package/src/components/resources/resource-utils-kyverno.ts +1 -7
  105. package/src/components/resources/resource-utils-velero.ts +0 -12
  106. package/src/components/resources/resource-utils.ts +17 -33
  107. package/src/components/resources/status-overclaim.test.ts +1 -1
  108. package/src/components/shared/DrainPlanDialog.tsx +1 -0
  109. package/src/components/shared/GitOpsWriteWarning.test.tsx +86 -0
  110. package/src/components/shared/GitOpsWriteWarning.tsx +135 -0
  111. package/src/components/shared/ResourceActionsBar.tsx +9 -20
  112. package/src/components/shared/ResourceRendererDispatch.tsx +1 -1
  113. package/src/components/shared/SetImageDialog.test.ts +75 -6
  114. package/src/components/shared/SetImageDialog.tsx +92 -84
  115. package/src/components/shared/index.ts +2 -1
  116. package/src/components/timeline/TimelineList.tsx +187 -38
  117. package/src/components/timeline/TimelineListFold.test.tsx +56 -0
  118. package/src/components/timeline/TimelineSwimlanes.tsx +2 -10
  119. package/src/components/timeline/TimelineToolbarState.test.tsx +98 -0
  120. package/src/components/timeline/index.ts +1 -0
  121. package/src/components/timeline/timeline-filters.ts +7 -0
  122. package/src/components/topology/TopologyGraph.tsx +5 -3
  123. package/src/components/ui/ScheduleValue.test.tsx +5 -0
  124. package/src/components/ui/ScheduleValue.tsx +3 -2
  125. package/src/components/ui/drawer-components.test.tsx +66 -1
  126. package/src/components/ui/drawer-components.tsx +43 -29
  127. package/src/components/workload/WorkloadView.deleted.test.tsx +58 -0
  128. package/src/components/workload/WorkloadView.deletedDialog.test.tsx +45 -0
  129. package/src/components/workload/WorkloadView.secretSave.test.tsx +62 -0
  130. package/src/components/workload/WorkloadView.tsx +216 -48
  131. package/src/components/workload/index.ts +1 -0
  132. package/src/components/workload/workload-logs-availability.test.ts +6 -0
  133. package/src/index.ts +3 -0
  134. package/src/types/capacity.ts +10 -0
  135. package/src/types/core.ts +32 -1
  136. package/src/utils/format.test.ts +40 -2
  137. package/src/utils/format.ts +5 -1
  138. package/src/utils/gateway-references.test.ts +17 -0
  139. package/src/utils/gateway-references.ts +29 -0
  140. package/src/utils/gitops-write-guard.test.ts +401 -0
  141. package/src/utils/gitops-write-guard.ts +509 -0
  142. package/src/utils/index.ts +2 -0
  143. package/src/utils/ingress-class-references.ts +22 -0
  144. package/src/utils/log-level.ts +14 -0
  145. package/src/utils/navigation.test.ts +13 -0
  146. package/src/utils/navigation.ts +5 -0
  147. package/src/utils/node-lifecycle.test.ts +36 -0
  148. package/src/utils/node-lifecycle.ts +77 -0
  149. package/src/utils/resource-hierarchy.test.ts +32 -0
  150. package/src/utils/resource-hierarchy.ts +28 -2
  151. package/src/utils/resource-refs.test.ts +23 -0
  152. 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 conditions = node.status?.conditions || []
1333
- const readyCondition = conditions.find((c: any) => c.type === 'Ready')
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
- // Problem conditions that indicate node issues
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' && cond.status !== 'True') {
1369
+ if (cond.type === 'Ready' && lifecycle.readinessFailed) {
1388
1370
  problems.push('NotReady')
1389
1371
  }
1390
1372
  // Other conditions are problems when True
1391
- if (NODE_PROBLEM_CONDITIONS.includes(cond.type) && cond.status === 'True') {
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
- return { problems, healthy: problems.length === 0 }
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
- if (!timestamp) return '-'
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: 'No recovery point', level: 'unknown' })
144
+ .toMatchObject({ text: 'Recovery point not reported', level: 'unknown' })
145
145
  })
146
146
 
147
147
  it('reports recoverable once a real point exists', () => {
@@ -18,6 +18,7 @@ export interface DrainPlanPod {
18
18
  emptyDir: boolean
19
19
  pdb?: string
20
20
  pdbChecked: boolean
21
+ terminating?: boolean
21
22
  }
22
23
 
23
24
  export interface DrainPlan {
@@ -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 ManagedImageSource } from './SetImageDialog'
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
- managedImageSources?: ManagedImageSource[]
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, managedImageSources,
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
- managedSources={managedImageSources}
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
- {formatTimeAgo(rev.createdAt)}
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
- managed: false,
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(canSubmitImageUpdates({ ...ready, managed: true })).toBe(false)
126
+ expect(managedGuard.requiresAck).toBe(true)
127
+ expect(
128
+ canSubmitImageUpdates({ ...ready, ownershipConfirmable: canConfirmGitOpsWrite(managedGuard, false) }),
129
+ ).toBe(false)
118
130
  expect(
119
- canSubmitImageUpdates({ ...ready, managed: true, acknowledged: true }),
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, ownershipResolved: false })).toBe(false)
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
  })