@skyhook-io/k8s-ui 1.0.0 → 1.1.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.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/k8s-ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/skyhook-io/radar",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@monaco-editor/react": "^4.7.0",
|
|
30
|
+
"html-to-image": "^1.11.0",
|
|
30
31
|
"react-virtuoso": "^4.18.1",
|
|
31
32
|
"shiki": "^4.0.0"
|
|
32
33
|
},
|
|
@@ -17,8 +17,10 @@ import {
|
|
|
17
17
|
MarkerType,
|
|
18
18
|
} from '@xyflow/react'
|
|
19
19
|
import '@xyflow/react/dist/style.css'
|
|
20
|
+
import { toCanvas } from 'html-to-image'
|
|
20
21
|
|
|
21
|
-
import { AlertTriangle, RotateCw, Scissors, Shield } from 'lucide-react'
|
|
22
|
+
import { AlertTriangle, Download, Loader2, RotateCw, Scissors, Shield } from 'lucide-react'
|
|
23
|
+
import { useToast } from '../ui/Toast'
|
|
22
24
|
import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
|
|
23
25
|
|
|
24
26
|
import { K8sResourceNode } from './K8sResourceNode'
|
|
@@ -152,6 +154,8 @@ interface TopologyGraphProps {
|
|
|
152
154
|
hideGroupHeader?: boolean
|
|
153
155
|
onNodeClick: (node: TopologyNode) => void
|
|
154
156
|
selectedNodeId?: string
|
|
157
|
+
/** Show image export button in controls. Default: true */
|
|
158
|
+
showExportButton?: boolean
|
|
155
159
|
}
|
|
156
160
|
|
|
157
161
|
export function TopologyGraph({
|
|
@@ -161,6 +165,7 @@ export function TopologyGraph({
|
|
|
161
165
|
hideGroupHeader = false,
|
|
162
166
|
onNodeClick,
|
|
163
167
|
selectedNodeId,
|
|
168
|
+
showExportButton = true,
|
|
164
169
|
}: TopologyGraphProps) {
|
|
165
170
|
const isTrafficView = viewMode === 'traffic'
|
|
166
171
|
const [nodes, setNodes, onNodesChange] = useNodesState([] as Node[])
|
|
@@ -169,6 +174,7 @@ export function TopologyGraph({
|
|
|
169
174
|
const [expandedPodGroups, setExpandedPodGroups] = useState<Set<string>>(new Set())
|
|
170
175
|
const [layoutError, setLayoutError] = useState<string | null>(null)
|
|
171
176
|
const [layoutRetryCount, setLayoutRetryCount] = useState(0)
|
|
177
|
+
const [isExporting, setIsExporting] = useState(false)
|
|
172
178
|
const prevStructureRef = useRef<string>('')
|
|
173
179
|
const layoutVersionRef = useRef(0) // Used to invalidate stale layout results
|
|
174
180
|
|
|
@@ -623,19 +629,291 @@ export function TopologyGraph({
|
|
|
623
629
|
minZoom={0.1}
|
|
624
630
|
maxZoom={2}
|
|
625
631
|
proOptions={{ hideAttribution: true }}
|
|
626
|
-
onlyRenderVisibleElements
|
|
632
|
+
onlyRenderVisibleElements={!isExporting}
|
|
627
633
|
>
|
|
628
634
|
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#334155" />
|
|
629
635
|
<Controls
|
|
630
636
|
className="bg-theme-surface border border-theme-border rounded-lg"
|
|
631
637
|
showInteractive={false}
|
|
632
|
-
|
|
638
|
+
>
|
|
639
|
+
{showExportButton && <ExportImageButton onExportingChange={setIsExporting} />}
|
|
640
|
+
</Controls>
|
|
633
641
|
<ViewportController structureKey={structureKey} />
|
|
634
642
|
</ReactFlow>
|
|
635
643
|
</ReactFlowProvider>
|
|
636
644
|
)
|
|
637
645
|
}
|
|
638
646
|
|
|
647
|
+
// Read the effective background color from the topology container
|
|
648
|
+
function getTopologyBgColor(): string {
|
|
649
|
+
const el = document.querySelector('.react-flow')
|
|
650
|
+
if (el) {
|
|
651
|
+
const bg = getComputedStyle(el).backgroundColor
|
|
652
|
+
if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') return bg
|
|
653
|
+
}
|
|
654
|
+
return '#0f172a'
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Compute export dimensions for the dialog preview
|
|
658
|
+
function useExportDimensions(captureMode: 'viewport' | 'full', scale: number) {
|
|
659
|
+
const { getNodes, getNodesBounds } = useReactFlow()
|
|
660
|
+
return useMemo(() => {
|
|
661
|
+
if (captureMode === 'viewport') {
|
|
662
|
+
const el = document.querySelector('.react-flow') as HTMLElement
|
|
663
|
+
if (!el) return null
|
|
664
|
+
const { width, height } = el.getBoundingClientRect()
|
|
665
|
+
const w = Math.ceil(width)
|
|
666
|
+
const h = Math.ceil(height)
|
|
667
|
+
return { pw: w * scale, ph: h * scale }
|
|
668
|
+
}
|
|
669
|
+
const nodes = getNodes()
|
|
670
|
+
if (nodes.length === 0) return null
|
|
671
|
+
const bounds = getNodesBounds(nodes)
|
|
672
|
+
const w = Math.ceil(bounds.width + EXPORT_PADDING * 2)
|
|
673
|
+
const h = Math.ceil(bounds.height + EXPORT_PADDING * 2)
|
|
674
|
+
// Full capture uses pixelRatio=1, so dimensions are 1:1 with graph bounds
|
|
675
|
+
return { pw: w, ph: h }
|
|
676
|
+
}, [captureMode, scale, getNodes, getNodesBounds])
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
type ImageFormat = 'image/png' | 'image/webp'
|
|
680
|
+
const FORMAT_LABELS: Record<ImageFormat, string> = { 'image/png': 'PNG', 'image/webp': 'WebP' }
|
|
681
|
+
const FORMAT_EXT: Record<ImageFormat, string> = { 'image/png': 'png', 'image/webp': 'webp' }
|
|
682
|
+
|
|
683
|
+
const EXPORT_PADDING = 16
|
|
684
|
+
const EXPORT_TIMEOUT_MS = 30_000
|
|
685
|
+
|
|
686
|
+
function withTimeout<T>(promise: Promise<T>, ms: number, msg: string): Promise<T> {
|
|
687
|
+
return Promise.race([
|
|
688
|
+
promise,
|
|
689
|
+
new Promise<never>((_, reject) => setTimeout(() => reject(new Error(msg)), ms)),
|
|
690
|
+
])
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Export topology as image button + dialog (must be inside ReactFlowProvider)
|
|
694
|
+
function ExportImageButton({ onExportingChange }: { onExportingChange: (v: boolean) => void }) {
|
|
695
|
+
const [showDialog, setShowDialog] = useState(false)
|
|
696
|
+
const [exporting, setExporting] = useState(false)
|
|
697
|
+
const [filename, setFilename] = useState('')
|
|
698
|
+
const [transparent, setTransparent] = useState(false)
|
|
699
|
+
const [scale, setScale] = useState(2)
|
|
700
|
+
const [captureMode, setCaptureMode] = useState<'viewport' | 'full'>('full')
|
|
701
|
+
const [format, setFormat] = useState<ImageFormat>('image/webp')
|
|
702
|
+
const { getNodes, getNodesBounds } = useReactFlow()
|
|
703
|
+
const { showError, showSuccess } = useToast()
|
|
704
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
705
|
+
const dims = useExportDimensions(captureMode, scale)
|
|
706
|
+
|
|
707
|
+
const openDialog = useCallback((e: React.MouseEvent) => {
|
|
708
|
+
e.stopPropagation()
|
|
709
|
+
e.preventDefault()
|
|
710
|
+
const nodes = getNodes()
|
|
711
|
+
if (nodes.length === 0) return
|
|
712
|
+
setFilename(`topology-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}`)
|
|
713
|
+
setShowDialog(true)
|
|
714
|
+
setTimeout(() => inputRef.current?.select(), 50)
|
|
715
|
+
}, [getNodes])
|
|
716
|
+
|
|
717
|
+
const doExport = useCallback(async () => {
|
|
718
|
+
const flowEl = document.querySelector('.react-flow__viewport') as HTMLElement
|
|
719
|
+
if (!flowEl) return
|
|
720
|
+
|
|
721
|
+
const nodes = getNodes()
|
|
722
|
+
if (nodes.length === 0) return
|
|
723
|
+
|
|
724
|
+
setExporting(true)
|
|
725
|
+
|
|
726
|
+
const isFullCapture = captureMode === 'full'
|
|
727
|
+
if (isFullCapture) {
|
|
728
|
+
onExportingChange(true)
|
|
729
|
+
// Wait for React to render all off-screen nodes
|
|
730
|
+
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Yield to let the UI paint the exporting state before heavy DOM work
|
|
734
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
735
|
+
|
|
736
|
+
try {
|
|
737
|
+
const bgColor = transparent ? 'transparent' : getTopologyBgColor()
|
|
738
|
+
|
|
739
|
+
let canvas: HTMLCanvasElement
|
|
740
|
+
if (isFullCapture) {
|
|
741
|
+
const bounds = getNodesBounds(nodes)
|
|
742
|
+
const w = Math.ceil(bounds.width + EXPORT_PADDING * 2)
|
|
743
|
+
const h = Math.ceil(bounds.height + EXPORT_PADDING * 2)
|
|
744
|
+
const tx = -bounds.x + EXPORT_PADDING
|
|
745
|
+
const ty = -bounds.y + EXPORT_PADDING
|
|
746
|
+
canvas = await withTimeout(toCanvas(flowEl, {
|
|
747
|
+
backgroundColor: bgColor,
|
|
748
|
+
width: w,
|
|
749
|
+
height: h,
|
|
750
|
+
pixelRatio: 1,
|
|
751
|
+
skipFonts: true,
|
|
752
|
+
style: {
|
|
753
|
+
width: `${w}px`,
|
|
754
|
+
height: `${h}px`,
|
|
755
|
+
transform: `translate(${tx}px, ${ty}px) scale(1)`,
|
|
756
|
+
},
|
|
757
|
+
}), EXPORT_TIMEOUT_MS, 'Export timed out — topology may be too large')
|
|
758
|
+
} else {
|
|
759
|
+
const flowContainer = document.querySelector('.react-flow') as HTMLElement
|
|
760
|
+
if (!flowContainer) throw new Error('Topology container not found')
|
|
761
|
+
const { width: vw, height: vh } = flowContainer.getBoundingClientRect()
|
|
762
|
+
|
|
763
|
+
canvas = await withTimeout(toCanvas(flowEl, {
|
|
764
|
+
backgroundColor: bgColor,
|
|
765
|
+
width: Math.ceil(vw),
|
|
766
|
+
height: Math.ceil(vh),
|
|
767
|
+
pixelRatio: scale,
|
|
768
|
+
skipFonts: true,
|
|
769
|
+
}), EXPORT_TIMEOUT_MS, 'Export timed out — topology may be too large')
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const ext = FORMAT_EXT[format]
|
|
773
|
+
// WebP: quality 1.0 (lossless) when transparent to avoid alpha artifacts, 0.92 for opaque. PNG ignores quality.
|
|
774
|
+
const quality = format === 'image/webp' ? (transparent ? 1.0 : 0.92) : undefined
|
|
775
|
+
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, format, quality))
|
|
776
|
+
if (!blob) throw new Error('Failed to create image — canvas may be too large or format unsupported')
|
|
777
|
+
|
|
778
|
+
const url = URL.createObjectURL(blob)
|
|
779
|
+
const a = document.createElement('a')
|
|
780
|
+
a.href = url
|
|
781
|
+
a.download = `${filename || 'topology'}.${ext}`
|
|
782
|
+
a.click()
|
|
783
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
784
|
+
const sizeMB = (blob.size / 1024 / 1024).toFixed(1)
|
|
785
|
+
showSuccess(`Exported ${ext.toUpperCase()} (${sizeMB} MB)`)
|
|
786
|
+
} catch (err) {
|
|
787
|
+
console.error('Failed to export topology:', err)
|
|
788
|
+
showError(`Export failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
789
|
+
} finally {
|
|
790
|
+
setExporting(false)
|
|
791
|
+
onExportingChange(false)
|
|
792
|
+
setShowDialog(false)
|
|
793
|
+
}
|
|
794
|
+
}, [getNodes, getNodesBounds, transparent, scale, captureMode, format, filename, showError, showSuccess, onExportingChange])
|
|
795
|
+
|
|
796
|
+
useEffect(() => {
|
|
797
|
+
if (!showDialog) return
|
|
798
|
+
const handleKey = (e: KeyboardEvent) => {
|
|
799
|
+
if (e.key === 'Escape') { e.stopPropagation(); setShowDialog(false) }
|
|
800
|
+
if (e.key === 'Enter' && !exporting) { e.stopPropagation(); doExport() }
|
|
801
|
+
}
|
|
802
|
+
document.addEventListener('keydown', handleKey, true)
|
|
803
|
+
return () => document.removeEventListener('keydown', handleKey, true)
|
|
804
|
+
}, [showDialog, exporting, doExport])
|
|
805
|
+
|
|
806
|
+
return (
|
|
807
|
+
<>
|
|
808
|
+
<button
|
|
809
|
+
className="react-flow__controls-button"
|
|
810
|
+
onClick={openDialog}
|
|
811
|
+
disabled={exporting}
|
|
812
|
+
title="Export as image"
|
|
813
|
+
>
|
|
814
|
+
{exporting ? <Loader2 className="w-3 h-3 animate-spin" /> : <Download className="w-3 h-3" />}
|
|
815
|
+
</button>
|
|
816
|
+
{showDialog && (
|
|
817
|
+
<div
|
|
818
|
+
className="absolute bottom-12 left-0 z-50 bg-theme-surface border border-theme-border rounded-lg shadow-2xl p-3 w-72"
|
|
819
|
+
onClick={(e) => e.stopPropagation()}
|
|
820
|
+
>
|
|
821
|
+
<div className="text-sm font-medium text-theme-text-primary mb-3">Export topology</div>
|
|
822
|
+
<label className="block text-xs text-theme-text-secondary mb-1">Filename</label>
|
|
823
|
+
<input
|
|
824
|
+
ref={inputRef}
|
|
825
|
+
type="text"
|
|
826
|
+
value={filename}
|
|
827
|
+
onChange={(e) => setFilename(e.target.value)}
|
|
828
|
+
className="w-full px-2 py-1.5 text-sm bg-theme-base border border-theme-border rounded text-theme-text-primary outline-none focus:border-blue-500 mb-3"
|
|
829
|
+
/>
|
|
830
|
+
<label className="block text-xs text-theme-text-secondary mb-1">Capture</label>
|
|
831
|
+
<div className="flex gap-1 mb-3">
|
|
832
|
+
{(['full', 'viewport'] as const).map(mode => (
|
|
833
|
+
<button
|
|
834
|
+
key={mode}
|
|
835
|
+
onClick={() => setCaptureMode(mode)}
|
|
836
|
+
className={`flex-1 px-2 py-1.5 text-xs rounded transition-colors ${captureMode === mode ? 'bg-blue-600 text-white' : 'bg-theme-base text-theme-text-secondary hover:text-theme-text-primary border border-theme-border'}`}
|
|
837
|
+
>
|
|
838
|
+
{mode === 'full' ? 'Entire graph' : 'Visible area'}
|
|
839
|
+
</button>
|
|
840
|
+
))}
|
|
841
|
+
</div>
|
|
842
|
+
<div className="flex items-center gap-3 mb-2">
|
|
843
|
+
<div className="flex-1">
|
|
844
|
+
<label className="block text-xs text-theme-text-secondary mb-1">Format</label>
|
|
845
|
+
<div className="flex gap-1">
|
|
846
|
+
{(['image/webp', 'image/png'] as ImageFormat[]).map(f => (
|
|
847
|
+
<button
|
|
848
|
+
key={f}
|
|
849
|
+
onClick={() => setFormat(f)}
|
|
850
|
+
className={`flex-1 px-2 py-1.5 text-xs rounded transition-colors ${format === f ? 'bg-blue-600 text-white' : 'bg-theme-base text-theme-text-secondary hover:text-theme-text-primary border border-theme-border'}`}
|
|
851
|
+
>
|
|
852
|
+
{FORMAT_LABELS[f]}
|
|
853
|
+
</button>
|
|
854
|
+
))}
|
|
855
|
+
</div>
|
|
856
|
+
</div>
|
|
857
|
+
{captureMode === 'viewport' && (
|
|
858
|
+
<div className="flex-1">
|
|
859
|
+
<label className="block text-xs text-theme-text-secondary mb-1">Quality</label>
|
|
860
|
+
<select
|
|
861
|
+
value={scale}
|
|
862
|
+
onChange={(e) => setScale(Number(e.target.value))}
|
|
863
|
+
className="w-full px-2 py-1.5 text-sm bg-theme-base border border-theme-border rounded text-theme-text-primary outline-none focus:border-blue-500"
|
|
864
|
+
>
|
|
865
|
+
<option value={1}>Standard</option>
|
|
866
|
+
<option value={2}>High (2x)</option>
|
|
867
|
+
<option value={3}>Ultra (3x)</option>
|
|
868
|
+
</select>
|
|
869
|
+
</div>
|
|
870
|
+
)}
|
|
871
|
+
</div>
|
|
872
|
+
{dims && (
|
|
873
|
+
<div className="text-[10px] text-theme-text-tertiary mb-2">
|
|
874
|
+
Output: {dims.pw} × {dims.ph} px
|
|
875
|
+
</div>
|
|
876
|
+
)}
|
|
877
|
+
<div className="flex items-center mb-3">
|
|
878
|
+
<label className="flex items-center gap-2 cursor-pointer">
|
|
879
|
+
<input
|
|
880
|
+
type="checkbox"
|
|
881
|
+
checked={transparent}
|
|
882
|
+
onChange={(e) => setTransparent(e.target.checked)}
|
|
883
|
+
className="rounded"
|
|
884
|
+
/>
|
|
885
|
+
<span className="text-xs text-theme-text-secondary">Transparent background</span>
|
|
886
|
+
</label>
|
|
887
|
+
</div>
|
|
888
|
+
<div className="flex gap-2">
|
|
889
|
+
<button
|
|
890
|
+
onClick={() => setShowDialog(false)}
|
|
891
|
+
className="flex-1 px-3 py-1.5 text-sm text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded transition-colors"
|
|
892
|
+
>
|
|
893
|
+
Cancel
|
|
894
|
+
</button>
|
|
895
|
+
<button
|
|
896
|
+
onClick={doExport}
|
|
897
|
+
disabled={exporting}
|
|
898
|
+
className="flex-1 px-3 py-1.5 text-sm font-medium bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors disabled:opacity-50 flex items-center justify-center gap-1.5"
|
|
899
|
+
>
|
|
900
|
+
{exporting ? <Loader2 className="w-3 h-3 animate-spin" /> : <Download className="w-3 h-3" />}
|
|
901
|
+
Export
|
|
902
|
+
</button>
|
|
903
|
+
</div>
|
|
904
|
+
</div>
|
|
905
|
+
)}
|
|
906
|
+
{exporting && (
|
|
907
|
+
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40">
|
|
908
|
+
<div className="bg-theme-surface border border-theme-border rounded-lg px-5 py-4 shadow-2xl">
|
|
909
|
+
<div className="text-sm text-theme-text-primary animate-pulse">Exporting topology…</div>
|
|
910
|
+
</div>
|
|
911
|
+
</div>
|
|
912
|
+
)}
|
|
913
|
+
</>
|
|
914
|
+
)
|
|
915
|
+
}
|
|
916
|
+
|
|
639
917
|
// Animation duration for viewport transitions
|
|
640
918
|
const VIEWPORT_ANIMATION_DURATION = 400
|
|
641
919
|
|