@skyhook-io/k8s-ui 1.7.11 → 1.7.12
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 +1 -1
- package/src/components/dock/TerminalTab.tsx +4 -2
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +1 -0
- package/src/components/issues/IssuesView.tsx +64 -17
- package/src/components/issues/index.ts +1 -1
- package/src/components/issues/issues.test.ts +4 -4
- package/src/components/issues/severity.ts +5 -0
- package/src/components/issues/types.ts +43 -0
- package/src/components/logs/LogCore.tsx +13 -2
- package/src/components/logs/LogToolbarSelects.tsx +6 -2
- package/src/components/logs/LogsViewer.tsx +66 -10
- package/src/components/logs/WorkloadLogsViewer.tsx +60 -8
- package/src/components/logs/useLogStream.ts +41 -3
- package/src/components/resources/ResourcesView.tsx +550 -52
- package/src/components/resources/column-filter-serialization.test.ts +26 -0
- package/src/components/resources/get-default-container-name.test.ts +31 -0
- package/src/components/resources/renderers/DeviceClassRenderer.tsx +49 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +7 -0
- package/src/components/resources/renderers/NvidiaClusterPolicyRenderer.tsx +57 -0
- package/src/components/resources/renderers/NvidiaDriverRenderer.tsx +47 -0
- package/src/components/resources/renderers/PodRenderer.tsx +2 -2
- package/src/components/resources/renderers/ResourceClaimRenderer.tsx +118 -0
- package/src/components/resources/renderers/ResourceClaimTemplateRenderer.tsx +39 -0
- package/src/components/resources/renderers/ResourceSliceRenderer.tsx +72 -0
- package/src/components/resources/renderers/dra-cells.tsx +80 -0
- package/src/components/resources/renderers/index.ts +8 -0
- package/src/components/resources/renderers/nvidia-cells.tsx +43 -0
- package/src/components/resources/resource-utils-dra.ts +90 -0
- package/src/components/resources/resource-utils-nvidia.ts +63 -0
- package/src/components/resources/resource-utils.ts +62 -4
- package/src/components/shared/ResourceActionsBar.tsx +5 -4
- package/src/components/shared/ResourceRendererDispatch.test.tsx +103 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +27 -2
- package/src/components/ui/ConfirmDialog.tsx +1 -1
- package/src/components/ui/drawer-components.tsx +23 -1
- package/src/types/core.ts +1 -0
- package/src/utils/api-resources.ts +21 -0
- package/src/utils/custom-columns.test.ts +111 -0
- package/src/utils/custom-columns.ts +49 -0
- package/src/utils/extended-resources.test.ts +152 -0
- package/src/utils/extended-resources.ts +121 -0
- package/src/utils/index.ts +1 -0
package/package.json
CHANGED
|
@@ -285,8 +285,10 @@ export function TerminalTab({
|
|
|
285
285
|
<>
|
|
286
286
|
<div className="text-amber-400 mb-2 text-sm">Shell not available</div>
|
|
287
287
|
<div className="text-xs text-theme-text-tertiary mb-4 max-w-md">
|
|
288
|
-
|
|
289
|
-
|
|
288
|
+
Container <span className="font-mono text-theme-text-secondary">{selectedContainer}</span> doesn't
|
|
289
|
+
have a shell (/bin/sh). This is common with distroless or minimal container images
|
|
290
|
+
{containers.length > 1 ? ' — try another container above, or' : '. You can'} create a
|
|
291
|
+
debug container to troubleshoot.
|
|
290
292
|
</div>
|
|
291
293
|
<div className="flex gap-2">
|
|
292
294
|
{createDebugContainerRef.current && (
|
|
@@ -626,6 +626,7 @@ function GitOpsCompactIssueStack({ issues, onSelectIssue }: { issues: GitOpsIssu
|
|
|
626
626
|
<span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">{issue.scope}</span>
|
|
627
627
|
</div>
|
|
628
628
|
<p className="mt-0.5 text-theme-text-secondary">{issue.message}</p>
|
|
629
|
+
{issue.cause && <p className="mt-0.5 text-[11px] text-theme-text-tertiary">{issue.cause}</p>}
|
|
629
630
|
{issue.action && <p className="mt-0.5 text-[11px] text-theme-text-tertiary">{issue.action}</p>}
|
|
630
631
|
</div>
|
|
631
632
|
{actionable && ref && (
|
|
@@ -49,7 +49,7 @@ export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clu
|
|
|
49
49
|
// queue stays scannable and you never lose your place to a wall of expansions.
|
|
50
50
|
const [openId, setOpenId] = useState<string | null>(null);
|
|
51
51
|
|
|
52
|
-
// Stable order keyed on severity →
|
|
52
|
+
// Stable order keyed on severity → observed age → identity (see compareIssues), so
|
|
53
53
|
// the queue doesn't reshuffle under the host's auto-refresh.
|
|
54
54
|
const sorted = useMemo(() => [...issues].sort(compareIssues), [issues]);
|
|
55
55
|
|
|
@@ -183,9 +183,10 @@ function IssueRow({
|
|
|
183
183
|
</div>
|
|
184
184
|
</div>
|
|
185
185
|
|
|
186
|
-
{/*
|
|
187
|
-
|
|
188
|
-
|
|
186
|
+
{/* Age chip: chronic-vs-acute signal. When issue_timing is
|
|
187
|
+
started_at_resource_creation, swap the raw age for "since deploy" —
|
|
188
|
+
the raw age is misleading (it reads as urgency, but this issue has
|
|
189
|
+
been present since the resource was first created). */}
|
|
189
190
|
{issue.first_seen ? (
|
|
190
191
|
<time
|
|
191
192
|
dateTime={issue.first_seen}
|
|
@@ -193,7 +194,9 @@ function IssueRow({
|
|
|
193
194
|
className="flex shrink-0 items-center gap-1 text-xs tabular-nums text-theme-text-tertiary"
|
|
194
195
|
>
|
|
195
196
|
<Clock className="h-3 w-3" aria-hidden />
|
|
196
|
-
{
|
|
197
|
+
{issue.issue_timing === 'started_at_resource_creation'
|
|
198
|
+
? 'since deploy'
|
|
199
|
+
: formatCompactAge(issue.first_seen)}
|
|
197
200
|
</time>
|
|
198
201
|
) : null}
|
|
199
202
|
|
|
@@ -238,17 +241,50 @@ function Diagnosis({ issue }: { issue: Issue }) {
|
|
|
238
241
|
.join(' · ')
|
|
239
242
|
: null;
|
|
240
243
|
const { headline, detail } = issueMessageParts(issue);
|
|
244
|
+
// When the issue carries a parsed plain-English cause (GitOps controller
|
|
245
|
+
// errors), lead with it — the raw controller message is long and buries the
|
|
246
|
+
// useful part, and the detector reason ("OperationFailed") just repeats the
|
|
247
|
+
// category chip. The raw message is kept below as de-emphasized detail.
|
|
248
|
+
const rawMessage = [headline, detail].filter(Boolean).join(' ');
|
|
241
249
|
return (
|
|
242
250
|
<section className="flex flex-col gap-1">
|
|
243
251
|
<h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">What's wrong</h4>
|
|
244
|
-
|
|
245
|
-
<
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
+
{issue.cause ? (
|
|
253
|
+
<p className="text-sm leading-relaxed text-theme-text-primary">{issue.cause}</p>
|
|
254
|
+
) : (
|
|
255
|
+
<p className="text-sm leading-relaxed text-theme-text-primary">
|
|
256
|
+
<span className="font-medium">{issue.reason}</span>
|
|
257
|
+
{headline ? <span className="text-theme-text-secondary"> — {headline}</span> : null}
|
|
258
|
+
</p>
|
|
259
|
+
)}
|
|
260
|
+
{/* For non-cause issues whose message was normalized to a short headline
|
|
261
|
+
(e.g. image-pull), keep the precise raw kubelet/containerd detail. The
|
|
262
|
+
cause branch shows its own raw block below. */}
|
|
263
|
+
{!issue.cause && detail ? (
|
|
264
|
+
<p className="break-words font-mono text-xs leading-relaxed text-theme-text-tertiary">{detail}</p>
|
|
265
|
+
) : null}
|
|
266
|
+
{issue.action ? (
|
|
267
|
+
<p className="text-sm leading-relaxed text-theme-text-secondary">
|
|
268
|
+
<span className="font-medium text-theme-text-primary">Next step: </span>
|
|
269
|
+
{issue.action}
|
|
270
|
+
</p>
|
|
271
|
+
) : null}
|
|
272
|
+
{issue.remediation_kind === 'create-namespace' && issue.remediation_target ? (
|
|
273
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
274
|
+
Suggested fix: create namespace <code className="rounded bg-theme-elevated px-1 font-mono">{issue.remediation_target}</code> — apply it from the GitOps detail page.
|
|
275
|
+
</p>
|
|
276
|
+
) : null}
|
|
277
|
+
{issue.stuck || issue.operation_retry_count ? (
|
|
278
|
+
<p className="text-xs text-theme-text-tertiary tabular-nums">
|
|
279
|
+
{issue.stuck ? 'Stuck' : 'Retrying'}
|
|
280
|
+
{issue.operation_retry_count ? ` · retried ${issue.operation_retry_count}×` : ''}
|
|
281
|
+
</p>
|
|
282
|
+
) : null}
|
|
283
|
+
{/* Raw controller message, de-emphasized — shown below the parsed cause so
|
|
284
|
+
the precise error (URLs, resource names) is available without leading. */}
|
|
285
|
+
{issue.cause && rawMessage ? (
|
|
286
|
+
<p className="break-words font-mono text-[11px] leading-relaxed text-theme-text-tertiary">{rawMessage}</p>
|
|
287
|
+
) : null}
|
|
252
288
|
{crash ? <p className="text-xs text-theme-text-tertiary tabular-nums">{crash}</p> : null}
|
|
253
289
|
{issue.change_context ? (
|
|
254
290
|
<p className="text-xs text-theme-text-tertiary">
|
|
@@ -257,8 +293,14 @@ function Diagnosis({ issue }: { issue: Issue }) {
|
|
|
257
293
|
) : null}
|
|
258
294
|
{issue.first_seen ? (
|
|
259
295
|
<p className="text-xs text-theme-text-tertiary tabular-nums">
|
|
260
|
-
|
|
261
|
-
|
|
296
|
+
{issue.issue_timing === 'started_at_resource_creation'
|
|
297
|
+
? 'Present since deployment'
|
|
298
|
+
: issue.issue_timing === 'started_after_resource_was_healthy'
|
|
299
|
+
? `Started ${formatRelativeAgeTime(issue.first_seen)} · was previously healthy`
|
|
300
|
+
: `Started ${formatRelativeAgeTime(issue.first_seen)}`}
|
|
301
|
+
{issue.last_seen && issue.issue_timing !== 'started_at_resource_creation'
|
|
302
|
+
? ` · last seen ${formatRelativeAgeTime(issue.last_seen)}`
|
|
303
|
+
: ''}
|
|
262
304
|
</p>
|
|
263
305
|
) : null}
|
|
264
306
|
</section>
|
|
@@ -364,11 +406,16 @@ function diagnosticFactLabel(type: string): string {
|
|
|
364
406
|
}
|
|
365
407
|
}
|
|
366
408
|
|
|
367
|
-
// Native-tooltip detail for the collapsed-row age chip: absolute
|
|
409
|
+
// Native-tooltip detail for the collapsed-row age chip: absolute first-seen + last-seen
|
|
368
410
|
// freshness, the two facts the compact "2h" hides.
|
|
369
411
|
function ageTitle(issue: Issue): string {
|
|
370
412
|
const parts: string[] = [];
|
|
371
|
-
if (issue.
|
|
413
|
+
if (issue.issue_timing === 'started_at_resource_creation') {
|
|
414
|
+
parts.push('Present since deployment — treat as baseline');
|
|
415
|
+
} else if (issue.issue_timing === 'started_after_resource_was_healthy') {
|
|
416
|
+
parts.push('Resource was previously healthy');
|
|
417
|
+
}
|
|
418
|
+
if (issue.first_seen) parts.push(`First seen ${new Date(issue.first_seen).toLocaleString()}`);
|
|
372
419
|
if (issue.last_seen) parts.push(`Last seen ${formatRelativeAgeTime(issue.last_seen)}`);
|
|
373
420
|
return parts.join('\n');
|
|
374
421
|
}
|
|
@@ -11,7 +11,7 @@ export {
|
|
|
11
11
|
subjectRef,
|
|
12
12
|
memberRef,
|
|
13
13
|
} from './types';
|
|
14
|
-
export type { Issue, IssueSeverity, IssueAffected, IssueResourceRef, IssueDiagnosticContext, IssueDiagnosticFact, IssueDiagnosticIssueRef, IssueDiagnosticRole, IssueChangeContext } from './types';
|
|
14
|
+
export type { Issue, IssueSeverity, IssueAffected, IssueResourceRef, IssueDiagnosticContext, IssueDiagnosticFact, IssueDiagnosticIssueRef, IssueDiagnosticRole, IssueChangeContext, IssueRecentChange, IssueRecentChangeField } from './types';
|
|
15
15
|
export {
|
|
16
16
|
ISSUE_SEVERITY_LABEL,
|
|
17
17
|
ISSUE_SEVERITY_BADGE_CLASS,
|
|
@@ -16,27 +16,27 @@ const base: Issue = {
|
|
|
16
16
|
const mk = (o: Partial<Issue>): Issue => ({ ...base, ...o })
|
|
17
17
|
|
|
18
18
|
describe('compareIssues', () => {
|
|
19
|
-
it('orders critical before warning regardless of
|
|
19
|
+
it('orders critical before warning regardless of observed age', () => {
|
|
20
20
|
const warn = mk({ id: 'w', severity: 'warning', first_seen: '2026-05-01T00:00:00Z' }) // newer
|
|
21
21
|
const crit = mk({ id: 'c', severity: 'critical', first_seen: '2026-01-01T00:00:00Z' }) // older
|
|
22
22
|
expect([warn, crit].sort(compareIssues).map((i) => i.id)).toEqual(['c', 'w'])
|
|
23
23
|
})
|
|
24
24
|
|
|
25
|
-
it('breaks same-severity ties by first_seen DESC (newest
|
|
25
|
+
it('breaks same-severity ties by first_seen DESC (newest observed issue first)', () => {
|
|
26
26
|
const older = mk({ id: 'o', first_seen: '2026-01-01T00:00:00Z' })
|
|
27
27
|
const newer = mk({ id: 'n', first_seen: '2026-05-01T00:00:00Z' })
|
|
28
28
|
expect([older, newer].sort(compareIssues).map((i) => i.id)).toEqual(['n', 'o'])
|
|
29
29
|
})
|
|
30
30
|
|
|
31
31
|
it('does NOT reshuffle same-severity rows when only last_seen changes (anti-churn)', () => {
|
|
32
|
-
// Two same-severity rows, same
|
|
32
|
+
// Two same-severity rows, same first_seen — order is the deterministic name tiebreak.
|
|
33
33
|
const a = mk({ id: 'id-a', name: 'a', first_seen: '2026-01-01T00:00:00Z', last_seen: '2026-05-01T00:00:00Z' })
|
|
34
34
|
const b = mk({ id: 'id-b', name: 'b', first_seen: '2026-01-01T00:00:00Z', last_seen: '2026-05-30T00:00:00Z' })
|
|
35
35
|
const before = [a, b].sort(compareIssues).map((i) => i.id)
|
|
36
36
|
expect(before).toEqual(['id-a', 'id-b'])
|
|
37
37
|
// A refetch bumps a's last_seen to "now". Sorting on last_seen would flip the
|
|
38
38
|
// order; keying on first_seen + identity must NOT — this is the whole point
|
|
39
|
-
// of the
|
|
39
|
+
// of the first_seen-based sort.
|
|
40
40
|
const aRefetched = mk({ ...a, last_seen: '2026-06-01T00:00:00Z' })
|
|
41
41
|
const after = [aRefetched, b].sort(compareIssues).map((i) => i.id)
|
|
42
42
|
expect(after).toEqual(before)
|
|
@@ -91,6 +91,11 @@ const CATEGORY_LABEL: Record<string, string> = {
|
|
|
91
91
|
node_not_ready: 'Node not ready',
|
|
92
92
|
operator_condition_failed: 'Controller condition',
|
|
93
93
|
gitops_sync_failed: 'GitOps sync failed',
|
|
94
|
+
gitops_render_failed: 'GitOps render failed',
|
|
95
|
+
gitops_spec_invalid: 'GitOps spec invalid',
|
|
96
|
+
gitops_operation_failed: 'GitOps operation failed',
|
|
97
|
+
gitops_out_of_sync: 'GitOps out of sync',
|
|
98
|
+
gitops_health_degraded: 'GitOps health degraded',
|
|
94
99
|
webhook_backend_down: 'Webhook backend down',
|
|
95
100
|
control_plane_not_ready: 'Control plane not ready',
|
|
96
101
|
machine_not_ready: 'Machine not ready',
|
|
@@ -85,6 +85,24 @@ export interface IssueChangeContext {
|
|
|
85
85
|
evidence?: string;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
export interface IssueRecentChangeField {
|
|
89
|
+
path: string;
|
|
90
|
+
oldValue?: unknown;
|
|
91
|
+
newValue?: unknown;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface IssueRecentChange {
|
|
95
|
+
kind: string;
|
|
96
|
+
namespace?: string;
|
|
97
|
+
name: string;
|
|
98
|
+
changeType: string;
|
|
99
|
+
summary?: string;
|
|
100
|
+
timestamp: string;
|
|
101
|
+
change_category?: 'spec_config' | 'lifecycle' | 'runtime_status' | string;
|
|
102
|
+
rank_reason?: string;
|
|
103
|
+
fields?: IssueRecentChangeField[];
|
|
104
|
+
}
|
|
105
|
+
|
|
88
106
|
/**
|
|
89
107
|
* A grouped live issue — one row of the triage queue. Subject (kind/group/
|
|
90
108
|
* namespace/name) is the topmost owner when the rows folded under a workload,
|
|
@@ -118,6 +136,18 @@ export interface Issue {
|
|
|
118
136
|
|
|
119
137
|
reason: string;
|
|
120
138
|
message?: string;
|
|
139
|
+
/** Parsed domain diagnosis (today GitOps controller errors): plain-English
|
|
140
|
+
* cause, suggested next step, and an optional structured one-click fix.
|
|
141
|
+
* Server-emitted (omitempty); empty for issues without a parser. */
|
|
142
|
+
cause?: string;
|
|
143
|
+
action?: string;
|
|
144
|
+
remediation_kind?: string;
|
|
145
|
+
remediation_target?: string;
|
|
146
|
+
/** Controller-operation retry count (e.g. Argo's "(retried N times)") —
|
|
147
|
+
* distinct from restart_count (pod restarts). stuck = not expected to
|
|
148
|
+
* self-recover. */
|
|
149
|
+
operation_retry_count?: number;
|
|
150
|
+
stuck?: boolean;
|
|
121
151
|
first_seen?: string;
|
|
122
152
|
last_seen?: string;
|
|
123
153
|
/** Affected-resource fan-out, EXCLUDING the subject (the row header).
|
|
@@ -135,6 +165,19 @@ export interface Issue {
|
|
|
135
165
|
// Pod crash context carried from the representative member.
|
|
136
166
|
restart_count?: number;
|
|
137
167
|
last_terminated_reason?: string;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Best-effort timing evidence from K8s-native signals. Absent when Radar has
|
|
171
|
+
* no confident signal. This is not a root-cause verdict.
|
|
172
|
+
*
|
|
173
|
+
* "started_at_resource_creation" — failing state began during resource
|
|
174
|
+
* creation or first reconciliation.
|
|
175
|
+
* "started_after_resource_was_healthy" — a meaningful healthy window preceded
|
|
176
|
+
* the failing state.
|
|
177
|
+
*/
|
|
178
|
+
issue_timing?: 'started_at_resource_creation' | 'started_after_resource_was_healthy';
|
|
179
|
+
/** The evidence that determined issue_timing (for auditability). */
|
|
180
|
+
issue_timing_basis?: 'condition' | 'owner_condition' | 'pod_creation' | 'deletion' | 'phase' | 'spec';
|
|
138
181
|
}
|
|
139
182
|
|
|
140
183
|
/** subjectRef builds a deep-linkable ref for an issue's subject — the row's
|
|
@@ -274,17 +274,27 @@ export function LogCore({
|
|
|
274
274
|
})
|
|
275
275
|
}, [])
|
|
276
276
|
|
|
277
|
-
// Keyboard
|
|
277
|
+
// Keyboard shortcuts: Ctrl+F opens search; bare 's' toggles streaming
|
|
278
|
+
// (matches k9s). 's' is ignored while typing in a field so it doesn't
|
|
279
|
+
// hijack search input or other text entry.
|
|
278
280
|
useEffect(() => {
|
|
279
281
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
280
282
|
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
|
281
283
|
e.preventDefault()
|
|
282
284
|
search.open()
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
if (e.key === 's' && !e.ctrlKey && !e.metaKey && !e.altKey && onStartStream) {
|
|
288
|
+
const target = e.target as HTMLElement | null
|
|
289
|
+
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable)) return
|
|
290
|
+
e.preventDefault()
|
|
291
|
+
if (isStreaming) onStopStream()
|
|
292
|
+
else onStartStream()
|
|
283
293
|
}
|
|
284
294
|
}
|
|
285
295
|
window.addEventListener('keydown', handleKeyDown)
|
|
286
296
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
287
|
-
}, [search.open])
|
|
297
|
+
}, [search.open, onStartStream, onStopStream, isStreaming])
|
|
288
298
|
|
|
289
299
|
const handleFollowOutput = useCallback((isAtBottom: boolean) => {
|
|
290
300
|
if (isAtBottom) return 'smooth' as const
|
|
@@ -846,6 +856,7 @@ export function LogCore({
|
|
|
846
856
|
|
|
847
857
|
{/* Keyboard shortcut hints */}
|
|
848
858
|
<div className={`flex items-center gap-4 px-3 py-1 border-t ${palette.border} ${palette.toolbarBg} text-[10px] ${palette.textDisabled}`}>
|
|
859
|
+
{onStartStream && <Shortcut keys="S" label={isStreaming ? 'Stop stream' : 'Stream'} palette={palette} />}
|
|
849
860
|
<Shortcut keys="Ctrl+F" label="Search" palette={palette} />
|
|
850
861
|
<Shortcut keys="Enter" label="Next match" palette={palette} />
|
|
851
862
|
<Shortcut keys="Shift+Enter" label="Prev match" palette={palette} />
|
|
@@ -48,6 +48,8 @@ interface LogRangeSelectProps {
|
|
|
48
48
|
tooltip?: string
|
|
49
49
|
/** Dark/light palette selector. Defaults to `true` (dark viewer). */
|
|
50
50
|
isDark?: boolean
|
|
51
|
+
/** When true, the control is greyed out and non-interactive. */
|
|
52
|
+
disabled?: boolean
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
export function LogRangeSelect({
|
|
@@ -56,14 +58,16 @@ export function LogRangeSelect({
|
|
|
56
58
|
lineOptions = [100, 500, 1000, 5000],
|
|
57
59
|
tooltip = 'How many logs to load — by line count or time range',
|
|
58
60
|
isDark = true,
|
|
61
|
+
disabled = false,
|
|
59
62
|
}: LogRangeSelectProps) {
|
|
60
63
|
const palette = getLogPalette(isDark)
|
|
61
64
|
return (
|
|
62
|
-
<Tooltip content={tooltip} position="bottom">
|
|
65
|
+
<Tooltip content={disabled ? 'Stop streaming to change the log range' : tooltip} position="bottom">
|
|
63
66
|
<select
|
|
64
67
|
value={value}
|
|
65
68
|
onChange={(e) => onChange(e.target.value)}
|
|
66
|
-
|
|
69
|
+
disabled={disabled}
|
|
70
|
+
className={`${selectClass(palette)} pr-5 ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
|
67
71
|
>
|
|
68
72
|
<optgroup label="Lines">
|
|
69
73
|
{lineOptions.map(n => (
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useEffect, useCallback } from 'react'
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
2
2
|
import { parseLogLine, parseLogRange } from '../../utils/log-format'
|
|
3
3
|
import { triggerDownload } from '../../utils/download'
|
|
4
4
|
import { useLogBuffer } from './useLogBuffer'
|
|
@@ -30,6 +30,12 @@ export interface LogsViewerProps {
|
|
|
30
30
|
overrideDownload?: (content: string, mime: string, filename: string) => void
|
|
31
31
|
/** Force dark mode on the logs container (default: true) */
|
|
32
32
|
forceDark?: boolean
|
|
33
|
+
/**
|
|
34
|
+
* Open the stream automatically on mount (and on container switch) instead of
|
|
35
|
+
* loading a static snapshot. The user can still Stop, and a manual Stop is not
|
|
36
|
+
* re-armed. Requires `createStream`. Default: false.
|
|
37
|
+
*/
|
|
38
|
+
autoStream?: boolean
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
export function LogsViewer({
|
|
@@ -41,6 +47,7 @@ export function LogsViewer({
|
|
|
41
47
|
createStream,
|
|
42
48
|
overrideDownload,
|
|
43
49
|
forceDark,
|
|
50
|
+
autoStream = false,
|
|
44
51
|
}: LogsViewerProps) {
|
|
45
52
|
const [selectedContainer, setSelectedContainer] = useState(initialContainer || containers[0] || '')
|
|
46
53
|
const [isLoading, setIsLoading] = useState(false)
|
|
@@ -51,7 +58,14 @@ export function LogsViewer({
|
|
|
51
58
|
|
|
52
59
|
const { tailLines, sinceSeconds } = parseLogRange(logRange)
|
|
53
60
|
const { entries, append, set, clear } = useLogBuffer()
|
|
54
|
-
const { isStreaming, startStreaming, stopStreaming } = useLogStream()
|
|
61
|
+
const { isStreaming, streamError, connecting, startStreaming, stopStreaming } = useLogStream()
|
|
62
|
+
|
|
63
|
+
const willAutoStream = autoStream && !!createStream
|
|
64
|
+
// Tracks the container we've already auto-started for, so re-renders don't
|
|
65
|
+
// re-open the stream, and a container switch arms a fresh auto-start.
|
|
66
|
+
const autoStartedForRef = useRef<string | null>(null)
|
|
67
|
+
// Once the user explicitly Stops, don't auto-resume for this viewer's lifetime.
|
|
68
|
+
const userStoppedRef = useRef(false)
|
|
55
69
|
|
|
56
70
|
const loadLogs = useCallback(async () => {
|
|
57
71
|
if (!selectedContainer) return
|
|
@@ -72,11 +86,30 @@ export function LogsViewer({
|
|
|
72
86
|
}
|
|
73
87
|
}, [selectedContainer, tailLines, sinceSeconds, showPrevious, fetchLogs, set])
|
|
74
88
|
|
|
75
|
-
|
|
89
|
+
// When auto-streaming the stream supplies the initial tail, so the static
|
|
90
|
+
// snapshot fetch is skipped to avoid a redundant request and a flash of
|
|
91
|
+
// snapshot content before the stream takes over. If the user has Stopped we
|
|
92
|
+
// won't auto-start, so fall back to the snapshot — otherwise a container
|
|
93
|
+
// switch would keep showing the previous container's lines.
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (!willAutoStream || userStoppedRef.current) loadLogs()
|
|
96
|
+
}, [loadLogs, willAutoStream])
|
|
76
97
|
useEffect(() => { stopStreaming() }, [selectedContainer, stopStreaming])
|
|
77
98
|
|
|
99
|
+
// If auto-stream turns off while a stream is open (e.g. the pod went
|
|
100
|
+
// terminal), stop following so live appends don't race the snapshot.
|
|
101
|
+
const prevWillAutoStreamRef = useRef(willAutoStream)
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (prevWillAutoStreamRef.current && !willAutoStream && isStreaming) stopStreaming()
|
|
104
|
+
prevWillAutoStreamRef.current = willAutoStream
|
|
105
|
+
}, [willAutoStream, isStreaming, stopStreaming])
|
|
106
|
+
|
|
78
107
|
const handleStartStreaming = useCallback(() => {
|
|
79
108
|
if (!createStream) return
|
|
109
|
+
// The stream replays the last N lines (TailLines + Follow); clear first so
|
|
110
|
+
// they don't duplicate lines already in the buffer (the snapshot on the
|
|
111
|
+
// manual path, or an earlier stream on restart).
|
|
112
|
+
clear()
|
|
80
113
|
startStreaming(
|
|
81
114
|
() => createStream({ container: selectedContainer, tailLines: 100, sinceSeconds }),
|
|
82
115
|
{
|
|
@@ -86,8 +119,26 @@ export function LogsViewer({
|
|
|
86
119
|
container: data.container || selectedContainer,
|
|
87
120
|
}),
|
|
88
121
|
},
|
|
122
|
+
'Log stream connection failed',
|
|
89
123
|
)
|
|
90
|
-
}, [createStream, startStreaming, selectedContainer, sinceSeconds, append])
|
|
124
|
+
}, [createStream, startStreaming, selectedContainer, sinceSeconds, append, clear])
|
|
125
|
+
|
|
126
|
+
const handleStopStreaming = useCallback(() => {
|
|
127
|
+
userStoppedRef.current = true
|
|
128
|
+
stopStreaming()
|
|
129
|
+
}, [stopStreaming])
|
|
130
|
+
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
if (!willAutoStream || !selectedContainer) return
|
|
133
|
+
if (userStoppedRef.current) return
|
|
134
|
+
if (autoStartedForRef.current === selectedContainer) return
|
|
135
|
+
autoStartedForRef.current = selectedContainer
|
|
136
|
+
handleStartStreaming()
|
|
137
|
+
// Reset the arm latch on teardown so a re-run re-streams — without this,
|
|
138
|
+
// React Strict Mode's mount→unmount→mount closes the stream but the latch
|
|
139
|
+
// stays set, leaving the viewer static.
|
|
140
|
+
return () => { autoStartedForRef.current = null }
|
|
141
|
+
}, [willAutoStream, selectedContainer, handleStartStreaming])
|
|
91
142
|
|
|
92
143
|
const downloadLogs = useCallback((format: DownloadFormat) => {
|
|
93
144
|
let content: string
|
|
@@ -122,30 +173,35 @@ export function LogsViewer({
|
|
|
122
173
|
<>
|
|
123
174
|
<ContainerSelect containers={containers} value={selectedContainer} onChange={setSelectedContainer} isDark={isDark} />
|
|
124
175
|
|
|
125
|
-
<Tooltip content="Show logs from the pod's previous instance (if it was restarted). Useful for troubleshooting crashed containers." position="bottom">
|
|
126
|
-
<label className={`flex items-center gap-1.5 text-xs ${palette.textSecondary}`}>
|
|
176
|
+
<Tooltip content={isStreaming ? 'Stop streaming to view the previous instance' : "Show logs from the pod's previous instance (if it was restarted). Useful for troubleshooting crashed containers."} position="bottom">
|
|
177
|
+
<label className={`flex items-center gap-1.5 text-xs ${palette.textSecondary} ${isStreaming ? 'opacity-50 cursor-not-allowed' : ''}`}>
|
|
127
178
|
<input
|
|
128
179
|
type="checkbox"
|
|
129
180
|
checked={showPrevious}
|
|
130
181
|
onChange={(e) => setShowPrevious(e.target.checked)}
|
|
182
|
+
disabled={isStreaming}
|
|
131
183
|
className={`w-3 h-3 rounded ${palette.borderLight} ${palette.elevatedBg} text-blue-500 focus:ring-blue-500 focus:ring-offset-0`}
|
|
132
184
|
/>
|
|
133
185
|
<span className={`border-b border-dotted ${isDark ? 'border-slate-500' : 'border-slate-400'}`}>Previous</span>
|
|
134
186
|
</label>
|
|
135
187
|
</Tooltip>
|
|
136
188
|
|
|
137
|
-
<LogRangeSelect value={logRange} onChange={setLogRange} isDark={isDark} />
|
|
189
|
+
<LogRangeSelect value={logRange} onChange={setLogRange} isDark={isDark} disabled={isStreaming} />
|
|
138
190
|
</>
|
|
139
191
|
)
|
|
140
192
|
|
|
193
|
+
// While the auto-stream is opening (before it first settles), show the
|
|
194
|
+
// loading state rather than the empty-logs placeholder.
|
|
195
|
+
const isConnecting = willAutoStream && connecting && entries.length === 0
|
|
196
|
+
|
|
141
197
|
return (
|
|
142
198
|
<LogCore
|
|
143
199
|
entries={entries}
|
|
144
|
-
isLoading={isLoading}
|
|
145
|
-
errorMessage={fetchError}
|
|
200
|
+
isLoading={isLoading || isConnecting}
|
|
201
|
+
errorMessage={fetchError || (entries.length === 0 ? streamError : null)}
|
|
146
202
|
isStreaming={isStreaming}
|
|
147
203
|
onStartStream={createStream ? handleStartStreaming : undefined}
|
|
148
|
-
onStopStream={
|
|
204
|
+
onStopStream={handleStopStreaming}
|
|
149
205
|
onRefresh={loadLogs}
|
|
150
206
|
onDownload={downloadLogs}
|
|
151
207
|
onClear={clear}
|
|
@@ -47,9 +47,15 @@ export interface WorkloadLogsViewerProps {
|
|
|
47
47
|
overrideDownload?: (content: string, mime: string, filename: string) => void
|
|
48
48
|
/** Force dark mode on the logs container (default: true) */
|
|
49
49
|
forceDark?: boolean
|
|
50
|
+
/**
|
|
51
|
+
* Open the stream automatically on mount (and on container switch) instead of
|
|
52
|
+
* loading a static snapshot. The user can still Stop, and a manual Stop is not
|
|
53
|
+
* re-armed. Requires `createStream`. Default: false.
|
|
54
|
+
*/
|
|
55
|
+
autoStream?: boolean
|
|
50
56
|
}
|
|
51
57
|
|
|
52
|
-
export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownload, forceDark }: WorkloadLogsViewerProps) {
|
|
58
|
+
export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownload, forceDark, autoStream = false }: WorkloadLogsViewerProps) {
|
|
53
59
|
const [selectedContainer, setSelectedContainer] = useState<string>('')
|
|
54
60
|
const [pods, setPods] = useState<WorkloadPodInfo[]>([])
|
|
55
61
|
const [selectedPods, setSelectedPods] = useState<Set<string>>(new Set())
|
|
@@ -61,7 +67,12 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
|
|
|
61
67
|
|
|
62
68
|
const { tailLines, sinceSeconds } = parseLogRange(logRange)
|
|
63
69
|
const { entries, append, set, clear } = useLogBuffer()
|
|
64
|
-
const { isStreaming, startStreaming, stopStreaming } = useLogStream()
|
|
70
|
+
const { isStreaming, streamError, connecting, startStreaming, stopStreaming } = useLogStream()
|
|
71
|
+
|
|
72
|
+
const willAutoStream = autoStream && !!createStream
|
|
73
|
+
// null sentinel so the initial selectedContainer ('' = all) still arms once.
|
|
74
|
+
const autoStartedForRef = useRef<string | null>(null)
|
|
75
|
+
const userStoppedRef = useRef(false)
|
|
65
76
|
|
|
66
77
|
// Map pod.name → index. Color classes are resolved at render time from the
|
|
67
78
|
// current palette (see LogCore / pod-filter dropdown below) so toggling
|
|
@@ -105,11 +116,30 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
|
|
|
105
116
|
}
|
|
106
117
|
}, [fetchAll, selectedContainer, tailLines, sinceSeconds, set])
|
|
107
118
|
|
|
108
|
-
|
|
119
|
+
// When auto-streaming the stream supplies the initial tail, so the static
|
|
120
|
+
// snapshot fetch is skipped to avoid a redundant request and a flash of
|
|
121
|
+
// snapshot content before the stream takes over. If the user has Stopped we
|
|
122
|
+
// won't auto-start, so fall back to the snapshot — otherwise a container
|
|
123
|
+
// switch would keep showing the previous selection's lines.
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (!willAutoStream || userStoppedRef.current) loadLogs()
|
|
126
|
+
}, [loadLogs, willAutoStream])
|
|
109
127
|
useEffect(() => { stopStreaming() }, [selectedContainer, stopStreaming])
|
|
110
128
|
|
|
129
|
+
// If auto-stream turns off while a stream is open, stop following so live
|
|
130
|
+
// appends don't race the snapshot.
|
|
131
|
+
const prevWillAutoStreamRef = useRef(willAutoStream)
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (prevWillAutoStreamRef.current && !willAutoStream && isStreaming) stopStreaming()
|
|
134
|
+
prevWillAutoStreamRef.current = willAutoStream
|
|
135
|
+
}, [willAutoStream, isStreaming, stopStreaming])
|
|
136
|
+
|
|
111
137
|
const handleStartStreaming = useCallback(() => {
|
|
112
138
|
if (!createStream) return
|
|
139
|
+
// The stream replays the last N lines per pod (TailLines + Follow); clear
|
|
140
|
+
// first so they don't duplicate lines already in the buffer (the snapshot on
|
|
141
|
+
// the manual path, or an earlier stream on restart).
|
|
142
|
+
clear()
|
|
113
143
|
startStreaming(
|
|
114
144
|
() => createStream({ container: selectedContainer || undefined, tailLines: 50, sinceSeconds }),
|
|
115
145
|
{
|
|
@@ -156,9 +186,26 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
|
|
|
156
186
|
}
|
|
157
187
|
},
|
|
158
188
|
},
|
|
159
|
-
'Workload log stream
|
|
189
|
+
'Workload log stream connection failed',
|
|
160
190
|
)
|
|
161
|
-
}, [createStream, startStreaming, selectedContainer, sinceSeconds, append, podColorIndex, selectedPods.size])
|
|
191
|
+
}, [createStream, startStreaming, selectedContainer, sinceSeconds, append, podColorIndex, selectedPods.size, clear])
|
|
192
|
+
|
|
193
|
+
const handleStopStreaming = useCallback(() => {
|
|
194
|
+
userStoppedRef.current = true
|
|
195
|
+
stopStreaming()
|
|
196
|
+
}, [stopStreaming])
|
|
197
|
+
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
if (!willAutoStream) return
|
|
200
|
+
if (userStoppedRef.current) return
|
|
201
|
+
if (autoStartedForRef.current === selectedContainer) return
|
|
202
|
+
autoStartedForRef.current = selectedContainer
|
|
203
|
+
handleStartStreaming()
|
|
204
|
+
// Reset the arm latch on teardown so a re-run re-streams — without this,
|
|
205
|
+
// React Strict Mode's mount→unmount→mount closes the stream but the latch
|
|
206
|
+
// stays set, leaving the viewer static.
|
|
207
|
+
return () => { autoStartedForRef.current = null }
|
|
208
|
+
}, [willAutoStream, selectedContainer, handleStartStreaming])
|
|
162
209
|
|
|
163
210
|
const allContainers = useMemo(() => {
|
|
164
211
|
const s = new Set<string>()
|
|
@@ -280,24 +327,29 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
|
|
|
280
327
|
lineOptions={[50, 100, 500, 1000]}
|
|
281
328
|
tooltip="How many logs to load per pod — by line count or time range"
|
|
282
329
|
isDark={isDark}
|
|
330
|
+
disabled={isStreaming}
|
|
283
331
|
/>
|
|
284
332
|
</>
|
|
285
333
|
)
|
|
286
334
|
|
|
335
|
+
// While the auto-stream is opening (before it first settles), show the
|
|
336
|
+
// loading state rather than the empty-logs placeholder.
|
|
337
|
+
const isConnecting = willAutoStream && connecting && entries.length === 0
|
|
338
|
+
|
|
287
339
|
return (
|
|
288
340
|
<LogCore
|
|
289
341
|
entries={filteredEntries}
|
|
290
|
-
isLoading={isLoading}
|
|
342
|
+
isLoading={isLoading || isConnecting}
|
|
291
343
|
isStreaming={isStreaming}
|
|
292
344
|
onStartStream={createStream ? handleStartStreaming : undefined}
|
|
293
|
-
onStopStream={
|
|
345
|
+
onStopStream={handleStopStreaming}
|
|
294
346
|
onRefresh={loadLogs}
|
|
295
347
|
onDownload={downloadLogs}
|
|
296
348
|
onClear={clear}
|
|
297
349
|
toolbarExtra={renderToolbarExtra}
|
|
298
350
|
showPodName
|
|
299
351
|
emptyMessage={pods.length === 0 ? 'No pods found' : 'No logs available'}
|
|
300
|
-
errorMessage={fetchError}
|
|
352
|
+
errorMessage={fetchError || (entries.length === 0 ? streamError : null)}
|
|
301
353
|
forceDark={forceDark}
|
|
302
354
|
/>
|
|
303
355
|
)
|