feat: export node inventory as Markdown table (copy to clipboard)
This commit is contained in:
@@ -2,6 +2,7 @@ import { useEffect, useCallback, useRef, useState } from 'react'
|
|||||||
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
|
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
|
||||||
import { type Node } from '@xyflow/react'
|
import { type Node } from '@xyflow/react'
|
||||||
import { applyDagreLayout } from '@/utils/layout'
|
import { applyDagreLayout } from '@/utils/layout'
|
||||||
|
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
||||||
import { exportToPng } from '@/utils/export'
|
import { exportToPng } from '@/utils/export'
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
import { Toaster } from '@/components/ui/sonner'
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
@@ -355,6 +356,13 @@ export default function App() {
|
|||||||
toast.success('Canvas auto-arranged')
|
toast.success('Canvas auto-arranged')
|
||||||
}, [nodes, edges, loadCanvas])
|
}, [nodes, edges, loadCanvas])
|
||||||
|
|
||||||
|
const handleExportMd = useCallback(async () => {
|
||||||
|
const md = generateMarkdownTable(nodes)
|
||||||
|
if (!md) { toast.error('No nodes to export'); return }
|
||||||
|
await navigator.clipboard.writeText(md)
|
||||||
|
toast.success('Markdown table copied to clipboard')
|
||||||
|
}, [nodes])
|
||||||
|
|
||||||
const handleExport = useCallback(async () => {
|
const handleExport = useCallback(async () => {
|
||||||
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
|
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
|
||||||
if (!el) { toast.error('Canvas not ready'); return }
|
if (!el) { toast.error('Canvas not ready'); return }
|
||||||
@@ -432,6 +440,7 @@ export default function App() {
|
|||||||
onUndo={undo}
|
onUndo={undo}
|
||||||
onRedo={redo}
|
onRedo={redo}
|
||||||
onShortcuts={() => setShortcutsOpen(true)}
|
onShortcuts={() => setShortcutsOpen(true)}
|
||||||
|
onExportMd={handleExportMd}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-1 min-h-0">
|
<div className="flex flex-1 min-h-0">
|
||||||
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle } from 'lucide-react'
|
import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Table2 } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Logo } from '@/components/ui/Logo'
|
import { Logo } from '@/components/ui/Logo'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
@@ -11,9 +11,10 @@ interface ToolbarProps {
|
|||||||
onUndo: () => void
|
onUndo: () => void
|
||||||
onRedo: () => void
|
onRedo: () => void
|
||||||
onShortcuts: () => void
|
onShortcuts: () => void
|
||||||
|
onExportMd: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts }: ToolbarProps) {
|
export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd }: ToolbarProps) {
|
||||||
const { hasUnsavedChanges, past, future } = useCanvasStore()
|
const { hasUnsavedChanges, past, future } = useCanvasStore()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -45,9 +46,12 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
|||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onChangeStyle}>
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onChangeStyle}>
|
||||||
<Palette size={14} /> Style
|
<Palette size={14} /> Style
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExport}>
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExport} title="Export as PNG">
|
||||||
<Download size={14} /> Export
|
<Download size={14} /> Export
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExportMd} title="Copy inventory as Markdown table">
|
||||||
|
<Table2 size={14} /> MD
|
||||||
|
</Button>
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onShortcuts} title="Keyboard shortcuts (?)">
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onShortcuts} title="Keyboard shortcuts (?)">
|
||||||
<HelpCircle size={14} />
|
<HelpCircle size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { generateMarkdownTable } from '../exportMarkdown'
|
||||||
|
import type { Node } from '@xyflow/react'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
|
||||||
|
const makeNode = (overrides: Partial<NodeData> = {}, id = '1'): Node<NodeData> => ({
|
||||||
|
id,
|
||||||
|
type: overrides.type ?? 'server',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: { label: 'Test', type: 'server', status: 'online', services: [], ...overrides },
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('generateMarkdownTable', () => {
|
||||||
|
it('returns empty string for empty node list', () => {
|
||||||
|
expect(generateMarkdownTable([])).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes groupRect nodes', () => {
|
||||||
|
const nodes = [makeNode({ type: 'groupRect', label: 'Zone' })]
|
||||||
|
expect(generateMarkdownTable(nodes)).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates header + separator + row', () => {
|
||||||
|
const nodes = [makeNode({ label: 'Router', type: 'router', ip: '192.168.1.1', status: 'online' })]
|
||||||
|
const md = generateMarkdownTable(nodes)
|
||||||
|
const lines = md.split('\n')
|
||||||
|
expect(lines[0]).toContain('Label')
|
||||||
|
expect(lines[0]).toContain('IP')
|
||||||
|
expect(lines[1]).toContain('---')
|
||||||
|
expect(lines[2]).toContain('Router')
|
||||||
|
expect(lines[2]).toContain('192.168.1.1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses — for missing fields', () => {
|
||||||
|
const nodes = [makeNode({ label: 'Node', type: 'generic', ip: undefined, hostname: undefined })]
|
||||||
|
const md = generateMarkdownTable(nodes)
|
||||||
|
expect(md).toContain('—')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists services as name:port pairs', () => {
|
||||||
|
const nodes = [makeNode({
|
||||||
|
label: 'Server',
|
||||||
|
services: [{ port: 80, protocol: 'tcp', service_name: 'nginx' }, { port: 443, protocol: 'tcp', service_name: 'https' }],
|
||||||
|
})]
|
||||||
|
const md = generateMarkdownTable(nodes)
|
||||||
|
expect(md).toContain('nginx:80')
|
||||||
|
expect(md).toContain('https:443')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('escapes pipe characters in cell values', () => {
|
||||||
|
const nodes = [makeNode({ label: 'A|B' })]
|
||||||
|
const md = generateMarkdownTable(nodes)
|
||||||
|
expect(md).toContain('A\\|B')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates one row per non-groupRect node', () => {
|
||||||
|
const nodes = [
|
||||||
|
makeNode({ type: 'server', label: 'A' }, '1'),
|
||||||
|
makeNode({ type: 'router', label: 'B' }, '2'),
|
||||||
|
makeNode({ type: 'groupRect', label: 'Zone' }, '3'),
|
||||||
|
]
|
||||||
|
const lines = generateMarkdownTable(nodes).split('\n')
|
||||||
|
// header + separator + 2 data rows
|
||||||
|
expect(lines).toHaveLength(4)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Node } from '@xyflow/react'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
|
||||||
|
const EMPTY = '—'
|
||||||
|
|
||||||
|
function cell(v: string | null | undefined): string {
|
||||||
|
if (!v) return EMPTY
|
||||||
|
// Escape pipe chars so they don't break the table
|
||||||
|
return v.replace(/\|/g, '\\|')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateMarkdownTable(nodes: Node<NodeData>[]): string {
|
||||||
|
const rows = nodes
|
||||||
|
.filter((n) => n.data.type !== 'groupRect')
|
||||||
|
.map((n) => {
|
||||||
|
const d = n.data
|
||||||
|
const services = d.services?.length
|
||||||
|
? d.services.map((s) => `${s.service_name}:${s.port}`).join(', ')
|
||||||
|
: EMPTY
|
||||||
|
return [
|
||||||
|
cell(d.label),
|
||||||
|
cell(d.type),
|
||||||
|
cell(d.ip),
|
||||||
|
cell(d.hostname),
|
||||||
|
cell(d.status),
|
||||||
|
services,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
if (rows.length === 0) return ''
|
||||||
|
|
||||||
|
const headers = ['Label', 'Type', 'IP', 'Hostname', 'Status', 'Services']
|
||||||
|
const separator = headers.map(() => '---')
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
`| ${headers.join(' | ')} |`,
|
||||||
|
`| ${separator.join(' | ')} |`,
|
||||||
|
...rows.map((r) => `| ${r.join(' | ')} |`),
|
||||||
|
]
|
||||||
|
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user