@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
@@ -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(' · ')
@@ -86,3 +86,101 @@ describe('TimelineSwimlanes controlled vs fallback', () => {
86
86
  expect(html).toContain('>View<')
87
87
  })
88
88
  })
89
+
90
+ describe('TimelineList routine activity toggle', () => {
91
+ const at = (id: string, over: Partial<TimelineEvent>): TimelineEvent => ({
92
+ ...EVENTS[0], id, timestamp: new Date(NOW).toISOString(), ...over,
93
+ })
94
+ const ROUTINE: TimelineEvent[] = [
95
+ at('pod-started', { kind: 'Pod', name: 'web-1', source: 'k8s_event', eventType: 'Normal', reason: 'Started' }),
96
+ at('pod-pulled', { kind: 'Pod', name: 'web-2', source: 'k8s_event', eventType: 'Normal', reason: 'Pulled' }),
97
+ at('rs-scaled', { kind: 'ReplicaSet', name: 'web-5f86', eventType: 'update' }),
98
+ ]
99
+
100
+ it('shows the checkbox with the hidden count and the (i) explanation', () => {
101
+ const html = renderToString(
102
+ <TimelineList events={EVENTS} isLoading={false} routineEvents={ROUTINE} onShowRoutineChange={() => {}} />,
103
+ )
104
+ expect(html).toContain('Show routine activity')
105
+ expect(html).toContain('type="checkbox"')
106
+ expect(html).toContain('· <!-- -->3 hidden')
107
+ expect(html).toContain('aria-label="About routine activity"')
108
+ })
109
+
110
+ it('counts only hidden rows the active filters would show', () => {
111
+ const html = renderToString(
112
+ <TimelineList
113
+ events={EVENTS}
114
+ isLoading={false}
115
+ kindFilter={['Pod']}
116
+ onKindFilterChange={() => {}}
117
+ routineEvents={ROUTINE}
118
+ onShowRoutineChange={() => {}}
119
+ />,
120
+ )
121
+ expect(html).toContain('2 routine events<!-- --> <!-- -->match')
122
+ })
123
+
124
+ it('says none hidden when nothing matching is routine', () => {
125
+ const html = renderToString(
126
+ <TimelineList events={EVENTS} isLoading={false} search="web" onSearchChange={() => {}} routineEvents={[]} onShowRoutineChange={() => {}} />,
127
+ )
128
+ expect(html).toContain('· <!-- -->none hidden')
129
+ })
130
+
131
+ it('reads shown, with the count, once the user opts in', () => {
132
+ const html = renderToString(
133
+ <TimelineList events={[...EVENTS, ...ROUTINE]} isLoading={false} showRoutine routineEvents={ROUTINE} onShowRoutineChange={() => {}} />,
134
+ )
135
+ expect(html).toContain('checked=""')
136
+ expect(html).toContain('· <!-- -->3 shown')
137
+ })
138
+
139
+ it('explains an empty list whose matching rows are all routine', () => {
140
+ const html = renderToString(
141
+ <TimelineList events={[]} isLoading={false} routineEvents={ROUTINE} onShowRoutineChange={() => {}} />,
142
+ )
143
+ expect(html).toContain('All matching activity is routine')
144
+ expect(html).toContain('3 routine events<!-- --> <!-- -->match<!-- -->. Warnings and failures would show here.')
145
+ expect(html).toContain('Show routine activity')
146
+ expect(html).not.toContain('No activity found')
147
+ })
148
+
149
+ it('has no toggle when the host does not offer it', () => {
150
+ const html = renderToString(<TimelineList events={EVENTS} isLoading={false} />)
151
+ expect(html).not.toContain('routine activity')
152
+ })
153
+
154
+ it('folds a resource\'s rows under its newest card, describing the rest', () => {
155
+ const crash = (id: string, minutesAgo: number, over: Partial<TimelineEvent>) =>
156
+ at(id, { kind: 'Pod', name: 'api-1', timestamp: new Date(NOW - minutesAgo * 60_000).toISOString(), ...over })
157
+ const rows = [
158
+ crash('newest', 0, { eventType: 'update', healthState: 'unhealthy', owner: { kind: 'ReplicaSet', name: 'api-5f86' } }),
159
+ crash('b1', 1, { source: 'k8s_event', eventType: 'Warning', reason: 'BackOff' }),
160
+ crash('b2', 2, { source: 'k8s_event', eventType: 'Warning', reason: 'BackOff' }),
161
+ crash('u1', 3, { eventType: 'update', healthState: 'degraded' }),
162
+ ]
163
+ const folded = renderToString(<TimelineList events={rows} isLoading={false} foldPerResource={(e) => e.kind === 'Pod'} />)
164
+ expect(folded).toContain('3<!-- --> more on this <!-- -->Pod<!-- -->: <!-- -->BackOff ×2, 1 status change')
165
+ expect(folded).toContain('deploy<!-- -->/</span>api')
166
+ expect(folded).toContain('data-event-id="newest"')
167
+ const plain = renderToString(<TimelineList events={rows} isLoading={false} />)
168
+ expect(plain).not.toContain('more on this')
169
+ })
170
+
171
+ it('counts each event\'s occurrences in the fold summary', () => {
172
+ const crash = (id: string, minutesAgo: number, over: Partial<TimelineEvent>) =>
173
+ at(id, { kind: 'Pod', name: 'api-1', timestamp: new Date(NOW - minutesAgo * 60_000).toISOString(), ...over })
174
+ const rows = [
175
+ crash('newest', 0, { eventType: 'update', healthState: 'unhealthy' }),
176
+ crash('backoff', 1, { source: 'k8s_event', eventType: 'Warning', reason: 'BackOff', count: 43 }),
177
+ ]
178
+ const html = renderToString(<TimelineList events={rows} isLoading={false} foldPerResource={() => true} />)
179
+ expect(html).toContain('BackOff ×43')
180
+ })
181
+
182
+ it('keeps the (i) explanation reachable by keyboard', () => {
183
+ const html = renderToString(<TimelineList events={EVENTS} isLoading={false} routineEvents={[]} onShowRoutineChange={() => {}} />)
184
+ expect(html).toContain('<button type="button" aria-label="About routine activity"')
185
+ })
186
+ })
@@ -1,6 +1,7 @@
1
1
  export * from './shared'
