@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.
Files changed (52) 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 +17 -3
  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/cnpg-cells.tsx +1 -1
  37. package/src/components/resources/renderers/eso-cells.tsx +17 -1
  38. package/src/components/resources/renderers/knative-cells.tsx +1 -1
  39. package/src/components/resources/renderers/kyverno-cells.tsx +7 -3
  40. package/src/components/resources/resource-utils-certmanager.ts +2 -2
  41. package/src/components/resources/resource-utils-eso.test.ts +26 -1
  42. package/src/components/resources/resource-utils-eso.ts +13 -0
  43. package/src/components/resources/resource-utils-knative.ts +3 -3
  44. package/src/components/topology/TopologyGraph.tsx +18 -1
  45. package/src/components/topology/pod-phase-fallback.test.ts +24 -0
  46. package/src/theme/components.css +29 -0
  47. package/src/utils/badge-colors.ts +1 -1
  48. package/src/utils/index.ts +1 -0
  49. package/src/utils/log-export.test.ts +225 -0
  50. package/src/utils/log-export.ts +149 -0
  51. package/src/utils/navigation.test.ts +10 -0
  52. 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",
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}
@@ -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-healthy'
148
+ ? 'status-green'
149
149
  : a.status === 'DEGRADED'
150
- ? 'status-unhealthy'
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' ? 'bg-red-500/20 text-red-400' :
96
- eventSeverity === 'warning' ? 'bg-yellow-500/20 text-yellow-400' :
97
- 'bg-blue-500/20 text-blue-400'
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 bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-50"
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 ? 'bg-green-500/20 text-green-400' : BADGE_INACTIVE
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
- ? 'bg-green-500/20 text-green-400'
284
+ ? 'status-green'
285
285
  : operationState.phase === 'Running'
286
- ? 'bg-blue-500/20 text-blue-400'
286
+ ? 'status-blue'
287
287
  : operationState.phase === 'Failed' || operationState.phase === 'Error'
288
- ? 'bg-red-500/20 text-red-400'
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 ? 'bg-green-500/20 text-green-400' : 'bg-theme-hover text-theme-text-secondary'}`}>
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
- ? 'bg-green-500/20 text-green-400'
124
- : 'bg-red-500/20 text-red-400'
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 bg-purple-500/20 text-purple-400">
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
- ? 'bg-green-500/20 text-green-400'
58
- : 'bg-red-500/20 text-red-400'
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
- ? 'bg-green-500/20 text-green-400'
70
+ ? 'status-green'
71
71
  : isDenied
72
- ? 'bg-red-500/20 text-red-400'
73
- : 'bg-yellow-500/20 text-yellow-400'
72
+ ? 'status-red'
73
+ : 'status-amber'
74
74
  )}>
75
75
  {isApproved ? 'Yes' : isDenied ? 'No' : 'Pending'}
76
76
  </span>