@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.
Files changed (42) hide show
  1. package/package.json +1 -1
  2. package/src/components/dock/TerminalTab.tsx +4 -2
  3. package/src/components/gitops/insights/GitOpsInsightViews.tsx +1 -0
  4. package/src/components/issues/IssuesView.tsx +64 -17
  5. package/src/components/issues/index.ts +1 -1
  6. package/src/components/issues/issues.test.ts +4 -4
  7. package/src/components/issues/severity.ts +5 -0
  8. package/src/components/issues/types.ts +43 -0
  9. package/src/components/logs/LogCore.tsx +13 -2
  10. package/src/components/logs/LogToolbarSelects.tsx +6 -2
  11. package/src/components/logs/LogsViewer.tsx +66 -10
  12. package/src/components/logs/WorkloadLogsViewer.tsx +60 -8
  13. package/src/components/logs/useLogStream.ts +41 -3
  14. package/src/components/resources/ResourcesView.tsx +550 -52
  15. package/src/components/resources/column-filter-serialization.test.ts +26 -0
  16. package/src/components/resources/get-default-container-name.test.ts +31 -0
  17. package/src/components/resources/renderers/DeviceClassRenderer.tsx +49 -0
  18. package/src/components/resources/renderers/NodeRenderer.tsx +7 -0
  19. package/src/components/resources/renderers/NvidiaClusterPolicyRenderer.tsx +57 -0
  20. package/src/components/resources/renderers/NvidiaDriverRenderer.tsx +47 -0
  21. package/src/components/resources/renderers/PodRenderer.tsx +2 -2
  22. package/src/components/resources/renderers/ResourceClaimRenderer.tsx +118 -0
  23. package/src/components/resources/renderers/ResourceClaimTemplateRenderer.tsx +39 -0
  24. package/src/components/resources/renderers/ResourceSliceRenderer.tsx +72 -0
  25. package/src/components/resources/renderers/dra-cells.tsx +80 -0
  26. package/src/components/resources/renderers/index.ts +8 -0
  27. package/src/components/resources/renderers/nvidia-cells.tsx +43 -0
  28. package/src/components/resources/resource-utils-dra.ts +90 -0
  29. package/src/components/resources/resource-utils-nvidia.ts +63 -0
  30. package/src/components/resources/resource-utils.ts +62 -4
  31. package/src/components/shared/ResourceActionsBar.tsx +5 -4
  32. package/src/components/shared/ResourceRendererDispatch.test.tsx +103 -0
  33. package/src/components/shared/ResourceRendererDispatch.tsx +27 -2
  34. package/src/components/ui/ConfirmDialog.tsx +1 -1
  35. package/src/components/ui/drawer-components.tsx +23 -1
  36. package/src/types/core.ts +1 -0
  37. package/src/utils/api-resources.ts +21 -0
  38. package/src/utils/custom-columns.test.ts +111 -0
  39. package/src/utils/custom-columns.ts +49 -0
  40. package/src/utils/extended-resources.test.ts +152 -0
  41. package/src/utils/extended-resources.ts +121 -0
  42. package/src/utils/index.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.7.11",
3
+ "version": "1.7.12",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -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
- This container doesn&apos;t have a shell (/bin/sh). This is common with distroless
289
- or minimal container images. You can create a debug container to troubleshoot.
288
+ Container <span className="font-mono text-theme-text-secondary">{selectedContainer}</span> doesn&apos;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 → onset → identity (see compareIssues), so
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
- {/* Onset age (first_seen, fixed for the issue's life) is the chronic-vs-
187
- acute signal "broken 2m" reads very differently from "broken 5d".
188
- Keyed on first_seen, not last_seen, so it doesn't churn on refresh. */}
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
- {formatCompactAge(issue.first_seen)}
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
- <p className="text-sm leading-relaxed text-theme-text-primary">
245
- <span className="font-medium">{issue.reason}</span>
246
- {headline ? <span className="text-theme-text-secondary"> — {headline}</span> : null}
247
- </p>
248
- {/* Raw source string kept as secondary detail only when we showed a
249
- normalized headline above (e.g. the verbose containerd image-pull
250
- error) — so the precise message is never lost, just de-emphasized. */}
251
- {detail ? <p className="break-words font-mono text-xs leading-relaxed text-theme-text-tertiary">{detail}</p> : null}
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
- Started {formatRelativeAgeTime(issue.first_seen)}
261
- {issue.last_seen ? ` · last seen ${formatRelativeAgeTime(issue.last_seen)}` : ''}
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 onset + last-seen
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.first_seen) parts.push(`Started ${new Date(issue.first_seen).toLocaleString()}`);
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 onset', () => {
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 onset first)', () => {
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 onset — order is the deterministic name tiebreak.
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 onset-based sort.
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 shortcut: Ctrl+F to open search
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
- className={`${selectClass(palette)} pr-5`}
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
- useEffect(() => { loadLogs() }, [loadLogs])
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={stopStreaming}
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
- useEffect(() => { loadLogs() }, [loadLogs])
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 error',
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={stopStreaming}
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
  )