5 Commits

Author SHA1 Message Date
Josh Creek a067cc3231 Merge pull request #21 from jcreek/feat/dark-mode
Feat/dark mode
2026-07-13 22:07:06 +01:00
Josh Creek 68496ae904 feat(*): Improve dark mode contrast 2026-07-13 22:01:28 +01:00
Josh Creek e58e427093 docs(*): Update screenshots 2026-07-13 22:01:13 +01:00
Josh Creek 94bc7085bf Merge pull request #20 from jcreek/feat/redesign
feat(*): Redesign UI to improve UX
2026-07-13 21:29:42 +01:00
Josh Creek 6517fcb373 feat(*): Redesign UI to improve UX 2026-07-13 21:20:04 +01:00
31 changed files with 4950 additions and 2391 deletions
+5
View File
@@ -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/
+6 -6
View File
@@ -21,7 +21,7 @@ Open Network Diagram helps you document your infrastructure in a visual UI while
## Features
- Network view with ethernet labels to make physical and logical links easy to read.
- Non-network view for host-first inventory and service mapping.
- Hosts & VMs view for host-first inventory and service mapping.
- Rack view: managed racks with U positions and heights, side-by-side shelf items, and click-through to the editor.
- Node search that highlights matches by name, IP, role or OS and dims everything else.
- IPAM panel: per-subnet utilization, duplicate-IP conflict detection, and next-free-IP suggestions when adding machines, devices or VMs.
@@ -81,13 +81,13 @@ docker rm open-network-diagram
## What It Looks Like
| Network view with ethernet labels | Non-network view with VMs expanded | Modal editing a machine |
| Network view with ethernet labels | Hosts & VMs view with VMs expanded | Modal editing a machine |
| -------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| ![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) |
| ![Open Network Diagram network view with ethernet labels](screenshot1.png) | ![Open Network Diagram Hosts & VMs 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 |
| ------------------------------------------------------------------ | -------------------------------------------------------- |
| ![Open Network Diagram IPAM panel with subnet utilization](screenshot4.png) | ![Open Network Diagram rack view with shelf items](screenshot5.png) |
| IPAM panel with subnet utilization | Rack view with shelf items | Dark mode |
| --------------------------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------------------------------- |
| ![Open Network Diagram IPAM panel with subnet utilization](screenshot4.png) | ![Open Network Diagram rack view with shelf items](screenshot5.png) | ![Open Network Diagram dark mode](screenshot6.png) |
## Docker Compose Option
+3 -3
View File
@@ -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
+4 -3
View File
@@ -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"
}
}
+28 -19
View File
@@ -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: {}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 96 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 117 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 148 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 114 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 70 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

