@skyhook-io/k8s-ui 1.3.0 → 1.3.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -64,7 +64,7 @@
64
64
  "lucide-react": "^0.575.0",
65
65
  "react": "^19.2.4",
66
66
  "react-dom": "^19.2.4",
67
- "typescript": "^5.9.3",
67
+ "typescript": "^6.0.2",
68
68
  "vitest": "^4.0.18",
69
69
  "yaml": "^2.8.3"
70
70
  }
@@ -1,6 +1,6 @@
1
1
  import { useState, useCallback, useRef, useEffect, ReactNode } from 'react'
2
2
  import { DURATION_DOCK } from '../../utils/animation'
3
- import { X, ChevronDown, ChevronUp, Terminal, FileText, Trash2, Layers, Maximize2, Minimize2 } from 'lucide-react'
3
+ import { X, ChevronDown, ChevronUp, Terminal, FileText, Trash2, Layers, Maximize2, Minimize2, Activity } from 'lucide-react'
4
4
  import { clsx } from 'clsx'
5
5
  import { useDock, DockTab } from './DockContext'
6
6
  import { useRegisterShortcut } from '../../hooks/useKeyboardShortcuts'
@@ -12,13 +12,25 @@ const MAXIMIZED_TOP_OFFSET = 48
12
12
 
13
13
  interface BottomDockProps {
14
14
  renderTabContent: (tab: DockTab, isActive: boolean) => ReactNode
15
+ /** Optional extra content rendered in the dock header bar (between tabs and action buttons) */
16
+ renderTabHeaderExtra?: (tab: DockTab) => ReactNode
15
17
  /** Offset from the left edge in px — use to avoid overlapping a fixed sidebar */
16
18
  leftOffset?: number
19
+ /** Override the default dock height in px */
20
+ defaultHeight?: number
17
21
  }
18
22
 
19
- export function BottomDock({ renderTabContent, leftOffset = 0 }: BottomDockProps) {
20
- const { tabs, activeTabId, isExpanded, removeTab, setActiveTab, toggleExpanded, closeAll } = useDock()
21
- const [height, setHeight] = useState(DEFAULT_HEIGHT)
23
+ export function BottomDock({ renderTabContent, renderTabHeaderExtra, leftOffset: leftOffsetProp, defaultHeight }: BottomDockProps) {
24
+ const { tabs, activeTabId, isExpanded, leftOffset: leftOffsetCtx, removeTab, setActiveTab, toggleExpanded, closeAll } = useDock()
25
+ const leftOffset = leftOffsetProp ?? leftOffsetCtx
26
+ const [height, setHeight] = useState(defaultHeight ?? DEFAULT_HEIGHT)
27
+ const prevDefaultHeight = useRef(defaultHeight)
28
+ useEffect(() => {
29
+ if (defaultHeight != null && defaultHeight !== prevDefaultHeight.current) {
30
+ setHeight(defaultHeight)
31
+ }
32
+ prevDefaultHeight.current = defaultHeight
33
+ }, [defaultHeight])
22
34
  const [isMaximized, setIsMaximized] = useState(false)
23
35
  const isDragging = useRef(false)
24
36
  const startY = useRef(0)
@@ -112,6 +124,12 @@ export function BottomDock({ renderTabContent, leftOffset = 0 }: BottomDockProps
112
124
  ))}
113
125
  </div>
114
126
 
127
+ {/* Per-tab extra header content */}
128
+ {renderTabHeaderExtra && activeTabId && (() => {
129
+ const activeTab = tabs.find(t => t.id === activeTabId)
130
+ return activeTab ? renderTabHeaderExtra(activeTab) : null
131
+ })()}
132
+
115
133
  <div className="flex items-center gap-1 ml-2">
