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

1423 lines
51 KiB
HTML
Raw 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;
}
/* ── LAYOUT ── */
.app-shell { display: flex; min-height: 100vh; }
/* SIDEBAR */
.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 SELECTOR */
.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); }
/* NAV */
.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 */
.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 CONTENT */
.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; }
/* SECTIONS */
.section { display: none; }
.section.active { display: block; }
/* ADD BAR */
.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;
}
/* BUTTONS */
.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 */
.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;
}
/* BADGES */
.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); }
/* TABLE (for ISP) */
.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; }
/* PASSWORD REVEAL */
.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 SECTION */
.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;
}
/* MODAL */
.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 STATE */
.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; }
/* TENANT NO-SELECT */
.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; }
}
</style>
</head>
<body>
<div class="app-shell">
<!-- SIDEBAR -->
<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
</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>
<!-- MAIN -->
<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">
<!-- NO TENANT -->
<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>
<!-- DEVICES -->
<div class="section" id="section-devices">
<div class="add-bar">
<h2>Devices &amp; Hosts</h2>
<button class="btn btn-primary" onclick="openModal('device')">+ Add Device</button>
</div>
<div class="cards-grid" id="devices-grid"></div>
</div>
<!-- SUBNETS -->
<div class="section" id="section-subnets">
<div class="add-bar">
<h2>IP Subnets</h2>
<button class="btn btn-primary" onclick="openModal('subnet')">+ Add Subnet</button>
</div>
<div class="cards-grid" id="subnets-grid"></div>
</div>
<!-- VLANS -->
<div class="section" id="section-vlans">
<div class="add-bar">
<h2>VLANs</h2>
<button class="btn btn-primary" onclick="openModal('vlan')">+ Add VLAN</button>
</div>
<div class="cards-grid" id="vlans-grid"></div>
</div>
<!-- ISP -->
<div class="section" id="section-isp">
<div class="add-bar">
<h2>ISP Connections</h2>
<button class="btn btn-primary" onclick="openModal('isp')">+ Add Connection</button>
</div>
<div class="tbl-wrap">
<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 id="isp-body"></tbody>
</table>
<div id="isp-empty" style="display:none" class="empty"><div class="e-icon">📡</div><p>No ISP connections yet.</p></div>
</div>
</div>
<!-- CREDENTIALS -->
<div class="section" id="section-creds">
<div class="add-bar">
<h2>Credentials &amp; Access</h2>
<button class="btn btn-primary" onclick="openModal('cred')">+ Add Credential</button>
</div>
<div class="cards-grid" id="creds-grid"></div>
</div>
<!-- NOTES -->
<div class="section" id="section-notes">
<div class="add-bar"><h2>General Notes</h2></div>
<div class="notes-editor">
<div class="notes-toolbar">
<span>Freeform notes for this site</span>
<button class="btn btn-ghost btn-sm" style="margin-left:auto" onclick="saveNotes()">Save Notes</button>
</div>
<textarea id="notes-area" placeholder="Write anything — network quirks, change log, vendor contacts, site-specific instructions...&#10;&#10;Tip: Use plain text, markdown-style headings (## Section), or bullet lists."></textarea>
</div>
</div>
</div><!-- /page-body -->
</div><!-- /main -->
</div><!-- /app-shell -->
<!-- ════ MODALS ════ -->
<!-- TENANT -->
<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>
<!-- DEVICE -->
<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>
<!-- SUBNET -->
<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>
<!-- VLAN -->
<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>
<!-- ISP -->
<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>
<!-- CREDENTIAL -->
<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 locally in your browser. Do not use this for highly sensitive production secrets without additional protection.
</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><input id="c-pass" type="password" placeholder="••••••••"></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><input id="c-enable" type="password" placeholder="Cisco enable secret, etc."></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>
<script>
// ════════════ STATE ════════════
let db = { tenants: {}, order: [] }; // { tenants: { id: { name, ..., devices[], subnets[], vlans[], isp[], creds[], notes } }, order: [] }
let currentTenant = null;
let editState = {};
let currentSection = 'notenant';
function load() {
try { const s = localStorage.getItem('netdoc_pro'); if (s) db = JSON.parse(s); } catch(e){}
refreshTenantDropdown();
if (db.order.length > 0) {
currentTenant = db.order[0];
document.getElementById('tenant-select').value = currentTenant;
activateTenant();
}
}
function save() { localStorage.setItem('netdoc_pro', JSON.stringify(db)); }
function getTenant() { return currentTenant ? db.tenants[currentTenant] : null; }
// ════════════ TENANTS ════════════
function refreshTenantDropdown() {
const sel = document.getElementById('tenant-select');
const cur = sel.value;
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 (cur && db.tenants[cur]) sel.value = cur;
}
function switchTenant() {
currentTenant = document.getElementById('tenant-select').value || null;
if (currentTenant) activateTenant(); else deactivateTenant();
}
function activateTenant() {
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');
}
function saveTenant() {
const name = v('t-name');
if (!name) { alert('Name required'); return; }
const id = 'tenant_' + Date.now();
db.tenants[id] = {
name, location: v('t-location'), contact: v('t-contact'),
phone: v('t-phone'), email: v('t-email'), notes: v('t-notes'),
devices: [], subnets: [], vlans: [], isp: [], creds: [], notesText: ''
};
db.order.push(id);
save(); refreshTenantDropdown();
currentTenant = id;
document.getElementById('tenant-select').value = id;
activateTenant();
closeModal('tenant');
}
function deleteTenant() {
if (!currentTenant) return;
const t = getTenant();
if (!confirm(`Delete "${t.name}" and ALL its data? This cannot be undone.`)) return;
db.order = db.order.filter(id => id !== currentTenant);
delete db.tenants[currentTenant];
currentTenant = null;
save(); 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);
}
// ════════════ RENDER ALL ════════════
function renderAll() {
renderDevices(); renderSubnets(); renderVlans(); renderIsp(); renderCreds();
const t = getTenant();
if (t) document.getElementById('notes-area').value = t.notesText || '';
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);
}
function setCount(k, n) {
const el = document.getElementById('cnt-' + k);
if (el) el.textContent = n;
}
// ════════════ DEVICES ════════════
function openDeviceModal(i = -1) {
editState.device = i;
document.getElementById('device-modal-title').textContent = i >= 0 ? '🖥 Edit Device' : '🖥 Add Device';
const d = i >= 0 ? getTenant().devices[i] : {};
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 });
openModal('device');
}
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') };
upsert('devices', rec, editState.device);
save(); 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));
if (!items.length) { grid.innerHTML = emptyHtml('🖥', 'No devices found.'); return; }
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' };
grid.innerHTML = items.map(d => {
const i = t.devices.indexOf(d);
return `<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(${i})">Edit</button>
<button class="btn btn-del btn-sm" onclick="del('devices',${i})">Delete</button>
</div>
</div>`;
}).join('');
}
// ════════════ SUBNETS ════════════
function openSubnetModal(i = -1) {
editState.subnet = i;
document.getElementById('subnet-modal-title').textContent = i >= 0 ? '🌐 Edit Subnet' : '🌐 Add Subnet';
const s = i >= 0 ? getTenant().subnets[i] : {};
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 });
openModal('subnet');
}
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') };
upsert('subnets', rec, editState.subnet);
save(); 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));
if (!items.length) { grid.innerHTML = emptyHtml('🌐', 'No subnets found.'); return; }
grid.innerHTML = items.map(s => {
const i = t.subnets.indexOf(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(${i})">Edit</button>
<button class="btn btn-del btn-sm" onclick="del('subnets',${i})">Delete</button>
</div>
</div>`;
}).join('');
}
// ════════════ VLANS ════════════
function openVlanModal(i = -1) {
editState.vlan = i;
document.getElementById('vlan-modal-title').textContent = i >= 0 ? '🔀 Edit VLAN' : '🔀 Add VLAN';
const v2 = i >= 0 ? getTenant().vlans[i] : {};
setFields({ 'v-id': v2.id, 'v-name': v2.name, 'v-purpose': v2.purpose||'Management', 'v-ports': v2.ports, 'v-notes': v2.notes });
openModal('vlan');
}
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') };
upsert('vlans', rec, editState.vlan);
save(); 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));
if (!items.length) { grid.innerHTML = emptyHtml('🔀', 'No VLANs found.'); return; }
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' };
grid.innerHTML = items.map(x => {
const i = t.vlans.indexOf(x);
return `<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(${i})">Edit</button>
<button class="btn btn-del btn-sm" onclick="del('vlans',${i})">Delete</button>
</div>
</div>`;
}).join('');
}
// ════════════ ISP ════════════
function openIspModal(i = -1) {
editState.isp = i;
document.getElementById('isp-modal-title').textContent = i >= 0 ? '📡 Edit ISP Connection' : '📡 Add ISP Connection';
const x = i >= 0 ? getTenant().isp[i] : {};
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 });
openModal('isp');
}
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') };
upsert('isp', rec, editState.isp);
save(); renderIsp(); updateCounts(); closeModal('isp');
}
function renderIsp(q = '') {
const t = getTenant(); if (!t) return;
const body = document.getElementById('isp-body');
const empty = document.getElementById('isp-empty');
const items = t.isp.filter(x => !q || search(x, q));
if (!items.length) { body.innerHTML = ''; empty.style.display = 'block'; return; }
empty.style.display = 'none';
const stBadge = { Active:'b-green', Backup:'b-amber', Down:'b-red', Provisioning:'b-blue' };
body.innerHTML = items.map(x => {
const i = t.isp.indexOf(x);
return `<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(${i})">Edit</button>
<button class="btn btn-del btn-sm" onclick="del('isp',${i})">Del</button>
</td>
</tr>`;
}).join('');
}
// ════════════ CREDENTIALS ════════════
function openCredModal(i = -1) {
editState.cred = i;
document.getElementById('cred-modal-title').textContent = i >= 0 ? '🔑 Edit Credential' : '🔑 Add Credential';
const x = i >= 0 ? getTenant().creds[i] : {};
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 });
openModal('cred');
}
function saveCred() {
const label = v('c-label');
if (!label) { alert('Label required'); return; }
const rec = { label, cat: v('c-cat'), user: v('c-user'), pass: v('c-pass'),
url: v('c-url'), enable: v('c-enable'), notes: v('c-notes') };
upsert('creds', rec, editState.cred);
save(); 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']));
if (!items.length) { grid.innerHTML = emptyHtml('🔑', 'No credentials found.'); return; }
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' };
grid.innerHTML = items.map(x => {
const i = t.creds.indexOf(x);
return `<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,'\\\'')}')" 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(${i})">Edit</button>
<button class="btn btn-del btn-sm" onclick="del('creds',${i})">Delete</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 copyText(val) {
navigator.clipboard.writeText(val).then(() => {
const tmp = document.createElement('div');
tmp.style.cssText = 'position:fixed;bottom:24px;right:24px;background:#1a1714;color:#faf8f4;padding:10px 18px;border-radius:8px;font-size:0.82rem;font-family:IBM Plex Sans,sans-serif;z-index:9999;box-shadow:0 4px 20px rgba(0,0,0,0.3)';
tmp.textContent = '✓ Copied to clipboard';
document.body.appendChild(tmp);
setTimeout(() => tmp.remove(), 1800);
});
}
// ════════════ NOTES ════════════
function saveNotes() {
const t = getTenant(); if (!t) return;
t.notesText = document.getElementById('notes-area').value;
save();
const tmp = document.createElement('div');
tmp.style.cssText = 'position:fixed;bottom:24px;right:24px;background:#2e7d4f;color:#fff;padding:10px 18px;border-radius:8px;font-size:0.82rem;font-family:IBM Plex Sans,sans-serif;z-index:9999;';
tmp.textContent = '✓ Notes saved';
document.body.appendChild(tmp);
setTimeout(() => tmp.remove(), 1800);
}
// ════════════ MODAL UTILS ════════════
function openModal(type) {
document.getElementById('modal-' + type).classList.add('open');
}
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'); });
});
// Alias so "openModal('device')" also works from nav
window.openModal = function(type, i) {
if (type === 'device') { openDeviceModal(i); return; }
if (type === 'subnet') { openSubnetModal(i !== undefined ? i : -1); return; }
if (type === 'vlan') { openVlanModal(i !== undefined ? i : -1); return; }
if (type === 'isp') { openIspModal(i !== undefined ? i : -1); return; }
if (type === 'cred') { openCredModal(i !== undefined ? i : -1); return; }
document.getElementById('modal-' + type).classList.add('open');
};
// ════════════ EXPORT / IMPORT ════════════
function exportData() {
const blob = new Blob([JSON.stringify(db, null, 2)], { type: 'application/json' });
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
a.download = 'netdoc-pro-export.json'; a.click();
}
function importData(e) {
const file = e.target.files[0]; if (!file) return;
const reader = new FileReader();
reader.onload = ev => {
try {
const imp = JSON.parse(ev.target.result);
if (imp.tenants && imp.order) {
db = imp; save(); load();
alert('Import successful!');
} else { alert('Invalid file.'); }
} catch { alert('Parse error.'); }
};
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 upsert(key, rec, i) {
const t = getTenant();
if (i >= 0) t[key][i] = rec; else t[key].push(rec);
}
function del(key, i) {
const t = getTenant();
const label = t[key][i].name || t[key][i].hostname || t[key][i].label || 'this item';
if (!confirm(`Delete "${label}"?`)) return;
t[key].splice(i, 1);
save();
if (key === 'devices') renderDevices();
else if (key === 'subnets') renderSubnets();
else if (key === 'vlans') renderVlans();
else if (key === 'isp') renderIsp();
else if (key === 'creds') renderCreds();
updateCounts();
}
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>