@skyhook-io/k8s-ui 1.14.10 → 1.14.11

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 (80) hide show
  1. package/package.json +3 -2
  2. package/src/components/applications/ApplicationDetail.tsx +24 -11
  3. package/src/components/audit/AuditAlerts.tsx +1 -3
  4. package/src/components/audit/AuditCoveragePresentation.test.tsx +48 -2
  5. package/src/components/checks/ChecksView.test.tsx +55 -0
  6. package/src/components/checks/ChecksView.tsx +38 -27
  7. package/src/components/dock/NodeTerminalTab.test.tsx +89 -0
  8. package/src/components/dock/NodeTerminalTab.tsx +51 -47
  9. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +17 -9
  10. package/src/components/gitops/detail-helpers.test.ts +32 -0
  11. package/src/components/gitops/detail-helpers.ts +21 -0
  12. package/src/components/gitops/health-provenance.test.ts +4 -3
  13. package/src/components/gitops/index.ts +2 -1
  14. package/src/components/gitops/insights/ArgoResourceDiff.tsx +131 -54
  15. package/src/components/issues/IssuesView.tsx +6 -0
  16. package/src/components/issues/ResourceIssuesSection.test.tsx +36 -0
  17. package/src/components/issues/diagnostic.ts +4 -0
  18. package/src/components/logs/LogCore.tsx +3 -3
  19. package/src/components/logs/WorkloadLogsViewer.tsx +41 -11
  20. package/src/components/logs/useLogBuffer.ts +1 -0
  21. package/src/components/logs/useLogStream.ts +1 -2
  22. package/src/components/resources/KueueAdmissionSection.test.tsx +95 -0
  23. package/src/components/resources/KueueAdmissionSection.tsx +87 -0
  24. package/src/components/resources/ResourcesView.default-sort.test.ts +26 -0
  25. package/src/components/resources/ResourcesView.tsx +91 -16
  26. package/src/components/resources/curated-column-ownership.test.ts +45 -0
  27. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +4 -3
  28. package/src/components/resources/renderers/ContainerEnvironmentSection.tsx +10 -3
  29. package/src/components/resources/renderers/JobSetRenderer.test.tsx +280 -0
  30. package/src/components/resources/renderers/JobSetRenderer.tsx +322 -0
  31. package/src/components/resources/renderers/KueueProvisioningRenderers.test.tsx +67 -0
  32. package/src/components/resources/renderers/KueueProvisioningRenderers.tsx +91 -0
  33. package/src/components/resources/renderers/KueueQueueNavigation.test.tsx +69 -0
  34. package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +123 -0
  35. package/src/components/resources/renderers/KueueQueueRenderers.tsx +299 -0
  36. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +121 -0
  37. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +230 -0
  38. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +25 -0
  39. package/src/components/resources/renderers/RayClusterRenderer.tsx +93 -0
  40. package/src/components/resources/renderers/RayServiceRenderer.test.tsx +62 -0
  41. package/src/components/resources/renderers/RayServiceRenderer.tsx +114 -0
  42. package/src/components/resources/renderers/index.ts +11 -0
  43. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +103 -2
  44. package/src/components/resources/resource-utils-jobset-lws.ts +43 -9
  45. package/src/components/resources/resource-utils-kueue.ts +145 -50
  46. package/src/components/resources/resource-utils-ray.test.ts +58 -1
  47. package/src/components/resources/resource-utils-ray.ts +56 -90
  48. package/src/components/shared/ResourceRendererDispatch.test.tsx +117 -2
  49. package/src/components/shared/ResourceRendererDispatch.tsx +47 -10
  50. package/src/components/timeline/TimelineList.tsx +5 -0
  51. package/src/components/timeline/TimelineSwimlanes.test.tsx +15 -0
  52. package/src/components/timeline/TimelineSwimlanes.tsx +43 -23
  53. package/src/components/topology/K8sResourceNode.tsx +3 -3
  54. package/src/components/ui/CodeViewer.tsx +4 -0
  55. package/src/components/ui/Toast.tsx +9 -3
  56. package/src/components/ui/drawer-components.tsx +3 -1
  57. package/src/components/workload/PodList.tsx +154 -0
  58. package/src/components/workload/WorkloadView.tsx +29 -159
  59. package/src/components/workload/index.ts +11 -1
  60. package/src/components/workload/workload-logs-availability.test.ts +20 -0
  61. package/src/components/workload/workload-pod-presentation.test.ts +27 -0
  62. package/src/types/core.ts +24 -0
  63. package/src/types/gitops-tree.ts +5 -2
  64. package/src/types/scheduling.ts +71 -0
  65. package/src/utils/api-resources.test.ts +20 -1
  66. package/src/utils/api-resources.ts +26 -0
  67. package/src/utils/application-topology.test.ts +25 -0
  68. package/src/utils/application-topology.ts +2 -1
  69. package/src/utils/applications.test.ts +82 -0
  70. package/src/utils/applications.ts +62 -24
  71. package/src/utils/gitops-route.test.ts +20 -1
  72. package/src/utils/gitops-route.ts +15 -7
  73. package/src/utils/log-format.ts +5 -2
  74. package/src/utils/log-stream-error.test.ts +13 -0
  75. package/src/utils/navigation.test.ts +2 -0
  76. package/src/utils/navigation.ts +1 -1
  77. package/src/utils/resource-hierarchy.test.ts +402 -15
  78. package/src/utils/resource-hierarchy.ts +263 -178
  79. package/src/utils/topology-neighborhood.test.ts +72 -1
  80. package/src/utils/topology-neighborhood.ts +40 -18
