@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.
- package/package.json +3 -2
- package/src/components/applications/ApplicationDetail.tsx +24 -11
- package/src/components/audit/AuditAlerts.tsx +1 -3
- package/src/components/audit/AuditCoveragePresentation.test.tsx +48 -2
- package/src/components/checks/ChecksView.test.tsx +55 -0
- package/src/components/checks/ChecksView.tsx +38 -27
- package/src/components/dock/NodeTerminalTab.test.tsx +89 -0
- package/src/components/dock/NodeTerminalTab.tsx +51 -47
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +17 -9
- package/src/components/gitops/detail-helpers.test.ts +32 -0
- package/src/components/gitops/detail-helpers.ts +21 -0
- package/src/components/gitops/health-provenance.test.ts +4 -3
- package/src/components/gitops/index.ts +2 -1
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +131 -54
- package/src/components/issues/IssuesView.tsx +6 -0
- package/src/components/issues/ResourceIssuesSection.test.tsx +36 -0
- package/src/components/issues/diagnostic.ts +4 -0
- package/src/components/logs/LogCore.tsx +3 -3
- package/src/components/logs/WorkloadLogsViewer.tsx +41 -11
- package/src/components/logs/useLogBuffer.ts +1 -0
- package/src/components/logs/useLogStream.ts +1 -2
- package/src/components/resources/KueueAdmissionSection.test.tsx +95 -0
- package/src/components/resources/KueueAdmissionSection.tsx +87 -0
- package/src/components/resources/ResourcesView.default-sort.test.ts +26 -0
- package/src/components/resources/ResourcesView.tsx +91 -16
- package/src/components/resources/curated-column-ownership.test.ts +45 -0
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +4 -3
- package/src/components/resources/renderers/ContainerEnvironmentSection.tsx +10 -3
- package/src/components/resources/renderers/JobSetRenderer.test.tsx +280 -0
- package/src/components/resources/renderers/JobSetRenderer.tsx +322 -0
- package/src/components/resources/renderers/KueueProvisioningRenderers.test.tsx +67 -0
- package/src/components/resources/renderers/KueueProvisioningRenderers.tsx +91 -0
- package/src/components/resources/renderers/KueueQueueNavigation.test.tsx +69 -0
- package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +123 -0
- package/src/components/resources/renderers/KueueQueueRenderers.tsx +299 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +121 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +230 -0
- package/src/components/resources/renderers/RayClusterRenderer.test.tsx +25 -0
- package/src/components/resources/renderers/RayClusterRenderer.tsx +93 -0
- package/src/components/resources/renderers/RayServiceRenderer.test.tsx +62 -0
- package/src/components/resources/renderers/RayServiceRenderer.tsx +114 -0
- package/src/components/resources/renderers/index.ts +11 -0
- package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +103 -2
- package/src/components/resources/resource-utils-jobset-lws.ts +43 -9
- package/src/components/resources/resource-utils-kueue.ts +145 -50
- package/src/components/resources/resource-utils-ray.test.ts +58 -1
- package/src/components/resources/resource-utils-ray.ts +56 -90
- package/src/components/shared/ResourceRendererDispatch.test.tsx +117 -2
- package/src/components/shared/ResourceRendererDispatch.tsx +47 -10
- package/src/components/timeline/TimelineList.tsx +5 -0
- package/src/components/timeline/TimelineSwimlanes.test.tsx +15 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +43 -23
- package/src/components/topology/K8sResourceNode.tsx +3 -3
- package/src/components/ui/CodeViewer.tsx +4 -0
- package/src/components/ui/Toast.tsx +9 -3
- package/src/components/ui/drawer-components.tsx +3 -1
- package/src/components/workload/PodList.tsx +154 -0
- package/src/components/workload/WorkloadView.tsx +29 -159
- package/src/components/workload/index.ts +11 -1
- package/src/components/workload/workload-logs-availability.test.ts +20 -0
- package/src/components/workload/workload-pod-presentation.test.ts +27 -0
- package/src/types/core.ts +24 -0
- package/src/types/gitops-tree.ts +5 -2
- package/src/types/scheduling.ts +71 -0
- package/src/utils/api-resources.test.ts +20 -1
- package/src/utils/api-resources.ts +26 -0
- package/src/utils/application-topology.test.ts +25 -0
- package/src/utils/application-topology.ts +2 -1
- package/src/utils/applications.test.ts +82 -0
- package/src/utils/applications.ts +62 -24
- package/src/utils/gitops-route.test.ts +20 -1
- package/src/utils/gitops-route.ts +15 -7
- package/src/utils/log-format.ts +5 -2
- package/src/utils/log-stream-error.test.ts +13 -0
- package/src/utils/navigation.test.ts +2 -0
- package/src/utils/navigation.ts +1 -1
- package/src/utils/resource-hierarchy.test.ts +402 -15
- package/src/utils/resource-hierarchy.ts +263 -178
- package/src/utils/topology-neighborhood.test.ts +72 -1
- 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
|
|
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 {
|
|
2
|
-
import { createPortal } from 'react-dom'
|
|
1
|
+
import { useState } from 'react'
|
|
3
2
|
import { createTwoFilesPatch } from 'diff'
|
|
4
|
-
import {
|
|
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
|
|
18
|
-
//
|
|
19
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
111
|
-
|
|
112
|
-
<div className="
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
<
|
|
116
|
-
<
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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-[
|
|
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
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
<
|
|
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={
|
|
401
|
+
errorMessage={entries.length === 0 ? fetchError || streamError : null}
|
|
372
402
|
forceDark={forceDark}
|
|
373
|
-
|
|
403
|
+
/></div></div>
|
|
374
404
|
)
|
|
375
405
|
}
|
|
@@ -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
|