@skyhook-io/k8s-ui 1.14.2 → 1.14.4

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 (62) hide show
  1. package/package.json +2 -2
  2. package/src/components/logs/LogCore.tsx +182 -16
  3. package/src/components/logs/LogsViewer.tsx +4 -21
  4. package/src/components/logs/WorkloadLogsViewer.tsx +5 -23
  5. package/src/components/resources/ResourcesView.tsx +150 -125
  6. package/src/components/resources/renderers/AWSManagedControlPlaneRenderer.tsx +2 -2
  7. package/src/components/resources/renderers/AlertRenderer.tsx +3 -3
  8. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +5 -5
  9. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +1 -1
  10. package/src/components/resources/renderers/CertificateRenderer.tsx +3 -3
  11. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +5 -5
  12. package/src/components/resources/renderers/ChallengeRenderer.tsx +8 -8
  13. package/src/components/resources/renderers/CiliumNetworkPolicyRenderer.tsx +3 -3
  14. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +2 -2
  15. package/src/components/resources/renderers/ClusterNetworkPolicyRenderer.tsx +3 -3
  16. package/src/components/resources/renderers/GatewayRenderer.tsx +2 -2
  17. package/src/components/resources/renderers/GenericRenderer.tsx +1 -1
  18. package/src/components/resources/renderers/IngressClassRenderer.tsx +1 -1
  19. package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +3 -3
  20. package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +2 -2
  21. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +3 -3
  22. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +2 -2
  23. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +1 -1
  24. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +23 -20
  25. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +2 -2
  26. package/src/components/resources/renderers/NodeRenderer.tsx +3 -3
  27. package/src/components/resources/renderers/OrderRenderer.tsx +6 -6
  28. package/src/components/resources/renderers/RoleRenderer.tsx +3 -3
  29. package/src/components/resources/renderers/RolloutRenderer.tsx +2 -2
  30. package/src/components/resources/renderers/SealedSecretRenderer.tsx +3 -3
  31. package/src/components/resources/renderers/SecretStoreRenderer.tsx +8 -8
  32. package/src/components/resources/renderers/VPARenderer.tsx +1 -1
  33. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +1 -1
  34. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +9 -9
  35. package/src/components/resources/renderers/badge-no-handrolled.test.tsx +6 -12
  36. package/src/components/resources/renderers/capi-cells.tsx +3 -0
  37. package/src/components/resources/renderers/certmanager-cells.tsx +2 -2
  38. package/src/components/resources/renderers/cnpg-cells.tsx +1 -1
  39. package/src/components/resources/renderers/eso-cells.tsx +17 -1
  40. package/src/components/resources/renderers/knative-cells.tsx +14 -1
  41. package/src/components/resources/renderers/kyverno-cells.tsx +14 -3
  42. package/src/components/resources/renderers/kyverno-modern-cells.tsx +6 -0
  43. package/src/components/resources/renderers/trivy-cells.tsx +7 -0
  44. package/src/components/resources/renderers/velero-cells.tsx +3 -0
  45. package/src/components/resources/resource-utils-calico.test.ts +6 -0
  46. package/src/components/resources/resource-utils-calico.ts +6 -2
  47. package/src/components/resources/resource-utils-certmanager.ts +2 -2
  48. package/src/components/resources/resource-utils-eso.test.ts +26 -1
  49. package/src/components/resources/resource-utils-eso.ts +13 -0
  50. package/src/components/resources/resource-utils-knative.ts +3 -3
  51. package/src/components/resources/resource-utils-trivy.ts +3 -0
  52. package/src/components/resources/resource-utils.ts +17 -1
  53. package/src/components/resources/resources-column-filter.test.ts +20 -0
  54. package/src/components/topology/TopologyGraph.tsx +18 -1
  55. package/src/components/topology/pod-phase-fallback.test.ts +24 -0
  56. package/src/theme/components.css +29 -0
  57. package/src/utils/badge-colors.ts +1 -1
  58. package/src/utils/index.ts +1 -0
  59. package/src/utils/log-export.test.ts +225 -0
  60. package/src/utils/log-export.ts +149 -0
  61. package/src/utils/navigation.test.ts +10 -0
  62. package/src/utils/navigation.ts +4 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.14.2",
