@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.0.0",
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