@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 +2 -2
- package/src/components/dock/BottomDock.tsx +23 -5
- package/src/components/dock/DockContext.tsx +9 -1
- package/src/components/logs/LogsViewer.tsx +8 -3
- package/src/components/logs/WorkloadLogsViewer.tsx +8 -4
- package/src/components/timeline/DiffViewer.tsx +131 -0
- package/src/components/timeline/TimelineList.tsx +817 -0
- package/src/components/timeline/index.ts +2 -0
- package/src/components/topology/GroupNode.tsx +189 -79
- package/src/components/topology/TopologyGraph.tsx +286 -52
- package/src/components/topology/layout.ts +288 -92
- package/src/components/topology/topology.css +111 -0
- package/src/components/ui/Toast.tsx +74 -27
- package/src/theme/variables.css +7 -2
- package/src/types/core.ts +39 -0
- package/src/utils/download.ts +18 -3
- package/tsconfig.json +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/k8s-ui",
|
|
3
|
-
"version": "1.3.
|
|
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": "^
|
|
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
|
|
20
|
-
const { tabs, activeTabId, isExpanded, removeTab, setActiveTab, toggleExpanded, closeAll } = useDock()
|
|
21
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
})
|