@skyhook-io/k8s-ui 1.16.3 → 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 (121) hide show
  1. package/package.json +1 -1
  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/primitives.tsx +3 -3
  6. package/src/components/gitops/GitOpsTableView.tsx +9 -24
  7. package/src/components/gitops/detail-helpers.ts +1 -1
  8. package/src/components/gitops/insights/GitOpsInsightViews.tsx +5 -12
  9. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +2 -5
  10. package/src/components/issues/issue-timing.ts +2 -2
  11. package/src/components/issues/issues.test.ts +1 -1
  12. package/src/components/logs/WorkloadLogsViewer.notice.test.tsx +59 -0
  13. package/src/components/logs/WorkloadLogsViewer.tsx +5 -0
  14. package/src/components/logs/useLogStream.ts +11 -0
  15. package/src/components/resources/ResourcesView.tsx +7 -8
  16. package/src/components/resources/renderers/AnalysisRunRenderer.tsx +3 -3
  17. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +6 -5
  18. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +2 -2
  19. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +2 -2
  20. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +2 -2
  21. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +2 -2
  22. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +2 -2
  23. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +2 -2
  24. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +2 -1
  25. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +2 -2
  26. package/src/components/resources/renderers/CNPGShared.tsx +2 -2
  27. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  28. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -2
  29. package/src/components/resources/renderers/CronJobRenderer.tsx +6 -4
  30. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +3 -2
  31. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +24 -0
  32. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +1 -1
  33. package/src/components/resources/renderers/EventRenderer.tsx +3 -3
  34. package/src/components/resources/renderers/ExperimentRenderer.tsx +2 -2
  35. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -2
  36. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +2 -2
  37. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -4
  38. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +11 -24
  39. package/src/components/resources/renderers/GatewayRouteReferences.test.tsx +63 -0
  40. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -3
  41. package/src/components/resources/renderers/HPARenderer.tsx +2 -2
  42. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +12 -25
  43. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -3
  44. package/src/components/resources/renderers/IngressClassReferences.test.tsx +52 -0
  45. package/src/components/resources/renderers/IngressClassRenderer.tsx +10 -3
  46. package/src/components/resources/renderers/IngressRenderer.tsx +6 -2
  47. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -14
  48. package/src/components/resources/renderers/KedaCredentials.test.tsx +85 -0
  49. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -2
  50. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +6 -3
  51. package/src/components/resources/renderers/KustomizationRenderer.tsx +2 -2
  52. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +3 -2
  53. package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +4 -3
  54. package/src/components/resources/renderers/LeaseRenderer.tsx +3 -15
  55. package/src/components/resources/renderers/NamespaceRenderer.test.ts +69 -2
  56. package/src/components/resources/renderers/NamespaceRenderer.tsx +6 -1
  57. package/src/components/resources/renderers/NodeRenderer.test.tsx +81 -1
  58. package/src/components/resources/renderers/NodeRenderer.tsx +56 -15
  59. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -3
  60. package/src/components/resources/renderers/PodClassReferences.test.tsx +42 -0
  61. package/src/components/resources/renderers/PodNomination.test.tsx +57 -0
  62. package/src/components/resources/renderers/PodRenderer.tsx +21 -7
  63. package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
  64. package/src/components/resources/renderers/RolloutRenderer.tsx +3 -2
  65. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  66. package/src/components/resources/renderers/SecretRenderer.test.tsx +88 -0
  67. package/src/components/resources/renderers/SecretRenderer.tsx +34 -5
  68. package/src/components/resources/renderers/ServiceRenderer.test.tsx +43 -0
  69. package/src/components/resources/renderers/ServiceRenderer.tsx +11 -3
  70. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +8 -21
  71. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +5 -7
  72. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +3 -3
  73. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +2 -2
  74. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +3 -3
  75. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +2 -2
  76. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  77. package/src/components/resources/renderers/WorkflowRenderer.tsx +4 -3
  78. package/src/components/resources/renderers/kyverno-modern-cells.tsx +1 -1
  79. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +2 -2
  80. package/src/components/resources/resource-utils-cnpg.ts +0 -4
  81. package/src/components/resources/resource-utils-keda.ts +1 -7
  82. package/src/components/resources/resource-utils-kyverno.ts +1 -7
  83. package/src/components/resources/resource-utils-velero.ts +0 -12
  84. package/src/components/resources/resource-utils.ts +17 -33
  85. package/src/components/shared/DrainPlanDialog.tsx +1 -0
  86. package/src/components/shared/GitOpsWriteWarning.test.tsx +86 -0
  87. package/src/components/shared/GitOpsWriteWarning.tsx +135 -0
  88. package/src/components/shared/ResourceActionsBar.tsx +9 -20
  89. package/src/components/shared/ResourceRendererDispatch.tsx +1 -1
  90. package/src/components/shared/SetImageDialog.test.ts +75 -6
  91. package/src/components/shared/SetImageDialog.tsx +92 -84
  92. package/src/components/shared/index.ts +2 -1
  93. package/src/components/timeline/TimelineList.tsx +2 -15
  94. package/src/components/timeline/TimelineSwimlanes.tsx +2 -10
  95. package/src/components/topology/TopologyGraph.tsx +5 -3
  96. package/src/components/ui/ScheduleValue.test.tsx +5 -0
  97. package/src/components/ui/ScheduleValue.tsx +3 -2
  98. package/src/components/ui/drawer-components.test.tsx +66 -1
  99. package/src/components/ui/drawer-components.tsx +43 -29
  100. package/src/components/workload/WorkloadView.deleted.test.tsx +58 -0
  101. package/src/components/workload/WorkloadView.deletedDialog.test.tsx +45 -0
  102. package/src/components/workload/WorkloadView.secretSave.test.tsx +62 -0
  103. package/src/components/workload/WorkloadView.tsx +120 -43
  104. package/src/types/capacity.ts +10 -0
  105. package/src/types/core.ts +32 -1
  106. package/src/utils/format.test.ts +40 -2
  107. package/src/utils/format.ts +5 -1
  108. package/src/utils/gateway-references.test.ts +17 -0
  109. package/src/utils/gateway-references.ts +29 -0
  110. package/src/utils/gitops-write-guard.test.ts +401 -0
  111. package/src/utils/gitops-write-guard.ts +509 -0
  112. package/src/utils/index.ts +2 -0
  113. package/src/utils/ingress-class-references.ts +22 -0
  114. package/src/utils/navigation.test.ts +13 -0
  115. package/src/utils/navigation.ts +5 -0
  116. package/src/utils/node-lifecycle.test.ts +36 -0
  117. package/src/utils/node-lifecycle.ts +77 -0
  118. package/src/utils/resource-hierarchy.test.ts +32 -0
  119. package/src/utils/resource-hierarchy.ts +28 -2
  120. package/src/utils/resource-refs.test.ts +23 -0
  121. package/src/utils/resource-refs.ts +30 -0