2
2
  export * from './DiffViewer'
3
3
  export type { ActivityTypeFilter, ActivityFilterKey } from './timeline-filters'
4
+ export { isTimelineProblem } from './timeline-filters'
4
5
  export type { TimelineSort } from './timeline-lane-sort'
5
6
  export * from './TimelineList'
6
7
  export * from './TimelineSwimlanes'
@@ -69,6 +69,13 @@ export function selectionToActivityKeys(sel: ActivitySelection): ActivityFilterK
69
69
  return []
70
70
  }
71
71
 
72
+ // A row the Problems chip counts. Hosts that hide routine activity must never
73
+ // hide these, so the Problems count and the list stay truthful.
74
+ const PROBLEM_KEYS = selectionToActivityKeys({ source: 'all', problemsOnly: true })
75
+ export function isTimelineProblem(event: TimelineEvent): boolean {
76
+ return matchesActivityFilter(event, PROBLEM_KEYS)
77
+ }
78
+
72
79
  // Inverse of selectionToActivityKeys for the six canonical states. Legacy
73
80
  // multi-select key sets (pre-two-axis URLs) fall back to the widest reading —
74
81
  // showing more than a stale link intended beats silently hiding activity.
@@ -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} />
@@ -0,0 +1,58 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+
4
+ import { WorkloadView } from './WorkloadView'
5
+
6
+ const pod = {
7
+ apiVersion: 'v1',
8
+ kind: 'Pod',
9
+ metadata: { name: 'web-1', namespace: 'shop' },
10
+ spec: { containers: [{ name: 'app', image: 'nginx' }] },
11
+ status: { phase: 'Running', containerStatuses: [{ name: 'app', ready: true, state: { running: {} } }] },
12
+ }
13
+
14
+ function render(resourceError: unknown) {
15
+ return renderToStaticMarkup(
16
+ <WorkloadView
17
+ kind="pods"
18
+ namespace="shop"
19
+ name="web-1"
20
+ onBack={vi.fn()}
21
+ resource={pod}
22
+ resourceError={resourceError}
23
+ relationships={{ deployment: { kind: 'Deployment', namespace: 'shop', name: 'web' } }}
24
+ onNavigateToResource={vi.fn()}
25
+ actionsBarProps={{ canExec: true, canViewLogs: true, onOpenTerminal: vi.fn(), onOpenLogs: vi.fn(), onDelete: vi.fn(), renderDiagnose: () => 'Diagnose' }}
26
+ hasOperationalIssues
27
+ renderOverviewLead={() => 'Host issues panel'}
28
+ renderOverviewExtra={() => 'Host reachability panel'}
29
+ renderDiagnoseTab={() => 'Host reachability tab'}
30
+ reachableVia={[{ kind: 'Service', namespace: 'shop', name: 'web' }]}
31
+ />,
32
+ )
33
+ }
34
+
35
+ describe('WorkloadView for an object deleted while open', () => {
36
+ it('says it is gone, names its owner, and offers nothing that acts on it', () => {
37
+ const html = render(Object.assign(new Error('pods "web-1" not found'), { status: 404 }))
38
+ expect(html).toContain('This Pod no longer exists in the cluster')
39
+ expect(html).toContain('Go to its Deployment web')
40
+ expect(html).toContain('>Deleted<')
41
+ expect(html).not.toContain('Terminal')
42
+ expect(html).not.toContain('Diagnose')
43
+ expect(html).not.toContain('Host issues panel')
44
+ expect(html).not.toContain('Host reachability panel')
45
+ expect(html).not.toContain('Reachability')
46
+ })
47
+
48
+ it('shows the live view when the fetch failed for another reason', () => {
49
+ const html = render(Object.assign(new Error('boom'), { status: 500 }))
50
+ expect(html).not.toContain('no longer exists')
51
+ expect(html).not.toContain('>Deleted<')
52
+ expect(html).toContain('Terminal')
53
+ expect(html).toContain('Diagnose')
54
+ expect(html).toContain('Host issues panel')
55
+ expect(html).toContain('Host reachability panel')
56
+ expect(html).toContain('Reachability')
57
+ })
58
+ })
@@ -0,0 +1,45 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { expect, it, vi } from 'vitest'
5
+
6
+ import { WorkloadView } from './WorkloadView'
7
+
8
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
9
+
10
+ const deployment = {
11
+ apiVersion: 'apps/v1',
12
+ kind: 'Deployment',
13
+ metadata: { name: 'web', namespace: 'shop' },
14
+ spec: { replicas: 1, template: { spec: { containers: [{ name: 'app', image: 'nginx' }] } } },
15
+ status: { replicas: 1, readyReplicas: 1, availableReplicas: 1 },
16
+ }
17
+
18
+ it('closes an open Delete dialog when the object is deleted elsewhere', async () => {
19
+ const element = document.createElement('div')
20
+ document.body.append(element)
21
+ const root = createRoot(element)
22
+ const render = (resourceError: unknown) =>
23
+ root.render(
24
+ <WorkloadView
25
+ kind="deployments"
26
+ namespace="shop"
27
+ name="web"
28
+ onBack={vi.fn()}
29
+ resource={deployment}
30
+ resourceError={resourceError}
31
+ actionsBarProps={{ onDelete: vi.fn() }}
32
+ />,
33
+ )
34
+ const dialogOpen = () => document.body.textContent?.includes('Are you sure you want to delete') ?? false
35
+
36
+ await act(async () => render(null))
37
+ await act(async () => { element.querySelector<HTMLButtonElement>('button:has(svg.lucide-trash-2)')!.click() })
38
+ expect(dialogOpen()).toBe(true)
39
+
40
+ await act(async () => render(Object.assign(new Error('deployments.apps "web" not found'), { status: 404 })))
41
+ expect(dialogOpen()).toBe(false)
42
+
43
+ await act(async () => root.unmount())
44
+ element.remove()
45
+ })
@@ -0,0 +1,62 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { expect, it, vi } from 'vitest'
5
+
6
+ import { WorkloadView } from './WorkloadView'
7
+
8
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
9
+
10
+ const secret = {
11
+ apiVersion: 'v1',
12
+ kind: 'Secret',
13
+ metadata: { name: 'db', namespace: 'shop', resourceVersion: '7' },
14
+ type: 'Opaque',
15
+ data: { password: btoa('old') },
16
+ }
17
+
18
+ function button(label: string): HTMLButtonElement | undefined {
19
+ return [...document.querySelectorAll('button')].find(b => b.textContent?.trim() === label || b.title === label)
20
+ }
21
+
22
+ it('saves a Secret value against the version on screen, and offers no edit once the Secret is gone', async () => {
23
+ const element = document.createElement('div')
24
+ document.body.append(element)
25
+ const root = createRoot(element)
26
+ const onUpdateResource = vi.fn().mockResolvedValue(undefined)
27
+ const render = (resourceError: unknown) =>
28
+ root.render(
29
+ <WorkloadView
30
+ kind="secrets"
31
+ namespace="shop"
32
+ name="db"
33
+ onBack={vi.fn()}
34
+ resource={secret}
35
+ resourceError={resourceError}
36
+ canUpdateSecrets
37
+ onUpdateResource={onUpdateResource}
38
+ />,
39
+ )
40
+
41
+ await act(async () => render(null))
42
+ await act(async () => button('Reveal')!.click())
43
+ await act(async () => button('Edit value')!.click())
44
+ await act(async () => button('Save')!.click())
45
+ await act(async () => button('Update')!.click())
46
+ expect(onUpdateResource).toHaveBeenCalledWith(expect.objectContaining({
47
+ kind: 'secrets',
48
+ namespace: 'shop',
49
+ name: 'db',
50
+ reviewedResourceVersion: '7',
51
+ }))
52
+
53
+ await act(async () => button('Edit value')!.click())
54
+ expect(element.querySelector('textarea')).not.toBeNull()
55
+ await act(async () => render(Object.assign(new Error('secrets "db" not found'), { status: 404 })))
56
+ expect(element.textContent).toContain('This Secret no longer exists in the cluster')
57
+ expect(element.querySelector('textarea')).toBeNull()
58
+ expect(button('Edit value')).toBeUndefined()
59
+
60
+ await act(async () => root.unmount())
61
+ element.remove()
62
+ })