@skyhook-io/k8s-ui 1.0.0

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 (193) hide show
  1. package/package.json +63 -0
  2. package/src/components/dock/BottomDock.tsx +204 -0
  3. package/src/components/dock/DockContext.tsx +212 -0
  4. package/src/components/dock/TerminalTab.tsx +333 -0
  5. package/src/components/dock/index.ts +3 -0
  6. package/src/components/gitops/GitOpsActions.tsx +227 -0
  7. package/src/components/gitops/GitOpsStatusBadge.tsx +221 -0
  8. package/src/components/gitops/ManagedResourcesList.tsx +210 -0
  9. package/src/components/gitops/SyncCountdown.tsx +146 -0
  10. package/src/components/gitops/index.ts +4 -0
  11. package/src/components/logs/JsonLogLine.tsx +90 -0
  12. package/src/components/logs/LogCore.tsx +553 -0
  13. package/src/components/logs/LogToolbarSelects.tsx +74 -0
  14. package/src/components/logs/LogsViewer.tsx +146 -0
  15. package/src/components/logs/WorkloadLogsViewer.tsx +263 -0
  16. package/src/components/logs/index.ts +13 -0
  17. package/src/components/logs/useLogBuffer.ts +138 -0
  18. package/src/components/logs/useLogSearch.ts +151 -0
  19. package/src/components/logs/useLogStream.ts +61 -0
  20. package/src/components/resources/ResourcesView.tsx +5494 -0
  21. package/src/components/resources/index.ts +17 -0
  22. package/src/components/resources/renderers/AlertRenderer.tsx +166 -0
  23. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +233 -0
  24. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +89 -0
  25. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +194 -0
  26. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +79 -0
  27. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +68 -0
  28. package/src/components/resources/renderers/CertificateRenderer.tsx +209 -0
  29. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +123 -0
  30. package/src/components/resources/renderers/ChallengeRenderer.tsx +111 -0
  31. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +164 -0
  32. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +140 -0
  33. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +153 -0
  34. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +221 -0
  35. package/src/components/resources/renderers/ConfigMapRenderer.tsx +124 -0
  36. package/src/components/resources/renderers/CronJobRenderer.tsx +86 -0
  37. package/src/components/resources/renderers/EventRenderer.tsx +175 -0
  38. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +119 -0
  39. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +172 -0
  40. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +242 -0
  41. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +263 -0
  42. package/src/components/resources/renderers/GatewayClassRenderer.tsx +68 -0
  43. package/src/components/resources/renderers/GatewayRenderer.tsx +218 -0
  44. package/src/components/resources/renderers/GenericRenderer.tsx +476 -0
  45. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +154 -0
  46. package/src/components/resources/renderers/HPARenderer.tsx +164 -0
  47. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +303 -0
  48. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +167 -0
  49. package/src/components/resources/renderers/IngressClassRenderer.tsx +49 -0
  50. package/src/components/resources/renderers/IngressRenderer.tsx +104 -0
  51. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +175 -0
  52. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +176 -0
  53. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +125 -0
  54. package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +84 -0
  55. package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +112 -0
  56. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +288 -0
  57. package/src/components/resources/renderers/JobRenderer.tsx +103 -0
  58. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +157 -0
  59. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +188 -0
  60. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +148 -0
  61. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +90 -0
  62. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +136 -0
  63. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +151 -0
  64. package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +70 -0
  65. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +309 -0
  66. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +142 -0
  67. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +232 -0
  68. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +97 -0
  69. package/src/components/resources/renderers/KnativeRouteRenderer.tsx +74 -0
  70. package/src/components/resources/renderers/KnativeServiceRenderer.tsx +138 -0
  71. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +220 -0
  72. package/src/components/resources/renderers/KustomizationRenderer.tsx +151 -0
  73. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +352 -0
  74. package/src/components/resources/renderers/LeaseRenderer.tsx +93 -0
  75. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +208 -0
  76. package/src/components/resources/renderers/NodeRenderer.tsx +282 -0
  77. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +153 -0
  78. package/src/components/resources/renderers/OrderRenderer.tsx +108 -0
  79. package/src/components/resources/renderers/PVCRenderer.tsx +98 -0
  80. package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +138 -0
  81. package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +126 -0
  82. package/src/components/resources/renderers/PodMonitorRenderer.tsx +63 -0
  83. package/src/components/resources/renderers/PodRenderer.tsx +677 -0
  84. package/src/components/resources/renderers/PriorityClassRenderer.tsx +50 -0
  85. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +62 -0
  86. package/src/components/resources/renderers/ReplicaSetRenderer.tsx +71 -0
  87. package/src/components/resources/renderers/RoleBindingRenderer.tsx +97 -0
  88. package/src/components/resources/renderers/RoleRenderer.tsx +192 -0
  89. package/src/components/resources/renderers/RolloutRenderer.tsx +227 -0
  90. package/src/components/resources/renderers/RuntimeClassRenderer.tsx +75 -0
  91. package/src/components/resources/renderers/SbomReportRenderer.tsx +98 -0
  92. package/src/components/resources/renderers/SealedSecretRenderer.tsx +121 -0
  93. package/src/components/resources/renderers/SecretRenderer.tsx +321 -0
  94. package/src/components/resources/renderers/SecretStoreRenderer.tsx +111 -0
  95. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +81 -0
  96. package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +65 -0
  97. package/src/components/resources/renderers/ServiceRenderer.tsx +98 -0
  98. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +195 -0
  99. package/src/components/resources/renderers/StorageClassRenderer.tsx +82 -0
  100. package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +257 -0
  101. package/src/components/resources/renderers/VPARenderer.tsx +193 -0
  102. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +73 -0
  103. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +193 -0
  104. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +161 -0
  105. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +134 -0
  106. package/src/components/resources/renderers/VeleroVSLRenderer.tsx +36 -0
  107. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +347 -0
  108. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +156 -0
  109. package/src/components/resources/renderers/WorkflowRenderer.tsx +202 -0
  110. package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +91 -0
  111. package/src/components/resources/renderers/WorkloadRenderer.tsx +311 -0
  112. package/src/components/resources/renderers/argo-cells.tsx +118 -0
  113. package/src/components/resources/renderers/certmanager-cells.tsx +164 -0
  114. package/src/components/resources/renderers/cnpg-cells.tsx +161 -0
  115. package/src/components/resources/renderers/eso-cells.tsx +115 -0
  116. package/src/components/resources/renderers/flux-cells.tsx +253 -0
  117. package/src/components/resources/renderers/index.ts +105 -0
  118. package/src/components/resources/renderers/istio-cells.tsx +210 -0
  119. package/src/components/resources/renderers/karpenter-cells.tsx +107 -0
  120. package/src/components/resources/renderers/keda-cells.tsx +94 -0
  121. package/src/components/resources/renderers/knative-cells.tsx +396 -0
  122. package/src/components/resources/renderers/kyverno-cells.tsx +85 -0
  123. package/src/components/resources/renderers/prometheus-cells.tsx +74 -0
  124. package/src/components/resources/renderers/traefik-cells.tsx +120 -0
  125. package/src/components/resources/renderers/trivy-cells.tsx +153 -0
  126. package/src/components/resources/renderers/trivy-shared.tsx +67 -0
  127. package/src/components/resources/renderers/velero-cells.tsx +164 -0
  128. package/src/components/resources/resource-utils-argo.ts +155 -0
  129. package/src/components/resources/resource-utils-certmanager.ts +198 -0
  130. package/src/components/resources/resource-utils-cnpg.ts +344 -0
  131. package/src/components/resources/resource-utils-eso.ts +385 -0
  132. package/src/components/resources/resource-utils-flux.ts +186 -0
  133. package/src/components/resources/resource-utils-istio.ts +372 -0
  134. package/src/components/resources/resource-utils-karpenter.ts +150 -0
  135. package/src/components/resources/resource-utils-keda.ts +203 -0
  136. package/src/components/resources/resource-utils-knative.ts +218 -0
  137. package/src/components/resources/resource-utils-kyverno.ts +224 -0
  138. package/src/components/resources/resource-utils-prometheus.ts +159 -0
  139. package/src/components/resources/resource-utils-traefik.ts +180 -0
  140. package/src/components/resources/resource-utils-trivy.ts +146 -0
  141. package/src/components/resources/resource-utils-velero.ts +293 -0
  142. package/src/components/resources/resource-utils.ts +1558 -0
  143. package/src/components/shared/EditableYamlView.tsx +302 -0
  144. package/src/components/shared/ResourceActionsBar.tsx +924 -0
  145. package/src/components/shared/ResourceRendererDispatch.tsx +557 -0
  146. package/src/components/shared/index.ts +3 -0
  147. package/src/components/timeline/index.ts +1 -0
  148. package/src/components/timeline/shared.tsx +614 -0
  149. package/src/components/topology/GroupNode.tsx +198 -0
  150. package/src/components/topology/K8sResourceNode.tsx +440 -0
  151. package/src/components/topology/TopologyControls.tsx +62 -0
  152. package/src/components/topology/TopologyFilterSidebar.tsx +377 -0
  153. package/src/components/topology/TopologyGraph.tsx +748 -0
  154. package/src/components/topology/TopologySearch.tsx +304 -0
  155. package/src/components/topology/index.ts +7 -0
  156. package/src/components/topology/layout.ts +734 -0
  157. package/src/components/topology/layout.worker.ts +272 -0
  158. package/src/components/topology/topology.css +129 -0
  159. package/src/components/ui/CodeViewer.tsx +451 -0
  160. package/src/components/ui/ConfirmDialog.tsx +188 -0
  161. package/src/components/ui/ForceDeleteConfirmDialog.tsx +57 -0
  162. package/src/components/ui/HealthRing.tsx +72 -0
  163. package/src/components/ui/MetricsChart.tsx +259 -0
  164. package/src/components/ui/ResourceBar.tsx +68 -0
  165. package/src/components/ui/Toast.tsx +225 -0
  166. package/src/components/ui/Tooltip.tsx +165 -0
  167. package/src/components/ui/YamlEditor.tsx +280 -0
  168. package/src/components/ui/drawer-components.tsx +792 -0
  169. package/src/components/ui/index.ts +10 -0
  170. package/src/components/workload/ResourceDetailDrawer.tsx +158 -0
  171. package/src/components/workload/WorkloadView.tsx +1109 -0
  172. package/src/components/workload/index.ts +2 -0
  173. package/src/hooks/index.ts +9 -0
  174. package/src/hooks/useAnimatedUnmount.ts +30 -0
  175. package/src/hooks/useKeyboardShortcuts.tsx +336 -0
  176. package/src/hooks/useRefreshAnimation.ts +58 -0
  177. package/src/index.ts +35 -0
  178. package/src/types/core.ts +894 -0
  179. package/src/types/gitops.ts +458 -0
  180. package/src/types/index.ts +2 -0
  181. package/src/utils/animation.ts +40 -0
  182. package/src/utils/api-resources.ts +185 -0
  183. package/src/utils/badge-colors.ts +399 -0
  184. package/src/utils/context-name.ts +72 -0
  185. package/src/utils/download.ts +19 -0
  186. package/src/utils/format.ts +233 -0
  187. package/src/utils/index.ts +10 -0
  188. package/src/utils/log-format.ts +300 -0
  189. package/src/utils/navigation.ts +119 -0
  190. package/src/utils/resource-hierarchy.ts +630 -0
  191. package/src/utils/resource-icons.ts +206 -0
  192. package/src/vite-env.d.ts +1 -0
  193. package/tsconfig.json +24 -0
