From f8cadba17b6f43faa7984e11cdd86f68a4166042 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 13 Mar 2026 12:36:31 +0100 Subject: [PATCH] feat: export node inventory as Markdown table (copy to clipboard) --- frontend/src/App.tsx | 9 +++ frontend/src/components/panels/Toolbar.tsx | 10 ++- .../utils/__tests__/exportMarkdown.test.ts | 66 +++++++++++++++++++ frontend/src/utils/exportMarkdown.ts | 42 ++++++++++++ 4 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 frontend/src/utils/__tests__/exportMarkdown.test.ts create mode 100644 frontend/src/utils/exportMarkdown.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0ddabf4..26ba702 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { useEffect, useCallback, useRef, useState } from 'react' import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react' import { type Node } from '@xyflow/react' import { applyDagreLayout } from '@/utils/layout' +import { generateMarkdownTable } from '@/utils/exportMarkdown' import { exportToPng } from '@/utils/export' import { TooltipProvider } from '@/components/ui/tooltip' import { Toaster } from '@/components/ui/sonner' @@ -355,6 +356,13 @@ export default function App() { toast.success('Canvas auto-arranged') }, [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 el = canvasRef.current?.querySelector('.react-flow') if (!el) { toast.error('Canvas not ready'); return } @@ -432,6 +440,7 @@ export default function App() { onUndo={undo} onRedo={redo} onShortcuts={() => setShortcutsOpen(true)} + onExportMd={handleExportMd} />
diff --git a/frontend/src/components/panels/Toolbar.tsx b/frontend/src/components/panels/Toolbar.tsx index fba251a..f7763cb 100644 --- a/frontend/src/components/panels/Toolbar.tsx +++ b/frontend/src/components/panels/Toolbar.tsx @@ -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 { Logo } from '@/components/ui/Logo' import { useCanvasStore } from '@/stores/canvasStore' @@ -11,9 +11,10 @@ interface ToolbarProps { onUndo: () => void onRedo: () => 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() return ( @@ -45,9 +46,12 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, - + diff --git a/frontend/src/utils/__tests__/exportMarkdown.test.ts b/frontend/src/utils/__tests__/exportMarkdown.test.ts new file mode 100644 index 0000000..6da24f4 --- /dev/null +++ b/frontend/src/utils/__tests__/exportMarkdown.test.ts @@ -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 = {}, id = '1'): Node => ({ + 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) + }) +}) diff --git a/frontend/src/utils/exportMarkdown.ts b/frontend/src/utils/exportMarkdown.ts new file mode 100644 index 0000000..19808c5 --- /dev/null +++ b/frontend/src/utils/exportMarkdown.ts @@ -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[]): 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') +}