@@ -5,6 +5,7 @@ import {
5
5
  formatGitOpsSourceUrl,
6
6
  getGitOpsResourceStatus,
7
7
  getGitOpsTool,
8
+ isDestinationRef,
8
9
  parseArgoRollbackID,
9
10
  } from './detail-helpers'
10
11
 
@@ -149,3 +150,34 @@ describe('getGitOpsResourceStatus - ApplicationSet', () => {
149
150
  expect(status?.health).toBe('Healthy')
150
151
  })
151
152
  })
153
+
154
+ describe('isDestinationRef', () => {
155
+ const node = (ref: { group?: string; kind: string; namespace: string; name: string }, remote?: boolean) =>
156
+ ({ id: `${ref.kind}/${ref.name}`, ref, role: 'declared' as const, tool: 'fluxcd' as const, remote })
157
+ const root = node({ group: 'kustomize.toolkit.fluxcd.io', kind: 'Kustomization', namespace: 'flux-system', name: 'fleet-prod' })
158
+ const tree = {
159
+ root,
160
+ nodes: [
161
+ root,
162
+ node({ group: 'apps', kind: 'Deployment', namespace: 'prod', name: 'billing' }, true),
163
+ node({ group: 'source.toolkit.fluxcd.io', kind: 'GitRepository', namespace: 'flux-system', name: 'fleet' }),
164
+ ],
165
+ edges: [],
166
+ remoteDestination: true,
167
+ }
168
+
169
+ test('a local tree has no destination refs', () => {
170
+ expect(isDestinationRef(tree, false, { group: 'apps', kind: 'Deployment', namespace: 'prod', name: 'billing' })).toBe(false)
171
+ })
172
+
173
+ test('declared resources are remote; the root and sources are local', () => {
174
+ expect(isDestinationRef(tree, true, { group: 'apps', kind: 'Deployment', namespace: 'prod', name: 'billing' })).toBe(true)
175
+ expect(isDestinationRef(tree, true, root.ref)).toBe(false)
176
+ expect(isDestinationRef(tree, true, { group: 'source.toolkit.fluxcd.io', kind: 'GitRepository', namespace: 'flux-system', name: 'fleet' })).toBe(false)
177
+ })
178
+
179
+ test('a ref the tree does not list is remote, even before the tree loads', () => {
180
+ expect(isDestinationRef(tree, true, { kind: 'Deployment', name: 'billing' })).toBe(true)
181
+ expect(isDestinationRef(null, true, { group: 'apps', kind: 'Deployment', namespace: 'prod', name: 'billing' })).toBe(true)
182
+ })
183
+ })
@@ -1,4 +1,5 @@
1
1
  import { argoApplicationSetConditionsToGitOpsStatus, argoStatusToGitOpsStatus, fluxConditionsToGitOpsStatus, type FluxCondition, type GitOpsStatus } from '../../types/gitops'
2
+ import type { GitOpsResourceTree } from '../../types/gitops-tree'
2
3
  import { formatCompactAge } from '../../utils/format'
3
4
 
4
5
  // =============================================================================
@@ -123,3 +124,23 @@ export function getGitOpsTool(kind: string, group?: string): 'argo' | 'flux' {
123
124
  if (group === 'argoproj.io' || kind === 'applications' || kind === 'applicationsets' || kind === 'appprojects') return 'argo'
124
125
  return 'flux'
125
126
  }
127
+
128
+ // isDestinationRef reports whether a resource named on a remote GitOps
129
+ // object's page lives on its destination cluster, where this cluster's API
130
+ // would answer for a different, same-named object. Only nodes the tree marks
131
+ // local (the root, Flux sources and dependencies) are local. A ref the tree
132
+ // doesn't list, such as an operation error naming just kind and name, is
133
+ // treated as remote, since it came from the destination's reconcile.
134
+ export function isDestinationRef(
135
+ tree: GitOpsResourceTree | null | undefined,
136
+ remoteDestination: boolean | undefined,
137
+ ref: { group?: string; kind: string; namespace?: string; name: string },
138
+ ): boolean {
139
+ if (!remoteDestination) return false
140
+ const node = tree?.nodes.find((n) =>
141
+ n.ref.kind === ref.kind &&
142
+ n.ref.name === ref.name &&
143
+ (n.ref.namespace || '') === (ref.namespace || '') &&
144
+ (n.ref.group || '') === (ref.group || ''))
145
+ return node ? !!node.remote : true
146
+ }
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from 'vitest'
2
2
 
3
3
  import { RADAR_HEALTH_NOTE, radarHealthNote } from './health-provenance'
