Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a067cc3231 | |||
| 68496ae904 | |||
| e58e427093 | |||
| 94bc7085bf | |||
| 6517fcb373 |
@@ -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/
|
||||
|
||||
@@ -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 |
|
||||
| -------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|
||||
| IPAM panel with subnet utilization | Rack view with shelf items |
|
||||
| ------------------------------------------------------------------ | -------------------------------------------------------- |
|
||||
|  |  |
|
||||
| IPAM panel with subnet utilization | Rack view with shelf items | Dark mode |
|
||||
| --------------------------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------------------------------- |
|
||||
|  |  |  |
|
||||
|
||||
## Docker Compose Option
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 219 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 191 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 97 KiB |
@@ -50,7 +50,9 @@ async function walk(dir) {
|
||||
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 nestedFileLists = await Promise.all(
|
||||
directories.map((childDirectory) => walk(childDirectory))
|
||||
);
|
||||
|
||||
for (const nestedFiles of nestedFileLists) {
|
||||
files.push(...nestedFiles);
|
||||
@@ -88,7 +90,8 @@ async function ensureIgnoreEntry(filePath, entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const suffix = contents.length > 0 && !contents.endsWith('\n') && !contents.endsWith('\r\n') ? eol : '';
|
||||
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');
|
||||
}
|
||||
@@ -141,7 +144,10 @@ async function main() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (candidate.priority === existing.priority && candidate.relativePath < existing.relativePath) {
|
||||
if (
|
||||
candidate.priority === existing.priority &&
|
||||
candidate.relativePath < existing.relativePath
|
||||
) {
|
||||
dedupedBySlug.set(slug, candidate);
|
||||
}
|
||||
}
|
||||
@@ -182,11 +188,10 @@ async function main() {
|
||||
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);
|
||||
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -32,14 +32,23 @@
|
||||
<div class="modal-backdrop" on:click={onBackdropClick}>
|
||||
<div class="modal-container" style={`max-width: ${maxWidth}`}>
|
||||
<header class="modal-header">
|
||||
<slot name="header">
|
||||
{#if title}
|
||||
<h2>{title}</h2>
|
||||
{/if}
|
||||
<button type="button" class="close-button" on:click={closeModal} aria-label="Close">×</button>
|
||||
</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;
|
||||
|
||||
@@ -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} ·
|
||||
{/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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
@@ -152,7 +156,10 @@ function ensureReciprocalConnections(data: NetworkData) {
|
||||
}
|
||||
}
|
||||
|
||||
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,9 +341,7 @@ export function deletePort(
|
||||
}
|
||||
}
|
||||
|
||||
clearInboundReferences(
|
||||
cloned,
|
||||
(connection) =>
|
||||
clearInboundReferences(cloned, (connection) =>
|
||||
Boolean(
|
||||
connection &&
|
||||
equalsIgnoreCase(connection.device, ownerName) &&
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -160,8 +181,10 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
||||
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 } : {})
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, {
|
||||
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, {
|
||||
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);
|
||||
|
||||
@@ -3,7 +3,8 @@ 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'
|
||||
const adapter =
|
||||
deployTarget === 'netlify'
|
||||
? netlifyAdapter()
|
||||
: staticAdapter({
|
||||
fallback: 'index.html',
|
||||
|
||||