@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.
- package/package.json +2 -2
- package/src/components/logs/LogCore.tsx +182 -16
- package/src/components/logs/LogsViewer.tsx +4 -21
- package/src/components/logs/WorkloadLogsViewer.tsx +5 -23
- package/src/components/resources/ResourcesView.tsx +150 -125
- package/src/components/resources/renderers/AWSManagedControlPlaneRenderer.tsx +2 -2
- package/src/components/resources/renderers/AlertRenderer.tsx +3 -3
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +5 -5
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +1 -1
- package/src/components/resources/renderers/CertificateRenderer.tsx +3 -3
- package/src/components/resources/renderers/CertificateRequestRenderer.tsx +5 -5
- package/src/components/resources/renderers/ChallengeRenderer.tsx +8 -8
- package/src/components/resources/renderers/CiliumNetworkPolicyRenderer.tsx +3 -3
- package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +2 -2
- package/src/components/resources/renderers/ClusterNetworkPolicyRenderer.tsx +3 -3
- package/src/components/resources/renderers/GatewayRenderer.tsx +2 -2
- package/src/components/resources/renderers/GenericRenderer.tsx +1 -1
- package/src/components/resources/renderers/IngressClassRenderer.tsx +1 -1
- package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +3 -3
- package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +2 -2
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +3 -3
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +2 -2
- package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +1 -1
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +23 -20
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +2 -2
- package/src/components/resources/renderers/NodeRenderer.tsx +3 -3
- package/src/components/resources/renderers/OrderRenderer.tsx +6 -6
- package/src/components/resources/renderers/RoleRenderer.tsx +3 -3
- package/src/components/resources/renderers/RolloutRenderer.tsx +2 -2
- package/src/components/resources/renderers/SealedSecretRenderer.tsx +3 -3
- package/src/components/resources/renderers/SecretStoreRenderer.tsx +8 -8
- package/src/components/resources/renderers/VPARenderer.tsx +1 -1
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +1 -1
- package/src/components/resources/renderers/WebhookConfigRenderer.tsx +9 -9
- package/src/components/resources/renderers/badge-no-handrolled.test.tsx +6 -12
- package/src/components/resources/renderers/capi-cells.tsx +3 -0
- package/src/components/resources/renderers/certmanager-cells.tsx +2 -2
- package/src/components/resources/renderers/cnpg-cells.tsx +1 -1
- package/src/components/resources/renderers/eso-cells.tsx +17 -1
- package/src/components/resources/renderers/knative-cells.tsx +14 -1
- package/src/components/resources/renderers/kyverno-cells.tsx +14 -3
- package/src/components/resources/renderers/kyverno-modern-cells.tsx +6 -0
- package/src/components/resources/renderers/trivy-cells.tsx +7 -0
- package/src/components/resources/renderers/velero-cells.tsx +3 -0
- package/src/components/resources/resource-utils-calico.test.ts +6 -0
- package/src/components/resources/resource-utils-calico.ts +6 -2
- package/src/components/resources/resource-utils-certmanager.ts +2 -2
- package/src/components/resources/resource-utils-eso.test.ts +26 -1
- package/src/components/resources/resource-utils-eso.ts +13 -0
- package/src/components/resources/resource-utils-knative.ts +3 -3
- package/src/components/resources/resource-utils-trivy.ts +3 -0
- package/src/components/resources/resource-utils.ts +17 -1
- package/src/components/resources/resources-column-filter.test.ts +20 -0
- package/src/components/topology/TopologyGraph.tsx +18 -1
- package/src/components/topology/pod-phase-fallback.test.ts +24 -0
- package/src/theme/components.css +29 -0
- package/src/utils/badge-colors.ts +1 -1
- package/src/utils/index.ts +1 -0
- package/src/utils/log-export.test.ts +225 -0
- package/src/utils/log-export.ts +149 -0
- package/src/utils/navigation.test.ts +10 -0
- 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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{/*
|
|
758
|
+
{/* Export */}
|
|
661
759
|
<div className="relative flex items-center" ref={downloadMenuRef}>
|
|
662
|
-
<Tooltip content="
|
|
760
|
+
<Tooltip content="Export logs" delay={TIP_DELAY} position="bottom" disabled={showDownloadMenu} preserveWrapperWhenDisabled>
|
|
663
761
|
<button
|
|
664
|
-
|
|
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-
|
|
672
|
-
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
className={
|
|
821
|
+
onClick={handleExportDownload}
|
|
822
|
+
disabled={exportEntries.length === 0}
|
|
823
|
+
className={exportActionCls}
|
|
677
824
|
>
|
|
678
|
-
|
|
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={() =>
|
|
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
|
-
|
|
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 {
|
|
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((
|
|
144
|
-
|
|
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
|
-
}, [
|
|
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 {
|
|
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((
|
|
264
|
-
|
|
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
|
-
}, [
|
|
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}
|