@skyhook-io/k8s-ui 1.1.0 → 1.2.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 (58) hide show
  1. package/package.json +8 -2
  2. package/src/components/dock/BottomDock.tsx +14 -15
  3. package/src/components/dock/DockContext.tsx +40 -1
  4. package/src/components/dock/LocalTerminalTab.tsx +241 -0
  5. package/src/components/dock/NodeTerminalTab.tsx +129 -0
  6. package/src/components/dock/TerminalTab.tsx +1 -1
  7. package/src/components/dock/index.ts +2 -0
  8. package/src/components/gitops/GitOpsStatusBadge.tsx +18 -67
  9. package/src/components/logs/LogCore.tsx +30 -4
  10. package/src/components/logs/StructuredLogLine.tsx +168 -0
  11. package/src/components/logs/index.ts +1 -1
  12. package/src/components/logs/useLogBuffer.ts +16 -10
  13. package/src/components/resources/ResourcesView.tsx +186 -67
  14. package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +207 -0
  15. package/src/components/resources/renderers/HPARenderer.tsx +2 -1
  16. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -1
  17. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +2 -1
  18. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -1
  19. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +7 -6
  20. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +2 -1
  21. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +3 -2
  22. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +3 -2
  23. package/src/components/resources/renderers/PodRenderer.tsx +164 -8
  24. package/src/components/resources/renderers/VPARenderer.tsx +2 -1
  25. package/src/components/resources/renderers/WorkloadRenderer.tsx +53 -55
  26. package/src/components/resources/renderers/contour-cells.tsx +48 -0
  27. package/src/components/resources/renderers/index.ts +2 -0
  28. package/src/components/resources/renderers/trivy-shared.tsx +9 -25
  29. package/src/components/resources/resource-utils-contour.ts +34 -0
  30. package/src/components/resources/resource-utils.ts +91 -0
  31. package/src/components/shared/ResourceActionsBar.tsx +276 -144
  32. package/src/components/shared/ResourceRendererDispatch.tsx +59 -5
  33. package/src/components/shared/index.ts +1 -1
  34. package/src/components/timeline/shared.tsx +8 -10
  35. package/src/components/topology/K8sResourceNode.tsx +1 -0
  36. package/src/components/topology/TopologyFilterSidebar.tsx +3 -0
  37. package/src/components/topology/TopologyGraph.tsx +2 -17
  38. package/src/components/topology/layout.ts +1 -0
  39. package/src/components/topology/topology.css +1 -0
  40. package/src/components/ui/CodeViewer.tsx +1 -2
  41. package/src/components/ui/ConfirmDialog.tsx +85 -130
  42. package/src/components/ui/DialogPortal.tsx +82 -0
  43. package/src/components/ui/ForceDeleteConfirmDialog.tsx +84 -10
  44. package/src/components/ui/Toast.tsx +5 -5
  45. package/src/components/ui/drawer-components.tsx +28 -22
  46. package/src/components/ui/index.ts +1 -0
  47. package/src/components/workload/WorkloadView.tsx +30 -2
  48. package/src/theme/index.ts +3 -0
  49. package/src/theme/tailwind-theme.css +61 -0
  50. package/src/theme/variables.css +121 -0
  51. package/src/types/core.ts +13 -2
  52. package/src/utils/animation.ts +1 -1
  53. package/src/utils/badge-colors.ts +158 -123
  54. package/src/utils/log-format.ts +127 -35
  55. package/src/utils/navigation.test.ts +142 -0
  56. package/src/utils/navigation.ts +52 -47
  57. package/src/utils/resource-hierarchy.ts +5 -0
  58. package/src/utils/resource-icons.ts +3 -0
@@ -1,9 +1,9 @@
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, CaseSensitive, Regex, WrapText, Clock, Copy, Trash2, Filter } from 'lucide-react'
3
+ import { Play, Square, Download, Search, X, Terminal, RotateCcw, ChevronUp, ChevronDown, CaseSensitive, Regex, WrapText, Clock, Copy, Trash2, Filter, Braces } from 'lucide-react'
4
4
  import type { LogEntry, LogLevel } from './useLogBuffer'