3
+ "version": "1.14.4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -70,7 +70,7 @@
70
70
  "@monaco-editor/react": "^4.7.0",
71
71
  "html-to-image": "^1.11.0",
72
72
  "monaco-yaml": "5.5.1",
73
- "react-virtuoso": "^4.18.12",
73
+ "react-virtuoso": "^4.18.13",
74
74
  "shiki": "^4.4.2"
75
75
  },
76
76
  "peerDependencies": {
@@ -1,11 +1,12 @@
1
1
  import { useRef, useCallback, useState, useMemo, useEffect, type ReactNode } from 'react'
2
2
  import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
3
- import { Play, Square, Download, Search, X, Terminal, RotateCcw, ChevronUp, ChevronDown, ChevronRight, CaseSensitive, Regex, WrapText, Clock, Copy, Trash2, Filter, Braces, Palette, ListCollapse, Sun, Moon } from 'lucide-react'
3
+ import { Play, Square, Download, FileDown, Search, X, Terminal, RotateCcw, ChevronUp, ChevronDown, ChevronRight, CaseSensitive, Regex, WrapText, Clock, Copy, Trash2, Filter, Braces, Palette, ListCollapse, Sun, Moon } from 'lucide-react'
4
4
  import type { LogEntry, LogLevel } from './useLogBuffer'
5
5
  import { useLogSearch } from './useLogSearch'
6
6
  import { StructuredLogLine } from './StructuredLogLine'
7
7
  import { Tooltip } from '../ui/Tooltip'
8
8
  import { Input } from '../ui/Input'
9
+ import { showApiError, showApiSuccess } from '../ui/Toast'
9
10
  import {
10
11
  formatLogTimestamp,
11
12
  highlightSearchMatches,
@@ -15,8 +16,18 @@ import {
15
16
  TIMESTAMP_FORMAT_LABELS,
16
17
  } from '../../utils/log-format'
17
18
  import { getLogPalette, getLogLevelColor, type LogPalette } from './log-palette'
19
+ import { copyText } from '../../utils/clipboard'
20
+ import {
21
+ LOG_EXPORT_FORMAT_LABELS,
22
+ LOG_EXPORT_FORMATS,
23
+ previewLogExport,
24
+ serializeLogEntries,
25
+ type LogExportFormat,
26
+ type LogExportPayload,
27
+ } from '../../utils/log-export'
18
28
 
19
- export type DownloadFormat = 'txt' | 'json' | 'csv'
29
+ /** Retained for consumers that already import it; the export module owns the definition. */
30
+ export type DownloadFormat = LogExportFormat
20
31
 
21
32
  /**
22
33
  * `toolbarExtra` may be a plain ReactNode, or a function that receives the
@@ -28,12 +39,19 @@ export type ToolbarExtraRenderer =
28
39
 
29
40
  interface LogCoreProps {
30
41
  entries: LogEntry[]
42
+ /**
43
+ * The unfiltered buffer, when the host filters `entries` before passing them
44
+ * (the workload viewer's pod picker), so that export's "All" describes the
45
+ * real buffer rather than the slice currently on screen.
46
+ */
47
+ allEntries?: LogEntry[]
31
48
  isLoading: boolean
32
49
  isStreaming: boolean
33
50
  onStartStream?: () => void
34
51
  onStopStream: () => void
35
52
  onRefresh: () => void
36
- onDownload: (format: DownloadFormat) => void
53
+ /** Receives a finished payload — the viewer owns serialization so copy and download cannot diverge. */
54
+ onDownload: (payload: LogExportPayload) => void
37
55
  onClear?: () => void
38
56
  toolbarExtra?: ToolbarExtraRenderer
39
57
  showPodName?: boolean
@@ -118,6 +136,7 @@ const TIP_DELAY = 150
118
136
 
119
137
  export function LogCore({
120
138
  entries,
139
+ allEntries,
121
140
  isLoading,
122
141
  isStreaming,
123
142
  onStartStream,
@@ -183,6 +202,13 @@ export function LogCore({
183
202
  new Set(['error', 'warn', 'info', 'debug'])
184
203
  )
185
204
  const [showDownloadMenu, setShowDownloadMenu] = useState(false)
205
+ const [exportScope, setExportScope] = useState<'visible' | 'all'>('visible')
206
+ const [exportFormat, setExportFormat] = useState<LogExportFormat>(() => {
207
+ try {
208
+ const v = localStorage.getItem('radar-logs-export-format') as LogExportFormat | null
209
+ return v === 'txt' || v === 'json' || v === 'csv' ? v : 'txt'
210
+ } catch { return 'txt' }
211
+ })
186
212
  const [showTsMenu, setShowTsMenu] = useState(false)
187
213
  const [showStructuredMenu, setShowStructuredMenu] = useState(false)
188
214
  const [structuredMode, setStructuredMode] = useState<StructuredMode>(() => {
@@ -228,6 +254,12 @@ export function LogCore({
228
254
  ? search.filteredEntries
229
255
  : levelFilteredEntries
230
256
 
257
+ const exportTriggerRef = useRef<HTMLButtonElement>(null)
258
+ const closeExportMenu = useCallback(() => {
259
+ setShowDownloadMenu(false)
260
+ exportTriggerRef.current?.focus()
261
+ }, [])
262
+
231
263
  // Close download menu on next click anywhere (deferred so current click doesn't trigger it)
232
264
  const downloadMenuRef = useRef<HTMLDivElement>(null)
233
265
  useEffect(() => {
@@ -240,6 +272,61 @@ export function LogCore({
240
272
  return () => window.removeEventListener('click', handleClick)
241
273
  }, [showDownloadMenu])
242
274
 
275
+ useEffect(() => {
276
+ if (!showDownloadMenu) return
277
+ const handleKey = (e: KeyboardEvent) => {
278
+ if (e.key !== 'Escape') return
279
+ // Capture phase + stopImmediatePropagation: the host's drawer listens for
280
+ // Escape on window too, and a bubble-phase stopPropagation would not stop
281
+ // a sibling listener on the same target — dismissing the popover would
282
+ // close the drawer behind it in the same keystroke.
283
+ e.stopImmediatePropagation()
284
+ e.preventDefault()
285
+ closeExportMenu()
286
+ }
287
+ window.addEventListener('keydown', handleKey, true)
288
+ return () => window.removeEventListener('keydown', handleKey, true)
289
+ }, [showDownloadMenu, closeExportMenu])
290
+
291
+ // Scope and format are the only export decisions the viewer cannot already
292
+ // express on screen: everything else (timestamps, ANSI, level, search) has a
293
+ // toolbar control, and the popover's preview shows where those land.
294
+ const bufferEntries = allEntries ?? entries
295
+ const exportEntries = exportScope === 'all' ? bufferEntries : displayEntries
296
+ const exportOptions = useMemo(
297
+ () => ({ format: exportFormat, showTimestamps, showPodName }),
298
+ [exportFormat, showTimestamps, showPodName],
299
+ )
300
+ const exportPreview = useMemo(
301
+ () => showDownloadMenu ? previewLogExport(exportEntries, exportOptions) : [],
302
+ [showDownloadMenu, exportEntries, exportOptions],
303
+ )
304
+ // Offering a scope that resolves to the same lines is a choice about nothing.
305
+ const scopeIsMeaningful = displayEntries.length !== bufferEntries.length
306
+
307
+ // Scope describes the filters in play at the moment of asking, not a lasting
308
+ // intent, so it starts over at the narrower option every time rather than
309
+ // silently exporting the whole buffer on a later visit.
310
+ const toggleExportMenu = useCallback(() => {
311
+ setShowDownloadMenu(prev => !prev)
312
+ setExportScope('visible')
313
+ }, [])
314
+
315
+ const handleExportCopy = useCallback(() => {
316
+ closeExportMenu()
317
+ const count = exportEntries.length
318
+ const failed = () => showApiError('Failed to copy logs', 'The browser blocked clipboard access.')
319
+ copyText(serializeLogEntries(exportEntries, exportOptions).content).then(
320
+ ok => ok ? showApiSuccess('Copied to clipboard', `${count} log line${count === 1 ? '' : 's'} copied.`) : failed(),
321
+ failed,
322
+ )
323
+ }, [closeExportMenu, exportEntries, exportOptions])
324
+
325
+ const handleExportDownload = useCallback(() => {
326
+ closeExportMenu()
327
+ onDownload(serializeLogEntries(exportEntries, exportOptions))
328
+ }, [closeExportMenu, onDownload, exportEntries, exportOptions])
329
+
243
330
  // Same close-on-outside-click for the timestamp format menu.
244
331
  const tsMenuRef = useRef<HTMLDivElement>(null)
245
332
  useEffect(() => {
@@ -263,6 +350,11 @@ export function LogCore({
263
350
  return () => window.removeEventListener('click', handleClick)
264
351
  }, [showStructuredMenu])
265
352
 
353
+ const pickExportFormat = useCallback((fmt: LogExportFormat) => {
354
+ setExportFormat(fmt)
355
+ try { localStorage.setItem('radar-logs-export-format', fmt) } catch {}
356
+ }, [])
357
+
266
358
  const pickStructuredMode = useCallback((mode: StructuredMode) => {
267
359
  setStructuredMode(mode)
268
360
  try { localStorage.setItem('radar-logs-structured-mode', mode) } catch {}
@@ -427,6 +519,12 @@ export function LogCore({
427
519
  const iconBtnInactiveTertiary = isDark
428
520
  ? 'p-1.5 rounded transition-colors text-slate-500 hover:text-slate-100 hover:bg-slate-800'
429
521
  : 'p-1.5 rounded transition-colors text-slate-400 hover:text-slate-900 hover:bg-slate-200'
522
+ // Export popover: house style for a group label, plus the two equal-weight
523
+ // actions the scope and format rows above them both apply to.
524
+ const exportLabelCls = `w-12 shrink-0 text-[10px] font-semibold uppercase tracking-wide ${palette.textTertiary}`
525
+ const exportActionCls = isDark
526
+ ? 'flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded text-xs font-medium transition-colors border border-slate-700 text-slate-200 hover:bg-slate-800 disabled:opacity-40 disabled:pointer-events-none'
527
+ : 'flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded text-xs font-medium transition-colors border border-slate-300 text-slate-700 hover:bg-slate-200 disabled:opacity-40 disabled:pointer-events-none'
430
528
  // "disabled → tertiary on hover" for inactive level filter chips.
431
529
  const levelChipInactive = isDark
432
530
  ? 'border-transparent text-slate-600 hover:text-slate-500'
@@ -657,27 +755,77 @@ export function LogCore({
657
755
  </Tooltip>
658
756
  )}
659
757
 
660
- {/* Download */}
758
+ {/* Export */}
661
759
  <div className="relative flex items-center" ref={downloadMenuRef}>
662
- <Tooltip content="Download logs" delay={TIP_DELAY} position="bottom">
760
+ <Tooltip content="Export logs" delay={TIP_DELAY} position="bottom" disabled={showDownloadMenu} preserveWrapperWhenDisabled>
663
761
  <button
664
- onClick={() => setShowDownloadMenu(prev => !prev)}
762
+ ref={exportTriggerRef}
763
+ onClick={toggleExportMenu}
764
+ aria-label="Export logs"
765
+ aria-haspopup="dialog"
766
+ aria-expanded={showDownloadMenu}
665
767
  className={iconBtnInactive}
666
768
  >
667
769
  <Download className="w-4 h-4" />
668
770
  </button>
669
771
  </Tooltip>
670
772
  {showDownloadMenu && (
671
- <div className={`absolute top-full right-0 mt-1 w-32 ${palette.menuBg} border ${palette.border} rounded-lg shadow-lg z-50`}>
672
- {(['txt', 'json', 'csv'] as DownloadFormat[]).map(fmt => (
773
+ <div role="dialog" aria-label="Export logs" className={`absolute top-full right-0 mt-1 w-[23rem] ${palette.menuBg} border ${palette.border} rounded-lg shadow-lg z-50 p-3 space-y-2.5`}>
774
+ <div className="flex items-center gap-3">
775
+ <span id="log-export-scope-label" className={exportLabelCls}>Lines</span>
776
+ {scopeIsMeaningful ? (
777
+ <div role="radiogroup" aria-labelledby="log-export-scope-label" className="flex items-center gap-1">
778
+ <ExportChoice active={exportScope === 'visible'} onClick={() => setExportScope('visible')} palette={palette}>
779
+ Filtered {displayEntries.length.toLocaleString()}
780
+ </ExportChoice>
781
+ <ExportChoice active={exportScope === 'all'} onClick={() => setExportScope('all')} palette={palette}>
782
+ All {bufferEntries.length.toLocaleString()}
783
+ </ExportChoice>
784
+ </div>
785
+ ) : (
786
+ <span className={`text-[11px] ${palette.textSecondary}`}>
787
+ {bufferEntries.length.toLocaleString()} line{bufferEntries.length === 1 ? '' : 's'}
788
+ </span>
789
+ )}
790
+ </div>
791
+
792
+ <div className="flex items-center gap-3">
793
+ <span id="log-export-format-label" className={exportLabelCls}>Format</span>
794
+ <div role="radiogroup" aria-labelledby="log-export-format-label" className="flex items-center gap-1">
795
+ {LOG_EXPORT_FORMATS.map(fmt => (
796
+ <ExportChoice key={fmt} active={exportFormat === fmt} onClick={() => pickExportFormat(fmt)} palette={palette}>
797
+ {LOG_EXPORT_FORMAT_LABELS[fmt]}
798
+ </ExportChoice>
799
+ ))}
800
+ </div>
801
+ </div>
802
+
803
+ {/* Shows the shape of the real output, so no caption has to claim
804
+ which fields ride along or that the toolbar toggles reach it. */}
805
+ <div className={`rounded border ${palette.border} ${palette.toolbarBg} px-2 py-1.5 font-mono text-[10px] leading-relaxed min-h-[44px] ${palette.textSecondary}`}>
806
+ {exportPreview.length > 0
807
+ ? exportPreview.map((line, i) => <div key={i} className="truncate">{line}</div>)
808
+ : <div className={palette.textTertiary}>Nothing to export.</div>}
809
+ </div>
810
+
811
+ <div className="flex items-center gap-2">
812
+ <button
813
+ onClick={handleExportCopy}
814
+ disabled={exportEntries.length === 0}
815
+ className={exportActionCls}
816
+ >
817
+ <Copy className="w-3.5 h-3.5" />
818
+ Copy
819
+ </button>
673
820
  <button
674
- key={fmt}
675
- onClick={() => { onDownload(fmt); setShowDownloadMenu(false) }}
676
- className={`w-full text-left px-3 py-2 text-xs ${palette.textPrimary} ${palette.hoverBg} first:rounded-t-lg last:rounded-b-lg`}
821
+ onClick={handleExportDownload}
822
+ disabled={exportEntries.length === 0}
823
+ className={exportActionCls}
677
824
  >
678
- {fmt.toUpperCase()}
825
+ <FileDown className="w-3.5 h-3.5" />
826
+ Download
679
827
  </button>
680
- ))}
828
+ </div>
681
829
  </div>
682
830
  )}
683
831
  </div>
@@ -813,7 +961,7 @@ export function LogCore({
813
961
  <Terminal className="w-8 h-8" />
814
962
  <span>{emptyMessage}</span>
815
963
  {emptyCommand && (
816
- <button type="button" onClick={() => navigator.clipboard.writeText(emptyCommand).catch(() => {})} className={`mt-2 inline-flex max-w-[80%] items-center gap-2 rounded border px-3 py-2 font-mono text-xs ${palette.border} ${palette.toolbarBg}`} title="Copy recovery command">
964
+ <button type="button" onClick={() => { void copyText(emptyCommand) }} className={`mt-2 inline-flex max-w-[80%] items-center gap-2 rounded border px-3 py-2 font-mono text-xs ${palette.border} ${palette.toolbarBg}`} title="Copy recovery command">
817
965
  <code className="truncate">{emptyCommand}</code>
818
966
  <Copy className="h-3.5 w-3.5 shrink-0" />
819
967
  </button>
@@ -876,6 +1024,25 @@ export function LogCore({
876
1024
  )
877
1025
  }
878
1026
 
1027
+ /** One pill in the export popover's scope and format rows. */
1028
+ function ExportChoice({ active, onClick, palette, children }: {
1029
+ active: boolean
1030
+ onClick: () => void
1031
+ palette: LogPalette
1032
+ children: ReactNode
1033
+ }) {
1034
+ return (
1035
+ <button
1036
+ onClick={onClick}
1037
+ role="radio"
1038
+ aria-checked={active}
1039
+ className={`px-2 py-1 rounded text-[11px] transition-colors ${active ? palette.toolbarActive : `${palette.textSecondary} ${palette.hoverBg}`}`}
1040
+ >
1041
+ {children}
1042
+ </button>
1043
+ )
1044
+ }
1045
+
879
1046
  function Shortcut({ keys, label, palette }: { keys: string; label: string; palette: LogPalette }) {
880
1047
  return (
881
1048
  <span className="flex items-center gap-1">
@@ -969,8 +1136,7 @@ function LogLine({
969
1136
  }
970
1137
 
971
1138
  const handleCopy = () => {
972
- const raw = stripAnsi(entry.content)
973
- navigator.clipboard.writeText(raw).catch(() => {})
1139
+ void copyText(stripAnsi(entry.content))
974
1140
  }
975
1141
 
976
1142
  return (
@@ -5,7 +5,7 @@ import { useLogBuffer } from './useLogBuffer'
5
5
  import { useLogStream } from './useLogStream'
6
6
  import { ContainerSelect, LogRangeSelect } from './LogToolbarSelects'
7
7
  import { LogCore } from './LogCore'
8
- import type { DownloadFormat } from './LogCore'
8
+ import type { LogExportPayload } from '../../utils/log-export'
9
9
  import type { LogPalette } from './log-palette'
10
10
  import { Tooltip } from '../ui/Tooltip'
11
11
  import { useToast } from '../ui/Toast'
@@ -140,25 +140,8 @@ export function LogsViewer({
140
140
  return () => { autoStartedForRef.current = null }
141
141
  }, [willAutoStream, selectedContainer, handleStartStreaming])
142
142
 
143
- const downloadLogs = useCallback((format: DownloadFormat) => {
144
- let content: string
145
- let mime: string
146
- const filename = `${podName}-${selectedContainer}-logs.${format}`
147
- switch (format) {
148
- case 'json':
149
- content = JSON.stringify(entries.map(l => ({ timestamp: l.timestamp, content: l.content, container: l.container })), null, 2)
150
- mime = 'application/json'
151
- break
152
- case 'csv':
153
- content = 'timestamp,container,content\n' + entries.map(l =>
154
- `${l.timestamp},${l.container},"${l.content.replace(/"/g, '""')}"`)
155
- .join('\n')
156
- mime = 'text/csv'
157
- break
158
- default:
159
- content = entries.map(l => `${l.timestamp} ${l.content}`).join('\n')
160
- mime = 'text/plain'
161
- }
143
+ const downloadLogs = useCallback(({ content, mime, extension }: LogExportPayload) => {
144
+ const filename = `${podName}-${selectedContainer}-logs.${extension}`
162
145
  try {
163
146
  triggerDownload(content, mime, filename, overrideDownload)
164
147
  if (!overrideDownload) {
@@ -167,7 +150,7 @@ export function LogsViewer({
167
150
  } catch (err) {
168
151
  showError('Failed to download logs', err instanceof Error ? err.message : 'Unknown download error')
169
152
  }
170
- }, [entries, podName, selectedContainer, overrideDownload, showError, showSuccess])
153
+ }, [podName, selectedContainer, overrideDownload, showError, showSuccess])
171
154
 
172
155
  const renderToolbarExtra = ({ isDark, palette }: { isDark: boolean; palette: LogPalette }) => (
173
156
  <>
@@ -6,7 +6,7 @@ import { useLogBuffer } from './useLogBuffer'
6
6
  import { useLogStream } from './useLogStream'
7
7
  import { ContainerSelect, LogRangeSelect } from './LogToolbarSelects'
8
8
  import { LogCore } from './LogCore'
9
- import type { DownloadFormat } from './LogCore'
9
+ import type { LogExportPayload } from '../../utils/log-export'
10
10
  import type { LogPalette } from './log-palette'
11
11
  import type { WorkloadPodInfo } from '../../types'
12
12
  import { useToast } from '../ui/Toast'
@@ -260,27 +260,8 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
260
260
  [entries, selectedPods],
261
261
  )
262
262
 
263
- const downloadLogs = useCallback((format: DownloadFormat) => {
264
- let content: string
265
- let mime: string
266
- const filename = `${name}-logs.${format}`
267
- switch (format) {
268
- case 'json':
269
- content = JSON.stringify(filteredEntries.map(l => ({
270
- timestamp: l.timestamp, pod: l.pod, container: l.container, content: l.content,
271
- })), null, 2)
272
- mime = 'application/json'
273
- break
274
- case 'csv':
275
- content = 'timestamp,pod,container,content\n' + filteredEntries.map(l =>
276
- `${l.timestamp},${l.pod || ''},${l.container},"${l.content.replace(/"/g, '""')}"`)
277
- .join('\n')
278
- mime = 'text/csv'
279
- break
280
- default:
281
- content = filteredEntries.map(l => `${l.timestamp} [${l.pod}/${l.container}] ${l.content}`).join('\n')
282
- mime = 'text/plain'
283
- }
263
+ const downloadLogs = useCallback(({ content, mime, extension }: LogExportPayload) => {
264
+ const filename = `${name}-logs.${extension}`
284
265
  try {
285
266
  triggerDownload(content, mime, filename, overrideDownload)
286
267
  if (!overrideDownload) {
@@ -289,7 +270,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
289
270
  } catch (err) {
290
271
  showError('Failed to download logs', err instanceof Error ? err.message : 'Unknown download error')
291
272
  }
292
- }, [filteredEntries, name, overrideDownload, showError, showSuccess])
273
+ }, [name, overrideDownload, showError, showSuccess])
293
274
 
294
275
  const renderToolbarExtra = ({ isDark, palette }: { isDark: boolean; palette: LogPalette }) => (
295
276
  <>
@@ -375,6 +356,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
375
356
  return (
376
357
  <LogCore
377
358
  entries={filteredEntries}
359
+ allEntries={entries}
378
360
  isLoading={isLoading || isConnecting}
379
361
  isStreaming={isStreaming}
380
362
  onStartStream={createStream ? handleStartStreaming : undefined}