+137 -132
View File
@@ -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;
});
+85 -16
View File
@@ -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: #3e4c61;
--graph-infra-border: #5b6c86;
--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);
}
+20
View File
@@ -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);
}
};
}
+815
View File
@@ -0,0 +1,815 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import clickOutside from '$lib/actions/clickOutside';
type DiagramViewMode = 'network' | 'device' | 'rack';
type SaveState = 'saved' | 'saving' | 'unsaved' | 'error';
export let dataSourceLabel = '';
export let saveState: SaveState = 'saved';
export let writable = false;
export let hasLoadedInitialData = false;
export let readOnlyNotice = '';
export let saveError: string | null = null;
export let isLoadingData = false;
export let viewMode: DiagramViewMode = 'network';
export let searchQuery = '';
export let searchCountLabel = '';
export let showEthernetLabels = false;
export let showCableSpeeds = true;
export let showVms = true;
export let hasAnyVmHosts = false;
export let ipamOpen = false;
export let theme: 'light' | 'dark' = 'light';
const dispatch = createEventDispatcher<{
viewchange: DiagramViewMode;
search: void;
searchcycle: { direction: 1 | -1 };
clearsearch: void;
addmachine: void;
adddevice: void;
toggleipam: void;
ethernetlabels: boolean;
cablespeeds: boolean;
showvms: boolean;
exportpng: void;
reload: void;
toggletheme: void;
}>();
let addMenuOpen = false;
let displayMenuOpen = false;
let searchInput: HTMLInputElement;
export function focusSearch() {
searchInput?.focus();
}
$: fileName = dataSourceLabel.split('/').pop() || dataSourceLabel;
$: status = (() => {
if (isLoadingData) {
return { text: 'Loading…', kind: 'muted', title: undefined as string | undefined };
}
if (hasLoadedInitialData && !writable) {
return { text: '⬦ Read-only', kind: 'warn', title: readOnlyNotice };
}
if (saveState === 'saving') {
return { text: 'Saving…', kind: 'muted', title: undefined };
}
if (saveState === 'error') {
return { text: '⚠ Save failed', kind: 'danger', title: saveError ?? undefined };
}
if (saveState === 'unsaved') {
return { text: 'Unsaved changes', kind: 'muted', title: undefined };
}
return { text: '✓ Saved', kind: 'ok', title: undefined };
})();
const views: Array<{ id: DiagramViewMode; label: string }> = [
{ id: 'network', label: 'Network' },
{ id: 'device', label: 'Hosts & VMs' },
{ id: 'rack', label: 'Rack' }
];
function selectView(mode: DiagramViewMode) {
if (mode !== viewMode) {
dispatch('viewchange', mode);
}
}
function onViewSelectChange(event: Event) {
selectView((event.currentTarget as HTMLSelectElement).value as DiagramViewMode);
}
function onSearchKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault();
dispatch('searchcycle', { direction: event.shiftKey ? -1 : 1 });
} else if (event.key === 'Escape') {
event.preventDefault();
dispatch('clearsearch');
searchInput?.blur();
}
}
function onWindowKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && (addMenuOpen || displayMenuOpen)) {
addMenuOpen = false;
displayMenuOpen = false;
}
}
</script>
<svelte:window on:keydown={onWindowKeydown} />
<header class="app-bar">
<div class="identity" title={dataSourceLabel}>
<div class="glyph" aria-hidden="true"></div>
<div class="identity-text">
<span class="file-name">{fileName}</span>
<span class="status status-{status.kind}" title={status.title}>{status.text}</span>
</div>
</div>
<div class="views">
<div class="seg" role="radiogroup" aria-label="Diagram view">
{#each views as view (view.id)}
<label class="seg-option" class:checked={viewMode === view.id}>
<input
type="radio"
name="diagram-view"
value={view.id}
checked={viewMode === view.id}
on:change={() => selectView(view.id)}
/>
{view.label}
</label>
{/each}
</div>
<select
class="seg-select"
value={viewMode}
aria-label="Select diagram view"
on:change={onViewSelectChange}
>
{#each views as view (view.id)}
<option value={view.id}>{view.label}</option>
{/each}
</select>
</div>
<div class="actions">
<div class="search" class:has-query={searchQuery.trim()}>
<span class="search-glyph" aria-hidden="true"></span>
<input
type="text"
placeholder="Find name, IP, VM…"
aria-label="Search diagram nodes"
bind:this={searchInput}
bind:value={searchQuery}
on:input={() => dispatch('search')}
on:keydown={onSearchKeydown}
/>
{#if searchQuery.trim()}
<span class="search-count">{searchCountLabel}</span>
<button
type="button"
class="search-clear"
aria-label="Clear search"
title="Clear search"
on:click={() => dispatch('clearsearch')}
>
×
</button>
{:else}
<kbd class="search-kbd" aria-hidden="true">/</kbd>
{/if}
</div>
<div class="menu-anchor" use:clickOutside={() => (addMenuOpen = false)}>
<button
type="button"
class="btn-primary"
disabled={!writable}
title={!writable ? readOnlyNotice : undefined}
aria-haspopup="menu"
aria-expanded={addMenuOpen}
on:click={() => (addMenuOpen = !addMenuOpen)}
>
+ <span class="btn-text">Add</span> <span class="caret" aria-hidden="true"></span>
</button>
{#if addMenuOpen}
<div class="menu" role="menu">
<button
type="button"
role="menuitem"
on:click={() => {
addMenuOpen = false;
dispatch('addmachine');
}}
>
Machine
</button>
<button
type="button"
role="menuitem"
on:click={() => {
addMenuOpen = false;
dispatch('adddevice');
}}
>
Device
</button>
</div>
{/if}
</div>
<button
type="button"
class="btn-secondary"
class:active={ipamOpen}
on:click={() => dispatch('toggleipam')}
>
IPAM
</button>
<div class="divider" aria-hidden="true"></div>
<div class="menu-anchor" use:clickOutside={() => (displayMenuOpen = false)}>
<button
type="button"
class="btn-quiet"
aria-haspopup="menu"
aria-expanded={displayMenuOpen}
on:click={() => (displayMenuOpen = !displayMenuOpen)}
>
<span class="btn-text">Display</span><span class="btn-text-narrow" aria-hidden="true"
></span
>
<span class="caret" aria-hidden="true"></span>
</button>
{#if displayMenuOpen}
<div class="menu display-menu">
<div class="menu-title">Display</div>
<label
class="menu-toggle"
class:disabled={viewMode !== 'network'}
title={viewMode !== 'network'
? 'Ethernet labels are available in Network view.'
: undefined}
>
<span>Ethernet labels</span>
<input
class="toggle-input"
type="checkbox"
checked={showEthernetLabels}
disabled={viewMode !== 'network'}
on:change={(event) => dispatch('ethernetlabels', event.currentTarget.checked)}
/>
<span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span>
</label>
<label
class="menu-toggle"
class:disabled={viewMode !== 'network' || !showEthernetLabels}
title={viewMode !== 'network' || !showEthernetLabels
? 'Cable speeds show inside ethernet labels.'
: undefined}
>
<span>Cable speeds</span>
<input
class="toggle-input"
type="checkbox"
checked={showCableSpeeds}
disabled={viewMode !== 'network' || !showEthernetLabels}
on:change={(event) => dispatch('cablespeeds', event.currentTarget.checked)}
/>
<span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span>
</label>
<label class="menu-toggle" class:disabled={!hasAnyVmHosts}>
<span>Show VMs</span>
<input
class="toggle-input"
type="checkbox"
checked={showVms}
disabled={!hasAnyVmHosts}
on:change={(event) => dispatch('showvms', event.currentTarget.checked)}
/>
<span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span>
</label>
<div class="menu-rule" aria-hidden="true"></div>
<button
type="button"
role="menuitem"
disabled={viewMode === 'rack'}
title={viewMode === 'rack' ? 'PNG export is available in the graph views.' : undefined}
on:click={() => {
displayMenuOpen = false;
dispatch('exportpng');
}}
>
Export PNG…
</button>
<button
type="button"
role="menuitem"
disabled={isLoadingData}
on:click={() => {
displayMenuOpen = false;
dispatch('reload');
}}
>
Reload from disk
</button>
</div>
{/if}
</div>
<button
type="button"
class="btn-icon"
aria-label="Toggle dark mode"
title="Toggle dark mode"
on:click={() => dispatch('toggletheme')}
>
{theme === 'dark' ? '☀' : '☾'}
</button>
</div>
</header>
<style>
.app-bar {
display: flex;
align-items: center;
gap: 16px;
height: 48px;
padding: 0 14px;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
/* Left: identity + status */
.identity {
display: flex;
align-items: center;
gap: 9px;
min-width: 0;
}
.glyph {
width: 24px;
height: 24px;
border-radius: var(--radius-control);
background: var(--accent);
color: var(--surface);
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
}
.identity-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.file-name {
font-size: 13px;
font-weight: 650;
line-height: 1;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status {
font-size: 10.5px;
line-height: 1;
white-space: nowrap;
}
.status-ok {
color: var(--status-ok);
}
.status-warn {
color: var(--status-warn);
}
.status-danger {
color: var(--danger);
}
.status-muted {
color: var(--text-2);
}
/* Centre: segmented view control */
.views {
margin: 0 auto 0 24px;
}
.seg {
display: flex;
background: var(--surface-2);
border-radius: 7px;
padding: 3px;
gap: 2px;
}
.seg-option {
padding: 5px 14px;
border-radius: 5px;
font-size: 13px;
font-weight: 500;
color: var(--text-2);
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.seg-option input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.seg-option.checked {
background: var(--surface);
box-shadow: 0 1px 2px rgb(0 0 0 / 0.1);
font-weight: 600;
color: var(--text);
}
.seg-option:focus-within {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.seg-select {
display: none;
height: var(--control-h);
padding: 0 8px;
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--surface);
color: var(--text);
font-size: 13px;
font-family: inherit;
}
/* Right cluster */
.actions {
display: flex;
align-items: center;
gap: 8px;
}
.search {
display: flex;
align-items: center;
gap: 7px;
height: var(--control-h);
width: 220px;
padding: 0 10px;
background: var(--surface-2);
border: 1px solid transparent;
border-radius: var(--radius-control);
box-sizing: border-box;
}
.search:focus-within {
border-color: var(--accent);
background: var(--surface);
}
.search-glyph {
color: var(--text-2);
font-size: 13px;
flex-shrink: 0;
}
.search input {
flex: 1;
min-width: 0;
border: none;
background: transparent;
color: var(--text);
font-size: 13px;
font-family: inherit;
outline: none;
}
.search input::placeholder {
color: var(--text-2);
opacity: 0.75;
}
.search-kbd {
font-size: 10px;
color: var(--text-2);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 5px;
background: var(--surface);
font-family: var(--font-mono);
}
.search-count {
font-size: 11px;
font-weight: 600;
color: var(--text-2);
white-space: nowrap;
}
.search-clear {
border: none;
background: transparent;
color: var(--text-2);
font-size: 14px;
cursor: pointer;
padding: 0 2px;
line-height: 1;
}
.search-clear:hover {
color: var(--text);
}
/* Buttons */
.btn-primary,
.btn-secondary,
.btn-quiet,
.btn-icon {
height: var(--control-h);
border-radius: var(--radius-control);
font-size: 13px;
font-family: inherit;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
}
.btn-primary {
padding: 0 13px;
background: var(--accent);
color: var(--surface);
border: none;
font-weight: 600;
}
.btn-primary:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn-secondary {
padding: 0 12px;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
font-weight: 500;
}
.btn-secondary.active {
border-color: var(--accent);
color: var(--accent);
}
.btn-quiet {
padding: 0 12px;
background: transparent;
color: var(--text-2);
border: none;
font-weight: 500;
}
.btn-quiet:hover,
.btn-icon:hover {
background: var(--surface-2);
}
.btn-icon {
width: var(--control-h);
justify-content: center;
background: transparent;
color: var(--text-2);
border: none;
font-size: 15px;
}
.caret {
font-size: 9px;
opacity: 0.8;
}
.btn-text-narrow {
display: none;
}
.divider {
width: 1px;
height: 20px;
background: var(--border);
}
/* Menus */
.menu-anchor {
position: relative;
}
.menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 170px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-panel);
box-shadow: var(--shadow-popover);
padding: 6px;
display: flex;
flex-direction: column;
z-index: 40;
}
.display-menu {
width: 250px;
}
.menu-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-2);
padding: 6px 10px 4px;
}
.menu > button {
display: flex;
align-items: center;
padding: 7px 10px;
border: none;
background: transparent;
color: var(--text);
font-size: 13px;
font-family: inherit;
text-align: left;
border-radius: var(--radius-control);
cursor: pointer;
}
.menu > button:hover:not(:disabled) {
background: var(--surface-2);
}
.menu > button:disabled {
color: var(--text-2);
opacity: 0.6;
cursor: not-allowed;
}
.menu-rule {
height: 1px;
background: var(--border);
margin: 6px 4px;
}
.menu-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 7px 10px;
border-radius: var(--radius-control);
font-size: 13px;
color: var(--text);
cursor: pointer;
}
.menu-toggle.disabled {
color: var(--text-2);
opacity: 0.6;
cursor: not-allowed;
}
.toggle-input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.toggle-track {
display: inline-flex;
width: 32px;
height: 19px;
background: color-mix(in oklab, var(--text-2) 40%, var(--surface-2));
border-radius: 999px;
position: relative;
transition: background 120ms ease;
flex-shrink: 0;
}
.toggle-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 15px;
height: 15px;
background: var(--surface);
border-radius: 50%;
transition: transform 120ms ease;
}
.toggle-input:checked + .toggle-track {
background: var(--accent);
}
.toggle-input:checked + .toggle-track .toggle-thumb {
transform: translateX(13px);
}
.toggle-input:focus-visible + .toggle-track {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* Responsive */
@media (max-width: 800px) {
.app-bar {
gap: 10px;
padding: 0 10px;
}
.views {
margin-left: 10px;
}
.seg {
display: none;
}
.seg-select {
display: block;
}
.search {
width: 36px;
padding: 0 9px;
transition: width 140ms ease;
}
.search:focus-within,
.search.has-query {
width: 180px;
}
.search-kbd {
display: none;
}
.btn-text {
display: none;
}
.btn-text-narrow {
display: inline;
}
.identity-text {
display: none;
}
}
/* Phone widths: shed the divider and tighten spacing so the single row
never overflows the viewport. */
@media (max-width: 480px) {
.app-bar {
gap: 6px;
padding: 0 8px;
}
.views {
margin-left: 2px;
min-width: 0;
}
.seg-select {
max-width: 108px;
}
.actions {
gap: 4px;
}
.divider {
display: none;
}
.btn-primary,
.btn-secondary,
.btn-quiet {
padding: 0 9px;
}
.caret {
display: none;
}
.search:focus-within,
.search.has-query {
width: 140px;
}
}
</style>
+161
View File
@@ -0,0 +1,161 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let isSampleData = false;
export let writable = false;
export let hasMachines = false;
const dispatch = createEventDispatcher<{
addmachine: void;
connectport: void;
openipam: void;
dismiss: void;
}>();
</script>
<div class="first-run">
<h2>Map your network</h2>
{#if isSampleData}
<p class="sample-note">This is sample data — replace it with your own machines.</p>
{/if}
<div class="actions">
{#if writable}
<button type="button" on:click={() => dispatch('addmachine')}>
<span class="action-glyph" aria-hidden="true">+</span>
<span class="action-text">
<strong>Add a machine</strong>
<span>Servers, NAS boxes, mini PCs — anything with an IP.</span>
</span>
</button>
{/if}
<button type="button" disabled={!hasMachines} on:click={() => dispatch('connectport')}>
<span class="action-glyph" aria-hidden="true"></span>
<span class="action-text">
<strong>Connect a port</strong>
<span>
{hasMachines
? 'Open a machine and add a port to draw your first cable.'
: 'Add a machine first, then wire up its ports.'}
</span>
</span>
</button>
<button type="button" on:click={() => dispatch('openipam')}>
<span class="action-glyph" aria-hidden="true"></span>
<span class="action-text">
<strong>Open IPAM</strong>
<span>See subnet utilisation and grab the next free IP.</span>
</span>
</button>
</div>
<button type="button" class="dismiss" on:click={() => dispatch('dismiss')}>Dismiss</button>
</div>
<style>
.first-run {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 20;
width: min(92vw, 440px);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-panel);
box-shadow: var(--shadow-popover);
padding: 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--text);
}
.sample-note {
margin: 0;
font-size: 12.5px;
font-weight: 500;
color: var(--status-warn);
}
.actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.actions > button {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-panel);
cursor: pointer;
text-align: left;
font-family: inherit;
}
.actions > button:hover:not(:disabled) {
border-color: var(--accent);
}
.actions > button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.action-glyph {
width: 28px;
height: 28px;
border-radius: var(--radius-control);
background: var(--surface-2);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 700;
flex-shrink: 0;
}
.action-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.action-text strong {
font-size: 13.5px;
font-weight: 600;
color: var(--text);
}
.action-text span {
font-size: 12px;
color: var(--text-2);
line-height: 1.45;
}
.dismiss {
align-self: flex-end;
border: none;
background: transparent;
color: var(--text-2);
font-size: 12.5px;
font-family: inherit;
cursor: pointer;
padding: 4px 8px;
border-radius: var(--radius-control);
}
.dismiss:hover {
background: var(--surface-2);
color: var(--text);
}
</style>
+196
View File
@@ -0,0 +1,196 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { listIconDefinitions } from '$lib/config/iconRegistry';
import clickOutside from '$lib/actions/clickOutside';
// Anchored popover for choosing an icon. The parent positions it (the
// wrapper just needs position: relative); selection is reported via the
// `select` event and the popover closes itself on outside click/Escape.
export let currentIconKey: string | undefined = undefined;
const dispatch = createEventDispatcher<{ select: string; close: void }>();
const iconDefinitions = listIconDefinitions();
const iconResultLimit = 100;
let iconSearch = '';
function focusInput(node: HTMLInputElement) {
node.focus();
}
function normalize(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
$: searchTokens = normalize(iconSearch).split(' ').filter(Boolean);
$: filteredIcons = iconDefinitions.filter((icon) => {
if (searchTokens.length === 0) {
return true;
}
const searchable = normalize(`${icon.label} ${icon.key}`);
return searchTokens.every((token) => searchable.includes(token));
});
$: visibleIcons = filteredIcons.slice(0, iconResultLimit);
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.stopPropagation();
dispatch('close');
}
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="icon-popover" use:clickOutside={() => dispatch('close')} on:keydown={onKeydown}>
<input
type="text"
placeholder="Search icons…"
aria-label="Search icons"
bind:value={iconSearch}
use:focusInput
/>
<div class="results-meta">
Showing {visibleIcons.length} of {filteredIcons.length} icons
</div>
{#if visibleIcons.length > 0}
<div class="results-grid" role="listbox" aria-label="Icon results">
{#each visibleIcons as icon (icon.key)}
<button
type="button"
role="option"
aria-selected={icon.key === currentIconKey}
class:selected={icon.key === currentIconKey}
title={icon.label}
on:click={() => dispatch('select', icon.key)}
>
<img src={icon.path} alt="" loading="lazy" />
<span>{icon.label}</span>
</button>
{/each}
</div>
{:else}
<div class="no-results">No icons match your search.</div>
{/if}
{#if currentIconKey}
<div class="popover-footer">
<button type="button" class="clear-icon" on:click={() => dispatch('select', '')}>
Remove icon
</button>
</div>
{/if}
</div>
<style>
.icon-popover {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 30;
width: min(420px, 80vw);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-panel);
box-shadow: var(--shadow-popover);
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
input {
height: var(--control-h);
padding: 0 10px;
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--surface);
color: var(--text);
font-size: 13.5px;
font-family: inherit;
box-sizing: border-box;
}
input:focus {
outline: none;
border-color: var(--accent);
}
.results-meta {
font-size: 11px;
color: var(--text-2);
}
.results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
gap: 6px;
max-height: 260px;
overflow: auto;
}
.results-grid button {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
padding: 8px 4px;
border: 1px solid transparent;
border-radius: var(--radius-control);
background: transparent;
cursor: pointer;
font-family: inherit;
}
.results-grid button:hover {
background: var(--surface-2);
}
.results-grid button.selected {
border-color: var(--accent);
}
.results-grid img {
width: 28px;
height: 28px;
object-fit: contain;
}
.results-grid span {
font-size: 10.5px;
color: var(--text-2);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.no-results {
font-size: 12.5px;
color: var(--text-2);
padding: 12px 4px;
}
.popover-footer {
border-top: 1px solid var(--surface-2);
padding-top: 8px;
}
.clear-icon {
border: none;
background: transparent;
color: var(--text-2);
font-size: 12.5px;
font-family: inherit;
cursor: pointer;
padding: 4px 6px;
border-radius: var(--radius-control);
}
.clear-icon:hover {
background: var(--surface-2);
color: var(--danger);
}
</style>
+47 -23
View File
@@ -32,14 +32,23 @@
<div class="modal-backdrop" on:click={onBackdropClick}>
<div class="modal-container" style={`max-width: ${maxWidth}`}>
<header class="modal-header">
{#if title}
<h2>{title}</h2>
{/if}
<button type="button" class="close-button" on:click={closeModal} aria-label="Close">×</button>
<slot name="header">
{#if title}
<h2>{title}</h2>
{/if}
</slot>
<button type="button" class="close-button" on:click={closeModal} aria-label="Close"
>×</button
>
</header>
<div class="modal-body">
<slot />
</div>
{#if $$slots.footer}
<footer class="modal-footer-bar">
<slot name="footer" />
</footer>
{/if}
</div>
</div>
{/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;
File diff suppressed because it is too large Load Diff
+323
View File
@@ -0,0 +1,323 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import type cytoscape from 'cytoscape';
import { vlanPaletteColor } from '$lib/graph/vlanPalette';
import type { GraphNodeElement } from '$lib/graph/types';
// HTML card overlay for graph nodes. Cytoscape keeps invisible hit-target
// nodes (interaction, layout, edge anchoring); this layer draws the actual
// cards at the same model coordinates and mirrors pan/zoom with a single
// container transform. pointer-events stay off so Cytoscape owns input.
export let cy: cytoscape.Core | null = null;
export let nodes: GraphNodeElement[] = [];
export let dimmedIds: ReadonlySet<string> = new Set();
export let matchedIds: ReadonlySet<string> = new Set();
export let activeId: string | null = null;
let viewport = { panX: 0, panY: 0, zoom: 1 };
let positionById: Record<string, { x: number; y: number }> = {};
let attachedCy: cytoscape.Core | null = null;
let rafId: number | null = null;
function syncFromCy() {
rafId = null;
if (!attachedCy || attachedCy.destroyed()) {
return;
}
const pan = attachedCy.pan();
viewport = { panX: pan.x, panY: pan.y, zoom: attachedCy.zoom() };
const next: Record<string, { x: number; y: number }> = {};
for (const node of attachedCy.nodes().toArray()) {
const position = node.position();
next[node.id()] = { x: position.x, y: position.y };
}
positionById = next;
}
function scheduleSync() {
if (rafId === null) {
rafId = requestAnimationFrame(syncFromCy);
}
}
function attach(core: cytoscape.Core | null) {
if (core === attachedCy) {
return;
}
if (attachedCy && !attachedCy.destroyed()) {
attachedCy.off('render', scheduleSync);
}
attachedCy = core;
if (attachedCy) {
// 'render' fires on pan, zoom, layout, add/remove and position
// changes, so one rAF-throttled listener keeps cards in lockstep.
attachedCy.on('render', scheduleSync);
scheduleSync();
}
}
$: attach(cy);
// Re-read positions whenever the visible node set changes (refreshGraph
// re-adds elements and re-runs layout synchronously).
$: if (nodes && attachedCy) {
scheduleSync();
}
onDestroy(() => {
attach(null);
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
});
function fallbackGlyph(kind: string, deviceClass: string | undefined): string {
if (kind === 'device') {
return deviceClass === 'infrastructure' ? '⇄' : '○';
}
if (kind === 'vm') {
return '◫';
}
return '🖥';
}
// position is passed in from the template (rather than read from
// positionById here) so Svelte's legacy-mode invalidation sees the
// dependency and re-renders cards when positions change.
function cardStyle(
node: GraphNodeElement,
position: { x: number; y: number } | undefined
): string {
const { data } = node;
if (!position) {
return 'display: none;';
}
const parts = [
`left: ${position.x}px`,
`top: ${position.y}px`,
`width: ${data.nodeWidth}px`,
`height: ${data.nodeHeight}px`
];
if (typeof data.vlanIndex === 'number') {
parts.push(`--card-vlan: ${vlanPaletteColor(data.vlanIndex)}`);
}
return `${parts.join('; ')};`;
}
</script>
<div class="card-layer" aria-hidden="true">
<div
class="card-plane"
style={`transform: translate(${viewport.panX}px, ${viewport.panY}px) scale(${viewport.zoom});`}
>
{#each nodes as node (node.data.id)}
{@const data = node.data}
{@const isDumb = data.kind === 'device' && data.deviceClass !== 'infrastructure'}
<div
class="node-card kind-{data.kind}"
class:infra={data.kind === 'device' && data.deviceClass === 'infrastructure'}
class:dumb={isDumb}
class:has-vlan={typeof data.vlanIndex === 'number'}
class:dimmed={dimmedIds.has(data.id)}
class:match={matchedIds.has(data.id)}
class:active={activeId === data.id}
style={cardStyle(node, positionById[node.data.id])}
>
<div class="icon-tile" class:round={isDumb}>
{#if data.iconUrl}
<img src={data.iconUrl} alt="" loading="lazy" />
{:else}
<span class="icon-fallback">{fallbackGlyph(data.kind, data.deviceClass)}</span>
{/if}
</div>
<div class="card-text">
<span class="card-name">{data.rawName ?? data.label}</span>
{#if !isDumb && (data.meta || data.vmCount)}
<span class="card-meta">
{#if data.meta}{data.meta}{/if}{#if data.kind === 'machine' && (data.vmCount ?? 0) > 0}{#if data.meta}&nbsp;·
{/if}<span class="vm-count">{data.vmCount} {data.vmCount === 1 ? 'VM' : 'VMs'}</span
>{/if}
</span>
{/if}
</div>
</div>
{/each}
</div>
</div>
<style>
.card-layer {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 5;
}
.card-plane {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
}
.node-card {
position: absolute;
transform: translate(-50%, -50%);
box-sizing: border-box;
display: flex;
align-items: center;
gap: 10px;
padding: 0 12px 0 10px;
background: var(--surface);
border: 1px solid var(--graph-node-border);
border-radius: 9px;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.07);
transition: opacity 120ms ease;
}
.node-card.has-vlan {
border-left: 4px solid var(--card-vlan);
padding-left: 7px;
}
.node-card.kind-vm {
background: var(--surface-2);
gap: 8px;
border-radius: 8px;
}
.node-card.infra {
background: var(--graph-infra-fill);
border-color: var(--graph-infra-border);
}
.node-card.dumb {
border: 1px dashed var(--graph-dumb-border);
border-radius: 999px;
padding: 0 14px 0 8px;
gap: 8px;
}
.node-card.dumb.has-vlan {
border-left: 4px solid var(--card-vlan);
}
.node-card.dimmed {
opacity: 0.15;
}
.node-card.match {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
.node-card.active {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 45%, transparent);
}
.icon-tile {
width: 34px;
height: 34px;
border-radius: 7px;
background: var(--surface-2);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
}
.icon-tile.round {
width: 24px;
height: 24px;
border-radius: 50%;
}
.kind-vm .icon-tile {
width: 26px;
height: 26px;
background: var(--surface);
}
.infra .icon-tile {
background: rgb(255 255 255 / 0.12);
}
:global([data-theme='dark']) .infra .icon-tile {
background: rgb(255 255 255 / 0.09);
}
.icon-tile img {
width: 76%;
height: 76%;
object-fit: contain;
}
.icon-fallback {
font-size: 15px;
color: var(--text-2);
}
.infra .icon-fallback {
color: #e7ebf2;
}
.card-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.card-name {
font-size: 13px;
font-weight: 650;
line-height: 1.15;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.infra .card-name {
color: #ffffff;
}
:global([data-theme='dark']) .infra .card-name {
color: var(--text);
}
.dumb .card-name {
font-size: 12.5px;
font-weight: 550;
color: var(--text);
}
.kind-vm .card-name {
font-size: 12px;
}
.card-meta {
font-family: var(--font-mono);
font-size: 10.5px;
line-height: 1;
color: var(--text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.infra .card-meta {
color: #b6bfcc;
}
.kind-vm .card-meta {
font-size: 10px;
}
.vm-count {
color: var(--accent);
font-weight: 600;
}
</style>
+99 -17
View File
@@ -65,11 +65,33 @@
</div>
{/each}
</div>
{#if layout.unrackedCount > 0}
<p class="rack-footnote">
{layout.unrackedCount}
{layout.unrackedCount === 1 ? 'entity is' : 'entities are'} not rack-mounted.
</p>
{#if layout.unracked.length > 0}
<details class="unracked">
<summary>
{layout.unracked.length}
{layout.unracked.length === 1 ? 'entity is' : 'entities are'} not rack-mounted
</summary>
<div class="unracked-list">
{#each layout.unracked as entity (`${entity.kind}:${entity.name}`)}
<button
type="button"
class="unracked-item"
title="Open the editor to place this in a rack"
on:click={() => dispatch('select', { kind: entity.kind, name: entity.name })}
>
{#if resolveIconPath(entity.iconKey)}
<img src={resolveIconPath(entity.iconKey)} alt="" loading="lazy" />
{:else}
<span class="unracked-glyph" aria-hidden="true">
{entity.kind === 'machine' ? '🖥' : '○'}
</span>
{/if}
<span class="unracked-name">{entity.name}</span>
<span class="unracked-kind">{entity.subtitle || entity.kind}</span>
</button>
{/each}
</div>
</details>
{/if}
{/if}
</div>
@@ -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;
}
</style>
+58 -48
View File
@@ -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;
}
+22 -4
View File
@@ -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<string, RackSlot[]>();
const canonicalNames = new Map<string, string>();
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[] {
+37
View File
@@ -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)
};
}
+38
View File
@@ -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<GraphTransformResult, 'nodes'>,
query: string
): string[] {
const ordered: string[] = [];
const seen = new Set<string>();
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;
}
+73 -19
View File
@@ -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 } : {})
}
});
+36 -1
View File
@@ -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;
+35 -5
View File
@@ -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<number, number> {
const map = new Map<number, number>();
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];
}
@@ -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 {
+48 -15
View File
@@ -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);
+7 -6
View File
@@ -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 = {