@@ -1,11 +1,5 @@
1
1
  import { useEffect, useMemo, useRef, useState, useId } from 'react'
2
- import {
3
- AlertTriangle,
4
- ExternalLink,
5
- Loader2,
6
- RefreshCw,
7
- X,
8
- } from 'lucide-react'
2
+ import { Loader2, RefreshCw, X } from 'lucide-react'
9
3
  import { clsx } from 'clsx'
10
4
 
11
5
  import type {
@@ -16,11 +10,33 @@ import type {
16
10
  } from '../../types/core'
17
11
  import { DialogPortal } from '../ui/DialogPortal'
18
12
  import { Badge } from '../ui/Badge'
13
+ import {
14
+ canConfirmGitOpsWrite,
15
+ gitOpsWriteGuardKey,
16
+ type GitOpsWrite,
17
+ type GitOpsWriteGuard,
18
+ } from '../../utils/gitops-write-guard'
19
+ import { GitOpsWriteWarning } from './GitOpsWriteWarning'
19
20
 
20
- export interface ManagedImageSource {
21
- type: 'GitOps' | 'Helm'
22
- label: string
23
- onOpen?: () => void
21
+ /** The GitOps write guard for the object whose Pod template the dialog
22
+ * writes, evaluated by the host: for {@link SET_IMAGE_WRITES} until the
23
+ * containers are known, then for {@link setImageWrites} of the containers
24
+ * {@link onSelectionChange} reports. */
25
+ export interface SetImageOwnership {
26
+ guard: GitOpsWriteGuard
27
+ onOpenOwner?: () => void
28
+ /** The containers the dialog lists and the ones being changed, so the
29
+ * guard can judge exactly those (null until the containers load). */
30
+ onSelectionChange?: (selection: SetImageSelection | null) => void
31
+ /** {@link setImageSelectionKey} of the selection `guard` was evaluated
32
+ * for. When set, Update waits until it matches the dialog's current
33
+ * selection. */
34
+ selectionKey?: string
35
+ }
36
+
37
+ export interface SetImageSelection {
38
+ containers: Pick<WorkloadContainerImage, 'type' | 'name'>[]
39
+ changed: Pick<WorkloadContainerImage, 'type' | 'name'>[]
24
40
  }
25
41
 
26
42
  export interface SetImageDialogProps {
@@ -28,7 +44,8 @@ export interface SetImageDialogProps {
28
44
  workloadLabel: string
29
45
  workloadName: string
30
46
  workloadResource: string
31
- managedSources?: ManagedImageSource[]
47
+ /** Undefined while the host can't verify ownership; the dialog then blocks. */
48
+ ownership?: SetImageOwnership
32
49
  pending?: boolean
33
50
  onClose: () => void
34
51
  onLoad: () => Promise<WorkloadImageInventory>
@@ -83,31 +100,49 @@ export function reconcileRefreshedImageDrafts(
83
100
  return { drafts: nextDrafts, changedCurrentKeys }
84
101
  }
85
102
 
103
+ export function setImageSelectionKey(selection: SetImageSelection | null): string {
104
+ if (!selection) return ''
105
+ const rows = (refs: SetImageSelection['containers']) => refs.map((c) => [c.type, c.name])
106
+ return JSON.stringify([rows(selection.containers), rows(selection.changed)])
107
+ }
108
+
109
+ /** One write per container, by name: the guard's verdict for exactly the
110
+ * images being changed. */
111
+ export function setImageWrites(containers: Pick<WorkloadContainerImage, 'type' | 'name'>[]): GitOpsWrite[] {
112
+ return containers.map((container) => ({
113
+ scope: 'spec',
114
+ paths: [`spec.template.spec.${container.type === 'initContainer' ? 'initContainers' : 'containers'}[name=${container.name}].image`],
115
+ description: container.name,
116
+ }))
117
+ }
118
+
119
+ // Before the containers load, the guard covers every container image.
120
+ export const SET_IMAGE_WRITES: GitOpsWrite[] = [
121
+ {
122
+ scope: 'spec',
123
+ paths: [
124
+ 'spec.template.spec.containers[*].image',
125
+ 'spec.template.spec.initContainers[*].image',
126
+ ],
127
+ description: 'Container images',
128
+ },
129
+ ]
130
+
86
131
  export function canSubmitImageUpdates({
87
132
  updateCount,
88
133
  hasEmptyImage,
89
- managed,
90
- ownershipResolved,
91
- acknowledged,
134
+ ownershipConfirmable,
92
135
  busy,
93
136
  loadFailed = false,
94
137
  }: {
95
138
  updateCount: number
96
139
  hasEmptyImage: boolean
97
- managed: boolean
98
- ownershipResolved: boolean
99
- acknowledged: boolean
140
+ /** Ownership resolved and, when it's managed, acknowledged. */
141
+ ownershipConfirmable: boolean
100
142
  busy: boolean
101
143
  loadFailed?: boolean
102
144
  }): boolean {
103
- return (
104
- updateCount > 0 &&
105
- !hasEmptyImage &&
106
- ownershipResolved &&
107
- (!managed || acknowledged) &&
108
- !busy &&
109
- !loadFailed
110
- )
145
+ return updateCount > 0 && !hasEmptyImage && ownershipConfirmable && !busy && !loadFailed
111
146
  }
112
147
 
113
148
  export function describeImageUpdateBehavior(
@@ -140,7 +175,7 @@ export function SetImageDialog({
140
175
  workloadLabel,
141
176
  workloadName,
142
177
  workloadResource,
143
- managedSources,
178
+ ownership,
144
179
  pending = false,
145
180
  onClose,
146
181
  onLoad,
@@ -155,7 +190,9 @@ export function SetImageDialog({
155
190
  const [submitting, setSubmitting] = useState(false)
156
191
  const [loadError, setLoadError] = useState<string | null>(null)
157
192
  const [submitError, setSubmitError] = useState<string | null>(null)
158
- const [acknowledged, setAcknowledged] = useState(false)
193
+ // The guard key the acknowledgment was given for: a different verdict
194
+ // (fresh evidence, other containers, a changed policy) asks again.
195
+ const [ackedKey, setAckedKey] = useState('')
159
196
  const [changedCurrentKeys, setChangedCurrentKeys] = useState<string[]>([])
160
197
  const onLoadRef = useRef(onLoad)
161
198
  const loadRequestRef = useRef(0)
@@ -216,7 +253,7 @@ export function SetImageDialog({
216
253
  setDrafts({})
217
254
  setChangedCurrentKeys([])
218
255
  setSubmitError(null)
219
- setAcknowledged(false)
256
+ setAckedKey('')
220
257
  void loadInventory()
221
258
  }, [open, workloadLabel, workloadName, workloadResource])
222
259
 
@@ -229,13 +266,27 @@ export function SetImageDialog({
229
266
  (container) => !(drafts[containerKey(container)] ?? '').trim(),
230
267
  ) ?? false
231
268
  const busy = pending || submitting
232
- const ownershipResolved = managedSources !== undefined
269
+ const ownershipResolved = ownership !== undefined
270
+ const onSelectionChange = ownership?.onSelectionChange
271
+ const selectionKey = setImageSelectionKey(
272
+ inventory ? { containers: inventory.containers, changed: updates } : null,
273
+ )
274
+ useEffect(() => {
275
+ if (!onSelectionChange) return
276
+ const [containers, changed] = selectionKey ? (JSON.parse(selectionKey) as [string[][], string[][]]) : [null, null]
277
+ const toRefs = (rows: string[][]) => rows.map(([type, name]) => ({ type: type as WorkloadContainerImage['type'], name }))
278
+ onSelectionChange(containers && changed ? { containers: toRefs(containers), changed: toRefs(changed) } : null)
279
+ }, [onSelectionChange, selectionKey])
280
+ const guard = ownership?.guard
281
+ // The host evaluates the guard after it hears of an edit; until then the
282
+ // verdict on screen is for other containers.
283
+ const guardCurrent = ownership?.selectionKey === undefined || ownership.selectionKey === selectionKey
284
+ const guardKey = gitOpsWriteGuardKey(guard)
285
+ const acknowledged = Boolean(guardKey) && ackedKey === guardKey
233
286
  const canSubmit = canSubmitImageUpdates({
234
287
  updateCount: updates.length,
235
288
  hasEmptyImage,
236
- managed: (managedSources?.length ?? 0) > 0,
237
- ownershipResolved,
238
- acknowledged,
289
+ ownershipConfirmable: guardCurrent && canConfirmGitOpsWrite(guard, acknowledged),
239
290
  busy,
240
291
  loadFailed: Boolean(loadError),
241
292
  })
@@ -378,57 +429,14 @@ export function SetImageDialog({
378
429
  {describeImageUpdateBehavior(inventory.behavior)}
379
430
  </div>
380
431
 
381
- {managedSources && managedSources.length > 0 && (
382
- <div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-3">
383
- <div className="flex items-start gap-2">
384
- <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
385
- <div className="min-w-0 flex-1">
386
- <p className="text-xs font-medium text-amber-800 dark:text-amber-300">
387
- The declared source may overwrite this live change
388
- </p>
389
- <p className="mt-1 text-xs text-theme-text-secondary">
390
- Managed by{' '}
391
- {managedSources
392
- .map((source) => `${source.type} ${source.label}`)
393
- .join(' and ')}
394
- . Update the declared source for a durable change; use
395
- this action only as a break-glass operation.
396
- </p>
397
- <div className="mt-2 flex flex-wrap gap-2">
398
- {managedSources
399
- .filter((source) => source.onOpen)
400
- .map((source) => (
401
- <button
402
- key={`${source.type}-${source.label}`}
403
- type="button"
404
- onClick={source.onOpen}
405
- className="inline-flex items-center gap-1 text-xs font-medium text-skyhook-600 hover:underline dark:text-skyhook-400"
406
- >
407
- {source.type === 'GitOps'
408
- ? 'View GitOps owner'
409
- : 'View Helm release'}
410
- <ExternalLink className="h-3 w-3" />
411
- </button>
412
- ))}
413
- </div>
414
- <label className="mt-3 flex cursor-pointer items-start gap-2 text-xs text-theme-text-primary">
415
- <input
416
- type="checkbox"
417
- checked={acknowledged}
418
- onChange={(event) =>
419
- setAcknowledged(event.target.checked)
420
- }
421
- disabled={busy}
422
- className="mt-0.5 h-3.5 w-3.5 accent-skyhook-500"
423
- />
424
- <span>
425
- I understand the declared source may overwrite this live
426
- change.
427
- </span>
428
- </label>
429
- </div>
430
- </div>
431
- </div>
432
+ {guard && (
433
+ <GitOpsWriteWarning
434
+ guard={guard}
435
+ acknowledged={acknowledged}
436
+ onAcknowledgedChange={(value) => setAckedKey(value ? guardKey : '')}
437
+ onOpenOwner={ownership?.onOpenOwner}
438
+ disabled={busy}
439
+ />
432
440
  )}
433
441
 
434
442
  <ContainerImageGroup
@@ -5,7 +5,8 @@ export {
5
5
  DrainPlanDialog, DrainPlanContent, canConfirmDrain, planMatches, emptyDirPodsAtRisk, DEFAULT_DRAIN_DIALOG_OPTIONS,
6
6
  type DrainPlan, type DrainPlanPod, type DrainOutcome, type DrainDialogOptions,
7
7
  } from './DrainPlanDialog'
8
- export { SetImageDialog, type ManagedImageSource, type SetImageDialogProps } from './SetImageDialog'
8
+ export { SetImageDialog, SET_IMAGE_WRITES, setImageWrites, setImageSelectionKey, type SetImageOwnership, type SetImageSelection, type SetImageDialogProps } from './SetImageDialog'
9
+ export { GitOpsWriteWarning, type GitOpsWriteWarningProps } from './GitOpsWriteWarning'
9
10
  export { CreateResourceDialog, type CreateResourceDialogProps, type ApplyResult } from './CreateResourceDialog'
10
11
  export { HelmManagedByChip, ManagedByChip, type HelmOwnerRef } from './ManagedByChip'
11
12
  export { DetailShell, type DetailShellProps, type DetailShellTab } from './DetailShell'
@@ -35,20 +35,7 @@ import type { NavigateToResource } from '../../utils/navigation'
35
35
  import { builtinGroupForKind } from '../../utils/api-resources'
36
36
  import { kindToPluralWithGroup, refToSelectedResource, apiVersionToGroup } from '../../utils/navigation'
37
37
  import { pluralize } from '../../utils/pluralize'
38
-
39
- /** Format resource age (e.g., "3d", "5h", "10m") */
40
- function formatResourceAge(createdAt: string): string {
41
- const diff = Date.now() - new Date(createdAt).getTime()
42
- const mins = Math.floor(diff / 60000)
43
- if (mins < 1) return '<1m'
44
- if (mins < 60) return `${mins}m`
45
- const hours = Math.floor(mins / 60)
46
- if (hours < 24) return `${hours}h`
47
- const days = Math.floor(hours / 24)
48
- if (days < 30) return `${days}d`
49
- const months = Math.floor(days / 30)
50
- return `${months}mo`
51
- }
38
+ import { formatCompactAge } from '../../utils/format'
52
39
 
53
40
  export type { ActivityTypeFilter, ActivityFilterKey }
54
41
 
@@ -736,7 +723,7 @@ function ActivityCard({ item, expanded, onToggle, onResourceClick, compact, sele
736
723
  {item.createdAt && (
737
724
  <Tooltip content={`Created: ${new Date(item.createdAt).toLocaleString()}`}>
738
725
  <span className="text-xs text-theme-text-quaternary">
739
- • {formatResourceAge(item.createdAt)} old
726
+ • {formatCompactAge(item.createdAt)} old
740
727
  </span>
741
728
  </Tooltip>
742
729
  )}
@@ -62,6 +62,7 @@ import {
62
62
  } from './shared'
63
63
  import { useRegisterShortcut } from '../../hooks/useKeyboardShortcuts'
64
64
  import { clampLensToSelection, formatLensDuration, MIN_WINDOW_MS, type ScrubberRange } from './scrubber-math'
65
+ import { formatRelativeAgeTime } from '../../utils/format'
65
66
 
66
67
  // Predefined zoom levels (window widths in hours): 15m, 30m, 1h, 2h, 4h, 8h,
67
68
  // 12h, 1d, 2d, 3d, 7d. Hoisted so the controlled-window adapter can size the
@@ -2884,15 +2885,6 @@ function EventMarker({ event, x, selected, onClick, dimmed, small }: EventMarker
2884
2885
  const markerClasses = getMarkerStyle()
2885
2886
 
2886
2887
  // Build tooltip text - focus on what happened, explain the color meaning
2887
- const getRelativeTime = (timestamp: string) => {
2888
- const diff = Date.now() - new Date(timestamp).getTime()
2889
- const mins = Math.floor(diff / 60000)
2890
- if (mins < 1) return 'just now'
2891
- if (mins < 60) return `${mins}m ago`
2892
- const hours = Math.floor(mins / 60)
2893
- if (hours < 24) return `${hours}h ago`
2894
- return `${Math.floor(hours / 24)}d ago`
2895
- }
2896
2888
 
2897
2889
  // Get human-readable operation label with color indicator
2898
2890
  const getOperationLabel = () => {
@@ -2922,7 +2914,7 @@ function EventMarker({ event, x, selected, onClick, dimmed, small }: EventMarker
2922
2914
  const msg = event.message.length > 110 ? event.message.slice(0, 110) + '…' : event.message
2923
2915
  tooltipLines.push(msg)
2924
2916
  }
2925
- tooltipLines.push(getRelativeTime(event.timestamp))
2917
+ tooltipLines.push(formatRelativeAgeTime(event.timestamp))
2926
2918
  if (isHistoricalEvent(event)) tooltipLines.push('(from metadata)')
2927
2919
 
2928
2920
  const tooltipText = tooltipLines.join(' · ')
@@ -46,7 +46,8 @@ const EDGE_COLORS = {
46
46
  'exposes': '#3b82f6', // Blue for service exposure
47
47
  'manages': '#64748b', // Gray for management relationships
48
48
  'configures': '#f59e0b', // Amber for config
49
- 'uses': '#ec4899', // Pink for HPA
49
+ 'uses': '#ec4899', // Pink: autoscalers, mounted claims, dependencies
50
+ 'protects': '#8b5cf6', // Violet for policies and disruption budgets
50
51
  } as const
51
52
 
52
53
  function getEdgeColor(type: string, isTrafficView: boolean): string {
@@ -59,10 +60,11 @@ function getEdgeColor(type: string, isTrafficView: boolean): string {
59
60
 
60
61
  // Human-readable edge legend for the resources view (traffic view is all-green).
61
62
  const EDGE_LEGEND: { label: string; color: string }[] = [
62
- { label: 'owns', color: EDGE_COLORS['manages'] },
63
+ { label: 'manages', color: EDGE_COLORS['manages'] },
63
64
  { label: 'exposes', color: EDGE_COLORS['exposes'] },
64
65
  { label: 'configures', color: EDGE_COLORS['configures'] },
65
- { label: 'scales', color: EDGE_COLORS['uses'] },
66
+ { label: 'uses', color: EDGE_COLORS['uses'] },
67
+ { label: 'protects', color: EDGE_COLORS['protects'] },
66
68
  { label: 'routes to', color: EDGE_COLORS['routes-to'] },
67
69
  ]
68
70
 
@@ -74,6 +74,11 @@ describe('TimeValue', () => {
74
74
  expect(html).not.toMatch(/\dT\d\d:\d\d:\d\dZ/)
75
75
  })
76
76
 
77
+ // A bare "6h" under "Started" or "Finished" reads as a duration.
78
+ it('says the moment was in the past', () => {
79
+ expect(render(<TimeValue timestamp={new Date(Date.now() - 6 * 3600_000).toISOString()} />)).toContain('6h ago')
80
+ })
81
+
77
82
  it('says so when the thing never happened', () => {
78
83
  expect(render(<TimeValue timestamp={undefined} fallback="Never" />)).toContain('Never')
79
84
  })
@@ -1,5 +1,6 @@
1
1
  import { Tooltip } from './Tooltip'
2
- import { cronToHuman, formatAge } from '../resources/resource-utils'
2
+ import { cronToHuman } from '../resources/resource-utils'
3
+ import { formatRelativeAgeTime } from '../../utils/format'
3
4
 
4
5
  /**
5
6
  * Hover affordance used across the drawer: without it a tooltip is a secret.
@@ -79,7 +80,7 @@ export function TimeValue({ timestamp, fallback = '-' }: TimeValueProps) {
79
80
  if (!timestamp) return <>{fallback}</>
80
81
  return (
81
82
  <Tooltip content={new Date(timestamp).toLocaleString()} position="right">
82
- <span className={HOVERABLE}>{formatAge(timestamp)}</span>
83
+ <span className={HOVERABLE}>{formatRelativeAgeTime(timestamp)}</span>
83
84
  </Tooltip>
84
85
  )
85
86
  }
@@ -1,12 +1,20 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { renderToString } from 'react-dom/server'
3
- import { EventsSection, ProblemAlerts, OperationalIssuesShownContext, RelatedResourcesSection } from './drawer-components'
3
+ import { EventsSection, ProblemAlerts, OperationalIssuesShownContext, RelatedResourcesSection, defaultConditionTone } from './drawer-components'
4
4
 
5
5
  const problems = [
6
6
  { color: 'red' as const, message: 'Application is Degraded' },
7
7
  { color: 'yellow' as const, message: 'Application is OutOfSync' },
8
8
  ]
9
9
 
10
+ describe('GKE node condition polarity', () => {
11
+ it.each(['StoragePressureRootFileSystem', 'DPv2MigrationUnsupportedCNI', 'UnsupportedEBPFPrograms'])('%s is a problem only when True', type => {
12
+ expect(defaultConditionTone({ type, status: 'False' })).toBe('ok')
13
+ expect(defaultConditionTone({ type, status: 'True' })).toBe('fail')
14
+ expect(defaultConditionTone({ type, status: 'Unknown' })).toBe('unknown')
15
+ })
16
+ })
17
+
10
18
  describe('ProblemAlerts', () => {
11
19
  it('renders every problem', () => {
12
20
  const html = renderToString(<ProblemAlerts problems={problems} />)
@@ -62,3 +70,60 @@ describe('Recent Events layout', () => {
62
70
  expect(fullscreen).toContain('Reason23')
63
71
  })
64
72
  })
73
+
74
+ describe('dependency relationships', () => {
75
+ it('renders a dependency-only response with the existing navigation control', () => {
76
+ const html = renderToString(<RelatedResourcesSection relationships={{ dependencies: [{ kind: 'Issuer', group: 'cert-manager.io', namespace: 'team', name: 'ca' }] }} onNavigate={() => {}} />)
77
+ expect(html).toContain('Depends On')
78
+ expect(html).toContain('ca')
79
+ expect(html).toContain('<button')
80
+ expect(html).not.toContain('Scale Target')
81
+ })
82
+
83
+ it('renders the reverse dependency without calling it an autoscaler', () => {
84
+ const html = renderToString(<RelatedResourcesSection relationships={{ dependents: [{ kind: 'Certificate', group: 'cert-manager.io', namespace: 'team', name: 'tls' }] }} />)
85
+ expect(html).toContain('Required By')
86
+ expect(html).toContain('tls')
87
+ expect(html).not.toContain('Autoscaler')
88
+ })
89
+ })
90
+
91
+ describe('versioned dependency projections', () => {
92
+ const issuer = { kind: 'Issuer', group: 'cert-manager.io', namespace: 'team', name: 'ca' }
93
+ const certificate = { kind: 'Certificate', group: 'cert-manager.io', namespace: 'team', name: 'tls' }
94
+
95
+ it('presents mirrored configuration dependencies once under the specific labels', () => {
96
+ const html = renderToString(<RelatedResourcesSection relationships={{ dependencies: [issuer], configRefs: [issuer], dependents: [certificate], consumers: [certificate] }} onNavigate={() => {}} />)
97
+ expect(html).toContain('Depends On')
98
+ expect(html).toContain('Required By')
99
+ expect(html).not.toContain('Configuration')
100
+ expect(html).not.toContain('Used By')
101
+ expect(html.match(/<button/g)).toHaveLength(3)
102
+ })
103
+
104
+ it('preserves configuration and consumer groups when dependency fields are absent', () => {
105
+ const html = renderToString(<RelatedResourcesSection relationships={{ configRefs: [issuer], consumers: [certificate] }} />)
106
+ expect(html).toContain('Configuration')
107
+ expect(html).toContain('Used By')
108
+ expect(html).not.toContain('Depends On')
109
+ })
110
+
111
+ it('retains navigation controls for identically named resources from distinct API groups', () => {
112
+ const html = renderToString(<RelatedResourcesSection relationships={{ dependencies: [issuer, { ...issuer, group: 'other.example.com' }, issuer] }} onNavigate={() => {}} />)
113
+ expect(html.match(/<button/g)).toHaveLength(3)
114
+ })
115
+ })
116
+
117
+ describe('namespace deletion condition polarity', () => {
118
+ it.each([
119
+ 'NamespaceDeletionDiscoveryFailure',
120
+ 'NamespaceDeletionGroupVersionParsingFailure',
121
+ 'NamespaceDeletionContentFailure',
122
+ 'NamespaceContentRemaining',
123
+ 'NamespaceFinalizersRemaining',
124
+ ])('%s treats True as a blocker and False as cleared', (type) => {
125
+ expect(defaultConditionTone({ type, status: 'True' })).toBe('fail')
126
+ expect(defaultConditionTone({ type, status: 'False' })).toBe('ok')
127
+ expect(defaultConditionTone({ type, status: 'Unknown' })).toBe('unknown')
128
+ })
129
+ })
@@ -1,8 +1,9 @@
1
+ import { dedupeResourceRefs, omitResourceRefs } from '../../utils/resource-refs'
1
2
  import { Disclosure } from './Disclosure'
2
3
  import { createContext, useContext, useState } from 'react'
3
4
  import { Copy, Check, Tag, AlertTriangle, CheckCircle, ExternalLink, Layers, X, Minus } from 'lucide-react'
4
5
  import { clsx } from 'clsx'
5
- import { formatAge, formatDuration, formatResources } from '../resources/resource-utils'
6
+ import { formatDuration, formatResources } from '../resources/resource-utils'
6
7
  import { getEffectiveResources } from '../../utils/extended-resources'
7
8
  import { Tooltip } from './Tooltip'
8
9
  import { getKindColorClass } from '../ui/Badge'
@@ -206,6 +207,12 @@ const NEGATIVE_POLARITY_TYPES = new Set([
206
207
  'NetworkUnavailable',
207
208
  // Core K8s — Pod
208
209
  'DisruptionTarget',
210
+ // Core K8s — Namespace
211
+ 'NamespaceDeletionDiscoveryFailure',
212
+ 'NamespaceDeletionGroupVersionParsingFailure',
213
+ 'NamespaceDeletionContentFailure',
214
+ 'NamespaceContentRemaining',
215
+ 'NamespaceFinalizersRemaining',
209
216
  // Core K8s — workloads
210
217
  'ReplicaFailure',
211
218
  // NPD — upstream default configs
@@ -220,6 +227,9 @@ const NEGATIVE_POLARITY_TYPES = new Set([
220
227
  'ReadOnlyRootFileSystem',
221
228
  'ResourceExhausted',
222
229
  'Swap',
230
+ 'StoragePressureRootFileSystem',
231
+ 'DPv2MigrationUnsupportedCNI',
232
+ 'UnsupportedEBPFPrograms',
223
233
  // NPD — AKS extensions (learn.microsoft.com/azure/aks/node-problem-detector)
224
234
  'FilesystemCorruptionProblem',
225
235
  'KubeletProblem',
@@ -402,15 +412,17 @@ interface AlertBannerProps {
402
412
  message?: React.ReactNode
403
413
  items?: string[]
404
414
  children?: React.ReactNode
415
+ /** Replaces the default bottom margin when the banner sits in a tighter layout. */
416
+ className?: string
405
417
  }
406
418
 
407
- export function AlertBanner({ variant, icon, title, message, items, children }: AlertBannerProps) {
419
+ export function AlertBanner({ variant, icon, title, message, items, children, className }: AlertBannerProps) {
408
420
  const colors = ALERT_COLORS[variant]
409
421
  const Icon = icon || DEFAULT_ICONS[variant]
410
422
  const hasBody = message || items || children
411
423
 
412
424
  return (
413
- <div className={clsx('mb-4 p-3 border rounded-lg', colors.bg, colors.border)}>
425
+ <div className={clsx('p-3 border rounded-lg', className ?? 'mb-4', colors.bg, colors.border)}>
414
426
  <div className={clsx('flex gap-2', hasBody ? 'items-start' : 'items-center')}>
415
427
  <Icon className={clsx('w-4 h-4 shrink-0', colors.title, hasBody && 'mt-0.5')} />
416
428
  {hasBody ? (
@@ -527,7 +539,7 @@ export function MetadataSection({ data }: { data: any }) {
527
539
  <Property label="Generation" value={meta.generation} />
528
540
  <Property label="Created" value={meta.creationTimestamp ? (
529
541
  <Tooltip content={new Date(meta.creationTimestamp).toLocaleString()}>
530
- <span className="border-b border-dotted border-theme-text-tertiary cursor-help">{formatAge(meta.creationTimestamp)}</span>
542
+ <span className="border-b border-dotted border-theme-text-tertiary cursor-help">{formatRelativeAgeTime(meta.creationTimestamp)}</span>
531
543
  </Tooltip>
532
544
  ) : '-'} />
533
545
  </PropertyList>
@@ -784,25 +796,19 @@ export type CopyHandler = (text: string, key: string) => void
784
796
  import type { TimelineEvent, Relationships, ResourceRef } from '../../types'
785
797
  import { isChangeEvent, isK8sEvent } from '../../types'
786
798
  import { Link } from 'lucide-react'
799
+ import { formatRelativeAgeTime } from '../../utils/format'
787
800
 
788
801
  interface RelatedResourcesSectionProps {
789
802
  relationships: Relationships | undefined
790
803
  onNavigate?: (ref: ResourceRef) => void
791
804
  }
792
805
 
793
- function dedupeRefs(refs: ResourceRef[]): ResourceRef[] {
794
- const seen = new Set<string>()
795
- return refs.filter(ref => {
796
- const key = `${ref.kind}/${ref.namespace}/${ref.name}`
797
- if (seen.has(key)) return false
798
- seen.add(key)
799
- return true
800
- })
801
- }
802
-
803
806
  export function RelatedResourcesSection({ relationships, onNavigate }: RelatedResourcesSectionProps) {
804
807
  if (!relationships) return null
805
808
 
809
+ const configRefs = omitResourceRefs(relationships.configRefs ?? [], relationships.dependencies ?? [])
810
+ const consumers = omitResourceRefs(relationships.consumers ?? [], relationships.dependents ?? [])
811
+
806
812
  const hasRelationships =
807
813
  relationships.owner ||
808
814
  relationships.deployment ||
@@ -820,6 +826,8 @@ export function RelatedResourcesSection({ relationships, onNavigate }: RelatedRe
820
826
  (relationships.pdbs && relationships.pdbs.length > 0) ||
821
827
  (relationships.networkPolicies && relationships.networkPolicies.length > 0) ||
822
828
  (relationships.resourceClaims && relationships.resourceClaims.length > 0) ||
829
+ (relationships.dependencies && relationships.dependencies.length > 0) ||
830
+ (relationships.dependents && relationships.dependents.length > 0) ||
823
831
  relationships.scaleTarget
824
832
 
825
833
  if (!hasRelationships) return null
@@ -837,43 +845,49 @@ export function RelatedResourcesSection({ relationships, onNavigate }: RelatedRe
837
845
  <RelationshipGroup label="Node" refs={[relationships.node]} onNavigate={onNavigate} />
838
846
  )}
839
847
  {relationships.children && relationships.children.length > 0 && (
840
- <RelationshipGroup label="Children" refs={dedupeRefs(relationships.children)} onNavigate={onNavigate} />
848
+ <RelationshipGroup label="Children" refs={dedupeResourceRefs(relationships.children)} onNavigate={onNavigate} />
841
849
  )}
842
850
  {relationships.services && relationships.services.length > 0 && (
843
- <RelationshipGroup label="Services" refs={dedupeRefs(relationships.services)} onNavigate={onNavigate} />
851
+ <RelationshipGroup label="Services" refs={dedupeResourceRefs(relationships.services)} onNavigate={onNavigate} />
844
852
  )}
845
853
  {relationships.ingresses && relationships.ingresses.length > 0 && (
846
- <RelationshipGroup label="Ingresses" refs={dedupeRefs(relationships.ingresses)} onNavigate={onNavigate} />
854
+ <RelationshipGroup label="Ingresses" refs={dedupeResourceRefs(relationships.ingresses)} onNavigate={onNavigate} />
847
855
  )}
848
856
  {relationships.gateways && relationships.gateways.length > 0 && (
849
- <RelationshipGroup label="Gateways" refs={dedupeRefs(relationships.gateways)} onNavigate={onNavigate} />
857
+ <RelationshipGroup label="Gateways" refs={dedupeResourceRefs(relationships.gateways)} onNavigate={onNavigate} />
850
858
  )}
851
859
  {relationships.routes && relationships.routes.length > 0 && (
852
- <RelationshipGroup label="Routes" refs={dedupeRefs(relationships.routes)} onNavigate={onNavigate} />
860
+ <RelationshipGroup label="Routes" refs={dedupeResourceRefs(relationships.routes)} onNavigate={onNavigate} />
853
861
  )}
854
862
  {relationships.pods && relationships.pods.length > 0 && (
855
- <RelationshipGroup label="Pods" refs={dedupeRefs(relationships.pods)} onNavigate={onNavigate} />
863
+ <RelationshipGroup label="Pods" refs={dedupeResourceRefs(relationships.pods)} onNavigate={onNavigate} />
856
864
  )}
857
- {relationships.configRefs && relationships.configRefs.length > 0 && (
858
- <RelationshipGroup label="Configuration" refs={dedupeRefs(relationships.configRefs)} onNavigate={onNavigate} />
865
+ {configRefs.length > 0 && (
866
+ <RelationshipGroup label="Configuration" refs={dedupeResourceRefs(configRefs)} onNavigate={onNavigate} />
859
867
  )}
860
- {relationships.consumers && relationships.consumers.length > 0 && (
861
- <RelationshipGroup label="Used By" refs={dedupeRefs(relationships.consumers)} onNavigate={onNavigate} />
868
+ {consumers.length > 0 && (
869
+ <RelationshipGroup label="Used By" refs={dedupeResourceRefs(consumers)} onNavigate={onNavigate} />
862
870
  )}
863
871
  {relationships.scalers && relationships.scalers.length > 0 && (
864
- <RelationshipGroup label="Autoscaler" refs={dedupeRefs(relationships.scalers)} onNavigate={onNavigate} />
872
+ <RelationshipGroup label="Autoscaler" refs={dedupeResourceRefs(relationships.scalers)} onNavigate={onNavigate} />
865
873
  )}
866
874
  {relationships.storageRefs && relationships.storageRefs.length > 0 && (
867
- <RelationshipGroup label="Storage" refs={dedupeRefs(relationships.storageRefs)} onNavigate={onNavigate} />
875
+ <RelationshipGroup label="Storage" refs={dedupeResourceRefs(relationships.storageRefs)} onNavigate={onNavigate} />
868
876
  )}
869
877
  {relationships.pdbs && relationships.pdbs.length > 0 && (
870
- <RelationshipGroup label="Disruption Budget" refs={dedupeRefs(relationships.pdbs)} onNavigate={onNavigate} />
878
+ <RelationshipGroup label="Disruption Budget" refs={dedupeResourceRefs(relationships.pdbs)} onNavigate={onNavigate} />
871
879
  )}
872
880
  {relationships.networkPolicies && relationships.networkPolicies.length > 0 && (
873
- <RelationshipGroup label="Network Policies" refs={dedupeRefs(relationships.networkPolicies)} onNavigate={onNavigate} />
881
+ <RelationshipGroup label="Network Policies" refs={dedupeResourceRefs(relationships.networkPolicies)} onNavigate={onNavigate} />
874
882
  )}
875
883
  {relationships.resourceClaims && relationships.resourceClaims.length > 0 && (
876
- <RelationshipGroup label="Resource Claims" refs={dedupeRefs(relationships.resourceClaims)} onNavigate={onNavigate} />
884
+ <RelationshipGroup label="Resource Claims" refs={dedupeResourceRefs(relationships.resourceClaims)} onNavigate={onNavigate} />
885
+ )}
886
+ {relationships.dependencies && relationships.dependencies.length > 0 && (
887
+ <RelationshipGroup label="Depends On" refs={dedupeResourceRefs(relationships.dependencies)} onNavigate={onNavigate} />
888
+ )}
889
+ {relationships.dependents && relationships.dependents.length > 0 && (
890
+ <RelationshipGroup label="Required By" refs={dedupeResourceRefs(relationships.dependents)} onNavigate={onNavigate} />
877
891
  )}
878
892
  {relationships.scaleTarget && (
879
893
  <RelationshipGroup label="Scale Target" refs={[relationships.scaleTarget]} onNavigate={onNavigate} />