diff --git a/.prettierignore b/.prettierignore index 2df23c2..50111d4 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,8 @@ yarn.lock bun.lock bun.lockb src/lib/config/vendorIconManifest.ts + +# Design handoff bundles (prototypes, not code) +new-design/ +claude-design/ +homelab-hub-main/ diff --git a/README.md b/README.md index d146d97..ad13924 100644 --- a/README.md +++ b/README.md @@ -85,8 +85,8 @@ docker rm open-network-diagram | -------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------- | | ![Open Network Diagram network view with ethernet labels](screenshot1.png) | ![Open Network Diagram non-network view with VMs expanded](screenshot2.png) | ![Open Network Diagram modal editing a machine](screenshot3.png) | -| IPAM panel with subnet utilization | Rack view with shelf items | -| ------------------------------------------------------------------ | -------------------------------------------------------- | +| IPAM panel with subnet utilization | Rack view with shelf items | +| --------------------------------------------------------------------------- | ------------------------------------------------------------------- | | ![Open Network Diagram IPAM panel with subnet utilization](screenshot4.png) | ![Open Network Diagram rack view with shelf items](screenshot5.png) | ## Docker Compose Option diff --git a/docker-compose.yml b/docker-compose.yml index 280ab4b..2400de4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,10 +5,10 @@ services: dockerfile: Dockerfile image: open-network-diagram:local ports: - - "8080:3000" + - '8080:3000' volumes: - ./data:/app/data environment: - NETWORK_DATA_FILE: "/app/data/network.json" - NETWORK_BACKUP_DIR: "/app/data/.backups" + NETWORK_DATA_FILE: '/app/data/network.json' + NETWORK_BACKUP_DIR: '/app/data/.backups' restart: unless-stopped diff --git a/package.json b/package.json index 4fbd7d5..5050675 100644 --- a/package.json +++ b/package.json @@ -38,8 +38,8 @@ "eslint-plugin-svelte": "^3.0.0", "globals": "^16.0.0", "prettier": "^3.8.1", - "prettier-plugin-svelte": "^3.3.3", - "prettier-plugin-tailwindcss": "^0.6.11", + "prettier-plugin-svelte": "^4.1.1", + "prettier-plugin-tailwindcss": "^0.8.0", "svelte": "^5.0.0", "svelte-check": "^4.0.0", "tailwindcss": "^4.0.0", @@ -55,6 +55,7 @@ "packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0", "dependencies": { "cytoscape": "^3.31.2", - "cytoscape-dagre": "^2.5.0" + "cytoscape-dagre": "^2.5.0", + "html-to-image": "^1.11.13" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f1cb2b..ae92314 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: cytoscape-dagre: specifier: ^2.5.0 version: 2.5.0(cytoscape@3.33.1) + html-to-image: + specifier: ^1.11.13 + version: 1.11.13 devDependencies: '@eslint/compat': specifier: ^1.2.5 @@ -76,11 +79,11 @@ importers: specifier: ^3.8.1 version: 3.8.1 prettier-plugin-svelte: - specifier: ^3.3.3 - version: 3.3.3(prettier@3.8.1)(svelte@5.25.8) + specifier: ^4.1.1 + version: 4.1.1(prettier@3.8.1)(svelte@5.25.8) prettier-plugin-tailwindcss: - specifier: ^0.6.11 - version: 0.6.11(prettier-plugin-svelte@3.3.3(prettier@3.8.1)(svelte@5.25.8))(prettier@3.8.1) + specifier: ^0.8.0 + version: 0.8.0(prettier-plugin-svelte@4.1.1(prettier@3.8.1)(svelte@5.25.8))(prettier@3.8.1) svelte: specifier: ^5.0.0 version: 5.25.8 @@ -1447,6 +1450,9 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + html-to-image@1.11.13: + resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1881,17 +1887,20 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier-plugin-svelte@3.3.3: - resolution: {integrity: sha512-yViK9zqQ+H2qZD1w/bH7W8i+bVfKrD8GIFjkFe4Thl6kCT9SlAsXVNmt3jCvQOCsnOhcvYgsoVlRV/Eu6x5nNw==} + prettier-plugin-svelte@4.1.1: + resolution: {integrity: sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==} + engines: {node: '>=20'} peerDependencies: prettier: ^3.0.0 - svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0 + svelte: ^5.0.0 - prettier-plugin-tailwindcss@0.6.11: - resolution: {integrity: sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA==} - engines: {node: '>=14.21.3'} + prettier-plugin-tailwindcss@0.8.0: + resolution: {integrity: sha512-V8ITGH87yuBDF6JpEZTOVlUz/saAwqb8f3HRgUj8Lh+tGCcrmorhsLpYqzygwFwK0PE2Ib6Mv3M7T/uE2tZV1g==} + engines: {node: '>=20.19'} peerDependencies: '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-hermes': '*' + '@prettier/plugin-oxc': '*' '@prettier/plugin-pug': '*' '@shopify/prettier-plugin-liquid': '*' '@trivago/prettier-plugin-sort-imports': '*' @@ -1899,18 +1908,20 @@ packages: prettier: ^3.0 prettier-plugin-astro: '*' prettier-plugin-css-order: '*' - prettier-plugin-import-sort: '*' prettier-plugin-jsdoc: '*' prettier-plugin-marko: '*' prettier-plugin-multiline-arrays: '*' prettier-plugin-organize-attributes: '*' prettier-plugin-organize-imports: '*' prettier-plugin-sort-imports: '*' - prettier-plugin-style-order: '*' prettier-plugin-svelte: '*' peerDependenciesMeta: '@ianvs/prettier-plugin-sort-imports': optional: true + '@prettier/plugin-hermes': + optional: true + '@prettier/plugin-oxc': + optional: true '@prettier/plugin-pug': optional: true '@shopify/prettier-plugin-liquid': @@ -1923,8 +1934,6 @@ packages: optional: true prettier-plugin-css-order: optional: true - prettier-plugin-import-sort: - optional: true prettier-plugin-jsdoc: optional: true prettier-plugin-marko: @@ -1937,8 +1946,6 @@ packages: optional: true prettier-plugin-sort-imports: optional: true - prettier-plugin-style-order: - optional: true prettier-plugin-svelte: optional: true @@ -3631,6 +3638,8 @@ snapshots: dependencies: function-bind: 1.1.2 + html-to-image@1.11.13: {} + ignore@5.3.2: {} import-fresh@3.3.1: @@ -4022,16 +4031,16 @@ snapshots: prelude-ls@1.2.1: {} - prettier-plugin-svelte@3.3.3(prettier@3.8.1)(svelte@5.25.8): + prettier-plugin-svelte@4.1.1(prettier@3.8.1)(svelte@5.25.8): dependencies: prettier: 3.8.1 svelte: 5.25.8 - prettier-plugin-tailwindcss@0.6.11(prettier-plugin-svelte@3.3.3(prettier@3.8.1)(svelte@5.25.8))(prettier@3.8.1): + prettier-plugin-tailwindcss@0.8.0(prettier-plugin-svelte@4.1.1(prettier@3.8.1)(svelte@5.25.8))(prettier@3.8.1): dependencies: prettier: 3.8.1 optionalDependencies: - prettier-plugin-svelte: 3.3.3(prettier@3.8.1)(svelte@5.25.8) + prettier-plugin-svelte: 4.1.1(prettier@3.8.1)(svelte@5.25.8) prettier@3.8.1: {} diff --git a/scripts/generate-vendor-icon-manifest.mjs b/scripts/generate-vendor-icon-manifest.mjs index 4f31db9..e951159 100644 --- a/scripts/generate-vendor-icon-manifest.mjs +++ b/scripts/generate-vendor-icon-manifest.mjs @@ -12,181 +12,186 @@ const prettierIgnorePath = path.join(projectRoot, '.prettierignore'); const allowedExtensions = new Set(['.svg', '.png', '.webp']); const extensionPriority = { - '.svg': 0, - '.png': 1, - '.webp': 2 + '.svg': 0, + '.png': 1, + '.webp': 2 }; /** @param {string} input */ function toPosixPath(input) { - return input.split(path.sep).join('/'); + return input.split(path.sep).join('/'); } /** @param {string} filename */ function toSlug(filename) { - return filename - .toLowerCase() - .replace(/[^a-z0-9._-]/g, '-') - .replace(/[._]+/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, ''); + return filename + .toLowerCase() + .replace(/[^a-z0-9._-]/g, '-') + .replace(/[._]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); } /** @param {string} slug */ function toLabel(slug) { - return slug - .split('-') - .filter(Boolean) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(' '); + return slug + .split('-') + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); } /** @param {string} dir */ async function walk(dir) { - const entries = await fs.readdir(dir, { withFileTypes: true }); - const files = entries - .filter((entry) => entry.isFile()) - .map((entry) => path.join(dir, entry.name)); - const directories = entries - .filter((entry) => entry.isDirectory()) - .map((entry) => path.join(dir, entry.name)); - const nestedFileLists = await Promise.all(directories.map((childDirectory) => walk(childDirectory))); + const entries = await fs.readdir(dir, { withFileTypes: true }); + const files = entries + .filter((entry) => entry.isFile()) + .map((entry) => path.join(dir, entry.name)); + const directories = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(dir, entry.name)); + const nestedFileLists = await Promise.all( + directories.map((childDirectory) => walk(childDirectory)) + ); - for (const nestedFiles of nestedFileLists) { - files.push(...nestedFiles); - } - return files; + for (const nestedFiles of nestedFileLists) { + files.push(...nestedFiles); + } + return files; } function compareIconRecords(a, b) { - const labelCompare = a.label.localeCompare(b.label); - if (labelCompare !== 0) { - return labelCompare; - } - return a.key.localeCompare(b.key); + const labelCompare = a.label.localeCompare(b.label); + if (labelCompare !== 0) { + return labelCompare; + } + return a.key.localeCompare(b.key); } /** @param {string} filePath @param {string} entry */ async function ensureIgnoreEntry(filePath, entry) { - const normalizedEntry = toPosixPath(entry).replace(/^\.?\//, ''); - let contents = ''; - try { - contents = await fs.readFile(filePath, 'utf8'); - } catch (error) { - if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') { - throw error; - } - } + const normalizedEntry = toPosixPath(entry).replace(/^\.?\//, ''); + let contents = ''; + try { + contents = await fs.readFile(filePath, 'utf8'); + } catch (error) { + if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') { + throw error; + } + } - const eol = contents.includes('\r\n') ? '\r\n' : '\n'; - const existingEntries = contents - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line && !line.startsWith('#')) - .map((line) => toPosixPath(line).replace(/^\.?\//, '')); - if (existingEntries.includes(normalizedEntry)) { - return; - } + const eol = contents.includes('\r\n') ? '\r\n' : '\n'; + const existingEntries = contents + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')) + .map((line) => toPosixPath(line).replace(/^\.?\//, '')); + if (existingEntries.includes(normalizedEntry)) { + return; + } - const suffix = contents.length > 0 && !contents.endsWith('\n') && !contents.endsWith('\r\n') ? eol : ''; - const nextContents = `${contents}${suffix}${normalizedEntry}${eol}`; - await fs.writeFile(filePath, nextContents, 'utf8'); + const suffix = + contents.length > 0 && !contents.endsWith('\n') && !contents.endsWith('\r\n') ? eol : ''; + const nextContents = `${contents}${suffix}${normalizedEntry}${eol}`; + await fs.writeFile(filePath, nextContents, 'utf8'); } async function main() { - const vendorExists = await fs - .access(vendorRoot) - .then(() => true) - .catch(() => false); + const vendorExists = await fs + .access(vendorRoot) + .then(() => true) + .catch(() => false); - if (!vendorExists) { - throw new Error(`Vendor icon directory not found: ${vendorRoot}`); - } + if (!vendorExists) { + throw new Error(`Vendor icon directory not found: ${vendorRoot}`); + } - const allFiles = await walk(vendorRoot); - const dedupedBySlug = new Map(); + const allFiles = await walk(vendorRoot); + const dedupedBySlug = new Map(); - for (const absoluteFilePath of allFiles) { - const extension = path.extname(absoluteFilePath).toLowerCase(); - if (!allowedExtensions.has(extension)) { - continue; - } + for (const absoluteFilePath of allFiles) { + const extension = path.extname(absoluteFilePath).toLowerCase(); + if (!allowedExtensions.has(extension)) { + continue; + } - const relativePath = toPosixPath(path.relative(vendorRoot, absoluteFilePath)); - const basename = path.basename(relativePath, extension); - const slug = toSlug(basename); - if (!slug) { - continue; - } + const relativePath = toPosixPath(path.relative(vendorRoot, absoluteFilePath)); + const basename = path.basename(relativePath, extension); + const slug = toSlug(basename); + if (!slug) { + continue; + } - const key = `homarr:${slug}`; - const candidate = { - key, - label: toLabel(slug), - path: `/icons/vendor/homarr/${relativePath}`, - source: 'homarr-dashboard-icons', - extension, - priority: extensionPriority[extension] ?? 99, - relativePath - }; + const key = `homarr:${slug}`; + const candidate = { + key, + label: toLabel(slug), + path: `/icons/vendor/homarr/${relativePath}`, + source: 'homarr-dashboard-icons', + extension, + priority: extensionPriority[extension] ?? 99, + relativePath + }; - const existing = dedupedBySlug.get(slug); - if (!existing) { - dedupedBySlug.set(slug, candidate); - continue; - } + const existing = dedupedBySlug.get(slug); + if (!existing) { + dedupedBySlug.set(slug, candidate); + continue; + } - if (candidate.priority < existing.priority) { - dedupedBySlug.set(slug, candidate); - continue; - } + if (candidate.priority < existing.priority) { + dedupedBySlug.set(slug, candidate); + continue; + } - if (candidate.priority === existing.priority && candidate.relativePath < existing.relativePath) { - dedupedBySlug.set(slug, candidate); - } - } + if ( + candidate.priority === existing.priority && + candidate.relativePath < existing.relativePath + ) { + dedupedBySlug.set(slug, candidate); + } + } - const records = Array.from(dedupedBySlug.values()) - .sort(compareIconRecords) - .map(({ key, label, path: iconPath, source }) => ({ key, label, path: iconPath, source })); + const records = Array.from(dedupedBySlug.values()) + .sort(compareIconRecords) + .map(({ key, label, path: iconPath, source }) => ({ key, label, path: iconPath, source })); - const header = [ - '/* eslint-disable */', - '// prettier-ignore', - '// Auto-generated by scripts/generate-vendor-icon-manifest.mjs', - '// Do not edit manually.', - '', - 'export interface VendorIconDefinition {', - ' key: string;', - ' label: string;', - ' path: string;', - " source: 'homarr-dashboard-icons';", - '}', - '', - 'export const VENDOR_ICON_DEFINITIONS: VendorIconDefinition[] = [' - ].join('\n'); + const header = [ + '/* eslint-disable */', + '// prettier-ignore', + '// Auto-generated by scripts/generate-vendor-icon-manifest.mjs', + '// Do not edit manually.', + '', + 'export interface VendorIconDefinition {', + ' key: string;', + ' label: string;', + ' path: string;', + " source: 'homarr-dashboard-icons';", + '}', + '', + 'export const VENDOR_ICON_DEFINITIONS: VendorIconDefinition[] = [' + ].join('\n'); - const body = records - .map((record) => { - const key = JSON.stringify(record.key); - const label = JSON.stringify(record.label); - const iconPath = JSON.stringify(record.path); - return ` { key: ${key}, label: ${label}, path: ${iconPath}, source: 'homarr-dashboard-icons' },`; - }) - .join('\n'); + const body = records + .map((record) => { + const key = JSON.stringify(record.key); + const label = JSON.stringify(record.label); + const iconPath = JSON.stringify(record.path); + return ` { key: ${key}, label: ${label}, path: ${iconPath}, source: 'homarr-dashboard-icons' },`; + }) + .join('\n'); - const footer = ['];', ''].join('\n'); - const output = `${header}\n${body}\n${footer}`; - const outputIgnoreEntry = toPosixPath(path.relative(projectRoot, outputPath)); - - await fs.mkdir(path.dirname(outputPath), { recursive: true }); - await ensureIgnoreEntry(prettierIgnorePath, outputIgnoreEntry); - await fs.writeFile(outputPath, output, 'utf8'); + const footer = ['];', ''].join('\n'); + const output = `${header}\n${body}\n${footer}`; + const outputIgnoreEntry = toPosixPath(path.relative(projectRoot, outputPath)); + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await ensureIgnoreEntry(prettierIgnorePath, outputIgnoreEntry); + await fs.writeFile(outputPath, output, 'utf8'); } main().catch((error) => { - const message = error instanceof Error ? error.stack ?? error.message : String(error); - process.stderr.write(`${message}\n`); - process.exitCode = 1; + const message = error instanceof Error ? (error.stack ?? error.message) : String(error); + process.stderr.write(`${message}\n`); + process.exitCode = 1; }); diff --git a/src/app.css b/src/app.css index a6f2d93..768fdfe 100644 --- a/src/app.css +++ b/src/app.css @@ -1,30 +1,99 @@ @import 'tailwindcss'; @plugin '@tailwindcss/typography'; +/* Design tokens from the UX review (new-design/project/UX Review.dc.html, panel 1b). + Principle: calm chrome, loud data. */ :root { - --app-bg: #e2e8f0; - --panel-bg: #f8fafc; - --panel-border: #cbd5e1; - --panel-contrast: #0f172a; - --muted-text: #334155; - --chip-bg: #e2e8f0; - --chip-text: #0f172a; - --modal-overlay: rgba(15, 23, 42, 0.58); + /* Core colours */ + --bg-canvas: #eef0f3; + --surface: #ffffff; + --surface-2: #f2f4f7; + --border: #dce0e6; + --text: #1b212b; + --text-2: #616a78; + --accent: #3565c4; + --danger: #c03d38; + + /* Graph canvas tokens (plain hex — read via getComputedStyle for Cytoscape) */ + --graph-edge: #8b94a3; + --graph-node-border: #c6ccd4; + --graph-infra-fill: #3a4250; + --graph-infra-border: #2b323d; + --graph-dumb-border: #b3bac4; + --graph-edge-chip-bg: #ffffff; + --graph-edge-chip-text: #3a4250; + + /* VLAN categorical set — equal lightness/chroma so no VLAN shouts louder */ + --vlan-0: oklch(0.55 0.13 290); + --vlan-1: oklch(0.55 0.13 15); + --vlan-2: oklch(0.55 0.13 175); + --vlan-3: oklch(0.55 0.13 75); + --vlan-4: oklch(0.55 0.13 240); + --vlan-5: oklch(0.55 0.13 125); + + /* Type */ + --font-sans: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + --font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; + + /* Shape & elevation */ + --radius-control: 6px; + --radius-panel: 10px; + --radius-modal: 14px; + --control-h: 32px; + --shadow-panel: 0 4px 16px rgb(0 0 0 / 0.08); + --shadow-popover: 0 8px 24px rgb(0 0 0 / 0.12); + --modal-overlay: rgb(15 18 24 / 0.45); + + /* Status */ + --status-ok: #2e7d4f; + --status-warn: #b0782f; + + /* Legacy aliases (older selectors still reference these) */ + --app-bg: var(--bg-canvas); + --panel-bg: var(--surface); + --panel-border: var(--border); + --panel-contrast: var(--text); + --muted-text: var(--text-2); + --chip-bg: var(--surface-2); + --chip-text: var(--text); } :root[data-theme='dark'] { - --app-bg: #0b1220; - --panel-bg: #111827; - --panel-border: #334155; - --panel-contrast: #e2e8f0; - --muted-text: #94a3b8; - --chip-bg: #1e293b; - --chip-text: #e2e8f0; - --modal-overlay: rgba(2, 6, 23, 0.78); + --bg-canvas: #14171c; + --surface: #1d222a; + --surface-2: #262d38; + --border: #363f4d; + --text: #e7ebf2; + --text-2: #9aa4b2; + --accent: #7da5e8; + --danger: #e0716c; + + --graph-edge: #5b6575; + --graph-node-border: #363f4d; + --graph-infra-fill: #2c3440; + --graph-infra-border: #414c5d; + --graph-dumb-border: #414c5d; + --graph-edge-chip-bg: #262d38; + --graph-edge-chip-text: #c3cad4; + + --vlan-0: oklch(0.68 0.13 290); + --vlan-1: oklch(0.68 0.13 15); + --vlan-2: oklch(0.68 0.13 175); + --vlan-3: oklch(0.68 0.13 75); + --vlan-4: oklch(0.68 0.13 240); + --vlan-5: oklch(0.68 0.13 125); + + --shadow-panel: 0 4px 16px rgb(0 0 0 / 0.35); + --shadow-popover: 0 8px 24px rgb(0 0 0 / 0.45); + --modal-overlay: rgb(8 10 14 / 0.6); + + --status-ok: #5cb884; + --status-warn: #f0b429; } html, body { background: var(--app-bg); color: var(--panel-contrast); + font-family: var(--font-sans); } diff --git a/src/lib/actions/clickOutside.ts b/src/lib/actions/clickOutside.ts new file mode 100644 index 0000000..a8d26b6 --- /dev/null +++ b/src/lib/actions/clickOutside.ts @@ -0,0 +1,20 @@ +// Svelte action: invoke the callback when a pointer goes down outside the +// node. Listens in the capture phase so clicks swallowed by the Cytoscape +// canvas still dismiss open popovers. +export default function clickOutside(node: HTMLElement, onOutside: () => void) { + let callback = onOutside; + const handler = (event: PointerEvent) => { + if (!node.contains(event.target as Node)) { + callback(); + } + }; + document.addEventListener('pointerdown', handler, true); + return { + update(next: () => void) { + callback = next; + }, + destroy() { + document.removeEventListener('pointerdown', handler, true); + } + }; +} diff --git a/src/lib/components/AppBar.svelte b/src/lib/components/AppBar.svelte new file mode 100644 index 0000000..fe21cab --- /dev/null +++ b/src/lib/components/AppBar.svelte @@ -0,0 +1,815 @@ + + + + +
+
+ +
+ {fileName} + {status.text} +
+
+ +
+
+ {#each views as view (view.id)} + + {/each} +
+ +
+ +
+ + + + + + + + + + + +
+
+ + diff --git a/src/lib/components/FirstRunCard.svelte b/src/lib/components/FirstRunCard.svelte new file mode 100644 index 0000000..bdd22e9 --- /dev/null +++ b/src/lib/components/FirstRunCard.svelte @@ -0,0 +1,161 @@ + + +
+

Map your network

+ {#if isSampleData} +

This is sample data — replace it with your own machines.

+ {/if} +
+ {#if writable} + + {/if} + + +
+ +
+ + diff --git a/src/lib/components/IconPickerPopover.svelte b/src/lib/components/IconPickerPopover.svelte new file mode 100644 index 0000000..6d5c4c6 --- /dev/null +++ b/src/lib/components/IconPickerPopover.svelte @@ -0,0 +1,196 @@ + + + +
dispatch('close')} on:keydown={onKeydown}> + +
+ Showing {visibleIcons.length} of {filteredIcons.length} icons +
+ {#if visibleIcons.length > 0} +
+ {#each visibleIcons as icon (icon.key)} + + {/each} +
+ {:else} +
No icons match your search.
+ {/if} + {#if currentIconKey} + + {/if} +
+ + diff --git a/src/lib/components/Modal.svelte b/src/lib/components/Modal.svelte index fc9ce35..37bbb89 100644 --- a/src/lib/components/Modal.svelte +++ b/src/lib/components/Modal.svelte @@ -32,14 +32,23 @@ {/if} @@ -61,49 +70,64 @@ max-height: 90vh; display: flex; flex-direction: column; - background: var(--panel-bg); - border: 1px solid var(--panel-border); - border-radius: 12px; - box-shadow: 0 24px 48px rgba(15, 23, 42, 0.24); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-modal); + box-shadow: 0 24px 64px rgb(15 18 24 / 0.35); + overflow: hidden; } .modal-header { display: flex; align-items: center; - justify-content: space-between; - gap: 0.5rem; - padding: 0.9rem 1rem; - border-bottom: 1px solid var(--panel-border); + gap: 14px; + padding: 16px 20px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; } .modal-header h2 { margin: 0; - font-size: 1.05rem; - color: var(--panel-contrast); + flex: 1; + font-size: 20px; + font-weight: 600; + color: var(--text); } .close-button { - border: 1px solid transparent; + border: none; background: transparent; - color: var(--muted-text); - font-size: 1.7rem; + color: var(--text-2); + font-size: 1.5rem; line-height: 1; - width: 2rem; - height: 2rem; - border-radius: 8px; + width: 30px; + height: 30px; + border-radius: var(--radius-control); cursor: pointer; + flex-shrink: 0; + margin-left: auto; } .close-button:hover { - background: var(--chip-bg); - color: var(--panel-contrast); + background: var(--surface-2); + color: var(--text); } .modal-body { - padding: 1rem; + padding: 20px; overflow: auto; } + .modal-footer-bar { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 20px; + border-top: 1px solid var(--border); + background: var(--surface-2); + flex-shrink: 0; + } + @media (max-width: 720px) { .modal-backdrop { padding: 0; diff --git a/src/lib/components/NetworkDiagram.svelte b/src/lib/components/NetworkDiagram.svelte index c0e4b84..b8844a4 100644 --- a/src/lib/components/NetworkDiagram.svelte +++ b/src/lib/components/NetworkDiagram.svelte @@ -1,10 +1,10 @@ -
-
+ + +
+ = 0 + ? `${searchActiveIndex + 1} of ${searchMatchIds.length}` + : String(searchMatchCount)} + {showEthernetLabels} + {showCableSpeeds} + showVms={!areAllVmHostsCollapsed} + {hasAnyVmHosts} + ipamOpen={showIpamPanel} + theme={currentTheme} + on:viewchange={(event) => handleViewChange(event.detail)} + on:search={applyEmphasis} + on:searchcycle={(event) => cycleSearchMatch(event.detail.direction)} + on:clearsearch={clearSearch} + on:addmachine={addMachine} + on:adddevice={addDevice} + on:toggleipam={() => (showIpamPanel = !showIpamPanel)} + on:ethernetlabels={(event) => handleEthernetLabelsChange(event.detail)} + on:cablespeeds={(event) => handleCableSpeedsChange(event.detail)} + on:showvms={(event) => setCollapsedStateForAll(!event.detail)} + on:exportpng={() => (showExportModal = true)} + on:reload={loadData} + on:toggletheme={() => themeMode.toggle()} + /> +
+
+ +
- {#if diagramViewMode === 'rack'} - { - const { kind, name } = event.detail; - const index = - kind === 'machine' ? findMachineIndexByName(name) : findDeviceIndexByName(name); - if (index >= 0) { - selectedTarget = { type: kind, index }; - } - }} - /> - {/if} + {#if diagramViewMode === 'rack'} + { + const { kind, name } = event.detail; + const index = + kind === 'machine' ? findMachineIndexByName(name) : findDeviceIndexByName(name); + if (index >= 0) { + selectedTarget = { type: kind, index }; + } + }} + /> + {/if} -
- {#if mapControlsCollapsed} - - {:else} -
-
- - {#if searchQuery.trim()} - {searchMatchCount} + {#if diagramViewMode === 'rack' && writable} +
+ + {#if showRackManager} + {#each rackLayout.racks as rack (rack.name)} +
+ + + {#if rack.declared && rack.slots.length === 0} {/if}
- - - - - - - - - - + {/each} +
+
{/if}
- - {#if diagramViewMode === 'rack'} -
-

Racks

- {#each rackLayout.racks as rack (rack.name)} -
- - - {#if rack.declared && rack.slots.length === 0} - - {/if} -
- {/each} -
- -
-
{/if} - {#if vlanLegend.length > 0 && diagramViewMode !== 'rack'} -
- {#each vlanLegend as entry (entry.vlanId)} - - {/each} + {#if diagramViewMode !== 'rack' && (vlanLegend.length > 0 || visibleGraphNodes.length > 0)} +
+ {#if vlanLegend.length > 0} +
VLANs · click to filter
+ {#each vlanLegend as entry (entry.vlanId)} + + {/each} +
+ {/if} +
+ Host + Infra + Device +
{/if} {#if showIpamPanel}
-

IP Address Management

+

IPAM

{/if}
@@ -2257,115 +2389,142 @@ {/if} -
-

Declared subnets

- {#each networkData.subnets ?? [] as subnet, subnetIndex (`${subnet.cidr}:${subnetIndex}`)} -
- - - - -
- {:else} -

- None declared; subnets above are inferred as /24 from the IPs in use. -

- {/each} -
- -
-
+ Edit subnets + + {#if subnetEditorOpen} + {#each networkData.subnets ?? [] as subnet, subnetIndex (`${subnet.cidr}:${subnetIndex}`)} +
+ + + + +
+ {:else} +

+ None declared; subnets above are inferred as /24 from the IPs in use. +

+ {/each} +
+ +
+ {/if} + + {/if}
{/if} -
Source: {dataSourceLabel}
-
- {saveStateLabel()} -
- - {#if hasLoadedInitialData && !writable} -
{readOnlyNotice}
+ {#if showFirstRun && diagramViewMode !== 'rack'} + 0} + on:addmachine={addMachine} + on:connectport={openFirstMachineEditor} + on:openipam={() => (showIpamPanel = true)} + on:dismiss={dismissFirstRun} + /> {/if} - {#if isLoadingData} -
Loading JSON...
- {/if} - - {#if loadError} -
{loadError}
- {/if} - {#if saveError} -
{saveError}
+ {#if loadError || saveError} +
+ {#if loadError} +
{loadError}
+ {/if} + {#if saveError} +
{saveError}
+ {/if} +
{/if} {#if tooltip.visible} -
+
{tooltip.text}
{/if} @@ -2373,7 +2532,11 @@ {#if warnings.length + ipamWarnings.length + rackLayout.warnings.length > 0}
- Data warnings ({warnings.length + ipamWarnings.length + rackLayout.warnings.length}) + Data warnings ({warnings.length + + ipamWarnings.length + + rackLayout.warnings.length})
    {#each [...warnings, ...ipamWarnings, ...rackLayout.warnings] as warning (warning)}
  • {warning}
  • @@ -2394,1101 +2557,1344 @@ {/if}
- - {#if selectedTargetIconKey && !iconPickerExpanded} -
-
- {#if selectedTargetIconPath} - Selected icon preview - {/if} - {selectedTargetIconKey} -
- -
- {:else} - - {/if} - - - - - - - - - - - - - - - - - - - - - - {#if selectedTarget?.type === 'machine' && selectedMachine} -
- {#if selectedMachineHostId && selectedMachineVmCount > 0} - - {/if} -
-
- - - - - -
+ + + + + + + + + + + + + + + + + + + + + + + {#each machineConnectableOwnerNames as ownerName (ownerName)} + + {/each} + + + {#each deviceConnectableOwnerNames as ownerName (ownerName)} + + {/each} + -
-

Hardware

-
- - - -
-
- -
-

Rack

-
- - {#if rackNewNameTarget?.kind === 'machine' && rackNewNameTarget?.index === selectedTarget.index} - - {/if} - - -
-
- -
-

Virtual Machines

-
+ + +
+
+ {#if iconPopoverOpen} + setSelectedTargetIconKey(event.detail)} + on:close={() => (iconPopoverOpen = false)} + /> + {/if}
- {#each selectedMachine.software.vms as vm, vmIndex (`${vm.name}:${vm.ipAddress}:${vmIndex}`)} -
+
+ {#if selectedMachine} +
+ {selectedMachine.machineName} + {#if selectedMachine.role}{selectedMachine.role}{/if} +
+ + {selectedMachine.ipAddress}{selectedMachine.operatingSystem + ? ` · ${selectedMachine.operatingSystem}` + : ''}{selectedMachine.rack + ? ` · ${selectedMachine.rack.name} U${selectedMachine.rack.unit}` + : ''} + + {:else if selectedDevice} +
+ {selectedDevice.name} + {#if selectedDevice.type}{selectedDevice.type}{/if} +
+ + {selectedDevice.ipAddress}{selectedDevice.rack + ? ` · ${selectedDevice.rack.name} U${selectedDevice.rack.unit}` + : ''} + + {:else if selectedVm} +
+ {selectedVm.name} + {#if selectedVm.role}{selectedVm.role}{/if} +
+ + {selectedVm.ipAddress} · VM on {networkData.machines[selectedVmMachineIndex] + ?.machineName ?? '?'} + + {/if} +
+
+ + + {#if selectedTarget?.type === 'machine' && selectedMachine} +
-
-

Ports

- - {#each machineConnectableOwnerNames as ownerName (ownerName)} - - {/each} - -
+ - -
{:else if selectedTarget?.type === 'device' && selectedDevice} -
- - - - -
- -
-

Rack

-
+
- -
-

Ports

- - {#each deviceConnectableOwnerNames as ownerName (ownerName)} - - {/each} - -
- +
+
- {#each selectedDevice.ports ?? [] as port, portIndex (`${port.portName}:${portIndex}`)} -
+ + +
- {:else if selectedTarget?.type === 'vm' && selectedVm} -
- - - - -
- - {/if} + {/if} + + + {writable ? 'Changes save automatically' : 'Read-only — changes are not saved'} + + + - - {#if addModalIconKey && !iconPickerExpanded} -
-
- {#if addModalIconPath} - Selected icon preview + + +
+
+ + {#if iconPopoverOpen} + setAddModalIconKey(event.detail)} + on:close={() => (iconPopoverOpen = false)} + /> {/if} - {addModalIconKey}
- -
- {:else} - @@ -79,8 +101,8 @@ position: absolute; inset: 0; overflow: auto; - padding: 10rem 1rem 1rem; - background: var(--app-bg, transparent); + padding: 1.25rem 1rem 1rem; + background: var(--bg-canvas, transparent); } .rack-empty { @@ -160,17 +182,17 @@ } .rack-slot.machine { - background: color-mix(in oklab, var(--panel-bg) 55%, #3b82f6 45%); - border-color: #3b82f6; + background: var(--surface); + border-color: var(--border); } .rack-slot.device { - background: color-mix(in oklab, var(--panel-bg) 55%, #d97706 45%); - border-color: #d97706; + background: var(--surface-2); + border-color: var(--border); } .rack-slot:hover { - filter: brightness(1.08); + border-color: var(--accent); } .rack-slot img { @@ -212,11 +234,71 @@ flex-shrink: 0; } - .rack-footnote { - text-align: center; - font-size: 0.76rem; + .unracked { + max-width: 560px; + margin: 1.25rem auto 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-panel); + padding: 0.5rem 0.8rem; + } + + .unracked summary { + font-size: 0.8rem; font-weight: 600; - color: var(--muted-text); - margin-top: 1rem; + color: var(--text-2); + cursor: pointer; + } + + .unracked-list { + display: flex; + flex-direction: column; + margin-top: 0.4rem; + } + + .unracked-item { + display: flex; + align-items: center; + gap: 0.5rem; + border: none; + background: transparent; + padding: 0.35rem 0.4rem; + border-radius: var(--radius-control); + cursor: pointer; + text-align: left; + font-family: inherit; + } + + .unracked-item:hover { + background: var(--surface-2); + } + + .unracked-item img { + width: 1.1rem; + height: 1.1rem; + object-fit: contain; + } + + .unracked-glyph { + width: 1.1rem; + text-align: center; + font-size: 0.8rem; + color: var(--text-2); + } + + .unracked-name { + font-size: 0.8rem; + font-weight: 600; + color: var(--text); + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .unracked-kind { + font-size: 0.7rem; + color: var(--text-2); + white-space: nowrap; } diff --git a/src/lib/data/networkEditor.ts b/src/lib/data/networkEditor.ts index 0a12e65..66d3851 100644 --- a/src/lib/data/networkEditor.ts +++ b/src/lib/data/networkEditor.ts @@ -61,7 +61,11 @@ export function cloneNetworkData(data: NetworkData): NetworkData { return cloned; } -function findOwnerByKindAndName(data: NetworkData, kind: OwnerKind, name: string): OwnerRef | undefined { +function findOwnerByKindAndName( + data: NetworkData, + kind: OwnerKind, + name: string +): OwnerRef | undefined { if (kind === 'machine') { const machine = data.machines.find((item) => equalsIgnoreCase(item.machineName, name)); if (!machine) { @@ -113,46 +117,49 @@ function ensureReciprocalConnections(data: NetworkData) { continue; } - const targetPort = findPort(targetOwner, port.connectedTo.port); - if (!targetPort) { - delete port.connectedTo; - continue; - } + const targetPort = findPort(targetOwner, port.connectedTo.port); + if (!targetPort) { + delete port.connectedTo; + continue; + } - const expectedReciprocal: Port['connectedTo'] = { - device: owner.name, - port: port.portName, - ...(port.connectedTo.cable ? { cable: deepClone(port.connectedTo.cable) } : {}) - }; - if (!targetPort.connectedTo) { - targetPort.connectedTo = expectedReciprocal; - continue; - } + const expectedReciprocal: Port['connectedTo'] = { + device: owner.name, + port: port.portName, + ...(port.connectedTo.cable ? { cable: deepClone(port.connectedTo.cable) } : {}) + }; + if (!targetPort.connectedTo) { + targetPort.connectedTo = expectedReciprocal; + continue; + } - const hasMatchingReciprocal = - equalsIgnoreCase(targetPort.connectedTo.device, owner.name) && - equalsIgnoreCase(targetPort.connectedTo.port, port.portName); - if (hasMatchingReciprocal) { - // keep cable metadata identical on both ends; the lexicographically - // smaller owner:port key is the canonical copy so the sync is deterministic - const ownKey = `${owner.name}:${port.portName}`.toLowerCase(); - const targetKey = `${targetOwner.name}:${targetPort.portName}`.toLowerCase(); - const [from, to] = - ownKey <= targetKey - ? [port.connectedTo, targetPort.connectedTo] - : [targetPort.connectedTo, port.connectedTo]; - if (from.cable) { - to.cable = deepClone(from.cable); - } else { - delete to.cable; - } - continue; + const hasMatchingReciprocal = + equalsIgnoreCase(targetPort.connectedTo.device, owner.name) && + equalsIgnoreCase(targetPort.connectedTo.port, port.portName); + if (hasMatchingReciprocal) { + // keep cable metadata identical on both ends; the lexicographically + // smaller owner:port key is the canonical copy so the sync is deterministic + const ownKey = `${owner.name}:${port.portName}`.toLowerCase(); + const targetKey = `${targetOwner.name}:${targetPort.portName}`.toLowerCase(); + const [from, to] = + ownKey <= targetKey + ? [port.connectedTo, targetPort.connectedTo] + : [targetPort.connectedTo, port.connectedTo]; + if (from.cable) { + to.cable = deepClone(from.cable); + } else { + delete to.cable; } + continue; } } } +} -function clearInboundReferences(data: NetworkData, predicate: (connection: Port['connectedTo']) => boolean) { +function clearInboundReferences( + data: NetworkData, + predicate: (connection: Port['connectedTo']) => boolean +) { for (const owner of allOwners(data)) { for (const port of owner.ports) { if (!port.connectedTo) { @@ -226,7 +233,9 @@ export function renameOwner( } if (kind === 'machine') { - const machine = cloned.machines.find((item) => equalsIgnoreCase(item.machineName, previousName)); + const machine = cloned.machines.find((item) => + equalsIgnoreCase(item.machineName, previousName) + ); if (machine) { machine.machineName = nextName; } @@ -292,14 +301,15 @@ export function deleteOwner(data: NetworkData, kind: OwnerKind, ownerName: strin const cloned = cloneNetworkData(data); if (kind === 'machine') { - cloned.machines = cloned.machines.filter((machine) => !equalsIgnoreCase(machine.machineName, ownerName)); + cloned.machines = cloned.machines.filter( + (machine) => !equalsIgnoreCase(machine.machineName, ownerName) + ); } else { cloned.devices = cloned.devices.filter((device) => !equalsIgnoreCase(device.name, ownerName)); } - clearInboundReferences( - cloned, - (connection) => Boolean(connection && equalsIgnoreCase(connection.device, ownerName)) + clearInboundReferences(cloned, (connection) => + Boolean(connection && equalsIgnoreCase(connection.device, ownerName)) ); ensureReciprocalConnections(cloned); return cloned; @@ -331,14 +341,12 @@ export function deletePort( } } - clearInboundReferences( - cloned, - (connection) => - Boolean( - connection && - equalsIgnoreCase(connection.device, ownerName) && - equalsIgnoreCase(connection.port, portName) - ) + clearInboundReferences(cloned, (connection) => + Boolean( + connection && + equalsIgnoreCase(connection.device, ownerName) && + equalsIgnoreCase(connection.port, portName) + ) ); ensureReciprocalConnections(cloned); return cloned; @@ -441,7 +449,9 @@ export function setPortConnection( const previousConnection = port.connectedTo; if (previousConnection) { const previousOwner = findOwnerByName(cloned, previousConnection.device); - const previousPort = previousOwner ? findPort(previousOwner, previousConnection.port) : undefined; + const previousPort = previousOwner + ? findPort(previousOwner, previousConnection.port) + : undefined; if (previousPort) { delete previousPort.connectedTo; } diff --git a/src/lib/data/rackLayout.ts b/src/lib/data/rackLayout.ts index a19ec57..97a57d6 100644 --- a/src/lib/data/rackLayout.ts +++ b/src/lib/data/rackLayout.ts @@ -21,10 +21,18 @@ export interface RackColumn { slots: RackSlot[]; } +export interface UnrackedEntity { + kind: 'machine' | 'device'; + name: string; + iconKey?: string; + subtitle: string; +} + export interface RackLayout { racks: RackColumn[]; warnings: string[]; unrackedCount: number; + unracked: UnrackedEntity[]; } const minimumRackHeightU = 12; @@ -112,7 +120,7 @@ function assignLanes(slots: RackSlot[], rackName: string, warnings: string[]) { export function buildRackLayout(data: NetworkData): RackLayout { const slotsByRack = new Map(); const canonicalNames = new Map(); - let unrackedCount = 0; + const unracked: UnrackedEntity[] = []; const canonical = (rawName: string): string => { const trimmed = rawName.trim(); @@ -149,7 +157,12 @@ export function buildRackLayout(data: NetworkData): RackLayout { for (const [index, machine] of data.machines.entries()) { if (!machine.rack?.name) { - unrackedCount += 1; + unracked.push({ + kind: 'machine', + name: machine.machineName, + iconKey: machine.iconKey, + subtitle: machine.role + }); continue; } place( @@ -167,7 +180,12 @@ export function buildRackLayout(data: NetworkData): RackLayout { } for (const [index, device] of data.devices.entries()) { if (!device.rack?.name) { - unrackedCount += 1; + unracked.push({ + kind: 'device', + name: device.name, + iconKey: device.iconKey, + subtitle: device.type + }); continue; } place( @@ -216,7 +234,7 @@ export function buildRackLayout(data: NetworkData): RackLayout { }; }); - return { racks, warnings, unrackedCount }; + return { racks, warnings, unrackedCount: unracked.length, unracked }; } export function knownRackNames(data: NetworkData): string[] { diff --git a/src/lib/graph/canvasTokens.ts b/src/lib/graph/canvasTokens.ts new file mode 100644 index 0000000..bc316d7 --- /dev/null +++ b/src/lib/graph/canvasTokens.ts @@ -0,0 +1,37 @@ +// Reads the graph-facing design tokens off the document root so the +// Cytoscape stylesheet (canvas-drawn edges) always matches the CSS theme. +// The --graph-* custom properties are plain hex on purpose: Cytoscape's +// colour parser doesn't understand oklch() or var(). +export interface CanvasTokens { + bgCanvas: string; + edge: string; + border: string; + chipBg: string; + chipText: string; + accent: string; +} + +const fallbacks: CanvasTokens = { + bgCanvas: '#eef0f3', + edge: '#8b94a3', + border: '#dce0e6', + chipBg: '#ffffff', + chipText: '#3a4250', + accent: '#3565c4' +}; + +export function readCanvasTokens(): CanvasTokens { + if (typeof document === 'undefined') { + return fallbacks; + } + const styles = getComputedStyle(document.documentElement); + const read = (name: string, fallback: string) => styles.getPropertyValue(name).trim() || fallback; + return { + bgCanvas: read('--bg-canvas', fallbacks.bgCanvas), + edge: read('--graph-edge', fallbacks.edge), + border: read('--border', fallbacks.border), + chipBg: read('--graph-edge-chip-bg', fallbacks.chipBg), + chipText: read('--graph-edge-chip-text', fallbacks.chipText), + accent: read('--accent', fallbacks.accent) + }; +} diff --git a/src/lib/graph/searchHighlight.ts b/src/lib/graph/searchHighlight.ts index ac86ce6..07610bb 100644 --- a/src/lib/graph/searchHighlight.ts +++ b/src/lib/graph/searchHighlight.ts @@ -51,3 +51,41 @@ export function computeSearchMatches( return matches; } + +/** + * Ordered variant used for Enter/Shift-Enter cycling: node IDs in graph + * order, deduped, with VM hits mapped to their host so the camera can land + * on something visible while the host's VMs are collapsed. + */ +export function computeSearchMatchList( + transformed: Pick, + query: string +): string[] { + const ordered: string[] = []; + const seen = new Set(); + const normalized = query.trim().toLowerCase(); + if (!normalized) { + return ordered; + } + + for (const node of transformed.nodes) { + const isMatch = collectSearchableText(node).some((text) => + text.toLowerCase().includes(normalized) + ); + if (!isMatch) { + continue; + } + const ids = + node.data.kind === 'vm' && node.data.hostMachineId + ? [node.data.id, node.data.hostMachineId] + : [node.data.id]; + for (const id of ids) { + if (!seen.has(id)) { + seen.add(id); + ordered.push(id); + } + } + } + + return ordered; +} diff --git a/src/lib/graph/transformNetworkData.ts b/src/lib/graph/transformNetworkData.ts index 5749b55..c6e2e3e 100644 --- a/src/lib/graph/transformNetworkData.ts +++ b/src/lib/graph/transformNetworkData.ts @@ -1,8 +1,15 @@ import type { ConnectedPortRef, NetworkData, Port, Subnet } from '../types'; import { cidrContains, parseCidr } from '../data/ipam'; import { resolveIconPath } from '../config/iconRegistry'; -import { vlanColor } from './vlanPalette'; -import type { GraphEdgeElement, GraphNodeElement, GraphTransformResult } from './types'; +import { buildVlanIndexMap } from './vlanPalette'; +import { + NODE_DIMENSIONS, + estimatePillWidth, + type DeviceClass, + type GraphEdgeElement, + type GraphNodeElement, + type GraphTransformResult +} from './types'; interface EndpointOwner { kind: 'machine' | 'device'; @@ -76,22 +83,36 @@ function resolveCableColor(name: string | undefined): string | undefined { return undefined; } const trimmed = name.trim(); - return cableColorHex[trimmed.toLowerCase()] ?? (/^#[0-9a-fA-F]{3,8}$/.test(trimmed) ? trimmed : undefined); + return ( + cableColorHex[trimmed.toLowerCase()] ?? + (/^#[0-9a-fA-F]{3,8}$/.test(trimmed) ? trimmed : undefined) + ); } function buildVlanResolver(subnets: Subnet[] | undefined) { const vlanSubnets = (subnets ?? []) .filter((subnet) => typeof subnet.vlanId === 'number') .sort((a, b) => (parseCidr(b.cidr)?.prefix ?? 0) - (parseCidr(a.cidr)?.prefix ?? 0)); - return (ip: string): { vlanId: number; vlanColor: string } | undefined => { + const vlanIndexById = buildVlanIndexMap(subnets); + return (ip: string): { vlanId: number; vlanIndex: number } | undefined => { const home = vlanSubnets.find((subnet) => cidrContains(subnet.cidr, ip)); if (!home || typeof home.vlanId !== 'number') { return undefined; } - return { vlanId: home.vlanId, vlanColor: vlanColor(home.vlanId) }; + return { vlanId: home.vlanId, vlanIndex: vlanIndexById.get(home.vlanId) ?? 0 }; }; } +// Devices whose type reads as network infrastructure get the dark "infra" +// treatment on the map; everything else (consoles, peripherals, UPSes) is an +// unmanaged "dumb" device rendered as a dashed pill. +const infrastructureTypePattern = + /switch|router|firewall|gateway|access\s?point|\bap\b|wan|isp|modem|dns|patch\s?panel/i; + +export function classifyDevice(type: string | undefined): DeviceClass { + return type && infrastructureTypePattern.test(type) ? 'infrastructure' : 'dumb'; +} + function edgeSpeed(a: number | undefined, b: number | undefined): number | undefined { if (typeof a === 'number' && typeof b === 'number') { return Math.min(a, b); @@ -154,14 +175,16 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra nodes.push({ data: { - id: machineNodeId, - label: machine.machineName, - rawName: machine.machineName, - kind: 'machine', + id: machineNodeId, + label: machine.machineName, + rawName: machine.machineName, + kind: 'machine', iconKey: machine.iconKey, iconUrl: resolveIconPath(machine.iconKey), - nodeWidth: 200, - nodeHeight: 110, + nodeWidth: NODE_DIMENSIONS.machine.width, + nodeHeight: NODE_DIMENSIONS.machine.height, + meta: machine.ipAddress, + vmCount: sourceMachineVms.length, ...resolveVlan(machine.ipAddress), details: { type: 'machine', @@ -199,8 +222,9 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra hostMachineId: machineNodeId, iconKey: vm.iconKey, iconUrl: resolveIconPath(vm.iconKey), - nodeWidth: 140, - nodeHeight: 66, + nodeWidth: NODE_DIMENSIONS.vm.width, + nodeHeight: NODE_DIMENSIONS.vm.height, + meta: vm.ipAddress, ...resolveVlan(vm.ipAddress), details: { type: 'vm', @@ -221,8 +245,7 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra id: hostingEdgeId, source: machineNodeId, target: vmNodeId, - kind: 'hosting', - label: 'hosts' + kind: 'hosting' } }); @@ -246,6 +269,15 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra for (const device of data.devices) { const deviceNodeId = toDeviceNodeId(device.name); const devicePorts = [...(device.ports ?? [])]; + const deviceClass = classifyDevice(device.type); + const portsInUse = devicePorts.filter((port) => parseConnectedTo(port.connectedTo)).length; + let deviceMeta: string | undefined; + if (deviceClass === 'infrastructure') { + deviceMeta = + devicePorts.length > 0 + ? `${devicePorts.length} ${devicePorts.length === 1 ? 'port' : 'ports'} · ${portsInUse} in use` + : device.ipAddress; + } nodes.push({ data: { @@ -253,10 +285,18 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra label: device.name, rawName: device.name, kind: 'device', + deviceClass, iconKey: device.iconKey, iconUrl: resolveIconPath(device.iconKey), - nodeWidth: 176, - nodeHeight: 116, + nodeWidth: + deviceClass === 'infrastructure' + ? NODE_DIMENSIONS.infrastructure.width + : estimatePillWidth(device.name), + nodeHeight: + deviceClass === 'infrastructure' + ? NODE_DIMENSIONS.infrastructure.height + : NODE_DIMENSIONS.dumbHeight, + ...(deviceMeta ? { meta: deviceMeta } : {}), ...resolveVlan(device.ipAddress), details: { type: 'device', @@ -339,20 +379,34 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra const speedGbps = edgeSpeed(port.speedGbps, targetPort.speedGbps); const cable = port.connectedTo?.cable ?? targetPort.connectedTo?.cable; + // One chip per cable: interface name (prefer the machine side — + // "eth0" reads better than "port 3") plus speed, with the network + // default of 1GbE omitted so only exceptions are informative. + const ifname = + owner.kind !== 'machine' && targetOwner.kind === 'machine' + ? connectedTo.port + : port.portName; + const chipLabelNoSpeed = ifname; + const chipLabel = + typeof speedGbps === 'number' && speedGbps !== 1 ? `${ifname} · ${speedGbps}GbE` : ifname; + edges.push({ data: { id: `physical:${physicalEdgeCounter}`, source: owner.nodeId, target: targetOwner.nodeId, kind: 'physical', - label: speedGbps ? `${speedGbps}GbE` : undefined, + chipLabel, + chipLabelNoSpeed, sourcePort: port.portName, targetPort: connectedTo.port, speedGbps, reciprocal, ...(cable?.type ? { cableType: cable.type } : {}), ...(cable?.color ? { cableColorName: cable.color } : {}), - ...(resolveCableColor(cable?.color) ? { cableColor: resolveCableColor(cable?.color) } : {}), + ...(resolveCableColor(cable?.color) + ? { cableColor: resolveCableColor(cable?.color) } + : {}), ...(typeof cable?.lengthM === 'number' ? { cableLengthM: cable.lengthM } : {}) } }); diff --git a/src/lib/graph/types.ts b/src/lib/graph/types.ts index ddb1e1d..8a86023 100644 --- a/src/lib/graph/types.ts +++ b/src/lib/graph/types.ts @@ -1,5 +1,35 @@ export type GraphNodeKind = 'machine' | 'device' | 'vm'; export type GraphEdgeKind = 'physical' | 'hosting'; +export type DeviceClass = 'infrastructure' | 'dumb'; + +// Hit-target dimensions for the invisible Cytoscape nodes. The HTML card +// layer sizes its cards from the same constants so edges anchor exactly at +// card borders. +export const NODE_DIMENSIONS = { + machine: { width: 218, height: 56 }, + infrastructure: { width: 218, height: 56 }, + vm: { width: 170, height: 44 }, + dumbHeight: 36 +} as const; + +// Content-hugging pills need a width so edges anchor at the card border: +// 24px icon tile + gaps/padding ≈ 54px, plus the rendered text width. +// Text is measured with a canvas context (matching the pill's 12.5px type); +// the char-count estimate is only an SSR/test fallback. +let pillMeasureContext: CanvasRenderingContext2D | null = null; + +export function estimatePillWidth(name: string): number { + let textWidth = name.length * 7.6; + if (typeof document !== 'undefined') { + pillMeasureContext ??= document.createElement('canvas').getContext('2d'); + if (pillMeasureContext) { + pillMeasureContext.font = + '600 12.5px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif'; + textWidth = pillMeasureContext.measureText(name).width; + } + } + return Math.round(Math.min(300, Math.max(110, textWidth + 60))); +} export interface PortDetails { portName: string; @@ -63,6 +93,7 @@ export interface GraphNodeData { id: string; label: string; kind: GraphNodeKind; + deviceClass?: DeviceClass; rawName?: string; hostMachineId?: string; nodeWidth?: number; @@ -70,7 +101,9 @@ export interface GraphNodeData { iconUrl?: string; iconKey?: string; vlanId?: number; - vlanColor?: string; + vlanIndex?: number; + meta?: string; + vmCount?: number; details?: GraphNodeDetails; } @@ -80,6 +113,8 @@ export interface GraphEdgeData { target: string; kind: GraphEdgeKind; label?: string; + chipLabel?: string; + chipLabelNoSpeed?: string; sourcePort?: string; targetPort?: string; speedGbps?: number; diff --git a/src/lib/graph/vlanPalette.ts b/src/lib/graph/vlanPalette.ts index 688e4aa..9591fae 100644 --- a/src/lib/graph/vlanPalette.ts +++ b/src/lib/graph/vlanPalette.ts @@ -1,7 +1,37 @@ -// Distinct from the kind colours (machine blue, VM green, device amber) so a -// VLAN border reads as VLAN, not as an entity kind. Mid-saturation hues stay -// legible against both the light and dark node fills. -const palette = [ +import type { Subnet } from '$lib/types'; + +// Categorical VLAN palette from the UX review: equal lightness/chroma +// (oklch 0.55/0.13 light, 0.68/0.13 dark), hue varies, so no VLAN shouts +// louder than another. The actual oklch values live in app.css as +// --vlan-0..--vlan-5 (light + dark variants); consumers are all HTML/CSS, +// so we hand out var() references and let the theme resolve them. +export const VLAN_PALETTE_SIZE = 6; + +export function vlanPaletteColor(index: number): string { + const slot = ((Math.trunc(index) % VLAN_PALETTE_SIZE) + VLAN_PALETTE_SIZE) % VLAN_PALETTE_SIZE; + return `var(--vlan-${slot})`; +} + +// VLANs are assigned palette slots in declaration order (first appearance in +// data.subnets), not by vlanId, so the first VLAN a user declares always gets +// the first hue. +export function buildVlanIndexMap(subnets: Subnet[] | undefined): Map { + const map = new Map(); + for (const subnet of subnets ?? []) { + const { vlanId } = subnet; + if (typeof vlanId !== 'number' || !Number.isFinite(vlanId)) { + continue; + } + if (!map.has(vlanId)) { + map.set(vlanId, map.size); + } + } + return map; +} + +// Legacy hex palette, still consumed by the pre-redesign Cytoscape node +// border styling; removed once the HTML card layer takes over VLAN colour. +const legacyPalette = [ '#e11d48', '#7c3aed', '#0891b2', @@ -15,5 +45,5 @@ const palette = [ ]; export function vlanColor(vlanId: number): string { - return palette[Math.abs(Math.trunc(vlanId)) % palette.length]; + return legacyPalette[Math.abs(Math.trunc(vlanId)) % legacyPalette.length]; } diff --git a/src/lib/shared/networkPersistenceCore.mjs b/src/lib/shared/networkPersistenceCore.mjs index 29a2b03..4854508 100644 --- a/src/lib/shared/networkPersistenceCore.mjs +++ b/src/lib/shared/networkPersistenceCore.mjs @@ -223,6 +223,7 @@ export async function getWritableState() { } try { + // eslint-disable-next-line no-bitwise -- Node fs access mode flags combine bitwise await access(source, constants.R_OK | constants.W_OK); } catch (error) { return { diff --git a/src/lib/shared/networkSchemaCore.mjs b/src/lib/shared/networkSchemaCore.mjs index 483b2e9..c9c6e4c 100644 --- a/src/lib/shared/networkSchemaCore.mjs +++ b/src/lib/shared/networkSchemaCore.mjs @@ -135,10 +135,15 @@ export function normalizePort(issues, path, value, ownerLabel) { optional: true, allowEmpty: true }); - const lengthM = readNumber(issues, `${path}.connectedTo.cable.lengthM`, cableRaw.lengthM, { - optional: true, - min: 0 - }); + const lengthM = readNumber( + issues, + `${path}.connectedTo.cable.lengthM`, + cableRaw.lengthM, + { + optional: true, + min: 0 + } + ); const candidate = { ...(cableType ? { type: cableType } : {}), ...(cableColor ? { color: cableColor } : {}), @@ -265,7 +270,10 @@ function normalizeMachine(issues, path, value) { const role = readString(issues, `${path}.role`, value.role); const operatingSystem = readString(issues, `${path}.operatingSystem`, value.operatingSystem); const iconKey = readString(issues, `${path}.iconKey`, value.iconKey, { optional: true }); - const notes = readString(issues, `${path}.notes`, value.notes, { optional: true, allowEmpty: true }); + const notes = readString(issues, `${path}.notes`, value.notes, { + optional: true, + allowEmpty: true + }); const rack = normalizeRack(issues, `${path}.rack`, value.rack); const softwareRaw = value.software; @@ -304,8 +312,16 @@ function normalizeMachine(issues, path, value) { issues.push({ path: `${path}.hardware`, message: 'must be an object' }); } const hardware = { - cpu: readString(issues, `${path}.hardware.cpu`, isRecord(hardwareRaw) ? hardwareRaw.cpu : undefined), - ram: readString(issues, `${path}.hardware.ram`, isRecord(hardwareRaw) ? hardwareRaw.ram : undefined), + cpu: readString( + issues, + `${path}.hardware.cpu`, + isRecord(hardwareRaw) ? hardwareRaw.cpu : undefined + ), + ram: readString( + issues, + `${path}.hardware.ram`, + isRecord(hardwareRaw) ? hardwareRaw.ram : undefined + ), networkPorts: readNumber( issues, `${path}.hardware.networkPorts`, @@ -318,10 +334,15 @@ function normalizeMachine(issues, path, value) { isRecord(hardwareRaw) ? hardwareRaw.networkPortSpeedGbps : undefined, { optional: true, min: 0 } ), - gpu: readString(issues, `${path}.hardware.gpu`, isRecord(hardwareRaw) ? hardwareRaw.gpu : undefined, { - optional: true, - allowEmpty: true - }) + gpu: readString( + issues, + `${path}.hardware.gpu`, + isRecord(hardwareRaw) ? hardwareRaw.gpu : undefined, + { + optional: true, + allowEmpty: true + } + ) }; const portsRaw = value.ports; @@ -389,7 +410,10 @@ function normalizeDevice(issues, path, value) { const name = readString(issues, `${path}.name`, value.name); const ipAddress = readString(issues, `${path}.ipAddress`, value.ipAddress); const type = readString(issues, `${path}.type`, value.type); - const notes = readString(issues, `${path}.notes`, value.notes, { optional: true, allowEmpty: true }); + const notes = readString(issues, `${path}.notes`, value.notes, { + optional: true, + allowEmpty: true + }); const iconKey = readString(issues, `${path}.iconKey`, value.iconKey, { optional: true }); const rack = normalizeRack(issues, `${path}.rack`, value.rack); @@ -523,7 +547,10 @@ export function validateNetworkData(value) { const machines = []; const machineEntries = []; - for (const [machineIndex, machineValue] of (Array.isArray(machinesRaw) ? machinesRaw : []).entries()) { + for (const [machineIndex, machineValue] of (Array.isArray(machinesRaw) + ? machinesRaw + : [] + ).entries()) { const machine = normalizeMachine(issues, `$.machines[${machineIndex}]`, machineValue); if (machine) { machines.push(machine); @@ -533,7 +560,10 @@ export function validateNetworkData(value) { const devices = []; const deviceEntries = []; - for (const [deviceIndex, deviceValue] of (Array.isArray(devicesRaw) ? devicesRaw : []).entries()) { + for (const [deviceIndex, deviceValue] of (Array.isArray(devicesRaw) + ? devicesRaw + : [] + ).entries()) { const device = normalizeDevice(issues, `$.devices[${deviceIndex}]`, deviceValue); if (device) { devices.push(device); @@ -581,7 +611,10 @@ export function validateNetworkData(value) { } const subnets = []; const subnetEntries = []; - for (const [subnetIndex, subnetValue] of (Array.isArray(subnetsRaw) ? subnetsRaw : []).entries()) { + for (const [subnetIndex, subnetValue] of (Array.isArray(subnetsRaw) + ? subnetsRaw + : [] + ).entries()) { const subnet = normalizeSubnet(issues, `$.subnets[${subnetIndex}]`, subnetValue); if (subnet) { subnets.push(subnet); diff --git a/svelte.config.js b/svelte.config.js index 49b4fa4..7b6189b 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -3,12 +3,13 @@ import staticAdapter from '@sveltejs/adapter-static'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; const deployTarget = (process.env.DEPLOY_TARGET ?? 'docker').toLowerCase(); -const adapter = deployTarget === 'netlify' - ? netlifyAdapter() - : staticAdapter({ - fallback: 'index.html', - strict: false - }); +const adapter = + deployTarget === 'netlify' + ? netlifyAdapter() + : staticAdapter({ + fallback: 'index.html', + strict: false + }); /** @type {import('@sveltejs/kit').Config} */ const config = {