Files
resolutionflow/frontend/src/pages/NetworkDiagrams/index.tsx
chihlasm 015df1fe5f fix(network): consolidate import buttons, redesign empty state, add shortcut overlay
- Import/Export button in editor header: removed standalone Import button, moved
  draw.io import into Export/Import dropdown with labelled sections; fixes
  conceptual trap where Import implied operating on the current diagram
- List page: replaced two identical Upload-icon Import buttons with a single
  dropdown (Import JSON / Import draw.io) with format descriptions
- Empty state: replaced icon-in-box with a horizontal card featuring a static
  SVG topology preview, MSP-specific value prop, and dual CTAs
- Keyboard shortcuts: new KeyboardShortcutsOverlay component (4-group grid),
  triggered by ? key or the ? button pinned to the canvas bottom-right corner;
  wired into useCanvasShortcuts hook
- Fixed Share2 → FileOutput icon for draw.io export (Share2 = send to someone,
  FileOutput = export file format)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 04:49:25 +00:00

512 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { Plus, Search, Network, MoreHorizontal, Upload, ChevronDown, FileJson, FileOutput } from 'lucide-react'
import { cn } from '@/lib/utils'
import { networkDiagramsApi } from '@/api'
import { toast } from '@/lib/toast'
import { CATEGORY_COLORS } from '@/components/network/nodes/deviceRegistry'
import type { NetworkDiagramListItem, DiagramImportData } from '@/types'
const OTHER_COLOR = '#4f5666'
function TopologyBar({ categoryCounts, nodeCount }: { categoryCounts: Record<string, number>; nodeCount: number }) {
if (nodeCount === 0) return null
const sorted = Object.entries(categoryCounts).sort(([, a], [, b]) => b - a)
const tooltipLabel = sorted.map(([cat, count]) => `${count} ${cat}`).join(' · ')
return (
<div className="group/bar relative flex h-2 w-full overflow-hidden rounded-full" title={tooltipLabel}>
{sorted.map(([cat, count]) => (
<div
key={cat}
style={{
width: `${(count / nodeCount) * 100}%`,
backgroundColor: CATEGORY_COLORS[cat] ?? OTHER_COLOR,
}}
/>
))}
</div>
)
}
export default function NetworkDiagramsPage() {
const navigate = useNavigate()
const [diagrams, setDiagrams] = useState<NetworkDiagramListItem[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [clientFilter, setClientFilter] = useState<string | null>(null)
const [clientOptions, setClientOptions] = useState<string[]>([])
const [clientDropdownOpen, setClientDropdownOpen] = useState(false)
const [clientSearch, setClientSearch] = useState('')
const [menuOpenId, setMenuOpenId] = useState<string | null>(null)
const [confirmArchiveId, setConfirmArchiveId] = useState<string | null>(null)
const [importMenuOpen, setImportMenuOpen] = useState(false)
const clientDropdownRef = useRef<HTMLDivElement>(null)
const importMenuRef = useRef<HTMLDivElement>(null)
const drawioListImportRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!clientDropdownOpen) return
const handleClick = (e: MouseEvent) => {
if (clientDropdownRef.current && !clientDropdownRef.current.contains(e.target as Node)) {
setClientDropdownOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [clientDropdownOpen])
useEffect(() => {
if (!importMenuOpen) return
const handleClick = (e: MouseEvent) => {
if (importMenuRef.current && !importMenuRef.current.contains(e.target as Node)) {
setImportMenuOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [importMenuOpen])
const loadDiagrams = useCallback(async () => {
try {
const params: Record<string, string> = {}
if (clientFilter) params.client_name = clientFilter
if (search) params.search = search
const data = await networkDiagramsApi.list(params)
setDiagrams(data)
} catch {
toast.error('Failed to load diagrams')
} finally {
setLoading(false)
}
}, [clientFilter, search])
const loadClients = useCallback(async () => {
try {
const clients = await networkDiagramsApi.listClients()
setClientOptions(clients)
} catch { /* ignore */ }
}, [])
useEffect(() => { loadDiagrams() }, [loadDiagrams])
useEffect(() => { loadClients() }, [loadClients])
const filteredClients = useMemo(() => {
if (!clientSearch) return clientOptions
const lower = clientSearch.toLowerCase()
return clientOptions.filter(c => c.toLowerCase().includes(lower))
}, [clientOptions, clientSearch])
const handleDuplicate = useCallback(async (id: string) => {
try {
const dup = await networkDiagramsApi.duplicate(id)
toast.success(`Created: ${dup.name}`)
loadDiagrams()
} catch {
toast.error('Failed to duplicate')
}
setMenuOpenId(null)
}, [loadDiagrams])
const handleArchive = useCallback(async (id: string) => {
try {
await networkDiagramsApi.archive(id)
toast.success('Diagram archived')
loadDiagrams()
} catch {
toast.error('Failed to archive')
}
setMenuOpenId(null)
setConfirmArchiveId(null)
}, [loadDiagrams])
const handleImport = useCallback(async () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.json'
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
try {
const text = await file.text()
const data = JSON.parse(text) as DiagramImportData
const result = await networkDiagramsApi.importJson(data)
if (result.warnings.length > 0) {
toast.warning(`Imported with warnings: ${result.warnings.join(', ')}`)
} else {
toast.success('Diagram imported')
}
navigate(`/network-diagrams/${result.diagram.id}`)
} catch {
toast.error('Failed to import — check that the file is a valid diagram JSON')
}
}
input.click()
}, [navigate])
const handleListDrawioImport = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
e.target.value = ''
try {
const { parseDrawioXml } = await import('@/lib/drawio-import')
const text = await file.text()
const { nodes: importedNodes, edges: importedEdges, warnings } = parseDrawioXml(text)
const result = await networkDiagramsApi.importJson({
schemaVersion: 1,
name: file.name.replace(/\.drawio$/i, '') || 'Imported Diagram',
client_name: null,
description: null,
nodes: importedNodes,
edges: importedEdges,
})
const allWarnings = [...warnings, ...result.warnings]
if (allWarnings.length > 0) {
toast.warning(`Imported with ${allWarnings.length} warning(s)`)
} else {
toast.success('Imported successfully')
}
navigate(`/network-diagrams/${result.diagram.id}`)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error'
toast.error(`Import failed: ${msg}`)
}
}, [navigate])
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
return (
<div className="mx-auto max-w-6xl px-6 py-8">
<div className="mb-6 flex items-start justify-between">
<div>
<h1 className="font-heading text-2xl font-bold text-heading">Network Maps</h1>
<p className="mt-1 text-sm text-muted-foreground">Visual network topology documentation for your clients</p>
</div>
<div className="flex gap-2">
{/* Single "Import" dropdown replacing two separate buttons */}
<div className="relative" ref={importMenuRef}>
<button
onClick={() => setImportMenuOpen(prev => !prev)}
className="flex items-center gap-1.5 rounded border border-default px-3 py-2 text-sm text-primary hover:border-hover"
>
<Upload size={14} />
Import
<ChevronDown size={12} className="text-muted-foreground" />
</button>
{importMenuOpen && (
<div className="absolute right-0 top-full z-50 mt-1 w-44 rounded border border-default bg-card py-1 shadow-lg">
<button
onClick={() => { setImportMenuOpen(false); handleImport() }}
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated"
>
<FileJson size={13} />
<div className="text-left">
<div>Import JSON</div>
<div className="text-[10px] text-muted-foreground">ResolutionFlow format</div>
</div>
</button>
<button
onClick={() => { setImportMenuOpen(false); drawioListImportRef.current?.click() }}
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated"
>
<FileOutput size={13} />
<div className="text-left">
<div>Import draw.io</div>
<div className="text-[10px] text-muted-foreground">.drawio or .xml file</div>
</div>
</button>
</div>
)}
</div>
<input
ref={drawioListImportRef}
type="file"
accept=".drawio,.xml"
className="hidden"
onChange={handleListDrawioImport}
/>
<button
onClick={() => navigate('/network-diagrams/new')}
className="flex items-center gap-1.5 rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90"
>
<Plus size={14} />
New Diagram
</button>
</div>
</div>
<div className="mb-6 flex gap-3">
<div className="relative flex-1">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder="Search diagrams..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full rounded border border-default bg-input pl-9 pr-3 py-2 text-sm text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
/>
</div>
<div className="relative w-48" ref={clientDropdownRef}>
<button
onClick={() => setClientDropdownOpen(prev => !prev)}
className="flex w-full items-center justify-between rounded border border-default bg-input px-3 py-2 text-sm text-primary"
>
<span className={clientFilter ? 'text-primary' : 'text-muted-foreground'}>
{clientFilter || 'All clients'}
</span>
<ChevronDown size={14} className="shrink-0 text-muted-foreground" />
</button>
{clientDropdownOpen && (
<div className="absolute left-0 top-full z-50 mt-1 w-full rounded border border-default bg-card shadow-lg">
<div className="p-2">
<input
type="text"
placeholder="Search clients..."
value={clientSearch}
onChange={e => setClientSearch(e.target.value)}
className="w-full rounded border border-default bg-input px-2 py-1 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
autoFocus
/>
</div>
<div className="max-h-48 overflow-y-auto">
<button
onClick={() => { setClientFilter(null); setClientDropdownOpen(false); setClientSearch('') }}
className={cn('w-full px-3 py-1.5 text-left text-xs hover:bg-elevated', !clientFilter && 'text-accent')}
>
All clients
</button>
{filteredClients.map(c => (
<button
key={c}
onClick={() => { setClientFilter(c); setClientDropdownOpen(false); setClientSearch('') }}
className={cn('w-full px-3 py-1.5 text-left text-xs text-primary hover:bg-elevated', clientFilter === c && 'text-accent')}
>
{c}
</button>
))}
</div>
</div>
)}
</div>
</div>
{loading && (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map(i => (
<div key={i} className="h-40 animate-pulse rounded-lg border border-default bg-card" />
))}
</div>
)}
{!loading && diagrams.length === 0 && (
<div className="rounded-lg border border-default bg-card overflow-hidden">
<div className="grid md:grid-cols-[1fr_380px]">
{/* Left: mini topology preview */}
<div className="relative flex items-center justify-center bg-[#0e1016] p-8 md:p-12 min-h-[280px]">
{/* Dot grid background */}
<svg className="absolute inset-0 h-full w-full opacity-20" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id="dots" x="0" y="0" width="24" height="24" patternUnits="userSpaceOnUse">
<circle cx="1" cy="1" r="1" fill="#4f5666" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#dots)" />
</svg>
{/* Static topology SVG */}
<svg viewBox="0 0 460 240" className="relative w-full max-w-md" xmlns="http://www.w3.org/2000/svg">
{/* Edges */}
<line x1="230" y1="48" x2="130" y2="130" stroke="#2a2e3a" strokeWidth="1.5" />
<line x1="230" y1="48" x2="230" y2="130" stroke="#2a2e3a" strokeWidth="1.5" />
<line x1="230" y1="48" x2="330" y2="130" stroke="#2a2e3a" strokeWidth="1.5" />
<line x1="130" y1="130" x2="80" y2="210" stroke="#2a2e3a" strokeWidth="1.5" strokeDasharray="4 3" />
<line x1="130" y1="130" x2="180" y2="210" stroke="#2a2e3a" strokeWidth="1.5" strokeDasharray="4 3" />
<line x1="330" y1="130" x2="280" y2="210" stroke="#2a2e3a" strokeWidth="1.5" strokeDasharray="4 3" />
<line x1="330" y1="130" x2="380" y2="210" stroke="#2a2e3a" strokeWidth="1.5" strokeDasharray="4 3" />
{/* Firewall node */}
<rect x="196" y="16" width="68" height="40" rx="6" fill="#1e2028" stroke="#3d4252" strokeWidth="1" />
<rect x="207" y="22" width="14" height="10" rx="2" fill="#f87171" opacity="0.9" />
<rect x="225" y="22" width="6" height="10" rx="1" fill="#3d4252" />
<rect x="235" y="22" width="20" height="10" rx="2" fill="#3d4252" />
<text x="230" y="46" textAnchor="middle" fill="#848b9b" fontSize="9" fontFamily="monospace">Firewall</text>
{/* Switch node */}
<rect x="96" y="108" width="68" height="40" rx="6" fill="#1e2028" stroke="#3d4252" strokeWidth="1" />
<circle cx="112" cy="124" r="4" fill="#34d399" opacity="0.9" />
<circle cx="122" cy="124" r="4" fill="#34d399" opacity="0.9" />
<circle cx="132" cy="124" r="4" fill="#fbbf24" opacity="0.8" />
<circle cx="142" cy="124" r="4" fill="#34d399" opacity="0.9" />
<text x="130" y="140" textAnchor="middle" fill="#848b9b" fontSize="9" fontFamily="monospace">Core Switch</text>
{/* Router node */}
<rect x="196" y="108" width="68" height="40" rx="6" fill="#1e2028" stroke="#60a5fa" strokeWidth="1" />
<circle cx="230" cy="124" r="10" fill="none" stroke="#60a5fa" strokeWidth="1.5" opacity="0.7" />
<circle cx="230" cy="124" r="5" fill="none" stroke="#60a5fa" strokeWidth="1" opacity="0.5" />
<line x1="220" y1="124" x2="240" y2="124" stroke="#60a5fa" strokeWidth="1" opacity="0.6" />
<text x="230" y="140" textAnchor="middle" fill="#93c5fd" fontSize="9" fontFamily="monospace">Router</text>
{/* Server farm */}
<rect x="296" y="108" width="68" height="40" rx="6" fill="#1e2028" stroke="#3d4252" strokeWidth="1" />
<rect x="308" y="116" width="44" height="7" rx="2" fill="#2a2e3a" />
<rect x="308" y="127" width="44" height="7" rx="2" fill="#2a2e3a" />
<circle cx="345" cy="119.5" r="2" fill="#34d399" opacity="0.9" />
<circle cx="345" cy="130.5" r="2" fill="#34d399" opacity="0.9" />
<text x="330" y="140" textAnchor="middle" fill="#848b9b" fontSize="9" fontFamily="monospace">Servers</text>
{/* Leaf nodes */}
<rect x="46" y="190" width="52" height="34" rx="5" fill="#1e2028" stroke="#2a2e3a" strokeWidth="1" />
<text x="72" y="211" textAnchor="middle" fill="#4f5666" fontSize="8" fontFamily="monospace">PC × 12</text>
<rect x="154" y="190" width="52" height="34" rx="5" fill="#1e2028" stroke="#2a2e3a" strokeWidth="1" />
<text x="180" y="211" textAnchor="middle" fill="#4f5666" fontSize="8" fontFamily="monospace">AP × 4</text>
<rect x="254" y="190" width="52" height="34" rx="5" fill="#1e2028" stroke="#2a2e3a" strokeWidth="1" />
<text x="280" y="211" textAnchor="middle" fill="#4f5666" fontSize="8" fontFamily="monospace">NAS</text>
<rect x="354" y="190" width="52" height="34" rx="5" fill="#1e2028" stroke="#2a2e3a" strokeWidth="1" />
<text x="380" y="211" textAnchor="middle" fill="#4f5666" fontSize="8" fontFamily="monospace">VM × 6</text>
</svg>
</div>
{/* Right: value prop + CTA */}
<div className="flex flex-col justify-center border-l border-default p-8">
<div className="mb-1 flex items-center gap-2">
<Network size={14} className="text-accent" />
<span className="text-[11px] font-semibold uppercase tracking-wider text-accent">Network Maps</span>
</div>
<h2 className="font-heading text-xl font-bold text-heading leading-snug">
Document every client's infrastructure — once
</h2>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
Drag-and-drop topology diagrams that live next to your tickets. Generate a first draft from a plain-text description, then keep it up to date as networks change.
</p>
<ul className="mt-4 space-y-2 text-xs text-muted-foreground">
<li className="flex items-center gap-2">
<div className="h-1.5 w-1.5 rounded-full bg-accent" />
AI topology generation from natural language
</li>
<li className="flex items-center gap-2">
<div className="h-1.5 w-1.5 rounded-full bg-accent" />
Export to PNG, SVG, PDF, or draw.io
</li>
<li className="flex items-center gap-2">
<div className="h-1.5 w-1.5 rounded-full bg-accent" />
Shared across your whole team instantly
</li>
</ul>
<div className="mt-6 flex flex-col gap-2 sm:flex-row sm:items-center">
<button
onClick={() => navigate('/network-diagrams/new')}
className="flex items-center justify-center gap-1.5 rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90"
>
<Plus size={14} />
Create Network Map
</button>
<button
onClick={() => { setImportMenuOpen(true) }}
className="flex items-center justify-center gap-1.5 rounded border border-default px-4 py-2 text-sm text-primary hover:border-hover"
>
<Upload size={14} />
Import existing
</button>
</div>
</div>
</div>
</div>
)}
{!loading && diagrams.length > 0 && (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{diagrams.map(d => (
<div
key={d.id}
onClick={() => navigate(`/network-diagrams/${d.id}`)}
className="group relative cursor-pointer rounded-lg border border-default bg-card p-4 hover:border-hover"
>
<div className="mb-2 flex items-start justify-between">
<h3 className="font-heading text-sm font-semibold text-heading">{d.name}</h3>
<button
onClick={e => { e.stopPropagation(); setMenuOpenId(menuOpenId === d.id ? null : d.id) }}
className="rounded p-1 text-muted-foreground opacity-0 hover:bg-elevated group-hover:opacity-100"
>
<MoreHorizontal size={14} />
</button>
</div>
{d.client_name && (
<span className="mb-2 inline-block rounded-full bg-elevated px-2 py-0.5 text-[10px] text-muted-foreground">
{d.client_name}
</span>
)}
{d.description && (
<p className="mb-3 line-clamp-2 text-xs text-muted-foreground">{d.description}</p>
)}
{/* Thumbnail preview */}
{d.thumbnail_url ? (
<div className="mb-2 overflow-hidden rounded border border-default">
<img
src={d.thumbnail_url}
alt={d.name}
className="h-[120px] w-full object-cover"
onError={e => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
</div>
) : (
<div className="mb-2 flex h-[120px] items-center justify-center rounded border border-default bg-elevated">
<Network size={32} className="text-muted-foreground/30" />
</div>
)}
{d.node_count > 0 && (
<div className="mb-2">
<TopologyBar categoryCounts={d.category_counts} nodeCount={d.node_count} />
</div>
)}
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>{d.node_count} device{d.node_count !== 1 ? 's' : ''}</span>
<span>{formatDate(d.created_at)}</span>
</div>
{menuOpenId === d.id && (
<div className="absolute right-2 top-10 z-50 w-44 rounded border border-default bg-card py-1 shadow-lg">
{confirmArchiveId === d.id ? (
<>
<p className="px-3 py-1.5 text-[10px] text-muted-foreground">Archive this diagram?</p>
<div className="flex gap-1 px-2 pb-1.5">
<button
onClick={e => { e.stopPropagation(); setConfirmArchiveId(null) }}
className="flex-1 rounded border border-default px-2 py-1 text-[10px] text-primary hover:bg-elevated"
>
Cancel
</button>
<button
onClick={e => { e.stopPropagation(); handleArchive(d.id) }}
className="flex-1 rounded bg-red-500/20 px-2 py-1 text-[10px] font-medium text-red-400 hover:bg-red-500/30"
>
Archive
</button>
</div>
</>
) : (
<>
<button
onClick={e => { e.stopPropagation(); navigate(`/network-diagrams/${d.id}`) }}
className="w-full px-3 py-1.5 text-left text-xs text-primary hover:bg-elevated"
>
Open
</button>
<button
onClick={e => { e.stopPropagation(); handleDuplicate(d.id) }}
className="w-full px-3 py-1.5 text-left text-xs text-primary hover:bg-elevated"
>
Duplicate
</button>
<button
onClick={e => { e.stopPropagation(); setConfirmArchiveId(d.id) }}
className="w-full px-3 py-1.5 text-left text-xs text-red-400 hover:bg-elevated"
>
Archive
</button>
</>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
)
}