@@ -0,0 +1,553 @@
1
+ import { useRef, useCallback, useState, useMemo, useEffect, type ReactNode } from 'react'
2
+ import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
3
+ import { Play, Square, Download, Search, X, Terminal, RotateCcw, ChevronUp, ChevronDown, CaseSensitive, Regex, WrapText, Clock, Copy, Trash2, Filter } from 'lucide-react'
4
+ import type { LogEntry, LogLevel } from './useLogBuffer'
5
+ import { useLogSearch } from './useLogSearch'
6
+ import { JsonLogLine } from './JsonLogLine'
7
+ import { Tooltip } from '../ui/Tooltip'
8
+ import {
9
+ formatLogTimestamp,
10
+ getLevelColor,
11
+ highlightSearchMatches,
12
+ stripAnsi,
13
+ ansiToHtml,
14
+ } from '../../utils/log-format'
15
+
16
+ export type DownloadFormat = 'txt' | 'json' | 'csv'
17
+
18
+ interface LogCoreProps {
19
+ entries: LogEntry[]
20
+ isLoading: boolean
21
+ isStreaming: boolean
22
+ onStartStream?: () => void
23
+ onStopStream: () => void
24
+ onRefresh: () => void
25
+ onDownload: (format: DownloadFormat) => void
26
+ onClear?: () => void
27
+ toolbarExtra?: ReactNode
28
+ showPodName?: boolean
29
+ emptyMessage?: string
30
+ errorMessage?: string | null
31
+ }
32
+
33
+ const LEVEL_OPTIONS: { level: LogLevel; label: string; color: string; activeColor: string }[] = [
34
+ { level: 'error', label: 'ERR', color: 'text-red-400', activeColor: 'bg-red-500/30 dark:bg-red-500/20 text-red-600 dark:text-red-400 border-red-500/60 dark:border-red-500/40' },
35
+ { level: 'warn', label: 'WARN', color: 'text-yellow-400', activeColor: 'bg-yellow-400/30 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400 border-yellow-500/60 dark:border-yellow-500/40' },
36
+ { level: 'info', label: 'INFO', color: 'text-blue-400', activeColor: 'bg-blue-500/25 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400 border-blue-500/60 dark:border-blue-500/40' },
37
+ { level: 'debug', label: 'DBG', color: 'text-theme-text-secondary', activeColor: 'bg-theme-surface text-theme-text-secondary border-theme-border-light' },
38
+ ]
39
+
40
+ const TIP_DELAY = 150
41
+
42
+ export function LogCore({
43
+ entries,
44
+ isLoading,
45
+ isStreaming,
46
+ onStartStream,
47
+ onStopStream,
48
+ onRefresh,
49
+ onDownload,
50
+ onClear,
51
+ toolbarExtra,
52
+ showPodName = false,
53
+ emptyMessage = 'No logs available',
54
+ errorMessage,
55
+ }: LogCoreProps) {
56
+ const virtuosoRef = useRef<VirtuosoHandle>(null)
57
+ const [atBottom, setAtBottom] = useState(true)
58
+ const [wordWrap, setWordWrap] = useState(() => {
59
+ try { return localStorage.getItem('radar-logs-wrap') !== 'false' } catch { return true }
60
+ })
61
+ const [showTimestamps, setShowTimestamps] = useState(() => {
62
+ try { return localStorage.getItem('radar-logs-timestamps') !== 'false' } catch { return true }
63
+ })
64
+ const [enabledLevels, setEnabledLevels] = useState<Set<LogLevel>>(
65
+ new Set(['error', 'warn', 'info', 'debug'])
66
+ )
67
+ const [showDownloadMenu, setShowDownloadMenu] = useState(false)
68
+
69
+ // Level-filtered entries
70
+ // 'unknown' logs are shown when all 4 known levels are enabled (no active filtering)
71
+ const levelFilteredEntries = useMemo(() => {
72
+ const allEnabled = LEVEL_OPTIONS.every(opt => enabledLevels.has(opt.level))
73
+ if (allEnabled) return entries
74
+ return entries.filter(e => enabledLevels.has(e.level))
75
+ }, [entries, enabledLevels])
76
+
77
+ // Level counts for badges
78
+ const levelCounts = useMemo(() => {
79
+ const counts: Record<LogLevel, number> = { error: 0, warn: 0, info: 0, debug: 0, unknown: 0 }
80
+ for (const e of entries) {
81
+ counts[e.level]++
82
+ }
83
+ return counts
84
+ }, [entries])
85
+
86
+ // Search
87
+ const search = useLogSearch(levelFilteredEntries, virtuosoRef)
88
+
89
+ // Display entries: use search-filtered when filter mode is active
90
+ const displayEntries = search.isFilterMode && search.query
91
+ ? search.filteredEntries
92
+ : levelFilteredEntries
93
+
94
+ // Close download menu on next click anywhere (deferred so current click doesn't trigger it)
95
+ const downloadMenuRef = useRef<HTMLDivElement>(null)
96
+ useEffect(() => {
97
+ if (!showDownloadMenu) return
98
+ const handleClick = (e: MouseEvent) => {
99
+ if (downloadMenuRef.current?.contains(e.target as Node)) return
100
+ setShowDownloadMenu(false)
101
+ }
102
+ window.addEventListener('click', handleClick)
103
+ return () => window.removeEventListener('click', handleClick)
104
+ }, [showDownloadMenu])
105
+
106
+ // Keyboard shortcut: Ctrl+F to open search
107
+ useEffect(() => {
108
+ const handleKeyDown = (e: KeyboardEvent) => {
109
+ if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
110
+ e.preventDefault()
111
+ search.open()
112
+ }
113
+ }
114
+ window.addEventListener('keydown', handleKeyDown)
115
+ return () => window.removeEventListener('keydown', handleKeyDown)
116
+ }, [search.open])
117
+
118
+ const handleFollowOutput = useCallback((isAtBottom: boolean) => {
119
+ if (isAtBottom) return 'smooth' as const
120
+ return false as const
121
+ }, [])
122
+
123
+ const handleAtBottomStateChange = useCallback((bottom: boolean) => {
124
+ setAtBottom(bottom)
125
+ }, [])
126
+
127
+ const scrollToBottom = useCallback(() => {
128
+ virtuosoRef.current?.scrollToIndex({
129
+ index: displayEntries.length - 1,
130
+ align: 'end',
131
+ behavior: 'smooth',
132
+ })
133
+ }, [displayEntries.length])
134
+
135
+ const toggleWrap = useCallback(() => {
136
+ setWordWrap(prev => {
137
+ const next = !prev
138
+ try { localStorage.setItem('radar-logs-wrap', String(next)) } catch {}
139
+ return next
140
+ })
141
+ }, [])
142
+
143
+ const toggleTimestamps = useCallback(() => {
144
+ setShowTimestamps(prev => {
145
+ const next = !prev
146
+ try { localStorage.setItem('radar-logs-timestamps', String(next)) } catch {}
147
+ return next
148
+ })
149
+ }, [])
150
+
151
+ const toggleLevel = useCallback((level: LogLevel) => {
152
+ setEnabledLevels(prev => {
153
+ const next = new Set(prev)
154
+ if (next.has(level)) {
155
+ next.delete(level)
156
+ } else {
157
+ next.add(level)
158
+ }
159
+ return next
160
+ })
161
+ }, [])
162
+
163
+ // Highlight set for current match
164
+ const currentHighlightId = search.matchIndices.length > 0
165
+ ? (search.isFilterMode
166
+ ? search.filteredEntries[search.currentMatch]?.id
167
+ : levelFilteredEntries[search.matchIndices[search.currentMatch]]?.id)
168
+ : -1
169
+
170
+ return (
171
+ <div className="flex flex-col h-full bg-theme-base">
172
+ {/* Toolbar */}
173
+ <div className="flex items-center gap-2 px-3 py-2 border-b border-theme-border bg-theme-surface">
174
+ {toolbarExtra}
175
+
176
+ {/* Stream / Stop toggle — only shown when streaming is supported */}
177
+ {onStartStream && (
178
+ <Tooltip content={isStreaming ? 'Stop streaming' : 'Start streaming'} delay={TIP_DELAY} position="bottom">
179
+ <button
180
+ onClick={isStreaming ? onStopStream : onStartStream}
181
+ className={`flex items-center gap-1.5 px-2 py-1.5 text-xs rounded transition-colors ${
182
+ isStreaming
183
+ ? 'bg-green-600 text-white hover:bg-green-700'
184
+ : 'bg-theme-elevated text-theme-text-secondary hover:bg-theme-hover'
185
+ }`}
186
+ >
187
+ {isStreaming ? <Square className="w-3 h-3" /> : <Play className="w-3 h-3" />}
188
+ <span className="hidden sm:inline">{isStreaming ? 'Stop' : 'Stream'}</span>
189
+ </button>
190
+ </Tooltip>
191
+ )}
192
+
193
+ {/* Refresh */}
194
+ <Tooltip content="Refresh logs" delay={TIP_DELAY} position="bottom">
195
+ <button
196
+ onClick={onRefresh}
197
+ disabled={isLoading || isStreaming}
198
+ className="flex items-center gap-1.5 px-2 py-1.5 text-xs rounded bg-theme-elevated text-theme-text-secondary hover:bg-theme-hover disabled:opacity-50 disabled:cursor-not-allowed"
199
+ >
200
+ <RotateCcw className={`w-3 h-3 ${isLoading ? 'animate-spin' : ''}`} />
201
+ </button>
202
+ </Tooltip>
203
+
204
+ {/* Level filter toggles */}
205
+ <div className="flex items-center gap-1 ml-1">
206
+ {LEVEL_OPTIONS.map(opt => {
207
+ const active = enabledLevels.has(opt.level)
208
+ const count = levelCounts[opt.level]
209
+ return (
210
+ <Tooltip key={opt.level} content={`${active ? 'Hide' : 'Show'} ${opt.label} logs`} delay={TIP_DELAY} position="bottom">
211
+ <button
212
+ onClick={() => toggleLevel(opt.level)}
213
+ className={`px-1.5 py-0.5 text-[10px] font-medium rounded border transition-colors ${
214
+ active
215
+ ? opt.activeColor
216
+ : 'border-transparent text-theme-text-disabled hover:text-theme-text-tertiary'
217
+ }`}
218
+ >
219
+ {opt.label}{count > 0 ? ` ${count}` : ''}
220
+ </button>
221
+ </Tooltip>
222
+ )
223
+ })}
224
+ </div>
225
+
226
+ <div className="flex-1" />
227
+
228
+ {/* Timestamp toggle */}
229
+ <Tooltip content={showTimestamps ? 'Hide timestamps' : 'Show timestamps'} delay={TIP_DELAY} position="bottom">
230
+ <button
231
+ onClick={toggleTimestamps}
232
+ className={`p-1.5 rounded transition-colors ${
233
+ showTimestamps ? 'bg-blue-600/50 text-theme-text-primary' : 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated'
234
+ }`}
235
+ >
236
+ <Clock className="w-4 h-4" />
237
+ </button>
238
+ </Tooltip>
239
+
240
+ {/* Wrap toggle */}
241
+ <Tooltip content={wordWrap ? 'Disable word wrap' : 'Enable word wrap'} delay={TIP_DELAY} position="bottom">
242
+ <button
243
+ onClick={toggleWrap}
244
+ className={`p-1.5 rounded transition-colors ${
245
+ wordWrap ? 'bg-blue-600/50 text-theme-text-primary' : 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated'
246
+ }`}
247
+ >
248
+ <WrapText className="w-4 h-4" />
249
+ </button>
250
+ </Tooltip>
251
+
252
+ {/* Search toggle */}
253
+ <Tooltip content="Search (Ctrl+F)" delay={TIP_DELAY} position="bottom">
254
+ <button
255
+ onClick={() => search.isOpen ? search.close() : search.open()}
256
+ className={`p-1.5 rounded transition-colors ${
257
+ search.isOpen ? 'bg-blue-600 text-theme-text-primary' : 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated'
258
+ }`}
259
+ >
260
+ <Search className="w-4 h-4" />
261
+ </button>
262
+ </Tooltip>
263
+
264
+ {/* Download */}
265
+ <div className="relative flex items-center" ref={downloadMenuRef}>
266
+ <Tooltip content="Download logs" delay={TIP_DELAY} position="bottom">
267
+ <button
268
+ onClick={() => setShowDownloadMenu(prev => !prev)}
269
+ className="p-1.5 rounded text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated"
270
+ >
271
+ <Download className="w-4 h-4" />
272
+ </button>
273
+ </Tooltip>
274
+ {showDownloadMenu && (
275
+ <div className="absolute top-full right-0 mt-1 w-32 bg-theme-elevated border border-theme-border rounded-lg shadow-lg z-50">
276
+ {(['txt', 'json', 'csv'] as DownloadFormat[]).map(fmt => (
277
+ <button
278
+ key={fmt}
279
+ onClick={() => { onDownload(fmt); setShowDownloadMenu(false) }}
280
+ className="w-full text-left px-3 py-2 text-xs text-theme-text-primary hover:bg-theme-hover first:rounded-t-lg last:rounded-b-lg"
281
+ >
282
+ {fmt.toUpperCase()}
283
+ </button>
284
+ ))}
285
+ </div>
286
+ )}
287
+ </div>
288
+
289
+ {/* Clear */}
290
+ {onClear && (
291
+ <Tooltip content="Clear logs" delay={TIP_DELAY} position="bottom">
292
+ <button
293
+ onClick={onClear}
294
+ className="p-1.5 rounded text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated"
295
+ >
296
+ <Trash2 className="w-4 h-4" />
297
+ </button>
298
+ </Tooltip>
299
+ )}
300
+ </div>
301
+
302
+ {/* Search bar */}
303
+ {search.isOpen && (
304
+ <div className="flex items-center gap-2 px-3 py-2 border-b border-theme-border bg-theme-surface/50">
305
+ <Search className="w-4 h-4 text-theme-text-secondary shrink-0" />
306
+ <input
307
+ type="text"
308
+ value={search.query}
309
+ onChange={(e) => search.setQuery(e.target.value)}
310
+ onKeyDown={(e) => {
311
+ if (e.key === 'Escape') {
312
+ search.close()
313
+ } else if (e.key === 'Enter') {
314
+ if (e.shiftKey) {
315
+ search.goToPrev()
316
+ } else {
317
+ search.goToNext()
318
+ }
319
+ }
320
+ }}
321
+ placeholder="Search logs..."
322
+ className="flex-1 bg-transparent text-theme-text-primary text-sm placeholder-theme-text-disabled focus:outline-none min-w-0"
323
+ autoFocus
324
+ />
325
+
326
+ {/* Regex toggle */}
327
+ <Tooltip content="Regex" delay={TIP_DELAY} position="bottom">
328
+ <button
329
+ onClick={search.toggleRegex}
330
+ className={`p-1 rounded transition-colors ${
331
+ search.isRegex ? 'bg-blue-600 text-theme-text-primary' : 'text-theme-text-tertiary hover:text-theme-text-secondary'
332
+ }`}
333
+ >
334
+ <Regex className="w-3.5 h-3.5" />
335
+ </button>
336
+ </Tooltip>
337
+
338
+ {/* Case sensitivity toggle */}
339
+ <Tooltip content="Match case" delay={TIP_DELAY} position="bottom">
340
+ <button
341
+ onClick={search.toggleCaseSensitive}
342
+ className={`p-1 rounded transition-colors ${
343
+ search.isCaseSensitive ? 'bg-blue-600 text-theme-text-primary' : 'text-theme-text-tertiary hover:text-theme-text-secondary'
344
+ }`}
345
+ >
346
+ <CaseSensitive className="w-3.5 h-3.5" />
347
+ </button>
348
+ </Tooltip>
349
+
350
+ {/* Filter mode toggle */}
351
+ <Tooltip content={search.isFilterMode ? 'Highlight mode' : 'Filter mode'} delay={TIP_DELAY} position="bottom">
352
+ <button
353
+ onClick={search.toggleFilterMode}
354
+ className={`p-1 rounded transition-colors ${
355
+ search.isFilterMode ? 'bg-blue-600 text-theme-text-primary' : 'text-theme-text-tertiary hover:text-theme-text-secondary'
356
+ }`}
357
+ >
358
+ <Filter className="w-3.5 h-3.5" />
359
+ </button>
360
+ </Tooltip>
361
+
362
+ {search.query && (
363
+ <>
364
+ <span className={`text-xs whitespace-nowrap ${search.regexError ? 'text-red-400' : 'text-theme-text-tertiary'}`}>
365
+ {search.regexError
366
+ ? 'Invalid regex'
367
+ : search.matchCount > 0
368
+ ? `${search.currentMatch + 1} / ${search.matchCount}`
369
+ : '0 results'}
370
+ </span>
371
+
372
+ {/* Navigation arrows */}
373
+ <Tooltip content="Previous (Shift+Enter)" delay={TIP_DELAY} position="bottom">
374
+ <button
375
+ onClick={search.goToPrev}
376
+ disabled={search.matchCount === 0}
377
+ className="p-1 rounded text-theme-text-secondary hover:text-theme-text-primary disabled:opacity-30"
378
+ >
379
+ <ChevronUp className="w-3.5 h-3.5" />
380
+ </button>
381
+ </Tooltip>
382
+ <Tooltip content="Next (Enter)" delay={TIP_DELAY} position="bottom">
383
+ <button
384
+ onClick={search.goToNext}
385
+ disabled={search.matchCount === 0}
386
+ className="p-1 rounded text-theme-text-secondary hover:text-theme-text-primary disabled:opacity-30"
387
+ >
388
+ <ChevronDown className="w-3.5 h-3.5" />
389
+ </button>
390
+ </Tooltip>
391
+
392
+ <button
393
+ onClick={() => search.setQuery('')}
394
+ className="p-1 rounded text-theme-text-secondary hover:text-theme-text-primary"
395
+ >
396
+ <X className="w-3 h-3" />
397
+ </button>
398
+ </>
399
+ )}
400
+ </div>
401
+ )}
402
+
403
+ {/* Log content */}
404
+ {isLoading && entries.length === 0 ? (
405
+ <div className="flex-1 flex items-center justify-center text-theme-text-tertiary">
406
+ <div className="flex items-center gap-2">
407
+ <RotateCcw className="w-4 h-4 animate-spin" />
408
+ <span>Loading logs...</span>
409
+ </div>
410
+ </div>
411
+ ) : errorMessage ? (
412
+ <div className="flex-1 flex flex-col items-center justify-center text-red-400 gap-2">
413
+ <Terminal className="w-8 h-8" />
414
+ <span>{errorMessage}</span>
415
+ </div>
416
+ ) : displayEntries.length === 0 ? (
417
+ <div className="flex-1 flex flex-col items-center justify-center text-theme-text-tertiary gap-2">
418
+ <Terminal className="w-8 h-8" />
419
+ <span>{emptyMessage}</span>
420
+ </div>
421
+ ) : (
422
+ <div className="flex-1 relative">
423
+ <Virtuoso
424
+ ref={virtuosoRef}
425
+ data={displayEntries}
426
+ followOutput={handleFollowOutput}
427
+ initialTopMostItemIndex={displayEntries.length - 1}
428
+ atBottomStateChange={handleAtBottomStateChange}
429
+ atBottomThreshold={50}
430
+ increaseViewportBy={200}
431
+ itemContent={(_index, entry) => (
432
+ <LogLine
433
+ entry={entry}
434
+ searchQuery={search.query}
435
+ searchIsRegex={search.isRegex}
436
+ searchIsCaseSensitive={search.isCaseSensitive}
437
+ showPodName={showPodName}
438
+ showTimestamp={showTimestamps}
439
+ isCurrentMatch={entry.id === currentHighlightId}
440
+ wordWrap={wordWrap}
441
+ />
442
+ )}
443
+ className="h-full font-mono text-xs"
444
+ />
445
+ {/* Scroll-to-bottom button */}
446
+ {!atBottom && (
447
+ <button
448
+ onClick={scrollToBottom}
449
+ className="absolute bottom-4 right-14 px-3 py-1.5 bg-blue-600 text-white text-xs rounded-full shadow-lg hover:bg-blue-700 z-10"
450
+ >
451
+ Scroll to bottom
452
+ </button>
453
+ )}
454
+ </div>
455
+ )}
456
+
457
+ {/* Keyboard shortcut hints */}
458
+ <div className="flex items-center gap-4 px-3 py-1 border-t border-theme-border bg-theme-surface text-[10px] text-theme-text-disabled">
459
+ <Shortcut keys="Ctrl+F" label="Search" />
460
+ <Shortcut keys="Enter" label="Next match" />
461
+ <Shortcut keys="Shift+Enter" label="Prev match" />
462
+ <Shortcut keys="Esc" label="Close search" />
463
+ </div>
464
+ </div>
465
+ )
466
+ }
467
+
468
+ function Shortcut({ keys, label }: { keys: string; label: string }) {
469
+ return (
470
+ <span className="flex items-center gap-1">
471
+ <kbd className="px-1 py-px rounded bg-theme-elevated border border-theme-border-light font-mono">{keys}</kbd>
472
+ <span>{label}</span>
473
+ </span>
474
+ )
475
+ }
476
+
477
+ function LogLine({
478
+ entry,
479
+ searchQuery,
480
+ searchIsRegex,
481
+ searchIsCaseSensitive,
482
+ showPodName,
483
+ showTimestamp,
484
+ isCurrentMatch,
485
+ wordWrap,
486
+ }: {
487
+ entry: LogEntry
488
+ searchQuery: string
489
+ searchIsRegex: boolean
490
+ searchIsCaseSensitive: boolean
491
+ showPodName: boolean
492
+ showTimestamp: boolean
493
+ isCurrentMatch: boolean
494
+ wordWrap: boolean
495
+ }) {
496
+ const levelColor = getLevelColor(entry.level)
497
+
498
+ // Determine content rendering
499
+ let contentElement: React.ReactNode
500
+ if (searchQuery) {
501
+ const plain = stripAnsi(entry.content)
502
+ const highlighted = highlightSearchMatches(plain, searchQuery, searchIsRegex, searchIsCaseSensitive)
503
+ contentElement = (
504
+ <span
505
+ className={`${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'} ${levelColor}`}
506
+ dangerouslySetInnerHTML={{ __html: highlighted }}
507
+ />
508
+ )
509
+ } else if (entry.isJson) {
510
+ contentElement = (
511
+ <JsonLogLine content={entry.content} level={entry.level} wordWrap={wordWrap} />
512
+ )
513
+ } else {
514
+ const html = ansiToHtml(entry.content)
515
+ contentElement = (
516
+ <span
517
+ className={`${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'} ${levelColor}`}
518
+ dangerouslySetInnerHTML={{ __html: html }}
519
+ />
520
+ )
521
+ }
522
+
523
+ const handleCopy = () => {
524
+ const raw = stripAnsi(entry.content)
525
+ navigator.clipboard.writeText(raw).catch(() => {})
526
+ }
527
+
528
+ return (
529
+ <div className={`flex hover:bg-theme-surface/50 group leading-5 px-2 ${isCurrentMatch ? 'bg-yellow-500/10' : ''}`}>
530
+ {showTimestamp && entry.timestamp && (
531
+ <span className="text-theme-text-tertiary select-none pr-2 whitespace-nowrap">
532
+ {formatLogTimestamp(entry.timestamp)}
533
+ </span>
534
+ )}
535
+ {showPodName && entry.pod && (
536
+ <span
537
+ className={`${entry.podColor || 'text-theme-text-primary'} select-none pr-2 whitespace-nowrap min-w-[80px] max-w-[120px] truncate`}
538
+ title={entry.pod}
539
+ >
540
+ [{entry.pod.split('-').slice(-2).join('-')}]
541
+ </span>
542
+ )}
543
+ <span className="flex-1 min-w-0">{contentElement}</span>
544
+ <button
545
+ onClick={handleCopy}
546
+ className="opacity-0 group-hover:opacity-100 ml-1 p-0.5 rounded text-theme-text-tertiary hover:text-theme-text-secondary shrink-0 transition-opacity"
547
+ title="Copy line"
548
+ >
549
+ <Copy className="w-3 h-3" />
550
+ </button>
551
+ </div>
552
+ )
553
+ }
@@ -0,0 +1,74 @@
1
+ import { ChevronDown } from 'lucide-react'
2
+ import { Tooltip } from '../ui/Tooltip'
3
+
4
+ const SELECT_CLASS =
5
+ 'appearance-none bg-theme-elevated text-theme-text-primary text-xs rounded px-2 py-1.5 border border-theme-border-light focus:outline-none focus:ring-1 focus:ring-blue-500'
6
+
7
+ // ── ContainerSelect ───────────────────────────────────────────────────────────
8
+
9
+ interface ContainerSelectProps {
10
+ containers: string[]
11
+ value: string
12
+ onChange: (value: string) => void
13
+ /** If true, prepend an "All containers" option with value="" */
14
+ includeAll?: boolean
15
+ }
16
+
17
+ export function ContainerSelect({ containers, value, onChange, includeAll = false }: ContainerSelectProps) {
18
+ if (containers.length <= 1 && !includeAll) return null
19
+ return (
20
+ <div className="relative">
21
+ <select
22
+ value={value}
23
+ onChange={(e) => onChange(e.target.value)}
24
+ className={`${SELECT_CLASS} pr-6`}
25
+ >
26
+ {includeAll && <option value="">All containers</option>}
27
+ {containers.map(c => <option key={c} value={c}>{c}</option>)}
28
+ </select>
29
+ <ChevronDown className="absolute right-1.5 top-1/2 -translate-y-1/2 w-3 h-3 text-theme-text-secondary pointer-events-none" />
30
+ </div>
31
+ )
32
+ }
33
+
34
+ // ── LogRangeSelect ────────────────────────────────────────────────────────────
35
+
36
+ interface LogRangeSelectProps {
37
+ value: string
38
+ onChange: (value: string) => void
39
+ /** Line count options to show. Defaults to [100, 500, 1000, 5000]. */
40
+ lineOptions?: number[]
41
+ tooltip?: string
42
+ }
43
+
44
+ export function LogRangeSelect({
45
+ value,
46
+ onChange,
47
+ lineOptions = [100, 500, 1000, 5000],
48
+ tooltip = 'How many logs to load — by line count or time range',
49
+ }: LogRangeSelectProps) {
50
+ return (
51
+ <Tooltip content={tooltip} position="bottom">
52
+ <select
53
+ value={value}
54
+ onChange={(e) => onChange(e.target.value)}
55
+ className={`${SELECT_CLASS} pr-5`}
56
+ >
57
+ <optgroup label="Lines">
58
+ {lineOptions.map(n => (
59
+ <option key={n} value={String(n)}>
60
+ {n.toLocaleString()} lines
61
+ </option>
62
+ ))}
63
+ </optgroup>
64
+ <optgroup label="Time">
65
+ <option value="since:60">Last 1 min</option>
66
+ <option value="since:300">Last 5 min</option>
67
+ <option value="since:900">Last 15 min</option>
68
+ <option value="since:1800">Last 30 min</option>
69
+ <option value="since:3600">Last 1 hour</option>
70
+ </optgroup>
71
+ </select>
72
+ </Tooltip>
73
+ )
74
+ }