@skyhook-io/k8s-ui 1.5.2 → 1.5.3

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.
@@ -1,8 +1,12 @@
1
1
  import { ChevronDown } from 'lucide-react'
2
2
  import { Tooltip } from '../ui/Tooltip'
3
+ import { getLogPalette, type LogPalette } from './log-palette'
3
4
 
4
- const SELECT_CLASS =
5
- 'appearance-none bg-theme-elevated text-theme-text-primary text-xs rounded px-2 py-1.5 border border-theme-border-light focus:outline-none focus:ring-1 focus:ring-blue-500'
5
+ // See log-palette.ts for why the viewer uses explicit palette classes
6
+ // instead of theme-* tokens.
7
+ function selectClass(palette: LogPalette): string {
8
+ return `appearance-none ${palette.elevatedBg} ${palette.textPrimary} text-xs rounded px-2 py-1.5 border ${palette.borderLight} focus:outline-none focus:ring-1 focus:ring-blue-500`
9
+ }
6
10
 
7
11
  // ── ContainerSelect ───────────────────────────────────────────────────────────
8
12
 
@@ -12,21 +16,24 @@ interface ContainerSelectProps {
12
16
  onChange: (value: string) => void
13
17
  /** If true, prepend an "All containers" option with value="" */
14
18
  includeAll?: boolean
19
+ /** Dark/light palette selector. Defaults to `true` (dark viewer). */
20
+ isDark?: boolean
15
21
  }
16
22
 
