1423 lines
51 KiB
HTML
1423 lines
51 KiB
HTML
<!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 & 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 & 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... 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
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>
|