@skyhook-io/k8s-ui 1.14.3 → 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 +17 -3
- 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/cnpg-cells.tsx +1 -1
- package/src/components/resources/renderers/eso-cells.tsx +17 -1
- package/src/components/resources/renderers/knative-cells.tsx +1 -1
- package/src/components/resources/renderers/kyverno-cells.tsx +7 -3
- 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/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}
|
|
@@ -1839,6 +1839,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
1839
1839
|
{ key: 'namespace', label: 'Namespace', width: 'w-36' },
|
|
1840
1840
|
{ key: 'status', label: 'Status', width: 'w-24' },
|
|
1841
1841
|
{ key: 'store', label: 'Store', width: 'w-36' },
|
|
1842
|
+
{ key: 'provider', label: 'Provider', width: 'w-48', tooltip: 'Backend the referenced store reads from. A dash means the store could not be read — it may not exist, or may be outside your access; an ellipsis means the stores are still loading.' },
|
|
1842
1843
|
{ key: 'refreshInterval', label: 'Refresh', width: 'w-24', tooltip: 'How often the value is re-pulled from the provider. A zero interval means it is fetched once and not updated afterward.' },
|
|
1843
1844
|
{ key: 'lastSync', label: 'Last Sync', width: 'w-28' },
|
|
1844
1845
|
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
@@ -3270,6 +3271,10 @@ interface ResourcesViewData {
|
|
|
3270
3271
|
onNavigate?: (path: string, options?: { replace?: boolean }) => void
|
|
3271
3272
|
certExpiry?: Record<string, { expired?: boolean; daysLeft: number }>
|
|
3272
3273
|
certExpiryError?: boolean
|
|
3274
|
+
// Provider type per SecretStore, keyed 'ns/name' and 'name' for the
|
|
3275
|
+
// cluster-scoped kind. An ExternalSecret names a store; only the store
|
|
3276
|
+
// knows which backend it reads from.
|
|
3277
|
+
storeProviders?: Record<string, string>
|
|
3273
3278
|
/** Cluster Audit findings keyed by "namespace/name". Raw compatibility
|
|
3274
3279
|
* counts render danger as High and warning as Medium. */
|
|
3275
3280
|
auditBadges?: Record<string, { danger: number; warning: number; messages?: AuditBadgeMessage[] }>
|
|
@@ -3330,6 +3335,10 @@ interface ResourcesViewProps {
|
|
|
3330
3335
|
topNodeMetrics?: TopNodeMetrics[]
|
|
3331
3336
|
certExpiry?: Record<string, { expired?: boolean; daysLeft: number }>
|
|
3332
3337
|
certExpiryError?: boolean
|
|
3338
|
+
// Provider type per SecretStore, keyed 'ns/name' and 'name' for the
|
|
3339
|
+
// cluster-scoped kind. An ExternalSecret names a store; only the store
|
|
3340
|
+
// knows which backend it reads from.
|
|
3341
|
+
storeProviders?: Record<string, string>
|
|
3333
3342
|
/** Cluster Audit findings keyed by "namespace/name". Raw compatibility
|
|
3334
3343
|
* counts render danger as High and warning as Medium. */
|
|
3335
3344
|
auditBadges?: Record<string, { danger: number; warning: number; messages?: AuditBadgeMessage[] }>
|
|
@@ -3579,6 +3588,7 @@ export function ResourcesView({
|
|
|
3579
3588
|
topNodeMetrics,
|
|
3580
3589
|
certExpiry,
|
|
3581
3590
|
certExpiryError,
|
|
3591
|
+
storeProviders,
|
|
3582
3592
|
auditBadges,
|
|
3583
3593
|
pinned = [],
|
|
3584
3594
|
togglePin = () => {},
|
|
@@ -6621,6 +6631,7 @@ export function ResourcesView({
|
|
|
6621
6631
|
isChecked={checkedResources.has(resourceKey)}
|
|
6622
6632
|
showCheckbox={isCheckboxMode}
|
|
6623
6633
|
majorityNodeMinorVersion={majorityNodeMinorVersion}
|
|
6634
|
+
storeProviders={storeProviders}
|
|
6624
6635
|
onRowClick={handleRowClick}
|
|
6625
6636
|
onRowMouseEnter={handleRowMouseEnter}
|
|
6626
6637
|
compareMode={compareMode}
|
|
@@ -6776,6 +6787,7 @@ interface ResourceRowCellsProps {
|
|
|
6776
6787
|
isChecked?: boolean
|
|
6777
6788
|
showCheckbox?: boolean
|
|
6778
6789
|
majorityNodeMinorVersion?: string
|
|
6790
|
+
storeProviders?: Record<string, string>
|
|
6779
6791
|
// Row callbacks receive the resource so the parent can pass referentially
|
|
6780
6792
|
// stable handlers — per-row closures would defeat React.memo and re-render
|
|
6781
6793
|
// every visible row on each SSE-driven refetch.
|
|
@@ -6810,7 +6822,7 @@ function rowHighlightClass(
|
|
|
6810
6822
|
return 'group-hover/row:bg-theme-surface/50'
|
|
6811
6823
|
}
|
|
6812
6824
|
|
|
6813
|
-
const ResourceRowCells = React.memo(function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, hasSpacerColumn, isSelected, isHighlighted, isChecked, showCheckbox, majorityNodeMinorVersion, onRowClick, onRowMouseEnter, compareMode, comparePickIndex = -1, rowHref, onRowCheckToggle }: ResourceRowCellsProps) {
|
|
6825
|
+
const ResourceRowCells = React.memo(function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, hasSpacerColumn, isSelected, isHighlighted, isChecked, showCheckbox, majorityNodeMinorVersion, storeProviders, onRowClick, onRowMouseEnter, compareMode, comparePickIndex = -1, rowHref, onRowCheckToggle }: ResourceRowCellsProps) {
|
|
6814
6826
|
const rowHighlight = rowHighlightClass(compareMode, comparePickIndex, isSelected, isHighlighted, isChecked)
|
|
6815
6827
|
const pickedSide = comparePickIndex === 0 ? 'a' : comparePickIndex === 1 ? 'b' : null
|
|
6816
6828
|
const onClick = onRowClick ? () => onRowClick(resource, !!isSelected) : undefined
|
|
@@ -6877,6 +6889,7 @@ const ResourceRowCells = React.memo(function ResourceRowCells({ resource, kind,
|
|
|
6877
6889
|
group={group}
|
|
6878
6890
|
column={col.key}
|
|
6879
6891
|
majorityNodeMinorVersion={majorityNodeMinorVersion}
|
|
6892
|
+
storeProviders={storeProviders}
|
|
6880
6893
|
extraColumn={extraColumnsByKey?.get(col.key)}
|
|
6881
6894
|
nameHref={col.key === 'name' ? rowHref : undefined}
|
|
6882
6895
|
/>
|
|
@@ -6920,6 +6933,7 @@ interface CellContentProps {
|
|
|
6920
6933
|
column: string
|
|
6921
6934
|
group?: string
|
|
6922
6935
|
majorityNodeMinorVersion?: string
|
|
6936
|
+
storeProviders?: Record<string, string>
|
|
6923
6937
|
/** When provided, the parent has injected an ExtraColumn for this
|
|
6924
6938
|
* column key. Render via the extra's render() and short-circuit
|
|
6925
6939
|
* the built-in cell logic. */
|
|
@@ -6929,7 +6943,7 @@ interface CellContentProps {
|
|
|
6929
6943
|
nameHref?: string
|
|
6930
6944
|
}
|
|
6931
6945
|
|
|
6932
|
-
function CellContent({ resource, kind, column, group, majorityNodeMinorVersion, extraColumn, nameHref }: CellContentProps) {
|
|
6946
|
+
function CellContent({ resource, kind, column, group, majorityNodeMinorVersion, storeProviders, extraColumn, nameHref }: CellContentProps) {
|
|
6933
6947
|
const { auditBadges } = useContext(ResourcesViewDataContext)
|
|
6934
6948
|
// Parent-injected extra columns short-circuit the built-in switch.
|
|
6935
6949
|
// Used by hosts that inject leading columns (e.g. a multi-cluster Cluster column).
|
|
@@ -7317,7 +7331,7 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
|
|
|
7317
7331
|
return <SbomReportCell resource={resource} column={column} />
|
|
7318
7332
|
// External Secrets Operator
|
|
7319
7333
|
case 'externalsecrets':
|
|
7320
|
-
return <ExternalSecretCell resource={resource} column={column} />
|
|
7334
|
+
return <ExternalSecretCell resource={resource} column={column} storeProviders={storeProviders} />
|
|
7321
7335
|
case 'clusterexternalsecrets':
|
|
7322
7336
|
return <ClusterExternalSecretCell resource={resource} column={column} />
|
|
7323
7337
|
case 'secretstores':
|
|
@@ -145,9 +145,9 @@ export function AWSManagedControlPlaneRenderer({ data }: Props) {
|
|
|
145
145
|
<td className="py-1 text-theme-text-secondary font-mono text-[10px]">{a.statusVersion !== '-' ? a.statusVersion : a.specVersion}</td>
|
|
146
146
|
<td className="py-1">
|
|
147
147
|
<span className={`badge badge-sm ${a.status === 'ACTIVE'
|
|
148
|
-
? 'status-
|
|
148
|
+
? 'status-green'
|
|
149
149
|
: a.status === 'DEGRADED'
|
|
150
|
-
? 'status-
|
|
150
|
+
? 'status-red'
|
|
151
151
|
: 'status-neutral'
|
|
152
152
|
}`}>{a.status}</span>
|
|
153
153
|
</td>
|
|
@@ -92,9 +92,9 @@ export function AlertRenderer({ data }: AlertRendererProps) {
|
|
|
92
92
|
value={
|
|
93
93
|
<span className={clsx(
|
|
94
94
|
'badge',
|
|
95
|
-
eventSeverity === 'error' ? '
|
|
96
|
-
eventSeverity === 'warning' ? '
|
|
97
|
-
'
|
|
95
|
+
eventSeverity === 'error' ? 'status-red' :
|
|
96
|
+
eventSeverity === 'warning' ? 'status-amber' :
|
|
97
|
+
'status-blue'
|
|
98
98
|
)}>
|
|
99
99
|
{eventSeverity}
|
|
100
100
|
</span>
|
|
@@ -178,7 +178,7 @@ export function ArgoApplicationRenderer({ data, onTerminate, isTerminating }: Ar
|
|
|
178
178
|
<button
|
|
179
179
|
onClick={() => onTerminate({ namespace, name })}
|
|
180
180
|
disabled={isTerminating}
|
|
181
|
-
className="flex items-center gap-1.5 px-2 py-1 rounded text-xs
|
|
181
|
+
className="flex items-center gap-1.5 px-2 py-1 rounded text-xs status-red hover:opacity-80 transition-colors disabled:opacity-50"
|
|
182
182
|
title="Terminate sync"
|
|
183
183
|
>
|
|
184
184
|
<XCircle className="w-3.5 h-3.5" />
|
|
@@ -237,7 +237,7 @@ export function ArgoApplicationRenderer({ data, onTerminate, isTerminating }: Ar
|
|
|
237
237
|
<span
|
|
238
238
|
className={clsx(
|
|
239
239
|
'badge',
|
|
240
|
-
syncPolicy.automated ? '
|
|
240
|
+
syncPolicy.automated ? 'status-green' : BADGE_INACTIVE
|
|
241
241
|
)}
|
|
242
242
|
>
|
|
243
243
|
{syncPolicy.automated ? 'Enabled' : 'Disabled'}
|
|
@@ -281,11 +281,11 @@ export function ArgoApplicationRenderer({ data, onTerminate, isTerminating }: Ar
|
|
|
281
281
|
className={clsx(
|
|
282
282
|
'badge',
|
|
283
283
|
operationState.phase === 'Succeeded'
|
|
284
|
-
? '
|
|
284
|
+
? 'status-green'
|
|
285
285
|
: operationState.phase === 'Running'
|
|
286
|
-
? '
|
|
286
|
+
? 'status-blue'
|
|
287
287
|
: operationState.phase === 'Failed' || operationState.phase === 'Error'
|
|
288
|
-
? '
|
|
288
|
+
? 'status-red'
|
|
289
289
|
: BADGE_INACTIVE
|
|
290
290
|
)}
|
|
291
291
|
>
|
|
@@ -287,7 +287,7 @@ export function CNPGClusterRenderer({ data, onNavigate, declared}: CNPGClusterRe
|
|
|
287
287
|
)}
|
|
288
288
|
{data.spec?.enableSuperuserAccess !== undefined && (
|
|
289
289
|
<Property label="Superuser Access" value={
|
|
290
|
-
<span className={`badge-sm ${data.spec.enableSuperuserAccess ? '
|
|
290
|
+
<span className={`badge-sm ${data.spec.enableSuperuserAccess ? 'status-green' : 'bg-theme-hover text-theme-text-secondary'}`}>
|
|
291
291
|
{data.spec.enableSuperuserAccess ? 'Enabled' : 'Disabled'}
|
|
292
292
|
</span>
|
|
293
293
|
} />
|
|
@@ -120,13 +120,13 @@ export function CertificateRenderer({ data }: CertificateRendererProps) {
|
|
|
120
120
|
<span className={clsx(
|
|
121
121
|
'badge',
|
|
122
122
|
isReady
|
|
123
|
-
? '
|
|
124
|
-
: '
|
|
123
|
+
? 'status-green'
|
|
124
|
+
: 'status-red'
|
|
125
125
|
)}>
|
|
126
126
|
{isReady ? 'Ready' : 'Not Ready'}
|
|
127
127
|
</span>
|
|
128
128
|
{spec.isCA && (
|
|
129
|
-
<span className="badge
|
|
129
|
+
<span className="badge status-purple">
|
|
130
130
|
CA Certificate
|
|
131
131
|
</span>
|
|
132
132
|
)}
|
|
@@ -54,8 +54,8 @@ export function CertificateRequestRenderer({ data }: CertificateRequestRendererP
|
|
|
54
54
|
<span className={clsx(
|
|
55
55
|
'badge',
|
|
56
56
|
isReady
|
|
57
|
-
? '
|
|
58
|
-
: '
|
|
57
|
+
? 'status-green'
|
|
58
|
+
: 'status-red'
|
|
59
59
|
)}>
|
|
60
60
|
{isReady ? 'Ready' : 'Not Ready'}
|
|
61
61
|
</span>
|
|
@@ -67,10 +67,10 @@ export function CertificateRequestRenderer({ data }: CertificateRequestRendererP
|
|
|
67
67
|
<span className={clsx(
|
|
68
68
|
'badge',
|
|
69
69
|
isApproved
|
|
70
|
-
? '
|
|
70
|
+
? 'status-green'
|
|
71
71
|
: isDenied
|
|
72
|
-
? '
|
|
73
|
-
: '
|
|
72
|
+
? 'status-red'
|
|
73
|
+
: 'status-amber'
|
|
74
74
|
)}>
|
|
75
75
|
{isApproved ? 'Yes' : isDenied ? 'No' : 'Pending'}
|
|
76
76
|
</span>
|