5
5
  import { useLogSearch } from './useLogSearch'
6
- import { JsonLogLine } from './JsonLogLine'
6
+ import { StructuredLogLine } from './StructuredLogLine'
7
7
  import { Tooltip } from '../ui/Tooltip'
8
8
  import {
9
9
  formatLogTimestamp,
@@ -65,6 +65,7 @@ export function LogCore({
65
65
  new Set(['error', 'warn', 'info', 'debug'])
66
66
  )
67
67
  const [showDownloadMenu, setShowDownloadMenu] = useState(false)
68
+ const [expandAllStructured, setExpandAllStructured] = useState(false)
68
69
 
69
70
  // Level-filtered entries
70
71
  // 'unknown' logs are shown when all 4 known levels are enabled (no active filtering)
@@ -83,6 +84,8 @@ export function LogCore({
83
84
  return counts
84
85
  }, [entries])
85
86
 
87
+ const hasStructuredEntries = useMemo(() => entries.some(e => e.isJson || e.isLogfmt), [entries])
88
+
86
89
  // Search
87
90
  const search = useLogSearch(levelFilteredEntries, virtuosoRef)
88
91
 
@@ -225,6 +228,20 @@ export function LogCore({
225
228
 
226
229
  <div className="flex-1" />
227
230
 
231
+ {/* Expand all structured logs toggle */}
232
+ {hasStructuredEntries && (
233
+ <Tooltip content={expandAllStructured ? 'Collapse all structured' : 'Expand all structured'} delay={TIP_DELAY} position="bottom">
234
+ <button
235
+ onClick={() => setExpandAllStructured(prev => !prev)}
236
+ className={`p-1.5 rounded transition-colors ${
237
+ expandAllStructured ? 'bg-blue-600/50 text-theme-text-primary' : 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated'
238
+ }`}
239
+ >
240
+ <Braces className="w-4 h-4" />
241
+ </button>
242
+ </Tooltip>
243
+ )}
244
+
228
245
  {/* Timestamp toggle */}
229
246
  <Tooltip content={showTimestamps ? 'Hide timestamps' : 'Show timestamps'} delay={TIP_DELAY} position="bottom">
230
247
  <button
@@ -438,6 +455,7 @@ export function LogCore({
438
455
  showTimestamp={showTimestamps}
439
456
  isCurrentMatch={entry.id === currentHighlightId}
440
457
  wordWrap={wordWrap}
458
+ defaultExpanded={expandAllStructured}
441
459
  />
442
460
  )}
443
461
  className="h-full font-mono text-xs"
@@ -483,6 +501,7 @@ function LogLine({
483
501
  showTimestamp,
484
502
  isCurrentMatch,
485
503
  wordWrap,
504
+ defaultExpanded,
486
505
  }: {
487
506
  entry: LogEntry
488
507
  searchQuery: string
@@ -492,6 +511,7 @@ function LogLine({
492
511
  showTimestamp: boolean
493
512
  isCurrentMatch: boolean
494
513
  wordWrap: boolean
514
+ defaultExpanded: boolean
495
515
  }) {
496
516
  const levelColor = getLevelColor(entry.level)
497
517
 
@@ -506,9 +526,15 @@ function LogLine({
506
526
  dangerouslySetInnerHTML={{ __html: highlighted }}
507
527
  />
508
528
  )
509
- } else if (entry.isJson) {
529
+ } else if (entry.isJson || entry.isLogfmt) {
510
530
  contentElement = (
511
- <JsonLogLine content={entry.content} level={entry.level} wordWrap={wordWrap} />
531
+ <StructuredLogLine
532
+ content={entry.content}
533
+ level={entry.level}
534
+ wordWrap={wordWrap}
535
+ isLogfmt={entry.isLogfmt}
536
+ defaultExpanded={defaultExpanded}
537
+ />
512
538
  )
513
539
  } else {
514
540
  const html = ansiToHtml(entry.content)
@@ -0,0 +1,168 @@
1
+ import { useState, useMemo } from 'react'
2
+ import { ChevronRight, ChevronDown } from 'lucide-react'
3
+ import type { LogLevel } from './useLogBuffer'
4
+ import {
5
+ getLevelColor,
6
+ highlightJson,
7
+ unescapeJsonStrings,
8
+ parseLogfmt,
9
+ SYNTAX_COLOR_KEY,
10
+ SYNTAX_COLOR_STRING,
11
+ } from '../../utils/log-format'
12
+ import { SEVERITY_BADGE_BORDERED } from '../../utils/badge-colors'
13
+
14
+ interface StructuredLogLineProps {
15
+ content: string
16
+ level: LogLevel
17
+ wordWrap: boolean
18
+ isLogfmt?: boolean
19
+ defaultExpanded?: boolean
20
+ }
21
+
22
+ export function StructuredLogLine({ content, level, wordWrap, isLogfmt, defaultExpanded }: StructuredLogLineProps) {
23
+ // null = user hasn't toggled this line; defers to defaultExpanded (global toggle)
24
+ const [localExpanded, setLocalExpanded] = useState<boolean | null>(null)
25
+ const expanded = localExpanded ?? defaultExpanded ?? false
26
+
27
+ const parsed = useMemo(() => {
28
+ try {
29
+ if (isLogfmt) {
30
+ return parseLogfmt(content)
31
+ }
32
+ return JSON.parse(content.trim())
33
+ } catch {
34
+ return null
35
+ }
36
+ }, [content, isLogfmt])
37
+
38
+ if (!parsed) {
39
+ return (
40
+ <span className={`${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'} ${getLevelColor(level)}`}>
41
+ {content}
42
+ </span>
43
+ )
44
+ }
45
+
46
+ const fieldCount = Object.keys(parsed).length
47
+
48
+ const toggle = () => setLocalExpanded(!expanded)
49
+ const chevron = expanded
50
+ ? <ChevronDown className="w-3 h-3 shrink-0 text-theme-text-tertiary" />
51
+ : <ChevronRight className="w-3 h-3 shrink-0 text-theme-text-tertiary" />
52
+
53
+ return (
54
+ <span>
55
+ {!expanded ? (
56
+ // Collapsed: entire summary line is clickable
57
+ <span
58
+ onClick={toggle}
59
+ className={`cursor-pointer hover:bg-theme-surface/50 rounded px-0.5 -ml-0.5 ${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'}`}
60
+ >
61
+ <span className="inline-flex items-center align-middle mr-0.5">{chevron}</span>
62
+ <SummaryLine obj={parsed} />
63
+ <span className="text-theme-text-tertiary ml-1">{`{${fieldCount} fields}`}</span>
64
+ </span>
65
+ ) : (
66
+ // Expanded: summary header is clickable to collapse, JSON content is selectable
67
+ <>
68
+ <span
69
+ onClick={toggle}
70
+ className="cursor-pointer hover:bg-theme-surface/50 rounded px-0.5 -ml-0.5"
71
+ >
72
+ <span className="inline-flex items-center align-middle mr-0.5">{chevron}</span>
73
+ <SummaryLine obj={parsed} />
74
+ <span className="text-theme-text-tertiary ml-1">{`{${fieldCount} fields}`}</span>
75
+ </span>
76
+ <span className={`block ml-4 ${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'}`}>
77
+ {isLogfmt ? (
78
+ <ExpandedLogfmt obj={parsed} />
79
+ ) : (
80
+ <span dangerouslySetInnerHTML={{
81
+ __html: highlightJson(unescapeJsonStrings(JSON.stringify(parsed, null, 2)))
82
+ }} />
83
+ )}
84
+ </span>
85
+ </>
86
+ )}
87
+ </span>
88
+ )
89
+ }
90
+
91
+ function SummaryLine({ obj }: { obj: Record<string, unknown> }) {
92
+ const lvl = obj.level ?? obj.severity ?? obj.lvl ?? nestedField(obj, 'log', 'level')
93
+ const msg = obj.msg ?? obj.message
94
+ const rawErr = obj.error ?? obj.err
95
+ const err = typeof rawErr === 'string'
96
+ ? rawErr
97
+ : nestedField(obj, 'error', 'message') ?? nestedField(obj, 'err', 'message')
98
+ const caller = obj.caller ?? obj.source
99
+
100
+ return (
101
+ <>
102
+ {lvl != null && (
103
+ <span className={`${getLevelBadgeColor(lvl)} text-[10px] font-semibold px-1 py-px rounded mr-1.5 inline-block`}>
104
+ {formatLevel(lvl)}
105
+ </span>
106
+ )}
107
+ {typeof msg === 'string' && (
108
+ <span className="text-theme-text-primary">{msg}</span>
109
+ )}
110
+ {typeof err === 'string' && (
111
+ <span className="text-red-400 ml-2">error={err}</span>
112
+ )}
113
+ {typeof caller === 'string' && (
114
+ <span className="text-theme-text-disabled ml-2">{caller}</span>
115
+ )}
116
+ </>
117
+ )
118
+ }
119
+
120
+ function ExpandedLogfmt({ obj }: { obj: Record<string, unknown> }) {
121
+ return (
122
+ <>
123
+ {Object.entries(obj).map(([key, val]) => (
124
+ <div key={key}>
125
+ <span style={{ color: SYNTAX_COLOR_KEY }}>{key}</span>
126
+ <span className="text-theme-text-tertiary">=</span>
127
+ <span style={{ color: SYNTAX_COLOR_STRING }}>{String(val)}</span>
128
+ </div>
129
+ ))}
130
+ </>
131
+ )
132
+ }
133
+
134
+ function nestedField(obj: Record<string, unknown>, parent: string, child: string): unknown {
135
+ const p = obj[parent]
136
+ if (p && typeof p === 'object' && !Array.isArray(p)) {
137
+ return (p as Record<string, unknown>)[child]
138
+ }
139
+ return undefined
140
+ }
141
+
142
+ function formatLevel(lvl: unknown): string {
143
+ if (typeof lvl === 'number') {
144
+ if (lvl >= 50) return 'ERR'
145
+ if (lvl >= 40) return 'WARN'
146
+ if (lvl >= 30) return 'INFO'
147
+ return 'DBG'
148
+ }
149
+ return String(lvl).toUpperCase()
150
+ }
151
+
152
+ function getLevelBadgeColor(lvl: unknown): string {
153
+ let normalized: string
154
+ if (typeof lvl === 'number') {
155
+ // Pino/bunyan numeric levels: 10=trace, 20=debug, 30=info, 40=warn, 50=error, 60=fatal
156
+ if (lvl >= 50) normalized = 'error'
157
+ else if (lvl >= 40) normalized = 'warn'
158
+ else if (lvl >= 30) normalized = 'info'
159
+ else normalized = 'debug'
160
+ } else {
161
+ normalized = String(lvl).toLowerCase()
162
+ }
163
+ if (/^(error|err|fatal|panic|critical|crit)$/.test(normalized)) return SEVERITY_BADGE_BORDERED.error
164
+ if (/^(warn|warning)$/.test(normalized)) return SEVERITY_BADGE_BORDERED.warning
165
+ if (/^(info|information|notice)$/.test(normalized)) return SEVERITY_BADGE_BORDERED.info
166
+ if (/^(debug|dbg|trace|verbose)$/.test(normalized)) return SEVERITY_BADGE_BORDERED.debug
167
+ return SEVERITY_BADGE_BORDERED.neutral
168
+ }
@@ -5,7 +5,7 @@ export type { LogLevel, LogEntry } from './useLogBuffer'
5
5
  export { useLogSearch } from './useLogSearch'
6
6
  export { useLogStream } from './useLogStream'
7
7
  export type { LogStreamHandlers } from './useLogStream'
8
- export { JsonLogLine } from './JsonLogLine'
8
+ export { StructuredLogLine } from './StructuredLogLine'
9
9
  export { ContainerSelect, LogRangeSelect } from './LogToolbarSelects'
10
10
  export { LogsViewer } from './LogsViewer'
11
11
  export type { LogsViewerProps, LogsFetchParams } from './LogsViewer'
@@ -1,4 +1,5 @@
1
1
  import { useState, useRef, useCallback } from 'react'
2
+ import { isLogfmt } from '../../utils/log-format'
2
3
 
3
4
  export type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'unknown'
4
5
 
@@ -11,13 +12,14 @@ export interface LogEntry {
11
12
  podColor?: string
12
13
  level: LogLevel
13
14
  isJson: boolean
15
+ isLogfmt: boolean
14
16
  }
15
17
 
16
18
  const MAX_BUFFER_SIZE = 10_000
17
19
 
18
20
  /**
19
21
  * Detect log level from content using word-boundary matching.
20
- * For JSON logs, prefer the `level` or `severity` field.
22
+ * For JSON logs, prefer the `level`, `severity`, or `lvl` field.
21
23
  */
22
24
  export function detectLogLevel(content: string): LogLevel {
23
25
  // Fast path for JSON: check level/severity field
@@ -56,26 +58,30 @@ function isJsonContent(content: string): boolean {
56
58
  return trimmed[0] === '{' && trimmed[trimmed.length - 1] === '}'
57
59
  }
58
60
 
61
+ type RawLogEntry = Omit<LogEntry, 'id' | 'level' | 'isJson' | 'isLogfmt'>
62
+
59
63
  interface UseLogBufferReturn {
60
64
  entries: LogEntry[]
61
- append: (entry: Omit<LogEntry, 'id' | 'level' | 'isJson'>) => void
62
- appendBatch: (entries: Omit<LogEntry, 'id' | 'level' | 'isJson'>[]) => void
63
- set: (entries: Omit<LogEntry, 'id' | 'level' | 'isJson'>[]) => void
65
+ append: (entry: RawLogEntry) => void
66
+ appendBatch: (entries: RawLogEntry[]) => void
67
+ set: (entries: RawLogEntry[]) => void
64
68
  clear: () => void
65
69
  }
66
70
 
67
71
  export function useLogBuffer(): UseLogBufferReturn {
68
72
  const [entries, setEntries] = useState<LogEntry[]>([])
69
73
  const idCounter = useRef(0)
70
- const pendingRef = useRef<Omit<LogEntry, 'id' | 'level' | 'isJson'>[]>([])
74
+ const pendingRef = useRef<RawLogEntry[]>([])
71
75
  const rafRef = useRef<number | null>(null)
72
76
 
73
- const enrichEntry = useCallback((raw: Omit<LogEntry, 'id' | 'level' | 'isJson'>): LogEntry => {
77
+ const enrichEntry = useCallback((raw: RawLogEntry): LogEntry => {
78
+ const isJ = isJsonContent(raw.content)
74
79
  return {
75
80
  ...raw,
76
81
  id: idCounter.current++,
77
82
  level: detectLogLevel(raw.content),
78
- isJson: isJsonContent(raw.content),
83
+ isJson: isJ,
84
+ isLogfmt: !isJ && isLogfmt(raw.content),
79
85
  }
80
86
  }, [])
81
87
 
@@ -95,21 +101,21 @@ export function useLogBuffer(): UseLogBufferReturn {
95
101
  })
96
102
  }, [enrichEntry])
97
103
 
98
- const append = useCallback((entry: Omit<LogEntry, 'id' | 'level' | 'isJson'>) => {
104
+ const append = useCallback((entry: RawLogEntry) => {
99
105
  pendingRef.current.push(entry)
100
106
  if (rafRef.current === null) {
101
107
  rafRef.current = requestAnimationFrame(flushPending)
102
108
  }
103
109
  }, [flushPending])
104
110
 
105
- const appendBatch = useCallback((batch: Omit<LogEntry, 'id' | 'level' | 'isJson'>[]) => {
111
+ const appendBatch = useCallback((batch: RawLogEntry[]) => {
106
112
  pendingRef.current.push(...batch)
107
113
  if (rafRef.current === null) {
108
114
  rafRef.current = requestAnimationFrame(flushPending)
109
115
  }
110
116
  }, [flushPending])
111
117
 
112
- const set = useCallback((rawEntries: Omit<LogEntry, 'id' | 'level' | 'isJson'>[]) => {
118
+ const set = useCallback((rawEntries: RawLogEntry[]) => {
113
119
  // Cancel any pending RAF
114
120
  if (rafRef.current !== null) {
115
121
  cancelAnimationFrame(rafRef.current)