4
- import { APP_TREE_API_ERROR_NOTICE, APP_TREE_HEALTH_NOTICE, APP_TREE_NO_FINDINGS_NOTICE, APP_TREE_PERSIST_REMEDY, REMOTE_DESTINATION_NOTICE, hasRadarFinding, healthSourceNoticeKind } from './GitOpsHealthSourceNotice'
4
+ import { APP_TREE_API_ERROR_NOTICE, APP_TREE_HEALTH_NOTICE, APP_TREE_NO_FINDINGS_NOTICE, APP_TREE_PERSIST_REMEDY, REMOTE_DESTINATION_NOTICE, REMOTE_FLUX_TARGET_NOTICE, hasRadarFinding, healthSourceNoticeKind } from './GitOpsHealthSourceNotice'
5
5
 
6
6
  describe('radarHealthNote', () => {
7
7
  test('marks only Radar-sourced problems', () => {
@@ -16,7 +16,7 @@ describe('radarHealthNote', () => {
16
16
  expect(radarHealthNote({ health: 'Degraded', healthSource: 'something-newer' })).toBe('')
17
17
  })
18
18
  test('copy stays in plain words', () => {
19
- for (const copy of [RADAR_HEALTH_NOTE, APP_TREE_HEALTH_NOTICE, REMOTE_DESTINATION_NOTICE]) {
19
+ for (const copy of [RADAR_HEALTH_NOTE, APP_TREE_HEALTH_NOTICE, REMOTE_DESTINATION_NOTICE, REMOTE_FLUX_TARGET_NOTICE]) {
20
20
  expect(copy).not.toMatch(/appTree|resourceHealthSource|persist|Tier|overlay/i)
21
21
  }
22
22
  // The remedy deliberately names the one Argo knob the user can turn;
@@ -40,7 +40,7 @@ describe('radarHealthNote', () => {
40
40
  })
41
41
 
42
42
  describe('healthSourceNoticeKind', () => {
43
- test('remote destination wins over appTree, Flux never notices', () => {
43
+ test('remote destination wins over appTree; Flux notices only a remote target', () => {
44
44
  expect(healthSourceNoticeKind({ tool: 'argocd', resourceHealthMode: 'appTree', remoteDestination: true })).toBe('remote')
45
45
  expect(healthSourceNoticeKind({ tool: 'argocd', resourceHealthMode: 'appTree', health: 'Degraded' })).toBe('appTree')
46
46
  expect(healthSourceNoticeKind({ tool: 'argocd', resourceHealthMode: 'appTree' })).toBe('appTree')
@@ -49,6 +49,7 @@ describe('healthSourceNoticeKind', () => {
49
49
  expect(healthSourceNoticeKind({ tool: 'argocd', resourceHealthMode: 'inline' })).toBeNull()
50
50
  expect(healthSourceNoticeKind({ tool: 'argocd' })).toBeNull()
51
51
  expect(healthSourceNoticeKind({ tool: 'fluxcd', resourceHealthMode: 'appTree' })).toBeNull()
52
+ expect(healthSourceNoticeKind({ tool: 'fluxcd', remoteDestination: true })).toBe('remote')
52
53
  expect(healthSourceNoticeKind(undefined)).toBeNull()
53
54
  })
54
55
  })
@@ -41,6 +41,7 @@ export {
41
41
  describeGitOpsTerminating,
42
42
  getGitOpsResourceStatus,
43
43
  getGitOpsTool,
44
+ isDestinationRef,
44
45
  } from './detail-helpers'
45
46
  export { SyncOptionsDialog } from './SyncOptionsDialog'
46
47
  export type { SyncOptionsDialogProps, ArgoSyncOpts } from './SyncOptionsDialog'
@@ -59,5 +60,5 @@ export type {
59
60
  FluxActionHandlers,
60
61
  GitOpsHelmValuesData,
61
62
  } from './GitOpsDetailLayout'
62
- export { GitOpsHealthSourceNotice, healthSourceNoticeKind, hasRadarFinding, APP_TREE_HEALTH_NOTICE, APP_TREE_NO_FINDINGS_NOTICE, REMOTE_DESTINATION_NOTICE } from './GitOpsHealthSourceNotice'
63
+ export { GitOpsHealthSourceNotice, healthSourceNoticeKind, hasRadarFinding, APP_TREE_HEALTH_NOTICE, APP_TREE_NO_FINDINGS_NOTICE, REMOTE_DESTINATION_NOTICE, REMOTE_FLUX_TARGET_NOTICE } from './GitOpsHealthSourceNotice'
63
64
  export { radarHealthNote, RADAR_HEALTH_NOTE } from './health-provenance'
@@ -1,9 +1,13 @@
1
- import { useEffect, useState } from 'react'
2
- import { createPortal } from 'react-dom'
1
+ import { useState } from 'react'
3
2
  import { createTwoFilesPatch } from 'diff'
4
- import { GitCompare, Maximize2, ShieldOff, X } from 'lucide-react'
3
+ import { clsx } from 'clsx'
4
+ import { FileText, GitCompare, Maximize2, Rows, ShieldOff, X } from 'lucide-react'
5
5
  import type { GitOpsResourceDiff } from '../../../types'
6
6
  import { DiffLine, hasDiffBodyChange } from '../../shared/UnifiedDiff'
7
+ import { CodeViewer } from '../../ui/CodeViewer'
8
+ import { DialogPortal } from '../../ui/DialogPortal'
9
+ import { YamlDiffEditor } from '../../ui/YamlEditor'
10
+ import { Tooltip } from '../../ui/Tooltip'
7
11
 
8
12
  interface ArgoResourceDiffProps {
9
13
  diff?: GitOpsResourceDiff | null
@@ -14,9 +18,9 @@ interface ArgoResourceDiffProps {
14
18
 
15
19
  // ArgoResourceDiff renders the full Git-rendered desired-vs-live diff for a
16
20
  // single Argo CD managed resource. Pure presentation: the host (web/) wires the
17
- // fetch and passes data / loading / error. The inline view is a unified line
18
- // diff; the maximize control opens the same content full-screen with side-by-
19
- // side panes on wide viewports.
21
+ // fetch and passes data / loading / error. The inline view is a compact unified
22
+ // preview; the maximize control opens the full view (Diff / Live manifest /
23
+ // Desired manifest, with the Monaco-based YamlDiffEditor powering the Diff tab).
20
24
  export function ArgoResourceDiff({ diff, loading, error }: ArgoResourceDiffProps) {
21
25
  const [maximized, setMaximized] = useState(false)
22
26
 
@@ -76,7 +80,13 @@ export function ArgoResourceDiff({ diff, loading, error }: ArgoResourceDiffProps
76
80
  </div>
77
81
  )}
78
82
 
79
- {maximized && <ArgoResourceDiffOverlay diff={diff} onClose={() => setMaximized(false)} />}
83
+ <DialogPortal
84
+ open={maximized}
85
+ onClose={() => setMaximized(false)}
86
+ className="dialog flex h-[90vh] w-full max-w-6xl flex-col"
87
+ >
88
+ <ArgoResourceDiffContent diff={diff} onClose={() => setMaximized(false)} />
89
+ </DialogPortal>
80
90
  </div>
81
91
  )
82
92
  }
@@ -90,68 +100,135 @@ function RedactedChip() {
90
100
  )
91
101
  }
92
102
 
93
- // Full-screen compare overlay. Side-by-side desired / live panes on wide
94
- // viewports; unified diff when narrow. Portaled to body so it escapes the
95
- // expanded row's overflow clipping.
96
- function ArgoResourceDiffOverlay({ diff, onClose }: { diff: GitOpsResourceDiff; onClose: () => void }) {
97
- useEffect(() => {
98
- const onKey = (e: KeyboardEvent) => {
99
- if (e.key === 'Escape') {
100
- e.stopPropagation()
101
- onClose()
102
- }
103
- }
104
- document.addEventListener('keydown', onKey, true)
105
- return () => document.removeEventListener('keydown', onKey, true)
106
- }, [onClose])
103
+ type ViewMode = 'diff' | 'live' | 'desired'
104
+
105
+ function ArgoResourceDiffContent({ diff, onClose }: { diff: GitOpsResourceDiff; onClose: () => void }) {
106
+ const [viewMode, setViewMode] = useState<ViewMode>('diff')
107
+ const [unified, setUnified] = useState(false)
108
+ const [hideUnchanged, setHideUnchanged] = useState(true)
107
109
 
108
110
  const unchanged = !docsDiffer(diff.desired, diff.live)
109
111
 
110
- return createPortal(
111
- <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
112
- <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
113
- <div className="dialog relative flex max-h-[90vh] w-full max-w-6xl flex-col">
114
- <div className="flex items-center justify-between gap-2 border-b border-theme-border px-4 py-3">
115
- <div className="flex min-w-0 items-center gap-2">
116
- <GitCompare className="h-4 w-4 shrink-0 text-theme-text-secondary" />
117
- <span className="truncate text-sm font-medium text-theme-text-primary">
118
- desired (Git-rendered) <span className="mx-0.5 text-theme-text-tertiary">→</span> live (normalized)
119
- </span>
120
- {diff.redacted && <RedactedChip />}
121
- </div>
122
- <button
123
- onClick={onClose}
124
- className="flex shrink-0 items-center gap-1 rounded px-2 py-1 text-xs text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary"
125
- >
126
- <X className="h-3.5 w-3.5" />
127
- Close
128
- </button>
112
+ return (
113
+ <>
114
+ <div className="flex items-center justify-between gap-2 border-b border-theme-border px-4 py-3">
115
+ <div className="flex min-w-0 items-center gap-2">
116
+ <GitCompare className="h-4 w-4 shrink-0 text-theme-text-secondary" />
117
+ <span className="truncate text-sm font-medium text-theme-text-primary">
118
+ desired (Git-rendered) <span className="mx-0.5 text-theme-text-tertiary">→</span> live (normalized)
119
+ </span>
120
+ {diff.redacted && <RedactedChip />}
129
121
  </div>
130
- {unchanged ? (
122
+ <button
123
+ onClick={onClose}
124
+ className="flex shrink-0 items-center gap-1 rounded px-2 py-1 text-xs text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary"
125
+ >
126
+ <X className="h-3.5 w-3.5" />
127
+ Close
128
+ </button>
129
+ </div>
130
+
131
+ <div className="flex flex-wrap items-center justify-between gap-3 border-b border-theme-border px-4 py-2">
132
+ <div className="flex gap-1.5" role="tablist" aria-label="View">
133
+ {([
134
+ ['diff', 'Diff'],
135
+ ['live', 'Live manifest'],
136
+ ['desired', 'Desired manifest'],
137
+ ] as const).map(([mode, label]) => (
138
+ <button
139
+ key={mode}
140
+ type="button"
141
+ role="tab"
142
+ aria-selected={viewMode === mode}
143
+ onClick={() => setViewMode(mode)}
144
+ className={clsx(
145
+ 'rounded-md border px-2.5 py-1 text-xs transition-colors',
146
+ viewMode === mode
147
+ ? 'selection selection-text selection-ring border-transparent'
148
+ : 'border-theme-border text-theme-text-secondary hover:bg-theme-hover',
149
+ )}
150
+ >
151
+ {label}
152
+ </button>
153
+ ))}
154
+ </div>
155
+ {viewMode === 'diff' && !unchanged && (
156
+ <div className="flex items-center gap-1">
157
+ <ToggleButton active={hideUnchanged} onClick={() => setHideUnchanged((v) => !v)} icon={<FileText className="h-3.5 w-3.5" />} label="Diff only" tooltip="Collapse unchanged regions" />
158
+ <ToggleButton active={unified} onClick={() => setUnified((v) => !v)} icon={<Rows className="h-3.5 w-3.5" />} label="Unified" tooltip="Switch between side-by-side and single-column" />
159
+ </div>
160
+ )}
161
+ </div>
162
+
163
+ <div className="min-h-0 flex-1 overflow-auto bg-theme-base/50">
164
+ {viewMode !== 'diff' ? (
165
+ diff[viewMode] ? (
166
+ <CodeViewer key={viewMode} code={diff[viewMode]} language="yaml" showLineNumbers showCopyButton maxHeight="calc(90vh - 180px)" />
167
+ ) : (
168
+ <div className="p-6 text-sm text-theme-text-secondary">
169
+ {viewMode === 'live'
170
+ ? 'This resource is not present in the live cluster state.'
171
+ : 'This resource is not present in the Git-rendered desired state.'}
172
+ </div>
173
+ )
174
+ ) : unchanged ? (
131
175
  <div className="p-6 text-sm text-theme-text-secondary">
132
176
  No differences between the Git-rendered desired state and live cluster state.
133
177
  </div>
134
178
  ) : (
135
- // Unified (not side-by-side) even full-screen: two unhighlighted
136
- // panes make the reader hunt for the drifted line by eye across a
137
- // whole manifest. The unified view marks every changed line — the
138
- // same default Argo's own UI makes. An aligned split-diff with
139
- // per-line highlighting can supersede this later.
140
- <div className="min-h-0 flex-1 overflow-auto bg-theme-base/50">
141
- <UnifiedDiffBody desired={diff.desired} live={diff.live} />
142
- </div>
179
+ <YamlDiffEditor
180
+ original={diff.desired}
181
+ modified={diff.live}
182
+ unified={unified}
183
+ hideUnchanged={hideUnchanged}
184
+ height="100%"
185
+ bleed
186
+ />
143
187
  )}
144
188
  </div>
145
- </div>,
146
- document.body,
189
+ </>
147
190
  )
148
191
  }
149
192
 
193
+ function ToggleButton({
194
+ active,
195
+ onClick,
196
+ icon,
197
+ label,
198
+ tooltip,
199
+ }: {
200
+ active: boolean
201
+ onClick: () => void
202
+ icon: React.ReactNode
203
+ label: string
204
+ tooltip: string
205
+ }) {
206
+ return (
207
+ <Tooltip content={tooltip}>
208
+ <button
209
+ type="button"
210
+ onClick={onClick}
211
+ aria-pressed={active}
212
+ className={clsx(
213
+ 'flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-medium transition-colors whitespace-nowrap',
214
+ active
215
+ ? 'border-skyhook-400/50 bg-skyhook-500/15 text-skyhook-300'
216
+ : 'border-transparent text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary',
217
+ )}
218
+ >
219
+ {icon}
220
+ {label}
221
+ </button>
222
+ </Tooltip>
223
+ )
224
+ }
150
225
 
151
226
  // Renders a unified diff of two YAML documents using the `diff` package, via the
152
- // shared DiffLine so this reads identically to the Helm manifest diff.
153
- function UnifiedDiffBody({ desired, live }: { desired: string; live: string }) {
154
- const patch = createTwoFilesPatch('desired', 'live', desired, live, '', '', { context: 3 })
227
+ // shared DiffLine so this reads identically to the Helm manifest diff — used
228
+ // only for the compact inline row preview above; the full-view overlay's Diff
229
+ // tab renders through YamlDiffEditor instead.
230
+ function UnifiedDiffBody({ desired, live, context = 3 }: { desired: string; live: string; context?: number }) {
231
+ const patch = createTwoFilesPatch('desired', 'live', desired, live, '', '', { context })
155
232
  const lines = patch.split('\n').filter((line) => !line.startsWith('===') && !line.startsWith('Index:'))
156
233
  return (
157
234
  <div className="p-3 font-mono text-[11px]">
@@ -221,6 +221,7 @@ export function IssueRow({
221
221
  // query toggles: inline at the row's right edge on a wide container, and on a
222
222
  // line of its own below the resource line once the row is too narrow to hold
223
223
  // it beside the title — so the title never has to truncate to make room.
224
+ const nodeCorroboration = issue.diagnostic_context?.facts?.find(fact => fact.type === 'node_startup_corroboration');
224
225
  const metaChips = (wrapperClass: string) => (
225
226
  <div className={`items-center gap-3 ${wrapperClass}`}>
226
227
  <span className={`badge-sm shrink-0 px-2.5 py-0.5 text-xs font-semibold ${open ? ISSUE_SEVERITY_SOLID_CLASS[severity] : ISSUE_SEVERITY_BADGE_CLASS[severity]}`}>
@@ -237,6 +238,11 @@ export function IssueRow({
237
238
  </time>
238
239
  </Tooltip>
239
240
  ) : null}
241
+ {nodeCorroboration ? (
242
+ <Tooltip content={nodeCorroboration.message} delay={200}>
243
+ <span className="badge-sm inline-flex items-center gap-1 border border-theme-border text-[10px] text-theme-text-secondary"><Workflow className="h-3 w-3 shrink-0" aria-hidden />Same-node evidence</span>
244
+ </Tooltip>
245
+ ) : null}
240
246
  {timing ? (
241
247
  <Tooltip content={timing.tooltip} delay={200}>
242
248
  <span className="badge-sm text-[10px] text-theme-text-secondary">{timing.chip}</span>
@@ -70,3 +70,39 @@ describe('sameResource', () => {
70
70
  expect(sameResource(subject, { kind: 'configmaps', namespace: 'demo', name: 'data' })).toBe(false)
71
71
  })
72
72
  })
73
+
74
+ it('makes typed node corroboration discoverable without replacing the failure or adding causal labels', () => {
75
+ const correlated: Issue = {
76
+ ...issue, kind: 'Pod', name: 'worker', reason: 'IPExhaustion', cause: undefined,
77
+ message: 'specific kubelet failure',
78
+ diagnostic_context: { role: 'context', facts: [{ type: 'node_startup_corroboration', message: 'Three visible Pods across two workloads on node-a; including Pod demo/worker.', refs: [{ kind: 'Pod', namespace: 'demo', name: 'peer' }] }] },
79
+ }
80
+ const collapsed = renderToString(<ResourceIssuesSection issues={[correlated]} />)
81
+ expect(collapsed).toContain('Same-node evidence')
82
+ expect(collapsed).toContain('specific kubelet failure')
83
+ const expanded = renderToString(<IssueRow issue={correlated} open onToggle={() => {}} resourceHref={ref => `/resources/${ref.kind}/${ref.namespace}/${ref.name}`} />)
84
+ expect(expanded).toContain('Three visible Pods across two workloads')
85
+ expect(expanded).toContain('/resources/Pod/demo/peer')
86
+ expect(expanded).not.toContain('confidence')
87
+ expect(expanded).not.toContain('Caused by')
88
+ expect(renderToString(<IssueRow issue={{ ...correlated, diagnostic_context: undefined }} open onToggle={() => {}} />)).not.toContain('Same-node evidence')
89
+ })
90
+
91
+ it('keeps pod/template evidence in expanded neutral context with navigable witnesses', () => {
92
+ const contextual: Issue = {
93
+ ...issue, kind: 'Pod', name: 'worker', reason: 'OOMKilled', cause: 'Container was OOMKilled.',
94
+ diagnostic_context: { role: 'context', facts: [{
95
+ type: 'pod_template_divergence', message: 'Memory limit is 64Mi; template specifies 128Mi. Writer and timing are unknown.',
96
+ refs: [{ kind: 'Pod', namespace: 'demo', name: 'worker' }, { kind: 'ReplicaSet', group: 'apps', namespace: 'demo', name: 'owner' }],
97
+ }] },
98
+ }
99
+ const collapsed = renderToString(<ResourceIssuesSection issues={[contextual]} />)
100
+ expect(collapsed).toContain('OOMKilled')
101
+ expect(collapsed).not.toContain('Pod/template differences')
102
+ const expanded = renderToString(<IssueRow issue={contextual} open onToggle={() => {}} resourceHref={ref => `/resources/${ref.kind}/${ref.namespace}/${ref.name}`} />)
103
+ expect(expanded).toContain('Pod/template differences')
104
+ expect(expanded).toContain('Writer and timing are unknown')
105
+ expect(expanded).toContain('/resources/ReplicaSet/demo/owner')
106
+ expect(expanded).not.toContain('confidence')
107
+ expect(expanded).not.toContain('Caused by')
108
+ })
@@ -24,6 +24,8 @@ export function diagnosticFactLabel(type: string): string {
24
24
  switch (type) {
25
25
  case 'explicit_reference':
26
26
  return 'Missing reference';
27
+ case 'node_startup_corroboration':
28
+ return 'Same-node failures';
27
29
  case 'owner_rollup':
28
30
  return 'Grouped from pods';
29
31
  case 'selected_backend_issue':
@@ -38,6 +40,8 @@ export function diagnosticFactLabel(type: string): string {
38
40
  return 'Init container';
39
41
  case 'restart_cause':
40
42
  return 'Restart evidence';
43
+ case 'pod_template_divergence':
44
+ return 'Pod/template differences';
41
45
  case 'node_blast_radius':
42
46
  return 'Affected workloads';
43
47
  case 'pvc_blast_radius':
@@ -1183,10 +1183,10 @@ function LogLine({
1183
1183
  )}
1184
1184
  {showPodName && entry.pod && (
1185
1185
  <span
1186
- className={`${podTextColor} select-none pr-2 whitespace-nowrap min-w-[80px] max-w-[120px] truncate`}
1187
- title={entry.pod}
1186
+ className={`${podTextColor} select-none pr-2 whitespace-nowrap min-w-[80px] max-w-[220px] truncate`}
1187
+ title={entry.sourceLabel || entry.pod}
1188
1188
  >
1189
- [{entry.pod.split('-').slice(-2).join('-')}]
1189
+ [{entry.sourceLabel || entry.pod.split('-').slice(-2).join('-')}]
1190
1190
  </span>
1191
1191
  )}
1192
1192
  <span className="flex-1 min-w-0">{contentElement}</span>
@@ -13,18 +13,23 @@ import { useToast } from '../ui/Toast'
13
13
 
14
14
  export interface WorkloadRawLog {
15
15
  pod: string
16
+ sourceLabel?: string
16
17
  container: string
17
18
  timestamp: string
18
19
  content: string
19
20
  }
20
21
 
21
22
  export interface WorkloadLogsFetchParams {
23
+ signal?: AbortSignal
22
24
  container?: string
23
25
  tailLines?: number
24
26
  sinceSeconds?: number
25
27
  }
26
28
 
27
29
  export interface WorkloadLogsResult {
30
+ capturedAt?: string
31
+ notice?: string
32
+ sourceLabels?: Record<string, string>
28
33
  pods: WorkloadPodInfo[]
29
34
  logs: WorkloadRawLog[]
30
35
  emptyReason?: string
@@ -63,6 +68,10 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
63
68
  const [pods, setPods] = useState<WorkloadPodInfo[]>([])
64
69
  const [selectedPods, setSelectedPods] = useState<Set<string>>(new Set())
65
70
  const [isLoading, setIsLoading] = useState(false)
71
+ const [notice, setNotice] = useState('')
72
+ const [capturedAt, setCapturedAt] = useState('')
73
+ const [sourceLabels, setSourceLabels] = useState<Record<string, string>>({})
74
+ const fetchController = useRef<AbortController | null>(null)
66
75
  const [fetchError, setFetchError] = useState<string | null>(null)
67
76
  const [emptyMessage, setEmptyMessage] = useState<string | null>(null)
68
77
  const [emptyCommand, setEmptyCommand] = useState<string | null>(null)
@@ -93,13 +102,20 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
93
102
  podColorIndexRef.current = podColorIndex
94
103
  }, [podColorIndex])
95
104
 
96
- const podsInitialized = useRef(false)
105
+ const previousSnapshotPods = useRef<string[] | null>(null)
97
106
 
98
107
  const loadLogs = useCallback(async () => {
108
+ fetchController.current?.abort()
109
+ const controller = new AbortController()
110
+ fetchController.current = controller
99
111
  setIsLoading(true)
100
112
  setFetchError(null)
101
113
  try {
102
- const result = await fetchAll({ container: selectedContainer || undefined, tailLines, sinceSeconds })
114
+ const result = await fetchAll({ container: selectedContainer || undefined, tailLines, sinceSeconds, signal: controller.signal })
115
+ if (controller.signal.aborted) return
116
+ setNotice(result.notice || '')
117
+ setCapturedAt(result.capturedAt || '')
118
+ setSourceLabels(result.sourceLabels ?? {})
103
119
  const resultPods = result.pods ?? []
104
120
  const resultLogs = result.logs ?? []
105
121
  setEmptyMessage(result.emptyMessage || null)
@@ -108,10 +124,12 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
108
124
  podColorIndexRef.current = new Map(resultPods.map((pod, i) => [pod.name, i]))
109
125
  setPods(resultPods)
110
126
 
111
- if (!podsInitialized.current && resultPods.length > 0) {
112
- podsInitialized.current = true
113
- setSelectedPods(new Set(resultPods.map(p => p.name)))
114
- }
127
+ const previousPods = previousSnapshotPods.current
128
+ const nextPods = resultPods.map(p => p.name)
129
+ previousSnapshotPods.current = nextPods
130
+ setSelectedPods(selected => previousPods === null || previousPods.every(pod => selected.has(pod))
131
+ ? new Set(nextPods)
132
+ : new Set(nextPods.filter(pod => selected.has(pod))))
115
133
 
116
134
  const indexByPod = new Map<string, number>()
117
135
  resultPods.forEach((pod, i) => indexByPod.set(pod.name, i))
@@ -121,13 +139,15 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
121
139
  content: log.content,
122
140
  container: log.container,
123
141
  pod: log.pod,
142
+ sourceLabel: log.sourceLabel,
124
143
  podColorIndex: indexByPod.get(log.pod),
125
144
  })))
126
145
  } catch (err) {
146
+ if (controller.signal.aborted) return
127
147
  console.error('Failed to fetch workload logs:', err)
128
148
  setFetchError(err instanceof Error ? err.message : 'Failed to fetch logs')
129
149
  } finally {
130
- setIsLoading(false)
150
+ if (!controller.signal.aborted) setIsLoading(false)
131
151
  }
132
152
  }, [fetchAll, selectedContainer, tailLines, sinceSeconds, set])
133
153
 
@@ -140,6 +160,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
140
160
  if (!willAutoStream || userStoppedRef.current) loadLogs()
141
161
  }, [loadLogs, willAutoStream])
142
162
  useEffect(() => { stopStreaming() }, [selectedContainer, stopStreaming])
163
+ useEffect(() => () => { fetchController.current?.abort() }, [])
143
164
 
144
165
  // If auto-stream turns off while a stream is open, stop following so live
145
166
  // appends don't race the snapshot.
@@ -154,6 +175,11 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
154
175
  // The stream replays the last N lines per pod (TailLines + Follow); clear
155
176
  // first so they don't duplicate lines already in the buffer (the snapshot on
156
177
  // the manual path, or an earlier stream on restart).
178
+ fetchController.current?.abort()
179
+ setIsLoading(false)
180
+ setNotice('')
181
+ setCapturedAt('')
182
+ setFetchError(null)
157
183
  clear()
158
184
  startStreaming(
159
185
  () => createStream({ container: selectedContainer || undefined, tailLines: 50, sinceSeconds }),
@@ -296,7 +322,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
296
322
  </div>
297
323
  {pods.map(pod => {
298
324
  const dotBg = palette.podColors[(podColorIndex.get(pod.name) ?? 0) % palette.podColors.length].bg
299
- const primaryLabel = pod.stepName || pod.name
325
+ const primaryLabel = sourceLabels[pod.name] || pod.stepName || pod.name
300
326
  const secondaryLabel = pod.stepName ? pod.name : ''
301
327
  const stateLabel = pod.stepPhase || pod.phase || (pod.ready ? 'Ready' : 'Not Ready')
302
328
  let readyColor: string
@@ -354,7 +380,11 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
354
380
  const isConnecting = willAutoStream && connecting && entries.length === 0
355
381
 
356
382
  return (
357
- <LogCore
383
+ <div className="flex h-full min-h-0 flex-col">
384
+ {notice && <div role="status" className="shrink-0 border-b border-theme-border bg-theme-elevated px-3 py-2 text-xs text-theme-text-secondary">{notice}</div>}
385
+ {capturedAt && <div className="shrink-0 border-b border-theme-border px-3 py-1 text-xs text-theme-text-secondary">Snapshot captured {new Date(capturedAt).toLocaleTimeString()}</div>}
386
+ {(fetchError || streamError) && entries.length > 0 && <div role="alert" className="shrink-0 border-b border-theme-border bg-theme-surface px-3 py-2 text-xs text-theme-text-secondary">{fetchError || streamError} · Previously loaded logs remain below.</div>}
387
+ <div className="min-h-0 flex-1"><LogCore
358
388
  entries={filteredEntries}
359
389
  allEntries={entries}
360
390
  isLoading={isLoading || isConnecting}
@@ -368,8 +398,8 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
368
398
  showPodName
369
399
  emptyMessage={emptyMessage || (pods.length === 0 ? 'No pods found' : 'No logs available')}
370
400
  emptyCommand={emptyCommand}
371
- errorMessage={fetchError || (entries.length === 0 ? streamError : null)}
401
+ errorMessage={entries.length === 0 ? fetchError || streamError : null}
372
402
  forceDark={forceDark}
373
- />
403
+ /></div></div>
374
404
  )
375
405
  }
@@ -4,6 +4,7 @@ import { isLogfmt } from '../../utils/log-format'
4
4
  export type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'unknown'
5
5
 
6
6
  export interface LogEntry {
7
+ sourceLabel?: string
7
8
  id: number
8
9
  timestamp: string
9
10
  content: string
@@ -112,8 +112,7 @@ export function useLogStream() {
112
112
  // The browser fires 'error' on the normal close that follows a clean
113
113
  // 'end'; that's not a failure, so don't log it or surface it.
114
114
  if (endedRef.current) return
115
- handleSSEError(event, errorContext, () => {})
116
- setStreamError(errorContext)
115
+ setStreamError(handleSSEError(event, errorContext, () => {}))
117
116
  })
118
117
 
119
118
  eventSourceRef.current = es