Files
net-docs/frontend/index.html
2026-04-10 15:36:34 -07:00

1760 lines
70 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NetDoc Pro</title>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f4f1eb;
--surface: #faf8f4;
--surface2: #ede9e0;
--border: #d4cfc4;
--border2: #c4bfb4;
--ink: #1a1714;
--ink2: #4a4540;
--muted: #8a847a;
--accent: #c8401a;
--accent2: #2a6496;
--accent3: #2e7d4f;
--warn: #b07d10;
--tag-bg: #e8e4db;
--shadow: 0 2px 8px rgba(26,23,20,0.08);
--shadow-lg: 0 8px 32px rgba(26,23,20,0.14);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'IBM Plex Sans', sans-serif;
background: var(--bg);
color: var(--ink);
min-height: 100vh;
display: flex;
flex-direction: column;
}
.app-shell { display: flex; min-height: 100vh; }
.sidebar {
width: 240px;
background: var(--ink);
color: #e8e4db;
display: flex;
flex-direction: column;
flex-shrink: 0;
position: fixed;
top: 0; left: 0; bottom: 0;
z-index: 50;
overflow-y: auto;
}
.sidebar-logo {
padding: 24px 20px 20px;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.sidebar-logo .wordmark {
font-family: 'IBM Plex Mono', monospace;
font-size: 1.1rem;
font-weight: 600;
letter-spacing: -0.5px;
color: #faf8f4;
}
.sidebar-logo .wordmark span { color: var(--accent); }
.sidebar-logo .tagline {
font-size: 0.68rem;
color: rgba(232,228,219,0.45);
letter-spacing: 1.5px;
text-transform: uppercase;
margin-top: 3px;
font-family: 'IBM Plex Mono', monospace;
}
.tenant-section {
padding: 16px 20px;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.tenant-label {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 1.5px;
color: rgba(232,228,219,0.4);
margin-bottom: 8px;
font-family: 'IBM Plex Mono', monospace;
}
.tenant-select-wrap { position: relative; }
.tenant-select-wrap select {
width: 100%;
background: rgba(255,255,255,0.07);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 6px;
padding: 8px 28px 8px 10px;
color: #faf8f4;
font-family: 'IBM Plex Mono', monospace;
font-size: 0.8rem;
outline: none;
cursor: pointer;
appearance: none;
}
.tenant-select-wrap::after {
content: '▾';
position: absolute;
right: 10px; top: 50%;
transform: translateY(-50%);
color: rgba(232,228,219,0.5);
pointer-events: none;
font-size: 0.75rem;
}
.tenant-select-wrap select option { background: #2a2520; }
.btn-new-tenant {
width: 100%;
margin-top: 8px;
background: rgba(200,64,26,0.2);
border: 1px dashed rgba(200,64,26,0.4);
border-radius: 6px;
padding: 6px;
color: var(--accent);
font-family: 'IBM Plex Sans', sans-serif;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
letter-spacing: 0.3px;
}
.btn-new-tenant:hover { background: rgba(200,64,26,0.3); }
.sidebar-nav { padding: 12px 0; flex: 1; }
.nav-section-label {
padding: 12px 20px 6px;
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 1.8px;
color: rgba(232,228,219,0.3);
font-family: 'IBM Plex Mono', monospace;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 20px;
cursor: pointer;
font-size: 0.85rem;
color: rgba(232,228,219,0.65);
transition: all 0.15s;
border-left: 3px solid transparent;
}
.nav-item:hover { color: #faf8f4; background: rgba(255,255,255,0.04); }
.nav-item.active {
color: #faf8f4;
background: rgba(255,255,255,0.07);
border-left-color: var(--accent);
}
.nav-item .icon { width: 18px; text-align: center; font-size: 0.9rem; opacity: 0.85; }
.nav-item .count {
margin-left: auto;
font-family: 'IBM Plex Mono', monospace;
font-size: 0.68rem;
background: rgba(255,255,255,0.1);
padding: 2px 7px;
border-radius: 10px;
}
.sidebar-footer {
padding: 16px 20px;
border-top: 1px solid rgba(255,255,255,0.08);
display: flex;
flex-direction: column;
gap: 8px;
}
.sidebar-footer button {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
padding: 7px 12px;
color: rgba(232,228,219,0.7);
font-family: 'IBM Plex Sans', sans-serif;
font-size: 0.78rem;
cursor: pointer;
transition: all 0.15s;
text-align: left;
}
.sidebar-footer button:hover { background: rgba(255,255,255,0.1); color: #faf8f4; }
.main { margin-left: 240px; flex: 1; display: flex; flex-direction: column; min-height: 100vh; }
.topbar {
padding: 20px 32px;
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 16px;
position: sticky; top: 0; z-index: 30;
}
.topbar h1 {
font-size: 1rem;
font-weight: 700;
letter-spacing: -0.2px;
}
.topbar .tenant-badge {
font-family: 'IBM Plex Mono', monospace;
font-size: 0.72rem;
background: var(--tag-bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 3px 10px;
color: var(--ink2);
}
.topbar .search-box { flex: 1; max-width: 360px; margin-left: auto; position: relative; }
.topbar .search-box input {
width: 100%;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 7px;
padding: 8px 12px 8px 34px;
font-family: 'IBM Plex Mono', monospace;
font-size: 0.8rem;
color: var(--ink);
outline: none;
transition: border-color 0.15s;
}
.topbar .search-box input:focus { border-color: var(--accent2); }
.topbar .search-box::before {
content: '⌕';
position: absolute; left: 10px; top: 50%;
transform: translateY(-50%);
color: var(--muted);
font-size: 1rem;
}
.page-body { padding: 28px 32px; flex: 1; }
.section { display: none; }
.section.active { display: block; }
.add-bar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.add-bar h2 {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.2px;
color: var(--muted);
font-family: 'IBM Plex Mono', monospace;
flex: 1;
}
.btn {
padding: 9px 18px;
border-radius: 7px;
border: none;
cursor: pointer;
font-family: 'IBM Plex Sans', sans-serif;
font-weight: 600;
font-size: 0.82rem;
transition: all 0.15s;
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.btn-primary { background: var(--accent); color: white; }
.btn-primary:hover { background: #a83415; }
.btn-ghost { background: white; border: 1px solid var(--border); color: var(--ink2); box-shadow: var(--shadow); }
.btn-ghost:hover { border-color: var(--border2); color: var(--ink); }
.btn-sm { padding: 5px 11px; font-size: 0.76rem; }
.btn-icon { padding: 5px 9px; font-size: 0.78rem; }
.btn-del { background: white; border: 1px solid #e8c4c0; color: #c8401a; }
.btn-del:hover { background: #fff0ed; }
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 16px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
box-shadow: var(--shadow);
transition: box-shadow 0.15s;
}
.card:hover { box-shadow: var(--shadow-lg); }
.card-header {
padding: 14px 18px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
background: var(--surface2);
}
.card-header .card-title {
font-weight: 700;
font-size: 0.9rem;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-header .card-icon { font-size: 1rem; }
.card-body { padding: 14px 18px; }
.field-row {
display: flex;
gap: 8px;
margin-bottom: 9px;
align-items: flex-start;
}
.field-row:last-child { margin-bottom: 0; }
.field-label {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--muted);
font-family: 'IBM Plex Mono', monospace;
width: 88px;
flex-shrink: 0;
padding-top: 1px;
}
.field-val {
font-family: 'IBM Plex Mono', monospace;
font-size: 0.8rem;
color: var(--ink);
flex: 1;
word-break: break-all;
}
.field-val.faded { color: var(--muted); font-family: 'IBM Plex Sans', sans-serif; font-style: italic; }
.card-footer {
padding: 10px 18px;
border-top: 1px solid var(--border);
display: flex;
gap: 8px;
justify-content: flex-end;
}
.badge {
display: inline-block;
padding: 2px 9px;
border-radius: 4px;
font-size: 0.68rem;
font-weight: 600;
font-family: 'IBM Plex Mono', monospace;
letter-spacing: 0.3px;
}
.b-red { background: #fce8e4; color: #c8401a; border: 1px solid #f0c8c0; }
.b-blue { background: #e4eef8; color: #2a6496; border: 1px solid #b8d4ec; }
.b-green { background: #e4f2ea; color: #2e7d4f; border: 1px solid #b8dfc8; }
.b-amber { background: #fdf4e0; color: #b07d10; border: 1px solid #e8d490; }
.b-gray { background: var(--tag-bg); color: var(--ink2); border: 1px solid var(--border); }
.tbl-wrap {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
box-shadow: var(--shadow);
}
table { width: 100%; border-collapse: collapse; }
thead tr { background: var(--surface2); }
th {
padding: 12px 16px;
text-align: left;
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1.4px;
color: var(--muted);
border-bottom: 1px solid var(--border);
font-family: 'IBM Plex Mono', monospace;
}
tbody tr { border-bottom: 1px solid var(--border); transition: background 0.12s; }
tbody tr:last-child { border-bottom: none; }
tbody tr:hover { background: var(--surface2); }
td {
padding: 12px 16px;
font-size: 0.83rem;
vertical-align: middle;
}
td.mono { font-family: 'IBM Plex Mono', monospace; font-size: 0.78rem; }
.pw-wrap { display: flex; align-items: center; gap: 6px; }
.pw-dots { font-family: 'IBM Plex Mono', monospace; font-size: 0.78rem; letter-spacing: 2px; }
.reveal-btn {
background: none; border: none; cursor: pointer;
color: var(--muted); font-size: 0.75rem; padding: 0 2px;
}
.reveal-btn:hover { color: var(--accent2); }
.notes-editor {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
box-shadow: var(--shadow);
}
.notes-toolbar {
background: var(--surface2);
border-bottom: 1px solid var(--border);
padding: 10px 16px;
display: flex;
align-items: center;
gap: 8px;
}
.notes-toolbar span { font-size: 0.72rem; color: var(--muted); font-family: 'IBM Plex Mono', monospace; text-transform: uppercase; letter-spacing: 1px; }
#notes-area {
width: 100%;
min-height: 400px;
background: transparent;
border: none;
padding: 20px;
font-family: 'IBM Plex Mono', monospace;
font-size: 0.85rem;
color: var(--ink);
resize: vertical;
outline: none;
line-height: 1.7;
}
.notes-save-bar {
border-top: 1px solid var(--border);
padding: 10px 16px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.overlay {
position: fixed; inset: 0;
background: rgba(26,23,20,0.55);
display: flex; align-items: center; justify-content: center;
z-index: 100;
opacity: 0; pointer-events: none;
transition: opacity 0.18s;
backdrop-filter: blur(2px);
}
.overlay.open { opacity: 1; pointer-events: all; }
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 28px 32px;
width: 100%;
max-width: 540px;
max-height: 90vh;
overflow-y: auto;
transform: translateY(10px) scale(0.99);
transition: transform 0.18s;
box-shadow: var(--shadow-lg);
}
.overlay.open .modal { transform: translateY(0) scale(1); }
.modal-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 22px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.modal-header h2 { font-size: 1rem; font-weight: 700; }
.modal-header .close-btn {
margin-left: auto;
background: none; border: none;
color: var(--muted); cursor: pointer; font-size: 1.2rem; padding: 0 4px;
}
.modal-header .close-btn:hover { color: var(--ink); }
.form-grid { display: grid; gap: 14px; }
.form-2 { grid-template-columns: 1fr 1fr; }
.form-group { display: flex; flex-direction: column; gap: 5px; }
.form-group label {
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--muted);
font-family: 'IBM Plex Mono', monospace;
}
.form-group input, .form-group select, .form-group textarea {
background: white;
border: 1px solid var(--border);
border-radius: 7px;
padding: 9px 12px;
color: var(--ink);
font-family: 'IBM Plex Mono', monospace;
font-size: 0.82rem;
outline: none;
transition: border-color 0.15s;
width: 100%;
}
.form-group input:focus, .form-group select:focus, .form-group textarea:focus {
border-color: var(--accent2);
box-shadow: 0 0 0 3px rgba(42,100,150,0.08);
}
.form-group textarea { resize: vertical; min-height: 72px; font-family: 'IBM Plex Sans', sans-serif; line-height: 1.5; }
.modal-actions {
display: flex; justify-content: flex-end; gap: 10px;
margin-top: 22px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.empty {
grid-column: 1/-1;
text-align: center;
padding: 56px 32px;
color: var(--muted);
}
.empty .e-icon { font-size: 2rem; margin-bottom: 10px; opacity: 0.4; }
.empty p { font-size: 0.85rem; }
.no-tenant {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
gap: 16px;
text-align: center;
color: var(--muted);
}
.no-tenant .big { font-size: 2.5rem; opacity: 0.3; }
.no-tenant p { font-size: 0.9rem; max-width: 280px; line-height: 1.6; }
@media (max-width: 800px) {
.sidebar { width: 100%; position: relative; height: auto; }
.main { margin-left: 0; }
.app-shell { flex-direction: column; }
.cards-grid { grid-template-columns: 1fr; }
.form-2 { grid-template-columns: 1fr; }
.page-body { padding: 16px; }
}
/* ── View Switcher ── */
.view-switcher {
display: flex;
gap: 2px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 7px;
padding: 3px;
}
.view-switcher button {
background: none;
border: none;
border-radius: 5px;
padding: 5px 10px;
font-size: 0.72rem;
font-family: 'IBM Plex Mono', monospace;
color: var(--muted);
cursor: pointer;
transition: all 0.12s;
white-space: nowrap;
}
.view-switcher button:hover { color: var(--ink); background: rgba(0,0,0,0.04); }
.view-switcher button.active { background: var(--surface); color: var(--ink); box-shadow: 0 1px 3px rgba(26,23,20,0.1); }
/* ── List View ── */
.list-view {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
box-shadow: var(--shadow);
}
.list-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
font-size: 0.83rem;
transition: background 0.12s;
}
.list-row:last-child { border-bottom: none; }
.list-row:hover { background: var(--surface2); }
.list-row .lr-primary { font-weight: 600; min-width: 140px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.list-row .lr-secondary { font-family: 'IBM Plex Mono', monospace; font-size: 0.76rem; color: var(--ink2); flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.list-row .lr-actions { display: flex; gap: 6px; margin-left: auto; flex-shrink: 0; align-items: center; }
</style>
</head>
<body>
<div class="app-shell">
<aside class="sidebar">
<div class="sidebar-logo">
<div class="wordmark">Net<span>Doc</span> Pro</div>
<div class="tagline">Network Documentation</div>
</div>
<div class="tenant-section">
<div class="tenant-label">Active Client / Site</div>
<div class="tenant-select-wrap">
<select id="tenant-select" onchange="switchTenant()">
<option value="">— Select —</option>
</select>
</div>
<button class="btn-new-tenant" onclick="openModal('tenant')">+ New Client / Site</button>
</div>
<nav class="sidebar-nav">
<div class="nav-section-label">Documentation</div>
<div class="nav-item active" onclick="nav('devices')" id="nav-devices">
<span class="icon">🖥</span> Devices
<span class="count" id="cnt-devices">0</span>
</div>
<div class="nav-item" onclick="nav('subnets')" id="nav-subnets">
<span class="icon">🌐</span> Subnets
<span class="count" id="cnt-subnets">0</span>
</div>
<div class="nav-item" onclick="nav('vlans')" id="nav-vlans">
<span class="icon">🔀</span> VLANs
<span class="count" id="cnt-vlans">0</span>
</div>
<div class="nav-item" onclick="nav('isp')" id="nav-isp">
<span class="icon">📡</span> ISP Connections
<span class="count" id="cnt-isp">0</span>
</div>
<div class="nav-item" onclick="nav('creds')" id="nav-creds">
<span class="icon">🔑</span> Credentials
<span class="count" id="cnt-creds">0</span>
</div>
<div class="nav-item" onclick="nav('notes')" id="nav-notes">
<span class="icon">📝</span> Notes
<span class="count" id="cnt-notes">0</span>
</div>
</nav>
<div class="sidebar-footer">
<button onclick="exportData()">⬇ Export All (JSON)</button>
<label style="cursor:pointer;">
⬆ Import JSON
<input type="file" accept=".json" style="display:none" onchange="importData(event)">
</label>
<button onclick="deleteTenant()" style="color:#f08070;" id="btn-del-tenant">🗑 Delete Current Site</button>
</div>
</aside>
<div class="main">
<div class="topbar">
<h1 id="topbar-title">Select a Client / Site</h1>
<span class="tenant-badge" id="topbar-badge" style="display:none"></span>
<div class="search-box">
<input type="text" id="global-search" placeholder="Search current section..." oninput="doSearch()">
</div>
</div>
<div class="page-body">
<div class="section active" id="section-notenant">
<div class="no-tenant">
<div class="big">🏢</div>
<p>Select a client / site from the sidebar, or create a new one to get started.</p>
<button class="btn btn-primary" onclick="openModal('tenant')">+ Create First Site</button>
</div>
</div>
<div class="section" id="section-devices">
<div class="add-bar">
<h2>Devices &amp; Hosts</h2>
<div class="view-switcher" id="vs-devices">
<button class="active" onclick="setView('devices','cards')">Cards</button>
<button onclick="setView('devices','table')">Table</button>
<button onclick="setView('devices','list')">List</button>
</div>
<button class="btn btn-primary" onclick="openModal('device')">+ Add Device</button>
</div>
<div id="devices-grid"></div>
</div>
<div class="section" id="section-subnets">
<div class="add-bar">
<h2>IP Subnets</h2>
<div class="view-switcher" id="vs-subnets">
<button class="active" onclick="setView('subnets','cards')">Cards</button>
<button onclick="setView('subnets','table')">Table</button>
<button onclick="setView('subnets','list')">List</button>
</div>
<button class="btn btn-primary" onclick="openModal('subnet')">+ Add Subnet</button>
</div>
<div id="subnets-grid"></div>
</div>
<div class="section" id="section-vlans">
<div class="add-bar">
<h2>VLANs</h2>
<div class="view-switcher" id="vs-vlans">
<button class="active" onclick="setView('vlans','cards')">Cards</button>
<button onclick="setView('vlans','table')">Table</button>
<button onclick="setView('vlans','list')">List</button>
</div>
<button class="btn btn-primary" onclick="openModal('vlan')">+ Add VLAN</button>
</div>
<div id="vlans-grid"></div>
</div>
<div class="section" id="section-isp">
<div class="add-bar">
<h2>ISP Connections</h2>
<div class="view-switcher" id="vs-isp">
<button onclick="setView('isp','cards')">Cards</button>
<button class="active" onclick="setView('isp','table')">Table</button>
<button onclick="setView('isp','list')">List</button>
</div>
<button class="btn btn-primary" onclick="openModal('isp')">+ Add Connection</button>
</div>
<div id="isp-grid"></div>
</div>
<div class="section" id="section-creds">
<div class="add-bar">
<h2>Credentials &amp; Access</h2>
<div class="view-switcher" id="vs-creds">
<button class="active" onclick="setView('creds','cards')">Cards</button>
<button onclick="setView('creds','table')">Table</button>
<button onclick="setView('creds','list')">List</button>
</div>
<button class="btn btn-primary" onclick="openModal('cred')">+ Add Credential</button>
</div>
<div id="creds-grid"></div>
</div>
<div class="section" id="section-notes">
<div class="add-bar">
<h2>Notes</h2>
<div class="view-switcher" id="vs-notes">
<button class="active" onclick="setView('notes','cards')">Cards</button>
<button onclick="setView('notes','table')">Table</button>
<button onclick="setView('notes','list')">List</button>
</div>
<button class="btn btn-primary" onclick="openModal('note')">+ Add Note</button>
</div>
<div id="notes-grid"></div>
</div>
</div>
</div>
</div>
<!-- ════ MODALS ════ -->
<div class="overlay" id="modal-tenant">
<div class="modal">
<div class="modal-header">
<h2>🏢 New Client / Site</h2>
<button class="close-btn" onclick="closeModal('tenant')"></button>
</div>
<div class="form-grid">
<div class="form-group"><label>Site / Client Name *</label><input id="t-name" placeholder="Acme Corp — Main Office"></div>
<div class="form-grid form-2">
<div class="form-group"><label>Location</label><input id="t-location" placeholder="123 Main St, City"></div>
<div class="form-group"><label>Contact Name</label><input id="t-contact" placeholder="John Doe"></div>
</div>
<div class="form-grid form-2">
<div class="form-group"><label>Contact Phone</label><input id="t-phone" placeholder="+1 555-000-0000"></div>
<div class="form-group"><label>Contact Email</label><input id="t-email" placeholder="john@acme.com"></div>
</div>
<div class="form-group"><label>Notes</label><textarea id="t-notes" placeholder="Site overview..."></textarea></div>
</div>
<div class="modal-actions">
<button class="btn btn-ghost" onclick="closeModal('tenant')">Cancel</button>
<button class="btn btn-primary" onclick="saveTenant()">Create Site</button>
</div>
</div>
</div>
<div class="overlay" id="modal-device">
<div class="modal">
<div class="modal-header">
<h2 id="device-modal-title">🖥 Add Device</h2>
<button class="close-btn" onclick="closeModal('device')"></button>
</div>
<div class="form-grid">
<div class="form-grid form-2">
<div class="form-group"><label>Hostname *</label><input id="d-hostname" placeholder="sw-core-01"></div>
<div class="form-group"><label>IP Address *</label><input id="d-ip" placeholder="192.168.1.1"></div>
</div>
<div class="form-grid form-2">
<div class="form-group"><label>Device Type</label>
<select id="d-type">
<option>Router</option><option>Switch</option><option>Firewall</option>
<option>Access Point</option><option>Server</option><option>Workstation</option>
<option>Printer</option><option>NAS / Storage</option><option>IoT</option><option>Other</option>
</select>
</div>
<div class="form-group"><label>Status</label>
<select id="d-status"><option>Online</option><option>Offline</option><option>Maintenance</option></select>
</div>
</div>
<div class="form-grid form-2">
<div class="form-group"><label>MAC Address</label><input id="d-mac" placeholder="AA:BB:CC:DD:EE:FF"></div>
<div class="form-group"><label>VLAN ID</label><input id="d-vlan" placeholder="10"></div>
</div>
<div class="form-grid form-2">
<div class="form-group"><label>Make / Model</label><input id="d-model" placeholder="Cisco SG350-28"></div>
<div class="form-group"><label>Location / Rack</label><input id="d-location" placeholder="Rack A, U4"></div>
</div>
<div class="form-group"><label>Notes</label><textarea id="d-notes" placeholder="Firmware version, config backup location, etc."></textarea></div>
</div>
<div class="modal-actions">
<button class="btn btn-ghost" onclick="closeModal('device')">Cancel</button>
<button class="btn btn-primary" onclick="saveDevice()">Save</button>
</div>
</div>
</div>
<div class="overlay" id="modal-subnet">
<div class="modal">
<div class="modal-header">
<h2 id="subnet-modal-title">🌐 Add Subnet</h2>
<button class="close-btn" onclick="closeModal('subnet')"></button>
</div>
<div class="form-grid">
<div class="form-group"><label>Subnet Name *</label><input id="s-name" placeholder="Management LAN"></div>
<div class="form-grid form-2">
<div class="form-group"><label>Network / CIDR *</label><input id="s-network" placeholder="192.168.1.0/24"></div>
<div class="form-group"><label>Gateway</label><input id="s-gateway" placeholder="192.168.1.1"></div>
</div>
<div class="form-grid form-2">
<div class="form-group"><label>DHCP Start</label><input id="s-ds" placeholder="192.168.1.100"></div>
<div class="form-group"><label>DHCP End</label><input id="s-de" placeholder="192.168.1.200"></div>
</div>
<div class="form-grid form-2">
<div class="form-group"><label>DNS Server(s)</label><input id="s-dns" placeholder="8.8.8.8, 8.8.4.4"></div>
<div class="form-group"><label>VLAN ID</label><input id="s-vlan" placeholder="10"></div>
</div>
<div class="form-group"><label>Notes</label><textarea id="s-notes" placeholder="Purpose, ACLs, routing info..."></textarea></div>
</div>
<div class="modal-actions">
<button class="btn btn-ghost" onclick="closeModal('subnet')">Cancel</button>
<button class="btn btn-primary" onclick="saveSubnet()">Save</button>
</div>
</div>
</div>
<div class="overlay" id="modal-vlan">
<div class="modal">
<div class="modal-header">
<h2 id="vlan-modal-title">🔀 Add VLAN</h2>
<button class="close-btn" onclick="closeModal('vlan')"></button>
</div>
<div class="form-grid">
<div class="form-grid form-2">
<div class="form-group"><label>VLAN ID *</label><input id="v-id" type="number" placeholder="10" min="1" max="4094"></div>
<div class="form-group"><label>Name *</label><input id="v-name" placeholder="MGMT"></div>
</div>
<div class="form-grid form-2">
<div class="form-group"><label>Purpose</label>
<select id="v-purpose">
<option>Management</option><option>Data</option><option>Voice</option>
<option>Guest</option><option>IoT</option><option>DMZ</option><option>Storage</option><option>Other</option>
</select>
</div>
<div class="form-group"><label>Tagged Ports</label><input id="v-ports" placeholder="Gi0/1, trunk"></div>
</div>
<div class="form-group"><label>Notes</label><textarea id="v-notes" placeholder="Security policy, routing, etc."></textarea></div>
</div>
<div class="modal-actions">
<button class="btn btn-ghost" onclick="closeModal('vlan')">Cancel</button>
<button class="btn btn-primary" onclick="saveVlan()">Save</button>
</div>
</div>
</div>
<div class="overlay" id="modal-isp">
<div class="modal">
<div class="modal-header">
<h2 id="isp-modal-title">📡 Add ISP Connection</h2>
<button class="close-btn" onclick="closeModal('isp')"></button>
</div>
<div class="form-grid">
<div class="form-grid form-2">
<div class="form-group"><label>Label *</label><input id="i-label" placeholder="Primary WAN"></div>
<div class="form-group"><label>Provider *</label><input id="i-provider" placeholder="Comcast Business"></div>
</div>
<div class="form-grid form-2">
<div class="form-group"><label>Connection Type</label>
<select id="i-type">
<option>Fiber</option><option>Cable / HFC</option><option>DSL</option><option>Fixed Wireless</option>
<option>MPLS</option><option>SD-WAN</option><option>4G/LTE</option><option>5G</option><option>Other</option>
</select>
</div>
<div class="form-group"><label>Status</label>
<select id="i-status"><option>Active</option><option>Backup</option><option>Down</option><option>Provisioning</option></select>
</div>
</div>
<div class="form-grid form-2">
<div class="form-group"><label>WAN / Static IP(s)</label><input id="i-ips" placeholder="203.0.113.5/30"></div>
<div class="form-group"><label>Gateway</label><input id="i-gw" placeholder="203.0.113.1"></div>
</div>
<div class="form-grid form-2">
<div class="form-group"><label>Download Speed</label><input id="i-dl" placeholder="1 Gbps"></div>
<div class="form-group"><label>Upload Speed</label><input id="i-ul" placeholder="200 Mbps"></div>
</div>
<div class="form-grid form-2">
<div class="form-group"><label>Circuit ID</label><input id="i-circuit" placeholder="COM-12345678"></div>
<div class="form-group"><label>Account #</label><input id="i-acct" placeholder="8001234567"></div>
</div>
<div class="form-grid form-2">
<div class="form-group"><label>Support Phone</label><input id="i-phone" placeholder="1-800-000-0000"></div>
<div class="form-group"><label>Monthly Cost</label><input id="i-cost" placeholder="$299/mo"></div>
</div>
<div class="form-group"><label>Contract / Renewal Date</label><input id="i-renew" placeholder="2026-06-01"></div>
<div class="form-group"><label>Notes</label><textarea id="i-notes" placeholder="SLA details, escalation contacts, modem info..."></textarea></div>
</div>
<div class="modal-actions">
<button class="btn btn-ghost" onclick="closeModal('isp')">Cancel</button>
<button class="btn btn-primary" onclick="saveIsp()">Save</button>
</div>
</div>
</div>
<div class="overlay" id="modal-cred">
<div class="modal">
<div class="modal-header">
<h2 id="cred-modal-title">🔑 Add Credential</h2>
<button class="close-btn" onclick="closeModal('cred')"></button>
</div>
<div style="background:#fdf4e0;border:1px solid #e8d490;border-radius:7px;padding:10px 14px;font-size:0.78rem;color:#7a5c00;margin-bottom:16px;">
⚠ Credentials are stored in the database. Ensure your server is properly secured.
</div>
<div class="form-grid">
<div class="form-grid form-2">
<div class="form-group"><label>Label *</label><input id="c-label" placeholder="Core Switch Admin"></div>
<div class="form-group"><label>Category</label>
<select id="c-cat">
<option>Network Device</option><option>Server</option><option>Firewall</option>
<option>Cloud / Portal</option><option>ISP Portal</option><option>VPN</option>
<option>Wi-Fi</option><option>Other</option>
</select>
</div>
</div>
<div class="form-grid form-2">
<div class="form-group"><label>Username</label><input id="c-user" placeholder="admin"></div>
<div class="form-group">
<label>Password</label>
<div style="display:flex;gap:6px;align-items:center">
<input id="c-pass" type="password" placeholder="••••••••" style="flex:1">
<button type="button" class="reveal-btn" onclick="toggleInputPw('c-pass')" title="Show / hide" style="font-size:1.1rem;padding:2px 4px">👁</button>
</div>
</div>
</div>
<div class="form-group"><label>URL / IP / Host</label><input id="c-url" placeholder="https://192.168.1.1 or ssh admin@10.0.0.1"></div>
<div class="form-group">
<label>Enable / Secret Password</label>
<div style="display:flex;gap:6px;align-items:center">
<input id="c-enable" type="password" placeholder="Cisco enable secret, etc." style="flex:1">
<button type="button" class="reveal-btn" onclick="toggleInputPw('c-enable')" title="Show / hide" style="font-size:1.1rem;padding:2px 4px">👁</button>
</div>
</div>
<div class="form-group"><label>Notes</label><textarea id="c-notes" placeholder="2FA app, access level, SNMP community, API key..."></textarea></div>
</div>
<div class="modal-actions">
<button class="btn btn-ghost" onclick="closeModal('cred')">Cancel</button>
<button class="btn btn-primary" onclick="saveCred()">Save</button>
</div>
</div>
</div>
<div class="overlay" id="modal-note">
<div class="modal">
<div class="modal-header">
<h2 id="note-modal-title">📝 Add Note</h2>
<button class="close-btn" onclick="closeModal('note')"></button>
</div>
<div class="form-grid">
<div class="form-group"><label>Title *</label><input id="n-title" placeholder="e.g. Change Log, Network Quirks, Vendor Contacts"></div>
<div class="form-group"><label>Content</label><textarea id="n-content" style="min-height:260px" placeholder="Write anything — free text, markdown-style headings (## Section), bullet lists..."></textarea></div>
</div>
<div class="modal-actions">
<button class="btn btn-ghost" onclick="closeModal('note')">Cancel</button>
<button class="btn btn-primary" onclick="saveNote()">Save</button>
</div>
</div>
</div>
<script>
const API = '/api';
let db = { tenants: {}, order: [] };
let currentTenant = null;
let editState = {};
let currentSection = 'notenant';
const sectionViews = { devices: 'cards', subnets: 'cards', vlans: 'cards', isp: 'table', creds: 'cards', notes: 'cards' };
function setView(section, view) {
sectionViews[section] = view;
const vs = document.getElementById('vs-' + section);
if (vs) vs.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.textContent.toLowerCase() === view));
const q = document.getElementById('global-search').value.toLowerCase();
if (section === 'devices') renderDevices(q);
else if (section === 'subnets') renderSubnets(q);
else if (section === 'vlans') renderVlans(q);
else if (section === 'isp') renderIsp(q);
else if (section === 'creds') renderCreds(q);
else if (section === 'notes') renderNotes(q);
}
// ════ LOAD ════
async function load() {
try {
const res = await fetch(`${API}/sites`);
const sites = await res.json();
db = { tenants: {}, order: [] };
sites.forEach(s => {
db.order.push(s._id);
db.tenants[s._id] = { ...s, devices: [], subnets: [], vlans: [], isp: [], creds: [], notes: [] };
});
} catch(e) { console.error('Failed to load sites', e); }
refreshTenantDropdown();
if (db.order.length > 0) {
currentTenant = db.order[0];
document.getElementById('tenant-select').value = currentTenant;
await activateTenant();
}
}
function getTenant() { return currentTenant ? db.tenants[currentTenant] : null; }
// ════ TENANTS ════
function refreshTenantDropdown() {
const sel = document.getElementById('tenant-select');
sel.innerHTML = '<option value="">— Select —</option>';
db.order.forEach(id => {
const opt = document.createElement('option');
opt.value = id;
opt.textContent = db.tenants[id].name;
sel.appendChild(opt);
});
if (currentTenant && db.tenants[currentTenant]) sel.value = currentTenant;
}
async function switchTenant() {
currentTenant = document.getElementById('tenant-select').value || null;
if (currentTenant) await activateTenant(); else deactivateTenant();
}
async function activateTenant() {
try {
const res = await fetch(`${API}/sites/${currentTenant}`);
const site = await res.json();
db.tenants[currentTenant] = site;
} catch(e) { console.error('Failed to load site', e); }
const t = getTenant();
document.getElementById('topbar-title').textContent = t.name;
const badge = document.getElementById('topbar-badge');
badge.textContent = t.location || '';
badge.style.display = t.location ? '' : 'none';
nav('devices');
renderAll();
}
function deactivateTenant() {
document.getElementById('topbar-title').textContent = 'Select a Client / Site';
document.getElementById('topbar-badge').style.display = 'none';
showSection('notenant');
}
async function saveTenant() {
const name = v('t-name');
if (!name) { alert('Name required'); return; }
const body = { name, location: v('t-location'), contact: v('t-contact'),
phone: v('t-phone'), email: v('t-email'), notes: v('t-notes') };
const res = await fetch(`${API}/sites`, {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)
});
const site = await res.json();
db.order.push(site._id);
db.tenants[site._id] = site;
currentTenant = site._id;
refreshTenantDropdown();
await activateTenant();
closeModal('tenant');
}
async function deleteTenant() {
if (!currentTenant) return;
const t = getTenant();
if (!confirm(`Delete "${t.name}" and ALL its data? This cannot be undone.`)) return;
await fetch(`${API}/sites/${currentTenant}`, { method: 'DELETE' });
db.order = db.order.filter(id => id !== currentTenant);
delete db.tenants[currentTenant];
currentTenant = null;
refreshTenantDropdown();
document.getElementById('tenant-select').value = '';
deactivateTenant();
}
// ════ NAV ════
function nav(section) {
if (!currentTenant && section !== 'notenant') return;
document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
const ni = document.getElementById('nav-' + section);
if (ni) ni.classList.add('active');
showSection(section);
currentSection = section;
document.getElementById('global-search').value = '';
doSearch();
}
function showSection(s) {
document.querySelectorAll('.section').forEach(el => el.classList.remove('active'));
document.getElementById('section-' + s).classList.add('active');
}
// ════ SEARCH ════
function doSearch() {
const q = document.getElementById('global-search').value.toLowerCase();
if (currentSection === 'devices') renderDevices(q);
else if (currentSection === 'subnets') renderSubnets(q);
else if (currentSection === 'vlans') renderVlans(q);
else if (currentSection === 'isp') renderIsp(q);
else if (currentSection === 'creds') renderCreds(q);
else if (currentSection === 'notes') renderNotes(q);
}
// ════ RENDER ALL ════
function renderAll() {
renderDevices(); renderSubnets(); renderVlans(); renderIsp(); renderCreds(); renderNotes();
updateCounts();
}
function updateCounts() {
const t = getTenant(); if (!t) return;
setCount('devices', t.devices.length);
setCount('subnets', t.subnets.length);
setCount('vlans', t.vlans.length);
setCount('isp', t.isp.length);
setCount('creds', t.creds.length);
setCount('notes', t.notes.length);
}
function setCount(k, n) { const el = document.getElementById('cnt-' + k); if (el) el.textContent = n; }
// ════ DEVICES ════
function openDeviceModal(id = null) {
editState.device = id;
document.getElementById('device-modal-title').textContent = id ? '🖥 Edit Device' : '🖥 Add Device';
const d = id ? getTenant().devices.find(x => x._id === id) || {} : {};
setFields({ 'd-hostname': d.hostname, 'd-ip': d.ip, 'd-type': d.type||'Router',
'd-status': d.status||'Online', 'd-mac': d.mac, 'd-vlan': d.vlan,
'd-model': d.model, 'd-location': d.location, 'd-notes': d.notes });
document.getElementById('modal-device').classList.add('open');
}
async function saveDevice() {
const hostname = v('d-hostname'), ip = v('d-ip');
if (!hostname || !ip) { alert('Hostname and IP required'); return; }
const rec = { hostname, ip, type: v('d-type'), status: v('d-status'),
mac: v('d-mac'), vlan: v('d-vlan'), model: v('d-model'),
location: v('d-location'), notes: v('d-notes') };
const _id = editState.device;
const url = _id ? `${API}/sites/${currentTenant}/devices/${_id}` : `${API}/sites/${currentTenant}/devices`;
const res = await fetch(url, { method: _id ? 'PUT' : 'POST',
headers: {'Content-Type': 'application/json'}, body: JSON.stringify(rec) });
const saved = await res.json();
const t = getTenant();
if (_id) { const i = t.devices.findIndex(x => x._id === _id); if (i >= 0) t.devices[i] = saved; }
else t.devices.push(saved);
renderDevices(); updateCounts(); closeModal('device');
}
function renderDevices(q = '') {
const t = getTenant(); if (!t) return;
const grid = document.getElementById('devices-grid');
const items = t.devices.filter(d => !q || search(d, q));
const typeBadge = { Router:'b-red', Switch:'b-blue', Firewall:'b-red', 'Access Point':'b-green',
Server:'b-blue', Workstation:'b-gray', Printer:'b-gray', 'NAS / Storage':'b-blue', IoT:'b-amber', Other:'b-gray' };
const statusBadge = { Online:'b-green', Offline:'b-gray', Maintenance:'b-amber' };
const view = sectionViews.devices;
if (!items.length) { grid.className = ''; grid.innerHTML = emptyHtml('🖥', 'No devices found.'); return; }
if (view === 'cards') {
grid.className = 'cards-grid';
grid.innerHTML = items.map(d => `<div class="card">
<div class="card-header">
<span class="card-icon">🖥</span>
<span class="card-title">${esc(d.hostname)}</span>
<span class="badge ${typeBadge[d.type]||'b-gray'}">${esc(d.type)}</span>
<span class="badge ${statusBadge[d.status]||'b-gray'}">${esc(d.status)}</span>
</div>
<div class="card-body">
${fr('IP', d.ip)} ${fr('MAC', d.mac)} ${fr('VLAN', d.vlan)} ${fr('Model', d.model)} ${fr('Location', d.location)} ${fr('Notes', d.notes)}
</div>
<div class="card-footer">
<button class="btn btn-ghost btn-sm" onclick="openDeviceModal('${d._id}')">Edit</button>
<button class="btn btn-del btn-sm" onclick="del('devices','${d._id}')">Delete</button>
</div>
</div>`).join('');
} else if (view === 'table') {
grid.className = 'tbl-wrap';
grid.innerHTML = `<table><thead><tr>
<th>Hostname</th><th>IP</th><th>Type</th><th>Status</th><th>MAC</th><th>VLAN</th><th>Model</th><th>Location</th><th></th>
</tr></thead><tbody>${items.map(d => `<tr>
<td><strong>${esc(d.hostname)}</strong></td>
<td class="mono">${esc(d.ip)}</td>
<td><span class="badge ${typeBadge[d.type]||'b-gray'}">${esc(d.type)}</span></td>
<td><span class="badge ${statusBadge[d.status]||'b-gray'}">${esc(d.status)}</span></td>
<td class="mono">${esc(d.mac)||'—'}</td>
<td class="mono">${esc(d.vlan)||'—'}</td>
<td>${esc(d.model)||'—'}</td>
<td>${esc(d.location)||'—'}</td>
<td style="white-space:nowrap">
<button class="btn btn-ghost btn-sm" onclick="openDeviceModal('${d._id}')">Edit</button>
<button class="btn btn-del btn-sm" onclick="del('devices','${d._id}')">Del</button>
</td>
</tr>`).join('')}</tbody></table>`;
} else {
grid.className = 'list-view';
grid.innerHTML = items.map(d => `<div class="list-row">
<span class="lr-primary">🖥 ${esc(d.hostname)}</span>
<span class="badge ${typeBadge[d.type]||'b-gray'}">${esc(d.type)}</span>
<span class="badge ${statusBadge[d.status]||'b-gray'}">${esc(d.status)}</span>
<span class="lr-secondary">${esc(d.ip)}${d.vlan ? ' · VLAN '+esc(d.vlan) : ''}${d.location ? ' · '+esc(d.location) : ''}</span>
<div class="lr-actions">
<button class="btn btn-ghost btn-sm" onclick="openDeviceModal('${d._id}')">Edit</button>
<button class="btn btn-del btn-sm" onclick="del('devices','${d._id}')">Del</button>
</div>
</div>`).join('');
}
}
// ════ SUBNETS ════
function openSubnetModal(id = null) {
editState.subnet = id;
document.getElementById('subnet-modal-title').textContent = id ? '🌐 Edit Subnet' : '🌐 Add Subnet';
const s = id ? getTenant().subnets.find(x => x._id === id) || {} : {};
setFields({ 's-name': s.name, 's-network': s.network, 's-gateway': s.gateway,
's-ds': s.dhcpStart, 's-de': s.dhcpEnd, 's-dns': s.dns, 's-vlan': s.vlan, 's-notes': s.notes });
document.getElementById('modal-subnet').classList.add('open');
}
async function saveSubnet() {
const name = v('s-name'), network = v('s-network');
if (!name || !network) { alert('Name and network required'); return; }
const rec = { name, network, gateway: v('s-gateway'), dhcpStart: v('s-ds'),
dhcpEnd: v('s-de'), dns: v('s-dns'), vlan: v('s-vlan'), notes: v('s-notes') };
const _id = editState.subnet;
const url = _id ? `${API}/sites/${currentTenant}/subnets/${_id}` : `${API}/sites/${currentTenant}/subnets`;
const res = await fetch(url, { method: _id ? 'PUT' : 'POST',
headers: {'Content-Type': 'application/json'}, body: JSON.stringify(rec) });
const saved = await res.json();
const t = getTenant();
if (_id) { const i = t.subnets.findIndex(x => x._id === _id); if (i >= 0) t.subnets[i] = saved; }
else t.subnets.push(saved);
renderSubnets(); updateCounts(); closeModal('subnet');
}
function renderSubnets(q = '') {
const t = getTenant(); if (!t) return;
const grid = document.getElementById('subnets-grid');
const items = t.subnets.filter(s => !q || search(s, q));
const view = sectionViews.subnets;
if (!items.length) { grid.className = ''; grid.innerHTML = emptyHtml('🌐', 'No subnets found.'); return; }
if (view === 'cards') {
grid.className = 'cards-grid';
grid.innerHTML = items.map(s => {
const dhcp = s.dhcpStart && s.dhcpEnd ? `${esc(s.dhcpStart)} ${esc(s.dhcpEnd)}` : null;
return `<div class="card">
<div class="card-header">
<span class="card-icon">🌐</span>
<span class="card-title">${esc(s.name)}</span>
<span class="badge b-blue">${esc(s.network)}</span>
${s.vlan ? `<span class="badge b-amber">VLAN ${esc(s.vlan)}</span>` : ''}
</div>
<div class="card-body">
${fr('Gateway', s.gateway)} ${dhcp ? fr('DHCP', dhcp) : ''} ${fr('DNS', s.dns)} ${fr('Notes', s.notes)}
</div>
<div class="card-footer">
<button class="btn btn-ghost btn-sm" onclick="openSubnetModal('${s._id}')">Edit</button>
<button class="btn btn-del btn-sm" onclick="del('subnets','${s._id}')">Delete</button>
</div>
</div>`;
}).join('');
} else if (view === 'table') {
grid.className = 'tbl-wrap';
grid.innerHTML = `<table><thead><tr>
<th>Name</th><th>Network / CIDR</th><th>Gateway</th><th>DHCP Range</th><th>DNS</th><th>VLAN</th><th></th>
</tr></thead><tbody>${items.map(s => `<tr>
<td><strong>${esc(s.name)}</strong></td>
<td class="mono">${esc(s.network)}</td>
<td class="mono">${esc(s.gateway)||'—'}</td>
<td class="mono">${s.dhcpStart && s.dhcpEnd ? esc(s.dhcpStart)+' '+esc(s.dhcpEnd) : '—'}</td>
<td class="mono">${esc(s.dns)||'—'}</td>
<td class="mono">${esc(s.vlan)||'—'}</td>
<td style="white-space:nowrap">
<button class="btn btn-ghost btn-sm" onclick="openSubnetModal('${s._id}')">Edit</button>
<button class="btn btn-del btn-sm" onclick="del('subnets','${s._id}')">Del</button>
</td>
</tr>`).join('')}</tbody></table>`;
} else {
grid.className = 'list-view';
grid.innerHTML = items.map(s => `<div class="list-row">
<span class="lr-primary">🌐 ${esc(s.name)}</span>
<span class="badge b-blue">${esc(s.network)}</span>
${s.vlan ? `<span class="badge b-amber">VLAN ${esc(s.vlan)}</span>` : ''}
<span class="lr-secondary">${s.gateway ? 'GW '+esc(s.gateway) : ''}${s.dns ? ' · DNS '+esc(s.dns) : ''}</span>
<div class="lr-actions">
<button class="btn btn-ghost btn-sm" onclick="openSubnetModal('${s._id}')">Edit</button>
<button class="btn btn-del btn-sm" onclick="del('subnets','${s._id}')">Del</button>
</div>
</div>`).join('');
}
}
// ════ VLANS ════
function openVlanModal(id = null) {
editState.vlan = id;
document.getElementById('vlan-modal-title').textContent = id ? '🔀 Edit VLAN' : '🔀 Add VLAN';
const v2 = id ? getTenant().vlans.find(x => x._id === id) || {} : {};
setFields({ 'v-id': v2.id, 'v-name': v2.name, 'v-purpose': v2.purpose||'Management', 'v-ports': v2.ports, 'v-notes': v2.notes });
document.getElementById('modal-vlan').classList.add('open');
}
async function saveVlan() {
const id = v('v-id'), name = v('v-name');
if (!id || !name) { alert('VLAN ID and name required'); return; }
const rec = { id, name, purpose: v('v-purpose'), ports: v('v-ports'), notes: v('v-notes') };
const _id = editState.vlan;
const url = _id ? `${API}/sites/${currentTenant}/vlans/${_id}` : `${API}/sites/${currentTenant}/vlans`;
const res = await fetch(url, { method: _id ? 'PUT' : 'POST',
headers: {'Content-Type': 'application/json'}, body: JSON.stringify(rec) });
const saved = await res.json();
const t = getTenant();
if (_id) { const i = t.vlans.findIndex(x => x._id === _id); if (i >= 0) t.vlans[i] = saved; }
else t.vlans.push(saved);
renderVlans(); updateCounts(); closeModal('vlan');
}
function renderVlans(q = '') {
const t = getTenant(); if (!t) return;
const grid = document.getElementById('vlans-grid');
const items = t.vlans.filter(x => !q || search(x, q));
const purpBadge = { Management:'b-amber', Data:'b-blue', Voice:'b-green', Guest:'b-green',
IoT:'b-amber', DMZ:'b-red', Storage:'b-blue', Other:'b-gray' };
const view = sectionViews.vlans;
if (!items.length) { grid.className = ''; grid.innerHTML = emptyHtml('🔀', 'No VLANs found.'); return; }
if (view === 'cards') {
grid.className = 'cards-grid';
grid.innerHTML = items.map(x => `<div class="card">
<div class="card-header">
<span class="card-icon">🔀</span>
<span class="card-title">VLAN ${esc(x.id)}${esc(x.name)}</span>
<span class="badge ${purpBadge[x.purpose]||'b-gray'}">${esc(x.purpose)}</span>
</div>
<div class="card-body">
${fr('Ports', x.ports)} ${fr('Notes', x.notes)}
</div>
<div class="card-footer">
<button class="btn btn-ghost btn-sm" onclick="openVlanModal('${x._id}')">Edit</button>
<button class="btn btn-del btn-sm" onclick="del('vlans','${x._id}')">Delete</button>
</div>
</div>`).join('');
} else if (view === 'table') {
grid.className = 'tbl-wrap';
grid.innerHTML = `<table><thead><tr>
<th>VLAN ID</th><th>Name</th><th>Purpose</th><th>Tagged Ports</th><th>Notes</th><th></th>
</tr></thead><tbody>${items.map(x => `<tr>
<td class="mono"><strong>${esc(x.id)}</strong></td>
<td>${esc(x.name)}</td>
<td><span class="badge ${purpBadge[x.purpose]||'b-gray'}">${esc(x.purpose)}</span></td>
<td class="mono">${esc(x.ports)||'—'}</td>
<td>${esc(x.notes)||'—'}</td>
<td style="white-space:nowrap">
<button class="btn btn-ghost btn-sm" onclick="openVlanModal('${x._id}')">Edit</button>
<button class="btn btn-del btn-sm" onclick="del('vlans','${x._id}')">Del</button>
</td>
</tr>`).join('')}</tbody></table>`;
} else {
grid.className = 'list-view';
grid.innerHTML = items.map(x => `<div class="list-row">
<span class="lr-primary">🔀 VLAN ${esc(x.id)}</span>
<span class="badge ${purpBadge[x.purpose]||'b-gray'}">${esc(x.purpose)}</span>
<span class="lr-secondary">${esc(x.name)}${x.ports ? ' · '+esc(x.ports) : ''}</span>
<div class="lr-actions">
<button class="btn btn-ghost btn-sm" onclick="openVlanModal('${x._id}')">Edit</button>
<button class="btn btn-del btn-sm" onclick="del('vlans','${x._id}')">Del</button>
</div>
</div>`).join('');
}
}
// ════ ISP ════
function openIspModal(id = null) {
editState.isp = id;
document.getElementById('isp-modal-title').textContent = id ? '📡 Edit ISP Connection' : '📡 Add ISP Connection';
const x = id ? getTenant().isp.find(i => i._id === id) || {} : {};
setFields({ 'i-label': x.label, 'i-provider': x.provider, 'i-type': x.type||'Fiber',
'i-status': x.status||'Active', 'i-ips': x.ips, 'i-gw': x.gw, 'i-dl': x.dl, 'i-ul': x.ul,
'i-circuit': x.circuit, 'i-acct': x.acct, 'i-phone': x.phone, 'i-cost': x.cost,
'i-renew': x.renew, 'i-notes': x.notes });
document.getElementById('modal-isp').classList.add('open');
}
async function saveIsp() {
const label = v('i-label'), provider = v('i-provider');
if (!label || !provider) { alert('Label and provider required'); return; }
const rec = { label, provider, type: v('i-type'), status: v('i-status'),
ips: v('i-ips'), gw: v('i-gw'), dl: v('i-dl'), ul: v('i-ul'),
circuit: v('i-circuit'), acct: v('i-acct'), phone: v('i-phone'),
cost: v('i-cost'), renew: v('i-renew'), notes: v('i-notes') };
const _id = editState.isp;
const url = _id ? `${API}/sites/${currentTenant}/isp/${_id}` : `${API}/sites/${currentTenant}/isp`;
const res = await fetch(url, { method: _id ? 'PUT' : 'POST',
headers: {'Content-Type': 'application/json'}, body: JSON.stringify(rec) });
const saved = await res.json();
const t = getTenant();
if (_id) { const i = t.isp.findIndex(x => x._id === _id); if (i >= 0) t.isp[i] = saved; }
else t.isp.push(saved);
renderIsp(); updateCounts(); closeModal('isp');
}
function renderIsp(q = '') {
const t = getTenant(); if (!t) return;
const grid = document.getElementById('isp-grid');
const items = t.isp.filter(x => !q || search(x, q));
const stBadge = { Active:'b-green', Backup:'b-amber', Down:'b-red', Provisioning:'b-blue' };
const view = sectionViews.isp;
if (!items.length) { grid.className = ''; grid.innerHTML = emptyHtml('📡', 'No ISP connections yet.'); return; }
if (view === 'cards') {
grid.className = 'cards-grid';
grid.innerHTML = items.map(x => `<div class="card">
<div class="card-header">
<span class="card-icon">📡</span>
<span class="card-title">${esc(x.label)}</span>
<span class="badge b-gray">${esc(x.type)}</span>
<span class="badge ${stBadge[x.status]||'b-gray'}">${esc(x.status)}</span>
</div>
<div class="card-body">
${fr('Provider', x.provider)} ${fr('IPs', x.ips)} ${fr('Gateway', x.gw)}
${fr('Speed', x.dl || x.ul ? '↓'+(x.dl||'?')+' ↑'+(x.ul||'?') : null)}
${fr('Circuit', x.circuit)} ${fr('Account', x.acct)} ${fr('Support', x.phone)}
${fr('Cost', x.cost)} ${fr('Renewal', x.renew)} ${fr('Notes', x.notes)}
</div>
<div class="card-footer">
<button class="btn btn-ghost btn-sm" onclick="openIspModal('${x._id}')">Edit</button>
<button class="btn btn-del btn-sm" onclick="del('isp','${x._id}')">Delete</button>
</div>
</div>`).join('');
} else if (view === 'table') {
grid.className = 'tbl-wrap';
grid.innerHTML = `<table><thead><tr>
<th>Label</th><th>Provider</th><th>Type</th><th>IPs / Circuit</th>
<th>Speed</th><th>Account #</th><th>Support</th><th>Status</th><th></th>
</tr></thead><tbody>${items.map(x => `<tr>
<td><strong>${esc(x.label)}</strong></td>
<td>${esc(x.provider)}</td>
<td><span class="badge b-gray">${esc(x.type)}</span></td>
<td class="mono">${esc(x.ips)||'—'}</td>
<td class="mono">${x.dl || x.ul ? '↓'+esc(x.dl||'?')+' ↑'+esc(x.ul||'?') : '—'}</td>
<td class="mono">${esc(x.acct)||'—'}</td>
<td class="mono">${esc(x.phone)||'—'}</td>
<td><span class="badge ${stBadge[x.status]||'b-gray'}">${esc(x.status)}</span></td>
<td style="white-space:nowrap">
<button class="btn btn-ghost btn-sm" onclick="openIspModal('${x._id}')">Edit</button>
<button class="btn btn-del btn-sm" onclick="del('isp','${x._id}')">Del</button>
</td>
</tr>`).join('')}</tbody></table>`;
} else {
grid.className = 'list-view';
grid.innerHTML = items.map(x => `<div class="list-row">
<span class="lr-primary">📡 ${esc(x.label)}</span>
<span class="badge b-gray">${esc(x.type)}</span>
<span class="badge ${stBadge[x.status]||'b-gray'}">${esc(x.status)}</span>
<span class="lr-secondary">${esc(x.provider)}${x.ips ? ' · '+esc(x.ips) : ''}${x.dl ? ' · ↓'+esc(x.dl) : ''}</span>
<div class="lr-actions">
<button class="btn btn-ghost btn-sm" onclick="openIspModal('${x._id}')">Edit</button>
<button class="btn btn-del btn-sm" onclick="del('isp','${x._id}')">Del</button>
</div>
</div>`).join('');
}
}
// ════ CREDENTIALS ════
function openCredModal(id = null) {
editState.cred = id;
document.getElementById('cred-modal-title').textContent = id ? '🔑 Edit Credential' : '🔑 Add Credential';
const x = id ? getTenant().creds.find(c => c._id === id) || {} : {};
setFields({ 'c-label': x.label, 'c-cat': x.cat||'Network Device', 'c-user': x.user,
'c-pass': x['pass'], 'c-url': x.url, 'c-enable': x.enable, 'c-notes': x.notes });
document.getElementById('modal-cred').classList.add('open');
}
async function saveCred() {
const label = v('c-label');
if (!label) { alert('Label required'); return; }
const rec = { label, cat: v('c-cat'), user: v('c-user'), password: v('c-pass'),
url: v('c-url'), enable: v('c-enable'), notes: v('c-notes') };
const _id = editState.cred;
const url = _id ? `${API}/sites/${currentTenant}/creds/${_id}` : `${API}/sites/${currentTenant}/creds`;
const res = await fetch(url, { method: _id ? 'PUT' : 'POST',
headers: {'Content-Type': 'application/json'}, body: JSON.stringify(rec) });
const saved = await res.json();
const t = getTenant();
if (_id) { const i = t.creds.findIndex(x => x._id === _id); if (i >= 0) t.creds[i] = saved; }
else t.creds.push(saved);
renderCreds(); updateCounts(); closeModal('cred');
}
function renderCreds(q = '') {
const t = getTenant(); if (!t) return;
const grid = document.getElementById('creds-grid');
const items = t.creds.filter(x => !q || search(x, q, ['label','cat','user','url','notes']));
const catBadge = { 'Network Device':'b-blue', Server:'b-blue', Firewall:'b-red',
'Cloud / Portal':'b-green', 'ISP Portal':'b-green', VPN:'b-amber', 'Wi-Fi':'b-green', Other:'b-gray' };
const view = sectionViews.creds;
if (!items.length) { grid.className = ''; grid.innerHTML = emptyHtml('🔑', 'No credentials found.'); return; }
if (view === 'cards') {
grid.className = 'cards-grid';
grid.innerHTML = items.map((x, i) => `<div class="card">
<div class="card-header">
<span class="card-icon">🔑</span>
<span class="card-title">${esc(x.label)}</span>
<span class="badge ${catBadge[x.cat]||'b-gray'}">${esc(x.cat)}</span>
</div>
<div class="card-body">
${fr('URL / Host', x.url)}
${fr('Username', x.user)}
${x['pass'] ? `<div class="field-row"><div class="field-label">Password</div><div class="field-val"><div class="pw-wrap"><span class="pw-dots" id="pw-${i}">••••••••</span><button class="reveal-btn" onclick="togglePw(${i},'${esc(x['pass']).replace(/'/g,'\\\'')}')" title="Reveal">👁</button><button class="reveal-btn" onclick="copyText('${esc(x['pass']).replace(/'/g,'\\\'')}','Password')" title="Copy">📋</button></div></div></div>` : ''}
${x.enable ? `<div class="field-row"><div class="field-label">Enable</div><div class="field-val"><div class="pw-wrap"><span class="pw-dots" id="en-${i}">••••••</span><button class="reveal-btn" onclick="toggleEn(${i},'${esc(x.enable).replace(/'/g,'\\\'')}')" title="Reveal">👁</button></div></div></div>` : ''}
${fr('Notes', x.notes)}
</div>
<div class="card-footer">
<button class="btn btn-ghost btn-sm" onclick="openCredModal('${x._id}')">Edit</button>
<button class="btn btn-del btn-sm" onclick="del('creds','${x._id}')">Delete</button>
</div>
</div>`).join('');
} else if (view === 'table') {
grid.className = 'tbl-wrap';
grid.innerHTML = `<table><thead><tr>
<th>Label</th><th>Category</th><th>Username</th><th>Password</th><th>URL / Host</th><th></th>
</tr></thead><tbody>${items.map((x, i) => `<tr>
<td><strong>${esc(x.label)}</strong></td>
<td><span class="badge ${catBadge[x.cat]||'b-gray'}">${esc(x.cat)}</span></td>
<td class="mono">${esc(x.user)||'—'}</td>
<td>${x['pass'] ? `<div class="pw-wrap"><span class="pw-dots" id="pw-${i}">••••••••</span><button class="reveal-btn" onclick="togglePw(${i},'${esc(x['pass']).replace(/'/g,'\\\'')}')" title="Reveal">👁</button><button class="reveal-btn" onclick="copyText('${esc(x['pass']).replace(/'/g,'\\\'')}','Password')" title="Copy">📋</button></div>` : '—'}</td>
<td class="mono">${esc(x.url)||'—'}</td>
<td style="white-space:nowrap">
<button class="btn btn-ghost btn-sm" onclick="openCredModal('${x._id}')">Edit</button>
<button class="btn btn-del btn-sm" onclick="del('creds','${x._id}')">Del</button>
</td>
</tr>`).join('')}</tbody></table>`;
} else {
grid.className = 'list-view';
grid.innerHTML = items.map((x, i) => `<div class="list-row">
<span class="lr-primary">🔑 ${esc(x.label)}</span>
<span class="badge ${catBadge[x.cat]||'b-gray'}">${esc(x.cat)}</span>
<span class="lr-secondary">${esc(x.user)||''}${x.url ? ' · '+esc(x.url) : ''}</span>
<div class="lr-actions">
${x['pass'] ? `<div class="pw-wrap"><span class="pw-dots" id="pw-${i}">••••••••</span><button class="reveal-btn" onclick="togglePw(${i},'${esc(x['pass']).replace(/'/g,'\\\'')}')" title="Reveal">👁</button><button class="reveal-btn" onclick="copyText('${esc(x['pass']).replace(/'/g,'\\\'')}','Password')" title="Copy">📋</button></div>` : ''}
<button class="btn btn-ghost btn-sm" onclick="openCredModal('${x._id}')">Edit</button>
<button class="btn btn-del btn-sm" onclick="del('creds','${x._id}')">Del</button>
</div>
</div>`).join('');
}
}
function togglePw(i, val) { const el = document.getElementById('pw-' + i); el.textContent = el.textContent.startsWith('•') ? val : '••••••••'; }
function toggleEn(i, val) { const el = document.getElementById('en-' + i); el.textContent = el.textContent.startsWith('•') ? val : '••••••'; }
function toggleInputPw(id) { const el = document.getElementById(id); el.type = el.type === 'password' ? 'text' : 'password'; }
function copyText(val, label) {
function showToast() {
const tmp = document.createElement('div');
tmp.style.cssText = 'position:fixed;bottom:28px;right:28px;background:#2e7d4f;color:#fff;padding:12px 20px;border-radius:8px;font-size:0.85rem;font-family:IBM Plex Sans,sans-serif;z-index:9999;box-shadow:0 4px 20px rgba(0,0,0,0.25);display:flex;align-items:center;gap:10px;opacity:0;transition:opacity 0.2s';
tmp.innerHTML = `<span style="font-size:1.1rem">✓</span> ${label ? label + ' copied to clipboard' : 'Copied to clipboard'}`;
document.body.appendChild(tmp);
requestAnimationFrame(() => { tmp.style.opacity = '1'; });
setTimeout(() => { tmp.style.opacity = '0'; setTimeout(() => tmp.remove(), 200); }, 2500);
}
function fallbackCopy() {
const ta = document.createElement('textarea');
ta.value = val;
ta.style.cssText = 'position:fixed;opacity:0;pointer-events:none';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
showToast();
}
if (navigator.clipboard) {
navigator.clipboard.writeText(val).then(showToast).catch(fallbackCopy);
} else {
fallbackCopy();
}
}
// ════ NOTES ════
function openNoteModal(id = null) {
editState.note = id;
document.getElementById('note-modal-title').textContent = id ? '📝 Edit Note' : '📝 Add Note';
const x = id ? getTenant().notes.find(n => n._id === id) || {} : {};
setFields({ 'n-title': x.title, 'n-content': x.content });
document.getElementById('modal-note').classList.add('open');
}
async function saveNote() {
const title = v('n-title');
if (!title) { alert('Title required'); return; }
const rec = { title, content: v('n-content') };
const _id = editState.note;
const url = _id ? `${API}/sites/${currentTenant}/notes/${_id}` : `${API}/sites/${currentTenant}/notes`;
const res = await fetch(url, { method: _id ? 'PUT' : 'POST',
headers: {'Content-Type': 'application/json'}, body: JSON.stringify(rec) });
const saved = await res.json();
const t = getTenant();
if (_id) { const i = t.notes.findIndex(x => x._id === _id); if (i >= 0) t.notes[i] = saved; }
else t.notes.push(saved);
renderNotes(); updateCounts(); closeModal('note');
}
function renderNotes(q = '') {
const t = getTenant(); if (!t) return;
const grid = document.getElementById('notes-grid');
const items = t.notes.filter(x => !q || search(x, q, ['title', 'content']));
const view = sectionViews.notes;
if (!items.length) { grid.className = ''; grid.innerHTML = emptyHtml('📝', 'No notes yet.'); return; }
if (view === 'cards') {
grid.className = 'cards-grid';
grid.innerHTML = items.map(x => {
const preview = x.content ? esc(x.content.slice(0, 200)) + (x.content.length > 200 ? '…' : '') : '';
return `<div class="card">
<div class="card-header">
<span class="card-icon">📝</span>
<span class="card-title">${esc(x.title)}</span>
</div>
${preview ? `<div class="card-body"><div class="field-val" style="white-space:pre-wrap;font-family:'IBM Plex Sans',sans-serif;font-size:0.82rem;line-height:1.6">${preview}</div></div>` : ''}
<div class="card-footer">
<button class="btn btn-ghost btn-sm" onclick="openNoteModal('${x._id}')">Edit</button>
<button class="btn btn-del btn-sm" onclick="del('notes','${x._id}')">Delete</button>
</div>
</div>`;
}).join('');
} else if (view === 'table') {
grid.className = 'tbl-wrap';
grid.innerHTML = `<table><thead><tr>
<th>Title</th><th>Preview</th><th></th>
</tr></thead><tbody>${items.map(x => `<tr>
<td><strong>${esc(x.title)}</strong></td>
<td style="color:var(--ink2);max-width:480px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
${x.content ? esc(x.content.slice(0, 120))+(x.content.length > 120 ? '…' : '') : '—'}
</td>
<td style="white-space:nowrap">
<button class="btn btn-ghost btn-sm" onclick="openNoteModal('${x._id}')">Edit</button>
<button class="btn btn-del btn-sm" onclick="del('notes','${x._id}')">Del</button>
</td>
</tr>`).join('')}</tbody></table>`;
} else {
grid.className = 'list-view';
grid.innerHTML = items.map(x => `<div class="list-row">
<span class="lr-primary">📝 ${esc(x.title)}</span>
<span class="lr-secondary">${x.content ? esc(x.content.slice(0, 80))+(x.content.length > 80 ? '…' : '') : ''}</span>
<div class="lr-actions">
<button class="btn btn-ghost btn-sm" onclick="openNoteModal('${x._id}')">Edit</button>
<button class="btn btn-del btn-sm" onclick="del('notes','${x._id}')">Del</button>
</div>
</div>`).join('');
}
}
// ════ DELETE ════
async function del(key, _id) {
const t = getTenant();
const item = t[key].find(x => x._id === _id);
const label = item?.name || item?.hostname || item?.label || 'this item';
if (!confirm(`Delete "${label}"?`)) return;
await fetch(`${API}/sites/${currentTenant}/${key}/${_id}`, { method: 'DELETE' });
t[key] = t[key].filter(x => x._id !== _id);
if (key === 'devices') renderDevices();
else if (key === 'subnets') renderSubnets();
else if (key === 'vlans') renderVlans();
else if (key === 'isp') renderIsp();
else if (key === 'creds') renderCreds();
else if (key === 'notes') renderNotes();
updateCounts();
}
// ════ MODAL UTILS ════
function closeModal(type) { document.getElementById('modal-' + type).classList.remove('open'); }
document.querySelectorAll('.overlay').forEach(o => {
o.addEventListener('click', e => { if (e.target === o) o.classList.remove('open'); });
});
window.openModal = function(type, id) {
if (type === 'device') { openDeviceModal(id || null); return; }
if (type === 'subnet') { openSubnetModal(id || null); return; }
if (type === 'vlan') { openVlanModal(id || null); return; }
if (type === 'isp') { openIspModal(id || null); return; }
if (type === 'cred') { openCredModal(id || null); return; }
if (type === 'note') { openNoteModal(id || null); return; }
document.getElementById('modal-' + type).classList.add('open');
};
// ════ EXPORT / IMPORT ════
async function exportData() {
const res = await fetch(`${API}/export`);
const data = await res.json();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
a.download = 'netdoc-pro-export.json'; a.click();
}
async function importData(e) {
const file = e.target.files[0]; if (!file) return;
const reader = new FileReader();
reader.onload = async ev => {
try {
const data = JSON.parse(ev.target.result);
await fetch(`${API}/import`, {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(data)
});
await load();
alert('Import successful!');
} catch { alert('Import failed.'); }
};
reader.readAsText(file); e.target.value = '';
}
// ════ HELPERS ════
function v(id) { const el = document.getElementById(id); return el ? el.value.trim() : ''; }
function esc(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function fr(label, val) {
if (!val) return '';
return `<div class="field-row"><div class="field-label">${label}</div><div class="field-val">${esc(val)}</div></div>`;
}
function emptyHtml(icon, msg) {
return `<div class="empty" style="grid-column:1/-1"><div class="e-icon">${icon}</div><p>${msg}</p></div>`;
}
function search(obj, q, keys) {
const vals = keys ? keys.map(k => obj[k]) : Object.values(obj);
return vals.some(val => val && String(val).toLowerCase().includes(q));
}
function setFields(map) {
Object.entries(map).forEach(([id, val]) => {
const el = document.getElementById(id); if (!el) return;
if (el.tagName === 'SELECT') { if (val) el.value = val; }
else el.value = val || '';
});
}
load();
</script>
</body>
</html>