mirror of
https://github.com/jcreek/OpenNetworkDiagram.git
synced 2026-07-14 19:43:44 +00:00
feat(*): Redesign UI to improve UX
This commit is contained in:
@@ -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">
|
||||
{#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;
|
||||
|
||||
+2666
-2074
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user