17
- export function ContainerSelect({ containers, value, onChange, includeAll = false }: ContainerSelectProps) {
23
+ export function ContainerSelect({ containers, value, onChange, includeAll = false, isDark = true }: ContainerSelectProps) {
18
24
  if (containers.length <= 1 && !includeAll) return null
25
+ const palette = getLogPalette(isDark)
19
26
  return (
20
27
  <div className="relative">
21
28
  <select
22
29
  value={value}
23
30
  onChange={(e) => onChange(e.target.value)}
24
- className={`${SELECT_CLASS} pr-6`}
31
+ className={`${selectClass(palette)} pr-6`}
25
32
  >
26
33
  {includeAll && <option value="">All containers</option>}
27
34
  {containers.map(c => <option key={c} value={c}>{c}</option>)}
28
35
  </select>
29
- <ChevronDown className="absolute right-1.5 top-1/2 -translate-y-1/2 w-3 h-3 text-theme-text-secondary pointer-events-none" />
36
+ <ChevronDown className={`absolute right-1.5 top-1/2 -translate-y-1/2 w-3 h-3 ${palette.textSecondary} pointer-events-none`} />
30
37
  </div>
31
38
  )
32
39
  }
@@ -39,6 +46,8 @@ interface LogRangeSelectProps {
39
46
  /** Line count options to show. Defaults to [100, 500, 1000, 5000]. */
40
47
  lineOptions?: number[]
41
48
  tooltip?: string
49
+ /** Dark/light palette selector. Defaults to `true` (dark viewer). */
50
+ isDark?: boolean
42
51
  }
43
52
 
44
53
  export function LogRangeSelect({
@@ -46,13 +55,15 @@ export function LogRangeSelect({
46
55
  onChange,
47
56
  lineOptions = [100, 500, 1000, 5000],
48
57
  tooltip = 'How many logs to load — by line count or time range',
58
+ isDark = true,
49
59
  }: LogRangeSelectProps) {
60
+ const palette = getLogPalette(isDark)
50
61
  return (
51
62
  <Tooltip content={tooltip} position="bottom">
52
63
  <select
53
64
  value={value}
54
65
  onChange={(e) => onChange(e.target.value)}
55
- className={`${SELECT_CLASS} pr-5`}
66
+ className={`${selectClass(palette)} pr-5`}
56
67
  >
57
68
  <optgroup label="Lines">
58
69
  {lineOptions.map(n => (
@@ -6,6 +6,7 @@ import { useLogStream } from './useLogStream'
6
6
  import { ContainerSelect, LogRangeSelect } from './LogToolbarSelects'
7
7
  import { LogCore } from './LogCore'
8
8
  import type { DownloadFormat } from './LogCore'
9
+ import type { LogPalette } from './log-palette'
9
10
  import { Tooltip } from '../ui/Tooltip'
10
11
  import { useToast } from '../ui/Toast'
11
12
 
@@ -117,23 +118,23 @@ export function LogsViewer({
117
118
  }
118
119
  }, [entries, podName, selectedContainer, overrideDownload, showError, showSuccess])
119
120
 
120
- const toolbarExtra = (
121
+ const renderToolbarExtra = ({ isDark, palette }: { isDark: boolean; palette: LogPalette }) => (
121
122
  <>
122
- <ContainerSelect containers={containers} value={selectedContainer} onChange={setSelectedContainer} />
123
+ <ContainerSelect containers={containers} value={selectedContainer} onChange={setSelectedContainer} isDark={isDark} />
123
124
 
124
125
  <Tooltip content="Show logs from the pod's previous instance (if it was restarted). Useful for troubleshooting crashed containers." position="bottom">
125
- <label className="flex items-center gap-1.5 text-xs text-theme-text-secondary">
126
+ <label className={`flex items-center gap-1.5 text-xs ${palette.textSecondary}`}>
126
127
  <input
127
128
  type="checkbox"
128
129
  checked={showPrevious}
129
130
  onChange={(e) => setShowPrevious(e.target.checked)}
130
- className="w-3 h-3 rounded border-theme-border-light bg-theme-elevated text-blue-500 focus:ring-blue-500 focus:ring-offset-0"
131
+ className={`w-3 h-3 rounded ${palette.borderLight} ${palette.elevatedBg} text-blue-500 focus:ring-blue-500 focus:ring-offset-0`}
131
132
  />
132
- <span className="border-b border-dotted border-theme-text-tertiary">Previous</span>
133
+ <span className={`border-b border-dotted ${isDark ? 'border-slate-500' : 'border-slate-400'}`}>Previous</span>
133
134
  </label>
134
135
  </Tooltip>
135
136
 
136
- <LogRangeSelect value={logRange} onChange={setLogRange} />
137
+ <LogRangeSelect value={logRange} onChange={setLogRange} isDark={isDark} />
137
138
  </>
138
139
  )
139
140
 
@@ -148,7 +149,7 @@ export function LogsViewer({
148
149
  onRefresh={loadLogs}
149
150
  onDownload={downloadLogs}
150
151
  onClear={clear}
151
- toolbarExtra={toolbarExtra}
152
+ toolbarExtra={renderToolbarExtra}
152
153
  forceDark={forceDark}
153
154
  />
154
155
  )
@@ -1,17 +1,8 @@
1
1
  import { useState, useMemo } from 'react'
2
2
  import { ChevronRight, ChevronDown, Filter } from 'lucide-react'
3
3
  import type { LogLevel } from './useLogBuffer'
4
- import {
5
- getLevelColor,
6
- unescapeJsonStrings,
7
- parseLogfmt,
8
- SYNTAX_COLOR_KEY,
9
- SYNTAX_COLOR_STRING,
10
- SYNTAX_COLOR_NUMBER,
11
- SYNTAX_COLOR_BOOLEAN,
12
- SYNTAX_COLOR_NULL,
13
- } from '../../utils/log-format'
14
- import { SEVERITY_BADGE_BORDERED } from '../../utils/badge-colors'
4
+ import { unescapeJsonStrings, parseLogfmt } from '../../utils/log-format'
5
+ import { getLogPalette, getLogLevelColor, type LogPalette } from './log-palette'
15
6
 
16
7
  interface StructuredLogLineProps {
17
8
  content: string
@@ -25,9 +16,16 @@ interface StructuredLogLineProps {
25
16
  * add the value to the log search/filter.
26
17
  */
27
18
  onFilterValue?: (value: string) => void
19
+ /**
20
+ * Whether the log viewer is in dark mode. Determines the color palette used
21
+ * for text, hover states, and level badges. Defaults to `true` since the
22
+ * viewer defaults to dark mode.
23
+ */
24
+ isDark?: boolean
28
25
  }
29
26
 
30
- export function StructuredLogLine({ content, level, wordWrap, isLogfmt, defaultExpanded, onFilterValue }: StructuredLogLineProps) {
27
+ export function StructuredLogLine({ content, level, wordWrap, isLogfmt, defaultExpanded, onFilterValue, isDark = true }: StructuredLogLineProps) {
28
+ const palette = useMemo(() => getLogPalette(isDark), [isDark])
31
29
  // null = user hasn't toggled this line; defers to defaultExpanded (global toggle)
32
30
  const [localExpanded, setLocalExpanded] = useState<boolean | null>(null)
33
31
  const expanded = localExpanded ?? defaultExpanded ?? false
@@ -45,7 +43,7 @@ export function StructuredLogLine({ content, level, wordWrap, isLogfmt, defaultE
45
43
 
46
44
  if (!parsed) {
47
45
  return (
48
- <span className={`${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'} ${getLevelColor(level)}`}>
46
+ <span className={`${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'} ${getLogLevelColor(level, isDark)}`}>
49
47
  {content}
50
48
  </span>
51
49
  )
@@ -55,8 +53,8 @@ export function StructuredLogLine({ content, level, wordWrap, isLogfmt, defaultE
55
53
 
56
54
  const toggle = () => setLocalExpanded(!expanded)
57
55
  const chevron = expanded
58
- ? <ChevronDown className="w-3 h-3 shrink-0 text-theme-text-tertiary" />
59
- : <ChevronRight className="w-3 h-3 shrink-0 text-theme-text-tertiary" />
56
+ ? <ChevronDown className={`w-3 h-3 shrink-0 ${palette.textTertiary}`} />
57
+ : <ChevronRight className={`w-3 h-3 shrink-0 ${palette.textTertiary}`} />
60
58
 
61
59
  return (
62
60
  <span>
@@ -64,30 +62,31 @@ export function StructuredLogLine({ content, level, wordWrap, isLogfmt, defaultE
64
62
  // Collapsed: entire summary line is clickable
65
63
  <span
66
64
  onClick={toggle}
67
- className={`cursor-pointer hover:bg-theme-surface/50 rounded px-0.5 -ml-0.5 ${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'}`}
65
+ className={`cursor-pointer ${palette.hoverSurface} rounded px-0.5 -ml-0.5 ${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'}`}
68
66
  >
69
67
  <span className="inline-flex items-center align-middle mr-0.5">{chevron}</span>
70
- <SummaryLine obj={parsed} />
71
- <span className="text-theme-text-tertiary ml-1">{`{${fieldCount} fields}`}</span>
68
+ <SummaryLine obj={parsed} palette={palette} />
69
+ <span className={`${palette.textTertiary} ml-1`}>{`{${fieldCount} fields}`}</span>
72
70
  </span>
73
71
  ) : (
74
72
  // Expanded: summary header is clickable to collapse, JSON content is selectable
75
73
  <>
76
74
  <span
77
75
  onClick={toggle}
78
- className="cursor-pointer hover:bg-theme-surface/50 rounded px-0.5 -ml-0.5"
76
+ className={`cursor-pointer ${palette.hoverSurface} rounded px-0.5 -ml-0.5`}
79
77
  >
80
78
  <span className="inline-flex items-center align-middle mr-0.5">{chevron}</span>
81
- <SummaryLine obj={parsed} />
82
- <span className="text-theme-text-tertiary ml-1">{`{${fieldCount} fields}`}</span>
79
+ <SummaryLine obj={parsed} palette={palette} />
80
+ <span className={`${palette.textTertiary} ml-1`}>{`{${fieldCount} fields}`}</span>
83
81
  </span>
84
82
  <span className={`block ml-4 ${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'}`}>
85
83
  {isLogfmt ? (
86
- <ExpandedLogfmt obj={parsed} onFilterValue={onFilterValue} />
84
+ <ExpandedLogfmt obj={parsed} onFilterValue={onFilterValue} palette={palette} />
87
85
  ) : (
88
86
  <JsonExpanded
89
87
  text={unescapeJsonStrings(JSON.stringify(parsed, null, 2))}
90
88
  onFilterValue={onFilterValue}
89
+ palette={palette}
91
90
  />
92
91
  )}
93
92
  </span>
@@ -102,18 +101,18 @@ export function StructuredLogLine({ content, level, wordWrap, isLogfmt, defaultE
102
101
  * Clicking the chip calls onFilter(value) so the caller can push it into log search.
103
102
  */
104
103
  function FilterableValue({
105
- value, onFilter, color,
106
- }: { value: string; onFilter?: (v: string) => void; color?: string }) {
104
+ value, onFilter, color, palette,
105
+ }: { value: string; onFilter?: (v: string) => void; color?: string; palette: LogPalette }) {
107
106
  if (!onFilter) {
108
107
  return <span style={color ? { color } : undefined}>{value}</span>
109
108
  }
110
109
  return (
111
- <span className="group/flt inline-flex items-baseline align-baseline gap-0.5 rounded hover:bg-theme-surface/60">
110
+ <span className={`group/flt inline-flex items-baseline align-baseline gap-0.5 rounded ${palette.hoverSurface}`}>
112
111
  <span style={color ? { color } : undefined}>{value}</span>
113
112
  <button
114
113
  type="button"
115
114
  onClick={(e) => { e.stopPropagation(); onFilter(value) }}
116
- className="opacity-0 group-hover/flt:opacity-100 transition-opacity text-theme-text-tertiary hover:text-theme-text-primary px-0.5"
115
+ className={`opacity-0 group-hover/flt:opacity-100 transition-opacity ${palette.textTertiary} ${palette.hoverText} px-0.5`}
117
116
  title={`Filter to lines containing "${value}"`}
118
117
  aria-label={`Filter to lines containing ${value}`}
119
118
  >
@@ -128,7 +127,7 @@ function FilterableValue({
128
127
  * the layout that JSON.stringify(..., null, 2) produces. Tokenizes the string
129
128
  * and emits React nodes so the hover chip can be wired per value.
130
129
  */
131
- function JsonExpanded({ text, onFilterValue }: { text: string; onFilterValue?: (v: string) => void }) {
130
+ function JsonExpanded({ text, onFilterValue, palette }: { text: string; onFilterValue?: (v: string) => void; palette: LogPalette }) {
132
131
  const tokenRe = /("(?:\\.|[^"\\])*")\s*:|("(?:\\.|[^"\\])*")|(-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)|\b(true|false)\b|\b(null)\b/g
133
132
  const nodes: React.ReactNode[] = []
134
133
  let lastIndex = 0
@@ -140,25 +139,25 @@ function JsonExpanded({ text, onFilterValue }: { text: string; onFilterValue?: (
140
139
  }
141
140
  const [, key, str, num, bool, nil] = match
142
141
  if (key !== undefined) {
143
- nodes.push(<span key={`k${idx++}`} style={{ color: SYNTAX_COLOR_KEY }}>{key}</span>)
142
+ nodes.push(<span key={`k${idx++}`} style={{ color: palette.syntaxKey }}>{key}</span>)
144
143
  nodes.push(<span key={`c${idx++}`}>:</span>)
145
144
  } else if (str !== undefined) {
146
145
  // Unescape the quoted string for the filter value (users expect to filter on
147
146
  // the displayed string, not JSON-escaped bytes).
148
147
  const inner = str.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\')
149
148
  nodes.push(
150
- <FilterableValue key={`s${idx++}`} value={inner} onFilter={onFilterValue} color={SYNTAX_COLOR_STRING} />
149
+ <FilterableValue key={`s${idx++}`} value={inner} onFilter={onFilterValue} color={palette.syntaxString} palette={palette} />
151
150
  )
152
151
  } else if (num !== undefined) {
153
152
  nodes.push(
154
- <FilterableValue key={`n${idx++}`} value={num} onFilter={onFilterValue} color={SYNTAX_COLOR_NUMBER} />
153
+ <FilterableValue key={`n${idx++}`} value={num} onFilter={onFilterValue} color={palette.syntaxNumber} palette={palette} />
155
154
  )
156
155
  } else if (bool !== undefined) {
157
156
  nodes.push(
158
- <FilterableValue key={`b${idx++}`} value={bool} onFilter={onFilterValue} color={SYNTAX_COLOR_BOOLEAN} />
157
+ <FilterableValue key={`b${idx++}`} value={bool} onFilter={onFilterValue} color={palette.syntaxBoolean} palette={palette} />
159
158
  )
160
159
  } else if (nil !== undefined) {
161
- nodes.push(<span key={`z${idx++}`} style={{ color: SYNTAX_COLOR_NULL }}>{nil}</span>)
160
+ nodes.push(<span key={`z${idx++}`} style={{ color: palette.syntaxNull }}>{nil}</span>)
162
161
  }
163
162
  lastIndex = tokenRe.lastIndex
164
163
  }
@@ -168,7 +167,7 @@ function JsonExpanded({ text, onFilterValue }: { text: string; onFilterValue?: (
168
167
  return <>{nodes}</>
169
168
  }
170
169
 
171
- function SummaryLine({ obj }: { obj: Record<string, unknown> }) {
170
+ function SummaryLine({ obj, palette }: { obj: Record<string, unknown>; palette: LogPalette }) {
172
171
  const lvl = obj.level ?? obj.severity ?? obj.lvl ?? nestedField(obj, 'log', 'level')
173
172
  const msg = obj.msg ?? obj.message
174
173
  const rawErr = obj.error ?? obj.err
@@ -180,33 +179,33 @@ function SummaryLine({ obj }: { obj: Record<string, unknown> }) {
180
179
  return (
181
180
  <>
182
181
  {lvl != null && (
183
- <span className={`${getLevelBadgeColor(lvl)} text-[10px] font-semibold px-1 py-px rounded mr-1.5 inline-block`}>
182
+ <span className={`${getLevelBadgeColor(lvl, palette)} text-[10px] font-semibold px-1 py-px rounded mr-1.5 inline-block`}>
184
183
  {formatLevel(lvl)}
185
184
  </span>
186
185
  )}
187
186
  {typeof msg === 'string' && (
188
- <span className="text-theme-text-primary">{msg}</span>
187
+ <span className={palette.textPrimary}>{msg}</span>
189
188
  )}
190
189
  {typeof err === 'string' && (
191
- <span className="text-red-400 ml-2">error={err}</span>
190
+ <span className={`${palette.textError} ml-2`}>error={err}</span>
192
191
  )}
193
192
  {typeof caller === 'string' && (
194
- <span className="text-theme-text-disabled ml-2">{caller}</span>
193
+ <span className={`${palette.textDisabled} ml-2`}>{caller}</span>
195
194
  )}
196
195
  </>
197
196
  )
198
197
  }
199
198
 
200
- function ExpandedLogfmt({ obj, onFilterValue }: { obj: Record<string, unknown>; onFilterValue?: (v: string) => void }) {
199
+ function ExpandedLogfmt({ obj, onFilterValue, palette }: { obj: Record<string, unknown>; onFilterValue?: (v: string) => void; palette: LogPalette }) {
201
200
  return (
202
201
  <>
203
202
  {Object.entries(obj).map(([key, val]) => {
204
203
  const str = String(val)
205
204
  return (
206
205
  <div key={key}>
207
- <span style={{ color: SYNTAX_COLOR_KEY }}>{key}</span>
208
- <span className="text-theme-text-tertiary">=</span>
209
- <FilterableValue value={str} onFilter={onFilterValue} color={SYNTAX_COLOR_STRING} />
206
+ <span style={{ color: palette.syntaxKey }}>{key}</span>
207
+ <span className={palette.textTertiary}>=</span>
208
+ <FilterableValue value={str} onFilter={onFilterValue} color={palette.syntaxString} palette={palette} />
210
209
  </div>
211
210
  )
212
211
  })}
@@ -232,7 +231,7 @@ function formatLevel(lvl: unknown): string {
232
231
  return String(lvl).toUpperCase()
233
232
  }
234
233
 
235
- function getLevelBadgeColor(lvl: unknown): string {
234
+ function getLevelBadgeColor(lvl: unknown, palette: LogPalette): string {
236
235
  let normalized: string
237
236
  if (typeof lvl === 'number') {
238
237
  // Pino/bunyan numeric levels: 10=trace, 20=debug, 30=info, 40=warn, 50=error, 60=fatal
@@ -243,9 +242,9 @@ function getLevelBadgeColor(lvl: unknown): string {
243
242
  } else {
244
243
  normalized = String(lvl).toLowerCase()
245
244
  }
246
- if (/^(error|err|fatal|panic|critical|crit)$/.test(normalized)) return SEVERITY_BADGE_BORDERED.error
247
- if (/^(warn|warning)$/.test(normalized)) return SEVERITY_BADGE_BORDERED.warning
248
- if (/^(info|information|notice)$/.test(normalized)) return SEVERITY_BADGE_BORDERED.info
249
- if (/^(debug|dbg|trace|verbose)$/.test(normalized)) return SEVERITY_BADGE_BORDERED.debug
250
- return SEVERITY_BADGE_BORDERED.neutral
245
+ if (/^(error|err|fatal|panic|critical|crit)$/.test(normalized)) return palette.levelBadgeError
246
+ if (/^(warn|warning)$/.test(normalized)) return palette.levelBadgeWarn
247
+ if (/^(info|information|notice)$/.test(normalized)) return palette.levelBadgeInfo
248
+ if (/^(debug|dbg|trace|verbose)$/.test(normalized)) return palette.levelBadgeDebug
249
+ return palette.levelBadgeNeutral
251
250
  }
@@ -7,6 +7,7 @@ import { useLogStream } from './useLogStream'
7
7
  import { ContainerSelect, LogRangeSelect } from './LogToolbarSelects'
8
8
  import { LogCore } from './LogCore'
9
9
  import type { DownloadFormat } from './LogCore'
10
+ import type { LogPalette } from './log-palette'
10
11
  import type { WorkloadPodInfo } from '../../types'
11
12
  import { useToast } from '../ui/Toast'
12
13
 
@@ -48,11 +49,6 @@ export interface WorkloadLogsViewerProps {
48
49
  forceDark?: boolean
49
50
  }
50
51
 
51
- const POD_COLORS = [
52
- 'text-blue-400', 'text-green-400', 'text-yellow-400', 'text-purple-400',
53
- 'text-pink-400', 'text-cyan-400', 'text-orange-400', 'text-lime-400',
54
- ]
55
-
56
52
  export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownload, forceDark }: WorkloadLogsViewerProps) {
57
53
  const [selectedContainer, setSelectedContainer] = useState<string>('')
58
54
  const [pods, setPods] = useState<WorkloadPodInfo[]>([])
@@ -67,9 +63,12 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
67
63
  const { entries, append, set, clear } = useLogBuffer()
68
64
  const { isStreaming, startStreaming, stopStreaming } = useLogStream()
69
65
 
70
- const podColors = useMemo(() => {
71
- const m = new Map<string, string>()
72
- pods.forEach((pod, i) => m.set(pod.name, POD_COLORS[i % POD_COLORS.length]))
66
+ // Map pod.name index. Color classes are resolved at render time from the
67
+ // current palette (see LogCore / pod-filter dropdown below) so toggling
68
+ // isDark re-themes pod labels without re-fetching.
69
+ const podColorIndex = useMemo(() => {
70
+ const m = new Map<string, number>()
71
+ pods.forEach((pod, i) => m.set(pod.name, i))
73
72
  return m
74
73
  }, [pods])
75
74
 
@@ -88,15 +87,15 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
88
87
  setSelectedPods(new Set(result.pods.map(p => p.name)))
89
88
  }
90
89
 
91
- const colors = new Map<string, string>()
92
- result.pods.forEach((pod, i) => colors.set(pod.name, POD_COLORS[i % POD_COLORS.length]))
90
+ const indexByPod = new Map<string, number>()
91
+ result.pods.forEach((pod, i) => indexByPod.set(pod.name, i))
93
92
 
94
93
  set(result.logs.map(log => ({
95
94
  timestamp: log.timestamp,
96
95
  content: log.content,
97
96
  container: log.container,
98
97
  pod: log.pod,
99
- podColor: colors.get(log.pod),
98
+ podColorIndex: indexByPod.get(log.pod),
100
99
  })))
101
100
  } catch (err) {
102
101
  console.error('Failed to fetch workload logs:', err)
@@ -129,7 +128,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
129
128
  content: data.content || '',
130
129
  container: data.container || '',
131
130
  pod: data.pod || '',
132
- podColor: podColors.get(data.pod || ''),
131
+ podColorIndex: podColorIndex.get(data.pod || ''),
133
132
  })
134
133
  }
135
134
  },
@@ -159,7 +158,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
159
158
  },
160
159
  'Workload log stream error',
161
160
  )
162
- }, [createStream, startStreaming, selectedContainer, sinceSeconds, append, podColors, selectedPods.size])
161
+ }, [createStream, startStreaming, selectedContainer, sinceSeconds, append, podColorIndex, selectedPods.size])
163
162
 
164
163
  const allContainers = useMemo(() => {
165
164
  const s = new Set<string>()
@@ -217,14 +216,14 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
217
216
  }
218
217
  }, [filteredEntries, name, overrideDownload, showError, showSuccess])
219
218
 
220
- const toolbarExtra = (
219
+ const renderToolbarExtra = ({ isDark, palette }: { isDark: boolean; palette: LogPalette }) => (
221
220
  <>
222
221
  {/* Pod filter */}
223
222
  <div className="relative">
224
223
  <button
225
224
  onClick={() => setShowPodFilter(v => !v)}
226
225
  className={`flex items-center gap-1.5 px-2 py-1.5 text-xs rounded transition-colors ${
227
- showPodFilter ? 'btn-brand-toggle' : 'bg-theme-elevated text-theme-text-secondary hover:bg-theme-hover'
226
+ showPodFilter ? palette.toolbarActive : `${palette.elevatedBg} ${palette.textSecondary} ${palette.hoverBg}`
228
227
  }`}
229
228
  >
230
229
  <Filter className="w-3 h-3" />
@@ -233,27 +232,36 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
233
232
  </button>
234
233
 
235
234
  {showPodFilter && (
236
- <div className="absolute top-full left-0 mt-1 w-64 bg-theme-elevated border border-theme-border rounded-lg shadow-lg z-50 max-h-64 overflow-y-auto">
237
- <div className="p-2 border-b border-theme-border">
238
- <button onClick={toggleAllPods} className="text-xs text-blue-400 hover:text-blue-300">
235
+ <div className={`absolute top-full left-0 mt-1 w-64 ${palette.menuBg} border ${palette.border} rounded-lg shadow-lg z-50 max-h-64 overflow-y-auto`}>
236
+ <div className={`p-2 border-b ${palette.border}`}>
237
+ <button onClick={toggleAllPods} className={`text-xs ${palette.textAccent} hover:underline`}>
239
238
  {pods.every(p => selectedPods.has(p.name)) ? 'Deselect all' : 'Select all'}
240
239
  </button>
241
240
  </div>
242
- {pods.map(pod => (
243
- <label key={pod.name} className="flex items-center gap-2 px-3 py-2 hover:bg-theme-hover">
244
- <input
245
- type="checkbox"
246
- checked={selectedPods.has(pod.name)}
247
- onChange={() => togglePod(pod.name)}
248
- className="w-3 h-3 rounded border-theme-border-light bg-theme-elevated text-blue-500 focus:ring-blue-500 focus:ring-offset-0"
249
- />
250
- <span className={`w-2 h-2 rounded-full ${podColors.get(pod.name)?.replace('text-', 'bg-')}`} />
251
- <span className="text-xs text-theme-text-primary truncate flex-1">{pod.name}</span>
252
- <span className={`text-xs ${pod.ready ? 'text-green-400' : 'text-yellow-400'}`}>
253
- {pod.ready ? 'Ready' : 'Not Ready'}
254
- </span>
255
- </label>
256
- ))}
241
+ {pods.map(pod => {
242
+ const dotBg = palette.podColors[(podColorIndex.get(pod.name) ?? 0) % palette.podColors.length].bg
243
+ let readyColor: string
244
+ if (pod.ready) {
245
+ readyColor = isDark ? 'text-emerald-400' : 'text-emerald-700'
246
+ } else {
247
+ readyColor = isDark ? 'text-amber-400' : 'text-amber-700'
248
+ }
249
+ return (
250
+ <label key={pod.name} className={`flex items-center gap-2 px-3 py-2 ${palette.hoverBg}`}>
251
+ <input
252
+ type="checkbox"
253
+ checked={selectedPods.has(pod.name)}
254
+ onChange={() => togglePod(pod.name)}
255
+ className={`w-3 h-3 rounded ${palette.borderLight} ${palette.elevatedBg} text-blue-500 focus:ring-blue-500 focus:ring-offset-0`}
256
+ />
257
+ <span className={`w-2 h-2 rounded-full ${dotBg}`} />
258
+ <span className={`text-xs ${palette.textPrimary} truncate flex-1`}>{pod.name}</span>
259
+ <span className={`text-xs ${readyColor}`}>
260
+ {pod.ready ? 'Ready' : 'Not Ready'}
261
+ </span>
262
+ </label>
263
+ )
264
+ })}
257
265
  </div>
258
266
  )}
259
267
  </div>
@@ -263,6 +271,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
263
271
  value={selectedContainer}
264
272
  onChange={setSelectedContainer}
265
273
  includeAll
274
+ isDark={isDark}
266
275
  />
267
276
 
268
277
  <LogRangeSelect
@@ -270,6 +279,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
270
279
  onChange={setLogRange}
271
280
  lineOptions={[50, 100, 500, 1000]}
272
281
  tooltip="How many logs to load per pod — by line count or time range"
282
+ isDark={isDark}
273
283
  />
274
284
  </>
275
285
  )
@@ -284,7 +294,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
284
294
  onRefresh={loadLogs}
285
295
  onDownload={downloadLogs}
286
296
  onClear={clear}
287
- toolbarExtra={toolbarExtra}
297
+ toolbarExtra={renderToolbarExtra}
288
298
  showPodName
289
299
  emptyMessage={pods.length === 0 ? 'No pods found' : 'No logs available'}
290
300
  errorMessage={fetchError}