116
134
  {tabs.length > 1 && (
117
135
  <button
@@ -175,7 +193,7 @@ function TabButton({
175
193
  onClose: () => void
176
194
  }) {
177
195
  const Icon = tab.type === 'terminal' || tab.type === 'node-terminal' || tab.type === 'local-terminal'
178
- ? Terminal : tab.type === 'workload-logs' ? Layers : FileText
196
+ ? Terminal : tab.type === 'workload-logs' ? Layers : tab.type === 'traffic-flows' ? Activity : FileText
179
197
 
180
198
  return (
181
199
  <div
@@ -1,6 +1,6 @@
1
1
  import { createContext, useContext, useState, useCallback, useRef, useEffect, ReactNode } from 'react'
2
2
 
3
- export type DockTabType = 'terminal' | 'logs' | 'workload-logs' | 'node-terminal' | 'local-terminal'
3
+ export type DockTabType = 'terminal' | 'logs' | 'workload-logs' | 'node-terminal' | 'local-terminal' | 'traffic-flows'
4
4
 
5
5
  export interface DockTab {
6
6
  id: string
@@ -26,11 +26,13 @@ export interface DockContextValue {
26
26
  tabs: DockTab[]
27
27
  activeTabId: string | null
28
28
  isExpanded: boolean
29
+ leftOffset: number
29
30
  addTab: (tab: Omit<DockTab, 'id'>) => string
30
31
  removeTab: (id: string) => void
31
32
  setActiveTab: (id: string) => void
32
33
  toggleExpanded: () => void
33
34
  setExpanded: (expanded: boolean) => void
35
+ setLeftOffset: (offset: number) => void
34
36
  closeAll: () => void
35
37
  }
36
38
 
@@ -42,6 +44,7 @@ export function DockProvider({ children }: { children: ReactNode }) {
42
44
  const [tabs, setTabs] = useState<DockTab[]>([])
43
45
  const [activeTabId, setActiveTabId] = useState<string | null>(null)
44
46
  const [isExpanded, setIsExpanded] = useState(false)
47
+ const [leftOffset, setLeftOffset] = useState(0)
45
48
  // Keep a ref to the latest tabs for deduplication without stale-closure issues
46
49
  const tabsRef = useRef<DockTab[]>(tabs)
47
50
  useEffect(() => { tabsRef.current = tabs }, [tabs])
@@ -62,6 +65,9 @@ export function DockProvider({ children }: { children: ReactNode }) {
62
65
  if (t.type === 'local-terminal') {
63
66
  return false // Allow multiple local terminals
64
67
  }
68
+ if (t.type === 'traffic-flows') {
69
+ return true // Singleton — always reuse existing tab
70
+ }
65
71
  return t.namespace === tabData.namespace &&
66
72
  t.podName === tabData.podName &&
67
73
  t.containerName === tabData.containerName
@@ -117,11 +123,13 @@ export function DockProvider({ children }: { children: ReactNode }) {
117
123
  tabs,
118
124
  activeTabId,
119
125
  isExpanded,
126
+ leftOffset,
120
127
  addTab,
121
128
  removeTab,
122
129
  setActiveTab,
123
130
  toggleExpanded,
124
131
  setExpanded: setIsExpanded,
132
+ setLeftOffset,
125
133
  closeAll,
126
134
  }}>
127
135
  {children}
@@ -25,6 +25,8 @@ export interface LogsViewerProps {
25
25
  fetchLogs: (params: LogsFetchParams) => Promise<{ [container: string]: string }>
26
26
  /** If provided, the stream button is enabled. Called to open an SSE connection. */
27
27
  createStream?: (params: Omit<LogsFetchParams, 'previous'>) => EventSource
28
+ /** Override the download mechanism (e.g. for desktop apps where blob URLs fail). */
29
+ overrideDownload?: (content: string, mime: string, filename: string) => void
28
30
  /** Force dark mode on the logs container (default: true) */
29
31
  forceDark?: boolean
30
32
  }
@@ -36,6 +38,7 @@ export function LogsViewer({
36
38
  initialContainer,
37
39
  fetchLogs,
38
40
  createStream,
41
+ overrideDownload,
39
42
  forceDark,
40
43
  }: LogsViewerProps) {
41
44
  const [selectedContainer, setSelectedContainer] = useState(initialContainer || containers[0] || '')
@@ -105,12 +108,14 @@ export function LogsViewer({
105
108
  mime = 'text/plain'
106
109
  }
107
110
  try {
108
- triggerDownload(content, mime, filename)
109
- showSuccess('Log download started', `Saving ${filename}. Check your browser or desktop Downloads location.`)
111
+ triggerDownload(content, mime, filename, overrideDownload)
112
+ if (!overrideDownload) {
113
+ showSuccess('Log download started', `Saving ${filename}. Check your browser Downloads.`)
114
+ }
110
115
  } catch (err) {
111
116
  showError('Failed to download logs', err instanceof Error ? err.message : 'Unknown download error')
112
117
  }
113
- }, [entries, podName, selectedContainer, showError, showSuccess])
118
+ }, [entries, podName, selectedContainer, overrideDownload, showError, showSuccess])
114
119
 
115
120
  const toolbarExtra = (
116
121
  <>
@@ -42,6 +42,8 @@ export interface WorkloadLogsViewerProps {
42
42
  * Called to open an SSE connection for the whole workload.
43
43
  */
44
44
  createStream?: (params: WorkloadLogsFetchParams) => EventSource
45
+ /** Override the download mechanism (e.g. for desktop apps where blob URLs fail). */
46
+ overrideDownload?: (content: string, mime: string, filename: string) => void
45
47
  /** Force dark mode on the logs container (default: true) */
46
48
  forceDark?: boolean
47
49
  }
@@ -51,7 +53,7 @@ const POD_COLORS = [
51
53
  'text-pink-400', 'text-cyan-400', 'text-orange-400', 'text-lime-400',
52
54
  ]
53
55
 
54
- export function WorkloadLogsViewer({ name, fetchAll, createStream, forceDark }: WorkloadLogsViewerProps) {
56
+ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownload, forceDark }: WorkloadLogsViewerProps) {
55
57
  const [selectedContainer, setSelectedContainer] = useState<string>('')
56
58
  const [pods, setPods] = useState<WorkloadPodInfo[]>([])
57
59
  const [selectedPods, setSelectedPods] = useState<Set<string>>(new Set())
@@ -206,12 +208,14 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, forceDark }:
206
208
  mime = 'text/plain'
207
209
  }
208
210
  try {
209
- triggerDownload(content, mime, filename)
210
- showSuccess('Log download started', `Saving ${filename}. Check your browser or desktop Downloads location.`)
211
+ triggerDownload(content, mime, filename, overrideDownload)
212
+ if (!overrideDownload) {
213
+ showSuccess('Log download started', `Saving ${filename}. Check your browser Downloads.`)
214
+ }
211
215
  } catch (err) {
212
216
  showError('Failed to download logs', err instanceof Error ? err.message : 'Unknown download error')
213
217
  }
214
- }, [filteredEntries, name, showError, showSuccess])
218
+ }, [filteredEntries, name, overrideDownload, showError, showSuccess])
215
219
 
216
220
  const toolbarExtra = (
217
221
  <>
@@ -0,0 +1,131 @@
1
+ import { memo } from 'react'
2
+ import { clsx } from 'clsx'
3
+ import { ArrowRight, Plus, Minus, RefreshCw } from 'lucide-react'
4
+ import type { DiffInfo, FieldChange } from '../../types'
5
+
6
+ interface DiffViewerProps {
7
+ diff: DiffInfo
8
+ compact?: boolean
9
+ }
10
+
11
+ export const DiffViewer = memo(function DiffViewer({ diff, compact = false }: DiffViewerProps) {
12
+ if (!diff || diff.fields.length === 0) {
13
+ return null
14
+ }
15
+
16
+ if (compact) {
17
+ return (
18
+ <div className="text-xs text-theme-text-secondary flex items-center gap-1">
19
+ <RefreshCw className="w-3 h-3" />
20
+ <span>{diff.summary || `${diff.fields.length} field(s) changed`}</span>
21
+ </div>
22
+ )
23
+ }
24
+
25
+ return (
26
+ <div className="space-y-2">
27
+ {/* Summary */}
28
+ {diff.summary && (
29
+ <div className="text-sm font-medium text-theme-text-secondary flex items-center gap-2">
30
+ <RefreshCw className="w-4 h-4 text-blue-400" />
31
+ {diff.summary}
32
+ </div>
33
+ )}
34
+
35
+ {/* Field changes */}
36
+ <div className="space-y-1.5">
37
+ {diff.fields.map((field, idx) => (
38
+ <FieldChangeRow key={`${field.path}-${idx}`} field={field} />
39
+ ))}
40
+ </div>
41
+ </div>
42
+ )
43
+ })
44
+
45
+ interface FieldChangeRowProps {
46
+ field: FieldChange
47
+ }
48
+
49
+ function FieldChangeRow({ field }: FieldChangeRowProps) {
50
+ const isAdded = field.oldValue === null || field.oldValue === undefined
51
+ const isRemoved = field.newValue === null || field.newValue === undefined
52
+ const isModified = !isAdded && !isRemoved
53
+
54
+ return (
55
+ <div className="rounded bg-theme-surface/50 border border-theme-border px-3 py-2">
56
+ {/* Field path */}
57
+ <div className="text-xs font-mono text-theme-text-tertiary mb-1">{field.path}</div>
58
+
59
+ {/* Values */}
60
+ <div className="flex items-center gap-2 text-sm">
61
+ {isAdded ? (
62
+ <>
63
+ <Plus className="w-3.5 h-3.5 text-green-400 shrink-0" />
64
+ <span className="text-green-400">
65
+ {formatValue(field.newValue)}
66
+ </span>
67
+ </>
68
+ ) : isRemoved ? (
69
+ <>
70
+ <Minus className="w-3.5 h-3.5 text-red-400 shrink-0" />
71
+ <span className="text-red-400 line-through">
72
+ {formatValue(field.oldValue)}
73
+ </span>
74
+ </>
75
+ ) : isModified ? (
76
+ <>
77
+ <span className="text-red-400 line-through">
78
+ {formatValue(field.oldValue)}
79
+ </span>
80
+ <ArrowRight className="w-3.5 h-3.5 text-theme-text-tertiary shrink-0" />
81
+ <span className="text-green-400">
82
+ {formatValue(field.newValue)}
83
+ </span>
84
+ </>
85
+ ) : null}
86
+ </div>
87
+ </div>
88
+ )
89
+ }
90
+
91
+ function formatValue(value: unknown): string {
92
+ if (value === null || value === undefined) {
93
+ return 'null'
94
+ }
95
+ if (typeof value === 'object') {
96
+ try {
97
+ const str = JSON.stringify(value)
98
+ // Truncate long values
99
+ if (str.length > 100) {
100
+ return str.slice(0, 97) + '...'
101
+ }
102
+ return str
103
+ } catch {
104
+ return String(value)
105
+ }
106
+ }
107
+ return String(value)
108
+ }
109
+
110
+ // Inline diff badge for use in event cards
111
+ interface DiffBadgeProps {
112
+ diff: DiffInfo
113
+ }
114
+
115
+ export const DiffBadge = memo(function DiffBadge({ diff }: DiffBadgeProps) {
116
+ if (!diff || !diff.summary) {
117
+ return null
118
+ }
119
+
120
+ return (
121
+ <span
122
+ className={clsx(
123
+ 'badge',
124
+ 'bg-skyhook-500/10 text-skyhook-400 border border-skyhook-500/20'
125
+ )}
126
+ >
127
+ <RefreshCw className="w-3 h-3" />
128
+ {diff.summary}
129
+ </span>
130
+ )
131
+ })