mirror of
https://github.com/luscis/openlan.git
synced 2026-04-22 23:07:11 +08:00
3908 lines
184 KiB
HTML
Executable File
3908 lines
184 KiB
HTML
Executable File
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>OpenLAN Dashboard</title>
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
:root {
|
|
--bg: #0d1117;
|
|
--sidebar-bg: #010409;
|
|
--sidebar-text: #8b949e;
|
|
--sidebar-active: #58a6ff;
|
|
--sidebar-hover: #1c2433;
|
|
--header-bg: #161b22;
|
|
--card-bg: #161b22;
|
|
--border: #30363d;
|
|
--text: #e6edf3;
|
|
--text-muted: #8b949e;
|
|
--primary: #58a6ff;
|
|
--primary-dark: #388bfd;
|
|
--success: #3fb950;
|
|
--warning: #d29922;
|
|
--danger: #f85149;
|
|
--info: #bc8cff;
|
|
--radius: 8px;
|
|
--shadow: 0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3);
|
|
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.5), 0 4px 6px -2px rgba(0,0,0,0.3);
|
|
--sidebar-w: 220px;
|
|
}
|
|
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
|
|
|
|
/* Login */
|
|
|
|
/* Layout */
|
|
#app { display: none; min-height: 100vh; }
|
|
.layout { display: flex; min-height: 100vh; }
|
|
|
|
/* Sidebar */
|
|
.sidebar {
|
|
width: var(--sidebar-w); background: var(--sidebar-bg); color: var(--sidebar-text);
|
|
display: flex; flex-direction: column; position: fixed; top: 0; left: 0;
|
|
height: 100vh; z-index: 100; flex-shrink: 0;
|
|
}
|
|
.sidebar-header {
|
|
padding: 20px 20px 16px; border-bottom: 1px solid rgba(255,255,255,.06);
|
|
display: flex; align-items: center; gap: 10px;
|
|
}
|
|
.sidebar-logo { width: 32px; height: 32px; flex-shrink: 0; }
|
|
.sidebar-brand { font-size: 18px; font-weight: 700; color: #fff; }
|
|
.sidebar-version { font-size: 11px; color: #5c697e; margin-top: 2px; }
|
|
.sidebar-nav { flex: 1; padding: 12px 0; overflow-y: auto; }
|
|
.nav-section { padding: 16px 16px 6px; font-size: 10px; font-weight: 700; color: #3d4f6e; text-transform: uppercase; letter-spacing: .08em; }
|
|
.nav-item {
|
|
display: flex; align-items: center; gap: 10px; padding: 9px 16px;
|
|
font-size: 13.5px; color: var(--sidebar-text); cursor: pointer;
|
|
transition: background .15s, color .15s; border-radius: 6px; margin: 1px 8px;
|
|
user-select: none;
|
|
}
|
|
.nav-item:hover { background: var(--sidebar-hover); color: #e2e8f0; }
|
|
.nav-item.active { background: rgba(59,130,246,.15); color: var(--sidebar-active); font-weight: 600; }
|
|
.nav-item svg { flex-shrink: 0; opacity: .75; }
|
|
.nav-item.active svg { opacity: 1; }
|
|
.nav-sub { padding-left: 28px; font-size: 12.5px; opacity: .85; }
|
|
.nav-sub.active { opacity: 1; }
|
|
.sidebar-footer { padding: 12px 16px; border-top: 1px solid rgba(255,255,255,.06); display: flex; flex-direction: column; gap: 8px; }
|
|
|
|
/* Main content */
|
|
.main { margin-left: var(--sidebar-w); flex: 1; display: flex; flex-direction: column; min-height: 100vh; }
|
|
.refresh-info { font-size: 12px; color: var(--text-muted); }
|
|
.btn-refresh {
|
|
padding: 6px 12px; background: rgba(88,166,255,.12); color: var(--primary); border: 1px solid rgba(88,166,255,.2);
|
|
border-radius: var(--radius); font-size: 12.5px; font-weight: 500; cursor: pointer;
|
|
display: flex; align-items: center; gap: 5px; transition: background .15s;
|
|
}
|
|
.btn-refresh:hover { background: var(--primary-dark); }
|
|
.btn-refresh.spinning svg { animation: spin .8s linear infinite; }
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
|
.page-content { flex: 1; padding: 24px; overflow-y: auto; }
|
|
|
|
/* Cards */
|
|
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
|
.stat-card {
|
|
background: var(--card-bg); border-radius: var(--radius); padding: 20px;
|
|
box-shadow: var(--shadow); display: flex; align-items: flex-start; gap: 14px;
|
|
}
|
|
.stat-icon {
|
|
width: 44px; height: 44px; border-radius: 10px; display: flex;
|
|
align-items: center; justify-content: center; flex-shrink: 0;
|
|
}
|
|
.stat-icon.blue { background: rgba(59,130,246,.1); color: var(--primary); }
|
|
.stat-icon.green { background: rgba(34,197,94,.1); color: var(--success); }
|
|
.stat-icon.yellow { background: rgba(245,158,11,.1); color: var(--warning); }
|
|
.stat-icon.purple { background: rgba(139,92,246,.1); color: var(--info); }
|
|
.stat-icon.red { background: rgba(239,68,68,.1); color: var(--danger); }
|
|
.stat-icon.teal { background: rgba(20,184,166,.1); color: #14b8a6; }
|
|
.stat-body { flex: 1; min-width: 0; }
|
|
.stat-label { font-size: 12px; color: var(--text-muted); font-weight: 500; margin-bottom: 4px; text-transform: uppercase; letter-spacing: .04em; }
|
|
.stat-value { font-size: 26px; font-weight: 700; color: var(--text); line-height: 1.1; }
|
|
.stat-sub { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
|
|
|
|
/* Sections */
|
|
.section { background: var(--card-bg); border-radius: var(--radius); box-shadow: var(--shadow); margin-bottom: 24px; }
|
|
.section-header {
|
|
padding: 16px 20px; border-bottom: 1px solid var(--border);
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
}
|
|
.section-title { font-size: 15px; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 8px; }
|
|
.section-count {
|
|
background: #21262d; color: var(--text-muted); font-size: 11px;
|
|
font-weight: 600; padding: 2px 7px; border-radius: 10px;
|
|
}
|
|
.section-body { padding: 0; overflow-x: auto; }
|
|
.section-footer { padding: 8px 16px; font-size: 12px; color: var(--text-muted); border-top: 1px solid var(--border); text-align: right; }
|
|
|
|
/* Tables */
|
|
table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
|
|
thead th {
|
|
background: #0d1117; padding: 10px 16px; text-align: left;
|
|
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em;
|
|
color: var(--text-muted); border-bottom: 1px solid var(--border); white-space: nowrap;
|
|
}
|
|
tbody tr { border-bottom: 1px solid var(--border); transition: background .1s; }
|
|
tbody tr:last-child { border-bottom: none; }
|
|
tbody tr:hover { background: #1c2433; }
|
|
tbody td { padding: 10px 16px; color: var(--text); vertical-align: middle; }
|
|
.td-mono { font-family: 'SFMono-Regular', Consolas, monospace; font-size: 12.5px; }
|
|
.td-muted { color: var(--text-muted); }
|
|
.empty-row td { text-align: center; color: var(--text-muted); padding: 32px; font-size: 14px; }
|
|
|
|
/* Badges */
|
|
.badge {
|
|
display: inline-flex; align-items: center; padding: 2px 8px;
|
|
border-radius: 12px; font-size: 11px; font-weight: 600; white-space: nowrap;
|
|
}
|
|
.badge-green { background: rgba(63,185,80,.15); color: #3fb950; }
|
|
.badge-red { background: rgba(248,81,73,.15); color: #f85149; }
|
|
.badge-yellow { background: rgba(210,153,34,.15); color: #d29922; }
|
|
.badge-blue { background: rgba(88,166,255,.15); color: #58a6ff; }
|
|
.badge-gray { background: #21262d; color: var(--text-muted); }
|
|
.badge-purple { background: rgba(188,140,255,.15); color: #bc8cff; }
|
|
|
|
/* Progress bar */
|
|
.progress-wrap { background: #21262d; border-radius: 4px; height: 6px; overflow: hidden; margin-top: 8px; }
|
|
.progress-bar { height: 100%; border-radius: 4px; transition: width .3s; }
|
|
.progress-bar.blue { background: var(--primary); }
|
|
.progress-bar.green { background: var(--success); }
|
|
.progress-bar.yellow { background: var(--warning); }
|
|
.progress-bar.red { background: var(--danger); }
|
|
|
|
/* Info grid */
|
|
.info-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; padding: 20px; }
|
|
.info-row { display: flex; justify-content: space-between; align-items: flex-start; padding: 8px 0; border-bottom: 1px solid var(--border); }
|
|
.info-row:last-child { border-bottom: none; }
|
|
.info-key { font-size: 12.5px; color: var(--text-muted); font-weight: 500; flex-shrink: 0; margin-right: 12px; }
|
|
.info-val { font-size: 13px; color: var(--text); font-weight: 500; text-align: right; word-break: break-all; }
|
|
.info-val.mono { font-family: monospace; font-size: 12px; }
|
|
|
|
/* Forms */
|
|
.form-panel { padding: 20px; border-bottom: 1px solid var(--border); }
|
|
.form-title { font-size: 13px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: .05em; margin-bottom: 14px; }
|
|
.form-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end; }
|
|
.form-group { display: flex; flex-direction: column; gap: 5px; }
|
|
.form-group label { font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: .05em; }
|
|
.form-group input, .form-group select {
|
|
padding: 8px 12px; border: 1px solid var(--border); border-radius: var(--radius);
|
|
font-size: 13.5px; color: var(--text); outline: none; min-width: 150px;
|
|
transition: border-color .15s; background: #0d1117;
|
|
}
|
|
.form-group input:focus, .form-group select:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(59,130,246,.12); }
|
|
.btn { padding: 8px 16px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; cursor: pointer; border: none; transition: background .15s, opacity .15s; display: inline-flex; align-items: center; gap: 6px; }
|
|
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
|
.btn-primary { background: var(--primary); color: #fff; }
|
|
.btn-primary:hover:not(:disabled) { background: var(--primary-dark); }
|
|
.btn-danger { background: var(--danger); color: #fff; }
|
|
.btn-danger:hover:not(:disabled) { background: #dc2626; }
|
|
.btn-ghost { background: transparent; color: var(--text-muted); border: 1px solid var(--border); }
|
|
.btn-ghost:hover { background: #21262d; }
|
|
.btn-dark { background: #21262d; color: var(--text-secondary); border: 1px solid var(--border); }
|
|
.btn-dark:hover { background: #30363d; color: var(--text); }
|
|
.btn-sm { padding: 5px 10px; font-size: 12px; }
|
|
|
|
/* Action buttons in table */
|
|
.action-btn {
|
|
padding: 4px 10px; border-radius: 5px; font-size: 12px; font-weight: 500;
|
|
cursor: pointer; border: none; transition: background .15s;
|
|
}
|
|
.action-delete { background: rgba(239,68,68,.1); color: var(--danger); }
|
|
.action-delete:hover { background: rgba(239,68,68,.2); }
|
|
.action-dark { background: #21262d; color: var(--text-secondary); border: 1px solid var(--border); }
|
|
.action-dark:hover { background: #30363d; color: var(--text); }
|
|
/* Alerts/toasts */
|
|
#toast {
|
|
position: fixed; top: 20px; right: 20px; z-index: 9999;
|
|
background: #21262d; color: #fff; padding: 12px 20px; border-radius: var(--radius);
|
|
font-size: 14px; box-shadow: var(--shadow-lg); display: none;
|
|
max-width: 320px; line-height: 1.4;
|
|
}
|
|
#toast.success { background: #166534; }
|
|
#toast.error { background: #991b1b; }
|
|
#toast.show { display: block; animation: slideIn .2s ease; }
|
|
@keyframes slideIn { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } }
|
|
|
|
/* Utilities */
|
|
.hidden { display: none !important; }
|
|
.loading { display: flex; align-items: center; justify-content: center; padding: 48px; }
|
|
.spinner { width: 28px; height: 28px; border: 3px solid var(--border); border-top-color: var(--primary); border-radius: 50%; animation: spin .7s linear infinite; }
|
|
.page { display: none; }
|
|
.page.active { display: block; }
|
|
|
|
/* Conntrack cards */
|
|
.conntrack-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; padding: 16px; }
|
|
.ct-card { text-align: center; padding: 14px; background: #0d1117; border-radius: var(--radius); border: 1px solid var(--border); }
|
|
.ct-num { font-size: 24px; font-weight: 700; color: var(--text); }
|
|
.ct-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: .06em; margin-top: 4px; }
|
|
|
|
/* Overview split */
|
|
.overview-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
@media (max-width: 900px) { .overview-grid { grid-template-columns: 1fr; } }
|
|
|
|
/* Node info table in overview */
|
|
.node-table { width: 100%; }
|
|
.node-table td { padding: 8px 16px; font-size: 13.5px; border-bottom: 1px solid var(--border); }
|
|
.node-table td:first-child { color: var(--text-muted); width: 130px; font-weight: 500; font-size: 12.5px; }
|
|
.node-table tr:last-child td { border-bottom: none; }
|
|
|
|
/* Network detail button */
|
|
.btn-detail { background: rgba(88,166,255,.1); color: var(--primary); border: 1px solid rgba(88,166,255,.2); }
|
|
.btn-detail:hover { background: rgba(88,166,255,.2); }
|
|
.btn-save { background: #21262d; color: var(--text-secondary); border: 1px solid var(--border); }
|
|
.btn-save:hover { background: #30363d; color: var(--text); }
|
|
|
|
/* Confirm dialog */
|
|
.confirm-overlay {
|
|
position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 2000;
|
|
display: flex; align-items: center; justify-content: center;
|
|
}
|
|
.confirm-overlay.hidden { display: none; }
|
|
.confirm-box {
|
|
background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px;
|
|
width: 100%; max-width: 360px; box-shadow: var(--shadow-lg); overflow: hidden;
|
|
}
|
|
.confirm-header { padding: 16px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 8px; }
|
|
.confirm-title { font-size: 15px; font-weight: 700; color: var(--text); }
|
|
.confirm-body { padding: 16px 20px 20px; }
|
|
.confirm-msg { font-size: 14px; color: var(--text-muted); margin-bottom: 20px; line-height: 1.5; }
|
|
.confirm-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
|
|
|
/* Modal */
|
|
.modal-overlay {
|
|
position: fixed; inset: 0; background: rgba(0,0,0,.6); z-index: 1000;
|
|
display: flex; align-items: center; justify-content: center; padding: 24px;
|
|
}
|
|
.modal-overlay.hidden { display: none; }
|
|
.modal-overlay.modal-front { z-index: 1100; }
|
|
.modal {
|
|
background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px;
|
|
width: 100%; max-width: 860px; max-height: 90vh; display: flex; flex-direction: column;
|
|
box-shadow: var(--shadow-lg);
|
|
}
|
|
.modal-header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 18px 24px; border-bottom: 1px solid var(--border); flex-shrink: 0;
|
|
}
|
|
.modal-title { font-size: 16px; font-weight: 700; color: var(--text); }
|
|
.modal-close {
|
|
background: none; border: none; color: var(--text-muted); font-size: 20px;
|
|
cursor: pointer; padding: 2px 6px; border-radius: 4px; line-height: 1;
|
|
}
|
|
.modal-close:hover { background: var(--sidebar-hover); color: var(--text); }
|
|
.modal-tabs {
|
|
display: flex; gap: 0; border-bottom: 1px solid var(--border); flex-shrink: 0; padding: 0 24px;
|
|
}
|
|
.modal-tab {
|
|
padding: 10px 16px; font-size: 13px; font-weight: 500; color: var(--text-muted);
|
|
cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px;
|
|
transition: color .15s, border-color .15s;
|
|
}
|
|
.modal-tab:hover { color: var(--text); }
|
|
.modal-tab.active { color: var(--primary); border-bottom-color: var(--primary); }
|
|
.modal-body { flex: 1; overflow-y: auto; padding: 20px 24px; }
|
|
.modal-footer {
|
|
display: flex; align-items: center; justify-content: flex-end; gap: 8px;
|
|
padding: 14px 24px; border-top: 1px solid var(--border); flex-shrink: 0;
|
|
}
|
|
.modal-tab-pane { display: none; }
|
|
.modal-tab-pane.active { display: block; }
|
|
|
|
/* Modal detail grid */
|
|
.detail-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 14px 24px; margin-bottom: 16px; }
|
|
.detail-item { display: flex; flex-direction: column; gap: 3px; }
|
|
.detail-key { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); }
|
|
.detail-val { font-size: 13px; color: var(--text); font-family: monospace; word-break: break-all; }
|
|
.detail-section-title {
|
|
font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em;
|
|
color: var(--primary); margin: 20px 0 10px; padding-top: 16px; border-top: 1px solid var(--border);
|
|
}
|
|
.detail-json { background: #010409; border: 1px solid var(--border); border-radius: 6px; padding: 14px; font-size: 12px; font-family: monospace; color: #79c0ff; white-space: pre-wrap; word-break: break-all; max-height: 400px; overflow-y: auto; }
|
|
.file-preview-body {
|
|
background: #010409; border: 1px solid var(--border); border-radius: 8px;
|
|
padding: 16px; font-size: 12.5px; font-family: monospace; color: var(--text);
|
|
white-space: pre-wrap; word-break: break-word; line-height: 1.6;
|
|
min-height: 220px; max-height: 60vh; overflow: auto;
|
|
}
|
|
.file-preview-loading { color: var(--text-muted); }
|
|
.file-preview-error { color: var(--danger); }
|
|
.modal-form-row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
|
|
.modal-input { padding: 7px 10px; border: 1px solid var(--border); border-radius: var(--radius); background: #0d1117; color: var(--text); font-size: 13px; }
|
|
.modal-input:focus { outline: none; border-color: var(--primary); }
|
|
.about-shell { padding: 24px; display: flex; flex-direction: column; gap: 20px; }
|
|
.about-hero {
|
|
display: grid; grid-template-columns: 72px 1fr auto; gap: 18px; align-items: center;
|
|
padding: 22px; border: 1px solid var(--border); border-radius: 16px;
|
|
background: linear-gradient(135deg, rgba(88,166,255,.12), rgba(13,17,23,.9));
|
|
}
|
|
.about-logo {
|
|
width: 72px; height: 72px; border-radius: 18px; background: rgba(255,255,255,.04);
|
|
display: flex; align-items: center; justify-content: center; border: 1px solid rgba(255,255,255,.08);
|
|
}
|
|
.about-kicker { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: #8b949e; }
|
|
.about-heading { font-size: 26px; font-weight: 800; color: var(--text); margin-top: 4px; }
|
|
.about-copy { font-size: 13px; color: var(--text-muted); margin-top: 8px; line-height: 1.6; max-width: 560px; }
|
|
.about-actions { display: flex; gap: 10px; flex-wrap: wrap; justify-content: flex-end; }
|
|
.about-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
|
|
.about-stat {
|
|
padding: 16px 18px; border-radius: 12px; border: 1px solid var(--border);
|
|
background: rgba(255,255,255,.02);
|
|
}
|
|
.about-stat-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; color: var(--text-muted); }
|
|
.about-stat-value { margin-top: 10px; font-size: 16px; font-weight: 700; color: var(--text); }
|
|
.about-grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(280px, .9fr); gap: 18px; }
|
|
.about-panel { border: 1px solid var(--border); border-radius: 14px; background: rgba(255,255,255,.02); overflow: hidden; }
|
|
.about-panel-head {
|
|
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
|
padding: 16px 18px; border-bottom: 1px solid var(--border);
|
|
}
|
|
.about-panel-title { font-size: 13px; font-weight: 700; color: var(--text); }
|
|
.about-panel-sub { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
|
|
.about-table { width: 100%; border-collapse: collapse; }
|
|
.about-table td { padding: 12px 18px; border-bottom: 1px solid var(--border); }
|
|
.about-table tr:last-child td { border-bottom: none; }
|
|
.about-table-key { width: 150px; font-size: 12px; font-weight: 600; color: var(--text-muted); }
|
|
.about-table-val { font-size: 13px; color: var(--text); }
|
|
.about-contribs { padding: 18px; display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px; }
|
|
.about-contrib {
|
|
padding: 12px 14px; border-radius: 10px; border: 1px solid var(--border); background: #21262d;
|
|
}
|
|
.about-contrib-name { font-size: 13px; color: var(--text); font-weight: 600; }
|
|
.about-contrib-id { margin-top: 4px; font-size: 12px; color: var(--text-muted); font-family: monospace; word-break: break-all; }
|
|
@media (max-width: 900px) {
|
|
.about-hero { grid-template-columns: 72px 1fr; }
|
|
.about-actions { grid-column: 1 / -1; justify-content: flex-start; }
|
|
.about-grid { grid-template-columns: 1fr; }
|
|
}
|
|
@media (max-width: 640px) {
|
|
.about-shell { padding: 18px; }
|
|
.about-hero { grid-template-columns: 1fr; }
|
|
.about-logo { width: 64px; height: 64px; }
|
|
}
|
|
select.modal-input,
|
|
select {
|
|
background-color: #0d1117;
|
|
color: var(--text);
|
|
color-scheme: dark;
|
|
}
|
|
select.modal-input option,
|
|
select option {
|
|
background: #0d1117;
|
|
color: var(--text);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<!-- Confirm Dialog -->
|
|
<div class="confirm-overlay hidden" id="confirm-dialog">
|
|
<div class="confirm-box">
|
|
<div class="confirm-header">
|
|
<svg width="16" height="16" fill="none" stroke="var(--danger)" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
|
<span class="confirm-title" id="confirm-title">Confirm Delete</span>
|
|
</div>
|
|
<div class="confirm-body">
|
|
<div class="confirm-msg" id="confirm-msg"></div>
|
|
<div class="confirm-actions">
|
|
<button class="btn btn-ghost btn-sm" onclick="confirmResolve(false)">Cancel</button>
|
|
<button class="btn btn-danger btn-sm" onclick="confirmResolve(true)">Delete</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add Network Dialog -->
|
|
<div class="modal-overlay hidden" id="dlg-network">
|
|
<div class="modal" style="max-width:440px;">
|
|
<div class="modal-header">
|
|
<span class="modal-title">Add Network</span>
|
|
<button class="modal-close" onclick="closeAddDialog('network')">✕</button>
|
|
</div>
|
|
<div class="modal-body" style="display:flex;flex-direction:column;gap:16px;">
|
|
<div class="form-group"><label>Name</label><input class="modal-input" style="width:100%;" type="text" id="net-name" placeholder="e.g. production"></div>
|
|
<div class="form-group"><label>Bridge Address <span style="color:var(--text-muted);font-weight:400;">(optional)</span></label><input class="modal-input" style="width:100%;" type="text" id="net-address" placeholder="e.g. 10.1.0.1/24"></div>
|
|
<div class="form-group"><label>Namespace <span style="color:var(--text-muted);font-weight:400;">(optional)</span></label><input class="modal-input" style="width:100%;" type="text" id="net-namespace" placeholder="default"></div>
|
|
<div style="display:flex;justify-content:flex-end;gap:8px;padding-top:4px;">
|
|
<button class="btn btn-ghost btn-sm" onclick="closeAddDialog('network')">Cancel</button>
|
|
<button class="btn btn-dark btn-sm" onclick="addNetwork()">Add</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add VPN Client Dialog -->
|
|
<div class="modal-overlay hidden" id="dlg-vpnclient">
|
|
<div class="modal" style="max-width:440px;">
|
|
<div class="modal-header">
|
|
<span class="modal-title">Add VPN Client</span>
|
|
<button class="modal-close" onclick="closeAddDialog('vpnclient')">✕</button>
|
|
</div>
|
|
<div class="modal-body" style="display:flex;flex-direction:column;gap:16px;">
|
|
<div class="form-group"><label>Network</label><select class="modal-input" style="width:100%;" id="vpn-client-network" onchange="onVpnNetworkChange()"></select></div>
|
|
<div class="form-group"><label>Name</label><input class="modal-input" style="width:100%;" type="text" id="vpn-client-name" placeholder="e.g. alice"></div>
|
|
<div class="form-group"><label>Address</label><input class="modal-input" style="width:100%;" type="text" id="vpn-client-address" placeholder="e.g. 10.0.0.2"></div>
|
|
<div style="display:flex;justify-content:flex-end;gap:8px;padding-top:4px;">
|
|
<button class="btn btn-ghost btn-sm" onclick="closeAddDialog('vpnclient')">Cancel</button>
|
|
<button class="btn btn-dark btn-sm" onclick="addVpnClient()">Add</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add/Edit User Dialog -->
|
|
<div class="modal-overlay hidden" id="dlg-user">
|
|
<div class="modal" style="max-width:440px;">
|
|
<div class="modal-header">
|
|
<span class="modal-title" id="user-dialog-title">Add User</span>
|
|
<button class="modal-close" onclick="userCancelEdit()">✕</button>
|
|
</div>
|
|
<div class="modal-body" style="display:flex;flex-direction:column;gap:16px;">
|
|
<div class="form-group"><label>Username@Network</label><input class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" type="text" id="user-name" placeholder="alice@mynet"></div>
|
|
<div class="form-group"><label>Password <span style="color:var(--text-muted);font-weight:400;" id="user-pass-hint">(auto-generated if empty)</span></label><input class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" type="text" id="user-password" placeholder=""></div>
|
|
<div class="form-group"><label>Role</label>
|
|
<select class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" id="user-role">
|
|
<option value="guest">guest</option>
|
|
<option value="admin">admin</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group"><label>Lease Until</label><input class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" type="date" id="user-lease"></div>
|
|
<div style="display:flex;justify-content:flex-end;gap:8px;padding-top:4px;">
|
|
<button class="btn btn-ghost btn-sm" onclick="userCancelEdit()">Cancel</button>
|
|
<button class="btn btn-dark btn-sm" id="user-dialog-submit" onclick="addUser()">Add</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-overlay modal-front hidden" id="dlg-file-preview">
|
|
<div class="modal" style="max-width:900px;">
|
|
<div class="modal-header">
|
|
<span class="modal-title" id="file-preview-title">File Preview</span>
|
|
<button class="modal-close" onclick="closeFilePreview()">✕</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div id="file-preview-tabs" style="display:none;justify-content:flex-end;margin-bottom:12px;">
|
|
<div id="file-preview-tab-buttons" style="display:inline-flex;gap:6px;padding:4px;border:1px solid var(--border);border-radius:10px;background:rgba(255,255,255,.02);"></div>
|
|
</div>
|
|
<pre class="file-preview-body" id="file-preview-body"><span class="file-preview-loading">Loading...</span></pre>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-dark btn-sm" id="file-preview-copy" onclick="copyCurrentPreview()">Copy</button>
|
|
<button class="btn btn-dark btn-sm" id="file-preview-download" onclick="downloadCurrentPreview()">Download</button>
|
|
<button class="btn btn-ghost btn-sm" onclick="closeFilePreview()">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add Router Private Subnet Dialog -->
|
|
<div class="modal-overlay hidden" id="dlg-bgp-neighbor">
|
|
<div class="modal" style="max-width:420px;">
|
|
<div class="modal-header">
|
|
<span class="modal-title">Add BGP Neighbor</span>
|
|
<button class="modal-close" onclick="closeAddDialog('bgp-neighbor')">✕</button>
|
|
</div>
|
|
<div class="modal-body" style="display:flex;flex-direction:column;gap:14px;">
|
|
<div class="form-group"><label>Address</label><input class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" type="text" id="bgp-nei-address" placeholder="e.g. 10.0.0.2"></div>
|
|
<div class="form-group"><label>Remote AS</label><input class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" type="text" inputmode="numeric" id="bgp-nei-remoteas" placeholder="e.g. 65002"></div>
|
|
<div class="form-group"><label>Password <span style="color:var(--text-muted);font-weight:400;">(optional)</span></label><input class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" type="password" id="bgp-nei-password" placeholder=""></div>
|
|
<div style="display:flex;justify-content:flex-end;gap:8px;padding-top:4px;">
|
|
<button class="btn btn-ghost btn-sm" onclick="closeAddDialog('bgp-neighbor')">Cancel</button>
|
|
<button class="btn btn-dark btn-sm" onclick="bgpAddNeighbor()">Add</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-overlay hidden" id="dlg-router-address">
|
|
<div class="modal" style="max-width:400px;">
|
|
<div class="modal-header">
|
|
<span class="modal-title">Add Address</span>
|
|
<button class="modal-close" onclick="closeAddDialog('router-address')">✕</button>
|
|
</div>
|
|
<div class="modal-body" style="display:flex;flex-direction:column;gap:14px;">
|
|
<div style="font-size:13px;color:var(--text-muted);">Device: <span class="td-mono" id="router-address-device-label">-</span></div>
|
|
<div class="form-group"><label>Address (CIDR)</label><input class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" type="text" id="router-address-value" placeholder="e.g. 192.168.1.1/24"></div>
|
|
<div style="display:flex;justify-content:flex-end;gap:8px;padding-top:4px;">
|
|
<button class="btn btn-ghost btn-sm" onclick="closeAddDialog('router-address')">Cancel</button>
|
|
<button class="btn btn-dark btn-sm" onclick="routerAddAddress()">Add</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-overlay hidden" id="dlg-router-iface">
|
|
<div class="modal" style="max-width:400px;">
|
|
<div class="modal-header">
|
|
<span class="modal-title">Add Interface</span>
|
|
<button class="modal-close" onclick="routerIfaceCancel()">✕</button>
|
|
</div>
|
|
<div class="modal-body" style="display:flex;flex-direction:column;gap:14px;">
|
|
<div class="form-group"><label>Device</label><select class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" id="router-iface-device"></select></div>
|
|
<div class="form-group"><label>VLAN <span style="color:var(--text-muted);font-weight:400;">(0 = none)</span></label><input class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" type="text" inputmode="numeric" id="router-iface-vlan" value="0"></div>
|
|
<div class="form-group"><label>Address (CIDR)</label><input class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" type="text" id="router-iface-address" placeholder="e.g. 192.168.1.1/24"></div>
|
|
<div style="display:flex;justify-content:flex-end;gap:8px;padding-top:4px;">
|
|
<button class="btn btn-ghost btn-sm" onclick="routerIfaceCancel()">Cancel</button>
|
|
<button class="btn btn-dark btn-sm" onclick="routerIfaceSubmit()">Add</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-overlay hidden" id="dlg-router-redirect">
|
|
<div class="modal" style="max-width:420px;">
|
|
<div class="modal-header">
|
|
<span class="modal-title">Add Redirect</span>
|
|
<button class="modal-close" onclick="closeAddDialog('router-redirect')">✕</button>
|
|
</div>
|
|
<div class="modal-body" style="display:flex;flex-direction:column;gap:14px;">
|
|
<div class="form-group">
|
|
<label>Source</label>
|
|
<input class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" type="text" id="router-redirect-source" list="router-redirect-source-list" placeholder="e.g. 10.0.0.0/24">
|
|
<datalist id="router-redirect-source-list"></datalist>
|
|
</div>
|
|
<div class="form-group"><label>NextHop</label><input class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" type="text" id="router-redirect-nexthop" placeholder="e.g. 192.168.1.1"></div>
|
|
<div class="form-group">
|
|
<label>Table</label>
|
|
<input class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" type="text" inputmode="numeric" id="router-redirect-table" list="router-redirect-table-list" placeholder="e.g. 100">
|
|
<datalist id="router-redirect-table-list"></datalist>
|
|
<div id="router-redirect-table-hint" style="margin-top:6px;font-size:12px;color:var(--text-muted);"></div>
|
|
</div>
|
|
<div style="display:flex;justify-content:flex-end;gap:8px;padding-top:4px;">
|
|
<button class="btn btn-ghost btn-sm" onclick="closeAddDialog('router-redirect')">Cancel</button>
|
|
<button class="btn btn-dark btn-sm" onclick="routerAddRedirect()">Add</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-overlay hidden" id="dlg-ipsec-tunnel">
|
|
<div class="modal" style="max-width:520px;">
|
|
<div class="modal-header">
|
|
<span class="modal-title" id="ipsec-tunnel-title">Add IPSec Tunnel</span>
|
|
<button class="modal-close" onclick="ipsecCancelEdit()">✕</button>
|
|
</div>
|
|
<div class="modal-body" style="display:flex;flex-direction:column;gap:16px;">
|
|
<input type="hidden" id="ipsec-edit-orig">
|
|
<!-- Local / Remote two-column layout -->
|
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
|
|
<!-- Local -->
|
|
<div style="display:flex;flex-direction:column;gap:10px;padding:12px;background:rgba(255,255,255,.03);border:1px solid var(--border);border-radius:6px;">
|
|
<div style="font-size:11px;font-weight:700;text-transform:uppercase;color:var(--text-muted);letter-spacing:.05em;">Local</div>
|
|
<div class="form-group" style="margin:0;"><label>Address</label><input class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" type="text" id="ipsec-local" value="%defaultroute" placeholder="e.g. 1.2.3.4"></div>
|
|
<div class="form-group" style="margin:0;"><label>ID</label><input class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" type="text" id="ipsec-localid" placeholder=""></div>
|
|
<div class="form-group" style="margin:0;"><label>Port</label><input class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" type="text" inputmode="numeric" id="ipsec-localport" value="4500"></div>
|
|
</div>
|
|
<!-- Remote -->
|
|
<div style="display:flex;flex-direction:column;gap:10px;padding:12px;background:rgba(255,255,255,.03);border:1px solid var(--border);border-radius:6px;">
|
|
<div style="font-size:11px;font-weight:700;text-transform:uppercase;color:var(--text-muted);letter-spacing:.05em;">Remote</div>
|
|
<div class="form-group" style="margin:0;"><label>Address</label><input class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" type="text" id="ipsec-remote" placeholder="e.g. 5.6.7.8"></div>
|
|
<div class="form-group" style="margin:0;"><label>ID</label><input class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" type="text" id="ipsec-remoteid" placeholder=""></div>
|
|
<div class="form-group" style="margin:0;"><label>Port</label><input class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" type="text" inputmode="numeric" id="ipsec-remoteport" value="4500"></div>
|
|
</div>
|
|
</div>
|
|
<!-- Protocol + Secret -->
|
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
|
<div class="form-group" style="margin:0;"><label>Protocol</label>
|
|
<select class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" id="ipsec-protocol">
|
|
<option value="gre">gre</option>
|
|
<option value="vxlan">vxlan</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group" style="margin:0;"><label>Secret</label><input class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" type="password" id="ipsec-secret" placeholder="Pre-shared key"></div>
|
|
</div>
|
|
<div style="display:flex;justify-content:flex-end;gap:8px;padding-top:4px;">
|
|
<button class="btn btn-ghost btn-sm" onclick="ipsecCancelEdit()">Cancel</button>
|
|
<button class="btn btn-dark btn-sm" id="ipsec-tunnel-submit" onclick="ipsecAddTunnel()">Add</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-overlay hidden" id="dlg-bgp-prefix">
|
|
<div class="modal" style="max-width:400px;">
|
|
<div class="modal-header">
|
|
<span class="modal-title" id="dlg-bgp-prefix-title">Add Prefix</span>
|
|
<button class="modal-close" onclick="closeAddDialog('bgp-prefix')">✕</button>
|
|
</div>
|
|
<div class="modal-body" style="display:flex;flex-direction:column;gap:14px;">
|
|
<div class="form-group"><label>Neighbor</label><input class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text-muted);" type="text" id="bgp-prefix-neighbor" readonly></div>
|
|
<div class="form-group"><label>Prefix (CIDR)</label><input class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" type="text" id="bgp-prefix-value" placeholder="e.g. 10.0.0.0/8"></div>
|
|
<div style="display:flex;justify-content:flex-end;gap:8px;padding-top:4px;">
|
|
<button class="btn btn-ghost btn-sm" onclick="closeAddDialog('bgp-prefix')">Cancel</button>
|
|
<button class="btn btn-dark btn-sm" onclick="bgpAddPrefix()">Add</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-overlay hidden" id="dlg-router-private">
|
|
<div class="modal" style="max-width:400px;">
|
|
<div class="modal-header">
|
|
<span class="modal-title">Add Private Subnet</span>
|
|
<button class="modal-close" onclick="closeAddDialog('router-private')">✕</button>
|
|
</div>
|
|
<div class="modal-body" style="display:flex;flex-direction:column;gap:16px;">
|
|
<div class="form-group"><label>Subnet (CIDR)</label><input class="modal-input" style="width:100%;" type="text" id="router-private-subnet" placeholder="e.g. 192.168.1.0/24"></div>
|
|
<div style="display:flex;justify-content:flex-end;gap:8px;padding-top:4px;">
|
|
<button class="btn btn-ghost btn-sm" onclick="closeAddDialog('router-private')">Cancel</button>
|
|
<button class="btn btn-dark btn-sm" onclick="routerAddPrivate()">Add</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-overlay hidden" id="dlg-ceci-tcp">
|
|
<div class="modal" style="max-width:460px;">
|
|
<div class="modal-header">
|
|
<span class="modal-title">Add Ceci TCP</span>
|
|
<button class="modal-close" onclick="closeAddDialog('ceci-tcp')">✕</button>
|
|
</div>
|
|
<div class="modal-body" style="display:flex;flex-direction:column;gap:16px;">
|
|
<div class="form-group"><label>Listen</label><input class="modal-input" style="width:100%;" type="text" id="ceci-tcp-listen" placeholder="e.g.:2222"></div>
|
|
<div class="form-group"><label>Target <span style="color:var(--text-muted);font-weight:400;">(one per line)</span></label><textarea class="modal-input" style="width:100%;min-height:120px;resize:vertical;" id="ceci-tcp-target" placeholder="e.g. 10.0.0.2:10002"></textarea></div>
|
|
<div style="display:flex;justify-content:flex-end;gap:8px;padding-top:4px;">
|
|
<button class="btn btn-ghost btn-sm" onclick="closeAddDialog('ceci-tcp')">Cancel</button>
|
|
<button class="btn btn-dark btn-sm" onclick="ceciAddTcp()">Add</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Network Detail Modal -->
|
|
<div class="modal-overlay hidden" id="net-modal">
|
|
<div class="modal">
|
|
<div class="modal-header">
|
|
<span class="modal-title" id="net-modal-title">Network</span>
|
|
<button class="modal-close" onclick="closeNetModal()">✕</button>
|
|
</div>
|
|
<div class="modal-tabs">
|
|
<div class="modal-tab active" onclick="switchNetTab('config')">Config</div>
|
|
<div class="modal-tab" onclick="switchNetTab('routes')">Routes</div>
|
|
<div class="modal-tab" onclick="switchNetTab('outputs')">Outputs</div>
|
|
<div class="modal-tab" onclick="switchNetTab('json')">YAML</div>
|
|
</div>
|
|
<div class="modal-body">
|
|
<!-- Config Tab -->
|
|
<div class="modal-tab-pane active" id="net-tab-config">
|
|
<div class="detail-grid" id="modal-base-info"></div>
|
|
<div id="modal-snat-section"></div>
|
|
<div id="modal-ovpn-section"></div>
|
|
</div>
|
|
<!-- Routes Tab -->
|
|
<div class="modal-tab-pane" id="net-tab-routes">
|
|
<div class="modal-form-row" style="justify-content:flex-end;">
|
|
<button class="btn btn-dark btn-sm" onclick="openAddDialog('network-route')">
|
|
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
Add Route
|
|
</button>
|
|
</div>
|
|
<div id="modal-routes-list"></div>
|
|
</div>
|
|
<!-- Outputs Tab -->
|
|
<div class="modal-tab-pane" id="net-tab-outputs">
|
|
<div class="modal-form-row" style="justify-content:flex-end;">
|
|
<button class="btn btn-dark btn-sm" onclick="openAddDialog('network-output')">
|
|
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
Add Output
|
|
</button>
|
|
</div>
|
|
<div id="modal-outputs-list"></div>
|
|
</div>
|
|
<!-- JSON Tab -->
|
|
<div class="modal-tab-pane" id="net-tab-json">
|
|
<div style="display:flex;justify-content:flex-end;margin-bottom:12px;">
|
|
<div style="display:inline-flex;gap:6px;padding:4px;border:1px solid var(--border);border-radius:10px;background:rgba(255,255,255,.02);">
|
|
<button class="btn btn-sm btn-dark" id="net-data-tab-json" type="button" onclick="switchNetDataFormat('json')">JSON</button>
|
|
<button class="btn btn-sm btn-ghost" id="net-data-tab-yaml" type="button" onclick="switchNetDataFormat('yaml')">YAML</button>
|
|
</div>
|
|
</div>
|
|
<pre class="detail-json" id="modal-json"></pre>
|
|
<pre class="detail-json" id="modal-yaml" style="display:none;">Loading...</pre>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-ghost btn-sm" onclick="closeNetModal()">Close</button>
|
|
<button class="btn btn-dark btn-sm" onclick="modalSave()">
|
|
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
|
Save
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-overlay hidden" id="dlg-network-route">
|
|
<div class="modal" style="max-width:440px;">
|
|
<div class="modal-header">
|
|
<span class="modal-title">Add Route</span>
|
|
<button class="modal-close" onclick="closeAddDialog('network-route')">✕</button>
|
|
</div>
|
|
<div class="modal-body" style="display:flex;flex-direction:column;gap:16px;">
|
|
<div class="form-group"><label>Prefix</label><input class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" id="network-route-prefix" type="text" placeholder="e.g. 10.0.0.0/24"></div>
|
|
<div class="form-group"><label>NextHop <span style="color:var(--text-muted);font-weight:400;">(optional)</span></label><input class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" id="network-route-nexthop" type="text" placeholder="e.g. 192.168.1.1"></div>
|
|
<div style="display:flex;justify-content:flex-end;gap:8px;padding-top:4px;">
|
|
<button class="btn btn-ghost btn-sm" onclick="closeAddDialog('network-route')">Cancel</button>
|
|
<button class="btn btn-dark btn-sm" onclick="modalAddRoute()">Add</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-overlay hidden" id="dlg-network-output">
|
|
<div class="modal" style="max-width:460px;">
|
|
<div class="modal-header">
|
|
<span class="modal-title">Add Output</span>
|
|
<button class="modal-close" onclick="closeAddDialog('network-output')">✕</button>
|
|
</div>
|
|
<div class="modal-body" style="display:flex;flex-direction:column;gap:16px;">
|
|
<div class="form-group"><label>Protocol</label>
|
|
<select class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" id="network-out-proto" onchange="onNetworkOutputProtocolChange()">
|
|
<option value="tcp">tcp</option>
|
|
<option value="udp">udp</option>
|
|
<option value="gre">gre</option>
|
|
<option value="vxlan">vxlan</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group"><label>Remote</label><input class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" id="network-out-remote" type="text" placeholder="e.g. 1.2.3.4:4500"></div>
|
|
<div class="form-group" id="network-out-segment-group"><label>Segment</label><input class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" id="network-out-segment" type="text" inputmode="numeric" placeholder="e.g. 100"></div>
|
|
<div class="form-group" id="network-out-secret-group"><label>Secret</label><input class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" id="network-out-secret" type="text" placeholder="e.g. user:password"></div>
|
|
<div class="form-group" id="network-out-crypt-group"><label>Crypt</label><input class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" id="network-out-crypt" type="text" placeholder="e.g. aes-256:my-secret-key"></div>
|
|
<div style="display:flex;justify-content:flex-end;gap:8px;padding-top:4px;">
|
|
<button class="btn btn-ghost btn-sm" onclick="closeAddDialog('network-output')">Cancel</button>
|
|
<button class="btn btn-dark btn-sm" onclick="modalAddOutput()">Add</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-overlay hidden" id="dlg-network-openvpn">
|
|
<div class="modal" style="max-width:460px;">
|
|
<div class="modal-header">
|
|
<span class="modal-title">Enable OpenVPN</span>
|
|
<button class="modal-close" onclick="closeAddDialog('network-openvpn')">✕</button>
|
|
</div>
|
|
<div class="modal-body" style="display:flex;flex-direction:column;gap:16px;">
|
|
<div class="form-group"><label>Listen</label><input class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" id="network-ovpn-listen" type="text" placeholder="e.g. :1194"></div>
|
|
<div class="form-group"><label>Protocol</label>
|
|
<select class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" id="network-ovpn-protocol">
|
|
<option value="tcp">tcp</option>
|
|
<option value="udp">udp</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group"><label>Subnet <span style="color:var(--text-muted);font-weight:400;">(optional)</span></label><input class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" id="network-ovpn-subnet" type="text" placeholder="e.g. 10.8.0.0/24"></div>
|
|
<div class="form-group"><label>Cipher <span style="color:var(--text-muted);font-weight:400;">(optional)</span></label><input class="modal-input" style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);" id="network-ovpn-cipher" type="text" placeholder="e.g. AES-256-GCM"></div>
|
|
<div style="display:flex;justify-content:flex-end;gap:8px;padding-top:4px;">
|
|
<button class="btn btn-ghost btn-sm" onclick="closeAddDialog('network-openvpn')">Cancel</button>
|
|
<button class="btn btn-dark btn-sm" onclick="enableNetworkOpenVPN()">Enable</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Login Page -->
|
|
|
|
<!-- Dashboard App -->
|
|
<div id="app">
|
|
<div class="layout">
|
|
<!-- Sidebar -->
|
|
<aside class="sidebar">
|
|
<div class="sidebar-header">
|
|
<img src="/openlan.png" class="sidebar-logo" alt="" onerror="this.style.display='none'">
|
|
<div>
|
|
<div class="sidebar-brand">OpenLAN</div>
|
|
<div class="sidebar-version" id="sw-version">Management Console</div>
|
|
</div>
|
|
</div>
|
|
<nav class="sidebar-nav">
|
|
<div class="nav-section">Monitor</div>
|
|
<div class="nav-item active" onclick="showPage('overview')" id="nav-overview">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
|
Overview
|
|
</div>
|
|
<div class="nav-item" onclick="showPage('clients')" id="nav-clients">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
|
Access Client
|
|
</div>
|
|
<div class="nav-item" onclick="showPage('routes')" id="nav-routes">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
|
Kernel Route
|
|
</div>
|
|
<div class="nav-section">Manage</div>
|
|
<div class="nav-item" onclick="showPage('networks')" id="nav-networks">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="5" r="3"/><circle cx="5" cy="19" r="3"/><circle cx="19" cy="19" r="3"/><line x1="12" y1="8" x2="5.5" y2="16.5"/><line x1="12" y1="8" x2="18.5" y2="16.5"/></svg>
|
|
Virtual Network
|
|
</div>
|
|
<div class="nav-item nav-sub" onclick="gotoPanel('bgp')" id="nav-bgp">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M2 12h3M19 12h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12"/></svg>
|
|
BGP Network
|
|
</div>
|
|
<div class="nav-item nav-sub" onclick="gotoPanel('router')" id="nav-router">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="2" y="7" width="20" height="10" rx="2"/><path d="M6 7V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v2"/><circle cx="12" cy="12" r="1" fill="currentColor"/></svg>
|
|
Router Network
|
|
</div>
|
|
<div class="nav-item nav-sub" onclick="gotoPanel('ipsec')" id="nav-ipsec">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
|
IPSec Network
|
|
</div>
|
|
<div class="nav-item nav-sub" onclick="gotoPanel('ceci')" id="nav-ceci">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h10"/><circle cx="18" cy="17" r="2"/></svg>
|
|
Ceci Network
|
|
</div>
|
|
<div class="nav-item" onclick="showPage('users')" id="nav-users">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
|
Authenticated User
|
|
</div>
|
|
<div class="nav-section">System</div>
|
|
<div class="nav-item" onclick="showPage('about')" id="nav-about">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
|
About
|
|
</div>
|
|
</nav>
|
|
<div class="sidebar-footer">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;">
|
|
<span class="refresh-info" id="refresh-countdown" style="font-size:12px;"></span>
|
|
<button class="btn-refresh" id="btn-refresh" onclick="refreshCurrentPage()">
|
|
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- Main Content -->
|
|
<main class="main">
|
|
<div class="page-content" style="position:relative;">
|
|
<div id="page-loading" style="display:none;position:absolute;inset:0;background:rgba(13,17,23,.45);z-index:100;align-items:center;justify-content:center;">
|
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" stroke-width="2.5" style="animation:spin .7s linear infinite;"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
|
|
</div>
|
|
<!-- OVERVIEW PAGE -->
|
|
<div class="page active" id="page-overview">
|
|
<div class="stats-grid" id="overview-stats"></div>
|
|
<div class="overview-grid">
|
|
<div class="section">
|
|
<div class="section-header">
|
|
<span class="section-title">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
|
Node Information
|
|
</span>
|
|
</div>
|
|
<div class="section-body">
|
|
<table class="node-table" id="node-info-table"></table>
|
|
</div>
|
|
</div>
|
|
<div class="section">
|
|
<div class="section-header">
|
|
<span class="section-title">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
|
|
Connection Tracking
|
|
</span>
|
|
</div>
|
|
<div class="conntrack-grid" id="conntrack-grid"></div>
|
|
</div>
|
|
</div>
|
|
<div class="section">
|
|
<div class="section-header">
|
|
<span class="section-title">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
|
Devices
|
|
</span>
|
|
</div>
|
|
<div class="section-body">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Network</th>
|
|
<th>Device</th>
|
|
<th>State</th>
|
|
<th>MTU</th>
|
|
<th>MAC</th>
|
|
<th>Addresses</th>
|
|
<th>Rx / Tx</th>
|
|
<th>Speed (Rx/Tx)</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="devices-tbody"></tbody>
|
|
</table>
|
|
<div class="section-footer" id="devices-count">0</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- CLIENTS PAGE -->
|
|
<div class="page" id="page-clients">
|
|
<div class="section" style="margin-bottom:24px;">
|
|
<div class="section-header">
|
|
<span class="section-title">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
|
Internal Output
|
|
</span>
|
|
</div>
|
|
<div class="section-body">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Network</th>
|
|
<th>Device</th>
|
|
<th>Remote</th>
|
|
<th>Protocol</th>
|
|
<th>Speed (Rx/Tx)</th>
|
|
<th>State</th>
|
|
<th>Alive</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="links-tbody"></tbody>
|
|
</table>
|
|
<div class="section-footer" id="links-count">0</div>
|
|
</div>
|
|
</div>
|
|
<div class="section" style="margin-bottom:24px;">
|
|
<div class="section-header">
|
|
<span class="section-title">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
|
Access Client
|
|
</span>
|
|
</div>
|
|
<div class="section-body">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Network</th>
|
|
<th>User</th>
|
|
<th>Alias</th>
|
|
<th>Device</th>
|
|
<th>Remote</th>
|
|
<th>Protocol</th>
|
|
<th>Speed (Rx/Tx)</th>
|
|
<th>State</th>
|
|
<th>Alive</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="access-tbody"></tbody>
|
|
</table>
|
|
<div class="section-footer" id="access-count">0</div>
|
|
</div>
|
|
</div>
|
|
<div class="section">
|
|
<div class="section-header">
|
|
<span class="section-title">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
|
OpenVPN Clients
|
|
</span>
|
|
</div>
|
|
<div class="section-body">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Device</th>
|
|
<th>Address</th>
|
|
<th>Remote</th>
|
|
<th>User</th>
|
|
<th>Speed (Rx/Tx)</th>
|
|
<th>State</th>
|
|
<th>Alive</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="vpn-tbody"></tbody>
|
|
</table>
|
|
<div class="section-footer" id="vpn-count">0</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ROUTES PAGE -->
|
|
<div class="page" id="page-routes">
|
|
<div class="section">
|
|
<div class="section-header">
|
|
<span class="section-title">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
|
Kernel Routing Table
|
|
</span>
|
|
</div>
|
|
<div class="section-body">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Table</th>
|
|
<th>Protocol</th>
|
|
<th>Destination</th>
|
|
<th>NextHop</th>
|
|
<th>Device</th>
|
|
<th>Source</th>
|
|
<th>Metric</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="routes-tbody"></tbody>
|
|
</table>
|
|
<div class="section-footer" id="routes-count">0</div>
|
|
</div>
|
|
</div>
|
|
<div class="section" style="margin-top:24px;">
|
|
<div class="section-header">
|
|
<span class="section-title">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
|
ARP Neighbors
|
|
</span>
|
|
</div>
|
|
<div class="section-body">
|
|
<table>
|
|
<thead>
|
|
<tr><th>IP Address</th><th>MAC</th><th>Device</th><th>State</th></tr>
|
|
</thead>
|
|
<tbody id="neighbors-tbody"></tbody>
|
|
</table>
|
|
<div class="section-footer" id="neighbors-count">0</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- NETWORKS PAGE -->
|
|
<div class="page" id="page-networks">
|
|
<div class="section">
|
|
<div class="section-header">
|
|
<span class="section-title">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="5" r="3"/><circle cx="5" cy="19" r="3"/><circle cx="19" cy="19" r="3"/><line x1="12" y1="8" x2="5.5" y2="16.5"/><line x1="12" y1="8" x2="18.5" y2="16.5"/></svg>
|
|
Virtual Network
|
|
</span>
|
|
<button class="btn btn-dark btn-sm" onclick="openAddDialog('network')">
|
|
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
Add Network
|
|
</button>
|
|
</div>
|
|
<div class="section-body">
|
|
<table>
|
|
<thead>
|
|
<tr><th>Name</th><th>Subnet</th><th>Routes</th><th>Outputs</th><th>SNAT</th><th>openvpn state</th><th style="width:160px;">Actions</th></tr>
|
|
</thead>
|
|
<tbody id="networks-tbody"></tbody>
|
|
</table>
|
|
<div class="section-footer" id="networks-count">0</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section" style="margin-top:16px;">
|
|
<div class="section-header">
|
|
<span class="section-title">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
|
OpenVPN Clients
|
|
</span>
|
|
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;justify-content:flex-end;">
|
|
<div class="form-group" style="min-width:180px;">
|
|
<select id="vpn-clients-network-filter" onchange="onVpnClientsNetworkFilterChange()" style="min-width:180px;padding:5px 10px;font-size:12px;line-height:1.2;">
|
|
<option value="">All Networks</option>
|
|
</select>
|
|
</div>
|
|
<button class="btn btn-dark btn-sm" onclick="openAddDialog('vpnclient')">
|
|
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
Add Client
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="section-body">
|
|
<table>
|
|
<thead>
|
|
<tr><th>Name</th><th>Network</th><th>Address</th><th>Remote</th><th>State</th><th>RX</th><th>TX</th><th>Alive</th><th style="width:120px;"></th></tr>
|
|
</thead>
|
|
<tbody id="vpn-clients-tbody"></tbody>
|
|
</table>
|
|
<div class="section-footer" id="vpn-clients-count">0</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- BGP PAGE -->
|
|
<div class="page" id="page-bgp">
|
|
<div class="section" id="panel-bgp">
|
|
<div class="section-header">
|
|
<span class="section-title">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M2 12h3M19 12h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12"/></svg>
|
|
BGP Network
|
|
</span>
|
|
<span class="badge badge-gray" id="bgp-status">-</span>
|
|
<button class="btn btn-dark btn-sm" onclick="saveNetwork('bgp')">
|
|
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
|
Save
|
|
</button>
|
|
</div>
|
|
<div class="section-body">
|
|
<div style="padding:14px 20px;display:flex;gap:16px;align-items:flex-end;border-bottom:1px solid var(--border);flex-wrap:wrap;">
|
|
<div class="form-group" style="margin:0;">
|
|
<label style="font-size:11px;font-weight:700;text-transform:uppercase;color:var(--text-muted);display:block;margin-bottom:4px;">Local AS</label>
|
|
<input class="modal-input" id="bgp-input-localas" type="text" inputmode="numeric" placeholder="e.g. 65001" style="width:120px;padding:5px 10px;font-size:13px;background:#21262d;border-color:var(--border);color:var(--text);">
|
|
</div>
|
|
<div class="form-group" style="margin:0;">
|
|
<label style="font-size:11px;font-weight:700;text-transform:uppercase;color:var(--text-muted);display:block;margin-bottom:4px;">Router ID</label>
|
|
<input class="modal-input" id="bgp-input-routerid" type="text" placeholder="e.g. 10.0.0.1" style="width:150px;padding:5px 10px;font-size:13px;background:#21262d;border-color:var(--border);color:var(--text);">
|
|
</div>
|
|
<button class="btn btn-dark btn-sm" onclick="bgpSaveGlobal()" style="margin-bottom:1px;">Apply</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section" style="margin-top:16px;">
|
|
<div class="section-header">
|
|
<span class="section-title">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="9" cy="7" r="4"/><path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>
|
|
Neighbors
|
|
</span>
|
|
<button class="btn btn-dark btn-sm" onclick="openAddDialog('bgp-neighbor')">
|
|
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
Add
|
|
</button>
|
|
</div>
|
|
<div class="section-body">
|
|
<table>
|
|
<thead><tr><th>Neighbor</th><th>Remote AS</th><th>State</th><th>Advertis</th><th>Receives</th><th></th></tr></thead>
|
|
<tbody id="bgp-neighbors-tbody"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ROUTER PAGE -->
|
|
<div class="page" id="page-router">
|
|
<div class="section" id="panel-router">
|
|
<div class="section-header">
|
|
<span class="section-title">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="2" y="7" width="20" height="10" rx="2"/><path d="M6 7V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v2"/><circle cx="12" cy="12" r="1" fill="currentColor"/></svg>
|
|
Router Network
|
|
</span>
|
|
<button class="btn btn-dark btn-sm" onclick="saveNetwork('router')">
|
|
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
|
Save
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section" style="margin-top:16px;">
|
|
<div class="section-header">
|
|
<span class="section-title">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="2" y="7" width="20" height="10" rx="2"/><path d="M6 7V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v2"/><circle cx="12" cy="12" r="1" fill="currentColor"/></svg>
|
|
Interfaces
|
|
</span>
|
|
<button class="btn btn-dark btn-sm" onclick="routerIfaceAdd()">
|
|
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
Add Interface
|
|
</button>
|
|
</div>
|
|
<div class="section-body">
|
|
<table>
|
|
<thead><tr><th>Interface</th><th>VLAN</th><th>MAC</th><th>Address</th><th>State</th><th></th></tr></thead>
|
|
<tbody id="router-iface-tbody"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section" style="margin-top:16px;">
|
|
<div class="section-header">
|
|
<span class="section-title">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M3 12h18M3 6h18M3 18h18"/></svg>
|
|
Private Subnets
|
|
</span>
|
|
<button class="btn btn-dark btn-sm" onclick="openAddDialog('router-private')">
|
|
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
Add
|
|
</button>
|
|
</div>
|
|
<div class="section-body" style="padding:16px 20px;">
|
|
<div id="router-private-tags" style="display:flex;flex-wrap:wrap;gap:8px;min-height:32px;align-items:center;"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section" style="margin-top:16px;">
|
|
<div class="section-header">
|
|
<span class="section-title">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M13 3L4 14h7l-1 7 9-11h-7l1-7z"/></svg>
|
|
Redirect
|
|
</span>
|
|
<button class="btn btn-dark btn-sm" onclick="openAddDialog('router-redirect')">
|
|
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
Add
|
|
</button>
|
|
</div>
|
|
<div class="section-body">
|
|
<table>
|
|
<thead><tr><th>Source</th><th>NextHop</th><th>Table</th><th></th></tr></thead>
|
|
<tbody id="router-redirect-tbody"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- IPSEC PAGE -->
|
|
<div class="page" id="page-ipsec">
|
|
<div class="section" id="panel-ipsec">
|
|
<div class="section-header">
|
|
<span class="section-title">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
|
IPSec Network
|
|
</span>
|
|
<button class="btn btn-dark btn-sm" onclick="saveNetwork('ipsec')">
|
|
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
|
Save
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section" style="margin-top:16px;">
|
|
<div class="section-header">
|
|
<span class="section-title">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
|
|
Tunnels
|
|
</span>
|
|
<button class="btn btn-dark btn-sm" onclick="openAddDialog('ipsec-tunnel')">
|
|
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
Add
|
|
</button>
|
|
</div>
|
|
<div class="section-body">
|
|
<table>
|
|
<thead><tr><th>Local</th><th>Local ID</th><th>Remote</th><th>Remote ID</th><th>Protocol</th><th>Secret</th><th>State</th><th></th></tr></thead>
|
|
<tbody id="ipsec-tbody"></tbody>
|
|
</table>
|
|
<div class="section-footer" id="ipsec-count">0</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="page" id="page-ceci">
|
|
<div class="section" id="panel-ceci">
|
|
<div class="section-header">
|
|
<span class="section-title">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h10"/><circle cx="18" cy="17" r="2"/></svg>
|
|
Ceci Network
|
|
</span>
|
|
<button class="btn btn-dark btn-sm" onclick="saveNetwork('ceci')">
|
|
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
|
Save
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section" style="margin-top:16px;">
|
|
<div class="section-header">
|
|
<span class="section-title">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h10"/><circle cx="18" cy="17" r="2"/></svg>
|
|
TCP Entries
|
|
</span>
|
|
<button class="btn btn-dark btn-sm" onclick="openAddDialog('ceci-tcp')">
|
|
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
Add
|
|
</button>
|
|
</div>
|
|
<div class="section-body">
|
|
<table>
|
|
<thead><tr><th>Mode</th><th>Listen</th><th>Target</th><th>Status</th><th style="width:160px;"></th></tr></thead>
|
|
<tbody id="ceci-tcp-tbody"></tbody>
|
|
</table>
|
|
<div class="section-footer" id="ceci-tcp-count">0</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- USERS PAGE -->
|
|
<div class="page" id="page-users">
|
|
<div class="section">
|
|
<div class="section-header">
|
|
<span class="section-title">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
|
Authenticated User
|
|
</span>
|
|
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;justify-content:flex-end;">
|
|
<div class="form-group" style="min-width:180px;">
|
|
<select id="users-network-filter" onchange="onUsersNetworkFilterChange()" style="min-width:180px;padding:5px 10px;font-size:12px;line-height:1.2;">
|
|
<option value="">All Networks</option>
|
|
</select>
|
|
</div>
|
|
<button class="btn btn-dark btn-sm" onclick="openAddDialog('user')">
|
|
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
Add User
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="section-body">
|
|
<table>
|
|
<thead>
|
|
<tr><th>Username</th><th>Role</th><th>Password</th><th>Lease Until</th><th>Actions</th></tr>
|
|
</thead>
|
|
<tbody id="users-tbody"></tbody>
|
|
</table>
|
|
<div class="section-footer" id="users-count">0</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ABOUT PAGE -->
|
|
<div class="page" id="page-about">
|
|
<div class="section">
|
|
<div class="section-header">
|
|
<span class="section-title">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
|
About OpenLAN
|
|
</span>
|
|
</div>
|
|
<div class="section-body about-shell">
|
|
<div class="about-hero">
|
|
<div class="about-logo">
|
|
<img src="/openlan.png" alt="OpenLAN" style="width:56px;height:56px;border-radius:14px;" onerror="this.style.display='none'">
|
|
</div>
|
|
<div>
|
|
<div class="about-kicker">Open Source Network Overlay Platform</div>
|
|
<div class="about-heading">OpenLAN</div>
|
|
<div class="about-copy">A compact control surface for overlay networking, routing, VPN access, and policy management across your node.</div>
|
|
</div>
|
|
<div class="about-actions">
|
|
<a href="https://github.com/luscis/openlan" target="_blank" rel="noopener" class="btn btn-ghost btn-sm" style="text-decoration:none;">
|
|
<svg width="14" height="14" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2z"/></svg>
|
|
GitHub
|
|
</a>
|
|
</div>
|
|
</div>
|
|
<div class="about-stats" id="about-stats"></div>
|
|
<div class="about-grid">
|
|
<div class="about-panel">
|
|
<div class="about-panel-head">
|
|
<div>
|
|
<div class="about-panel-title">Build & Node</div>
|
|
<div class="about-panel-sub">Version, runtime identity, and node-level context.</div>
|
|
</div>
|
|
</div>
|
|
<table class="about-table">
|
|
<tbody id="about-info-tbody"></tbody>
|
|
</table>
|
|
</div>
|
|
<div class="about-panel">
|
|
<div class="about-panel-head">
|
|
<div>
|
|
<div class="about-panel-title">Contributors</div>
|
|
<div class="about-panel-sub">Core people behind the project.</div>
|
|
</div>
|
|
</div>
|
|
<div class="about-contribs">
|
|
<div class="about-contrib">
|
|
<div class="about-contrib-name">Daniel Ding</div>
|
|
<div class="about-contrib-id">danieldin186@gmail.com</div>
|
|
</div>
|
|
<div class="about-contrib">
|
|
<div class="about-contrib-name">fanzhengweiwudi</div>
|
|
<div class="about-contrib-id">fanzhengweiwudi@github</div>
|
|
</div>
|
|
<div class="about-contrib">
|
|
<div class="about-contrib-name">Teddy_Zhu</div>
|
|
<div class="about-contrib-id">teddyzhu15@gmail.com</div>
|
|
</div>
|
|
<div class="about-contrib">
|
|
<div class="about-contrib-name">sicheng</div>
|
|
<div class="about-contrib-id">albert216@126.com</div>
|
|
</div>
|
|
<div class="about-contrib">
|
|
<div class="about-contrib-name">buliangjun</div>
|
|
<div class="about-contrib-id">buliangjunpp@github</div>
|
|
</div>
|
|
<div class="about-contrib">
|
|
<div class="about-contrib-name">lishuai</div>
|
|
<div class="about-contrib-id">872505094@qq.com</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div><!-- /page-content -->
|
|
</main>
|
|
</div><!-- /layout -->
|
|
</div><!-- /app -->
|
|
|
|
<div id="toast"></div>
|
|
|
|
<script>
|
|
// =====================
|
|
// State
|
|
// =====================
|
|
const MONITOR_PAGES = new Set(['overview', 'clients', 'routes']);
|
|
const REFRESH_INTERVAL = 30;
|
|
|
|
const State = {
|
|
token: '',
|
|
baseUrl: '',
|
|
currentPage: 'overview',
|
|
refreshTimer: null,
|
|
indexData: null
|
|
};
|
|
|
|
// =====================
|
|
// Utilities
|
|
// =====================
|
|
function fmt_bytes(b) {
|
|
if (!b || b === 0) return '0 B';
|
|
const units = ['B','KB','MB','GB','TB'];
|
|
const i = Math.floor(Math.log(b) / Math.log(1024));
|
|
return (b / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1) + ' ' + units[i];
|
|
}
|
|
|
|
function fmt_bits(bytesPerSecond) {
|
|
if (!bytesPerSecond || bytesPerSecond === 0) return '0 bps';
|
|
const bps = bytesPerSecond * 8;
|
|
const units = ['bps','Kbps','Mbps','Gbps'];
|
|
const i = Math.floor(Math.log(bps) / Math.log(1000));
|
|
return (bps / Math.pow(1000, i)).toFixed(1) + ' ' + units[i];
|
|
}
|
|
|
|
function fmt_duration(secs) {
|
|
if (!secs || secs <= 0) return '-';
|
|
const d = Math.floor(secs / 86400);
|
|
const h = Math.floor((secs % 86400) / 3600);
|
|
const m = Math.floor((secs % 3600) / 60);
|
|
const s = secs % 60;
|
|
if (d > 0) return d + 'd ' + h + 'h';
|
|
if (h > 0) return h + 'h ' + m + 'm';
|
|
if (m > 0) return m + 'm ' + s + 's';
|
|
return s + 's';
|
|
}
|
|
|
|
function state_badge(s) {
|
|
if (!s) s = 'unknown';
|
|
const sl = s.toLowerCase();
|
|
if (sl === 'authenticated' || sl === 'up' || sl === 'success') return '<span class="badge badge-green">' + s + '</span>';
|
|
if (sl === 'unauthenticated' || sl === 'down' || sl === 'failed' || sl === 'closed') return '<span class="badge badge-red">' + s + '</span>';
|
|
if (sl === 'connecting' || sl === 'initialized') return '<span class="badge badge-yellow">' + s + '</span>';
|
|
if (sl === 'connected' || sl === 'terminal') return '<span class="badge badge-blue">' + s + '</span>';
|
|
return '<span class="badge badge-gray">' + s + '</span>';
|
|
}
|
|
|
|
function role_badge(r) {
|
|
if (r === 'admin') return '<span class="badge badge-purple">admin</span>';
|
|
return '<span class="badge badge-blue">guest</span>';
|
|
}
|
|
|
|
function show_toast(msg, type) {
|
|
const t = document.getElementById('toast');
|
|
t.textContent = msg;
|
|
t.className = 'show ' + (type || '');
|
|
setTimeout(() => { t.className = ''; }, 3500);
|
|
}
|
|
|
|
let _filePreviewDownload = null;
|
|
let _filePreviewText = '';
|
|
let _filePreviewViews = [];
|
|
let _filePreviewActiveView = '';
|
|
|
|
function escapeHtml(str) {
|
|
return String(str == null ? '' : str)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function renderFilePreviewTabs() {
|
|
const tabs = document.getElementById('file-preview-tabs');
|
|
const buttons = document.getElementById('file-preview-tab-buttons');
|
|
if (!tabs || !buttons) return;
|
|
if (_filePreviewViews.length <= 1) {
|
|
tabs.style.display = 'none';
|
|
buttons.innerHTML = '';
|
|
return;
|
|
}
|
|
tabs.style.display = 'flex';
|
|
buttons.innerHTML = _filePreviewViews.map(view =>
|
|
'<button class="btn btn-sm ' + (_filePreviewActiveView === view.id ? 'btn-dark' : 'btn-ghost') + '" type="button" onclick="switchFilePreviewView(\'' + esc(view.id) + '\')">' + esc(view.label || view.id) + '</button>'
|
|
).join('');
|
|
}
|
|
|
|
async function switchFilePreviewView(id) {
|
|
const body = document.getElementById('file-preview-body');
|
|
const copyBtn = document.getElementById('file-preview-copy');
|
|
const view = _filePreviewViews.find(v => v.id === id);
|
|
if (!body || !view) return;
|
|
_filePreviewActiveView = id;
|
|
renderFilePreviewTabs();
|
|
body.innerHTML = '<span class="file-preview-loading">Loading...</span>';
|
|
copyBtn.disabled = true;
|
|
_filePreviewText = '';
|
|
try {
|
|
if (typeof view.text !== 'string') {
|
|
view.text = await view.load();
|
|
}
|
|
_filePreviewText = String(view.text || '');
|
|
body.textContent = _filePreviewText;
|
|
copyBtn.disabled = false;
|
|
} catch(e) {
|
|
body.innerHTML = '<span class="file-preview-error">' + escapeHtml(e.message || 'Failed to load file') + '</span>';
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async function openTextPreview(url, filename, downloadAction, extraViews, primaryViewLabel) {
|
|
const title = document.getElementById('file-preview-title');
|
|
const body = document.getElementById('file-preview-body');
|
|
const downloadBtn = document.getElementById('file-preview-download');
|
|
const copyBtn = document.getElementById('file-preview-copy');
|
|
title.textContent = filename;
|
|
body.innerHTML = '<span class="file-preview-loading">Loading...</span>';
|
|
_filePreviewText = '';
|
|
_filePreviewViews = [];
|
|
_filePreviewActiveView = '';
|
|
_filePreviewDownload = typeof downloadAction === 'function' ? downloadAction : null;
|
|
downloadBtn.disabled = !_filePreviewDownload;
|
|
copyBtn.disabled = true;
|
|
document.getElementById('dlg-file-preview').classList.remove('hidden');
|
|
_filePreviewViews = [{
|
|
id: 'yaml',
|
|
label: primaryViewLabel || 'YAML',
|
|
load: async function() {
|
|
const resp = await fetch(url, { method: 'GET' });
|
|
if (!resp.ok) {
|
|
throw new Error('Failed to load file: HTTP ' + resp.status + ': ' + resp.statusText);
|
|
}
|
|
return await resp.text();
|
|
}
|
|
}];
|
|
if (Array.isArray(extraViews) && extraViews.length > 0) {
|
|
_filePreviewViews = _filePreviewViews.concat(extraViews.filter(Boolean).map(function(view) {
|
|
return {
|
|
id: view.id,
|
|
label: view.label,
|
|
load: view.load,
|
|
text: typeof view.text === 'string' ? view.text : undefined
|
|
};
|
|
}));
|
|
}
|
|
renderFilePreviewTabs();
|
|
await switchFilePreviewView('yaml');
|
|
}
|
|
|
|
function closeFilePreview() {
|
|
document.getElementById('dlg-file-preview').classList.add('hidden');
|
|
_filePreviewDownload = null;
|
|
_filePreviewText = '';
|
|
_filePreviewViews = [];
|
|
_filePreviewActiveView = '';
|
|
renderFilePreviewTabs();
|
|
}
|
|
|
|
function downloadCurrentPreview() {
|
|
if (typeof _filePreviewDownload === 'function') _filePreviewDownload();
|
|
}
|
|
|
|
function copyCurrentPreview() {
|
|
if (!_filePreviewText) return;
|
|
copyTextToClipboard(_filePreviewText)
|
|
.then(function() { show_toast('Copied', 'success'); })
|
|
.catch(function(e) { show_toast('Failed to copy: ' + e.message, 'error'); });
|
|
}
|
|
|
|
function gen_password() {
|
|
const chars = 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789!@#';
|
|
let p = '';
|
|
for (let i = 0; i < 12; i++) p += chars[Math.floor(Math.random() * chars.length)];
|
|
return p;
|
|
}
|
|
|
|
// =====================
|
|
// API
|
|
// =====================
|
|
async function api_get(path) {
|
|
const url = State.baseUrl + path + (path.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(State.token);
|
|
const resp = await fetch(url, { method: 'GET', headers: { 'Accept': 'application/json' } });
|
|
if (!resp.ok) throw new Error('HTTP ' + resp.status + ': ' + resp.statusText);
|
|
return resp.json();
|
|
}
|
|
|
|
async function api_get_text(path) {
|
|
const url = State.baseUrl + path + (path.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(State.token);
|
|
const resp = await fetch(url, { method: 'GET', headers: { 'Accept': 'text/plain' } });
|
|
if (!resp.ok) throw new Error('HTTP ' + resp.status + ': ' + resp.statusText);
|
|
return resp.text();
|
|
}
|
|
|
|
async function api_post(path, data) {
|
|
const url = State.baseUrl + path + '?token=' + encodeURIComponent(State.token);
|
|
const resp = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
body: JSON.stringify(data)
|
|
});
|
|
if (!resp.ok) {
|
|
let msg = 'HTTP ' + resp.status;
|
|
try { const t = await resp.text(); msg = t.trim() || msg; } catch(e) {}
|
|
throw new Error(msg);
|
|
}
|
|
return resp;
|
|
}
|
|
|
|
async function api_put(path, data) {
|
|
const url = State.baseUrl + path + '?token=' + encodeURIComponent(State.token);
|
|
const opts = { method: 'PUT', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' } };
|
|
if (data !== undefined) opts.body = JSON.stringify(data);
|
|
const resp = await fetch(url, opts);
|
|
if (!resp.ok) {
|
|
let msg = 'HTTP ' + resp.status;
|
|
try { const t = await resp.text(); msg = t.trim() || msg; } catch(e) {}
|
|
throw new Error(msg);
|
|
}
|
|
return resp;
|
|
}
|
|
|
|
async function api_delete(path, data) {
|
|
const url = State.baseUrl + path + '?token=' + encodeURIComponent(State.token);
|
|
const opts = { method: 'DELETE', headers: { 'Accept': 'application/json' } };
|
|
if (data) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(data); }
|
|
const resp = await fetch(url, opts);
|
|
if (!resp.ok) {
|
|
let msg = 'HTTP ' + resp.status;
|
|
try { const t = await resp.text(); msg = t.trim() || msg; } catch(e) {}
|
|
throw new Error(msg);
|
|
}
|
|
return resp;
|
|
}
|
|
|
|
// =====================
|
|
// Auth
|
|
// =====================
|
|
function show_dashboard() {
|
|
document.getElementById('app').style.display = 'block';
|
|
start_refresh_timer();
|
|
}
|
|
|
|
function start_refresh_timer() {
|
|
stop_refresh_timer();
|
|
let countdown = REFRESH_INTERVAL;
|
|
const el = document.getElementById('refresh-countdown');
|
|
State.refreshTimer = setInterval(() => {
|
|
countdown--;
|
|
if (el) el.textContent = 'Auto-refresh in ' + countdown + 's';
|
|
if (countdown <= 0) {
|
|
countdown = REFRESH_INTERVAL;
|
|
refreshCurrentPage();
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
function stop_refresh_timer() {
|
|
if (State.refreshTimer) { clearInterval(State.refreshTimer); State.refreshTimer = null; }
|
|
const el = document.getElementById('refresh-countdown');
|
|
if (el) el.textContent = '';
|
|
}
|
|
|
|
// =====================
|
|
// Navigation
|
|
// =====================
|
|
const PAGE_TITLES = {
|
|
overview: 'Overview',
|
|
clients: 'Clients / Links',
|
|
routes: 'Routes',
|
|
networks: 'Virtual Network',
|
|
bgp: 'BGP Network',
|
|
router: 'Router Network',
|
|
ipsec: 'IPSec Network',
|
|
ceci: 'Ceci Network',
|
|
users: 'Users',
|
|
about: 'About'
|
|
};
|
|
|
|
function showPage(name) {
|
|
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
|
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
|
const page = document.getElementById('page-' + name);
|
|
const nav = document.getElementById('nav-' + name);
|
|
if (page) page.classList.add('active');
|
|
if (nav) nav.classList.add('active');
|
|
State.currentPage = name;
|
|
if (MONITOR_PAGES.has(name)) {
|
|
start_refresh_timer();
|
|
} else {
|
|
stop_refresh_timer();
|
|
}
|
|
refreshCurrentPage();
|
|
}
|
|
|
|
function gotoPanel(panel) {
|
|
showPage(panel);
|
|
}
|
|
|
|
function refreshCurrentPage() {
|
|
const btn = document.getElementById('btn-refresh');
|
|
const overlay = document.getElementById('page-loading');
|
|
btn.classList.add('spinning');
|
|
overlay.style.display = 'flex';
|
|
const done = () => { btn.classList.remove('spinning'); overlay.style.display = 'none'; };
|
|
const p = State.currentPage;
|
|
if (p === 'overview') load_overview().then(done).catch(done);
|
|
else if (p === 'clients') load_clients().then(done).catch(done);
|
|
else if (p === 'routes') load_routes().then(done).catch(done);
|
|
else if (p === 'networks') load_networks().then(done).catch(done);
|
|
else if (p === 'bgp') load_bgp().then(done).catch(done);
|
|
else if (p === 'router') load_router().then(done).catch(done);
|
|
else if (p === 'ipsec') load_ipsec().then(done).catch(done);
|
|
else if (p === 'ceci') load_ceci().then(done).catch(done);
|
|
else if (p === 'users') load_users().then(done).catch(done);
|
|
else if (p === 'about') load_about().then(done).catch(done);
|
|
else done();
|
|
}
|
|
|
|
|
|
// =====================
|
|
// Overview
|
|
// =====================
|
|
async function load_overview() {
|
|
try {
|
|
const d = await api_get('/api/index');
|
|
State.indexData = d;
|
|
render_overview(d);
|
|
} catch(e) {
|
|
show_toast('Failed to load overview: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
function pct(used, total) {
|
|
if (!total || total === 0) return 0;
|
|
return Math.min(100, Math.round(used / total * 100));
|
|
}
|
|
|
|
function pct_class(p) {
|
|
if (p > 85) return 'red';
|
|
if (p > 60) return 'yellow';
|
|
return 'green';
|
|
}
|
|
|
|
function render_overview(d) {
|
|
// Version info for sidebar
|
|
if (d.version && d.version.version) {
|
|
document.getElementById('sw-version').textContent = d.version.version;
|
|
}
|
|
|
|
// Stats grid
|
|
const u = d.usage || {};
|
|
const cpuPct = u.cpuUsage || 0;
|
|
const memPct = pct(u.memUsed, u.memTotal);
|
|
const diskPct = pct(u.diskUsed, u.diskTotal);
|
|
const connTotal = (d.conntrack || {}).total || 0;
|
|
const accessCount = d.accessLen || (d.access || []).length;
|
|
const vpnCount = d.clientLen || (d.clients || []).length;
|
|
const clientCount = accessCount + vpnCount;
|
|
const linkCount = d.linkLen || (d.output || []).length;
|
|
|
|
document.getElementById('overview-stats').innerHTML =
|
|
stat_card('CPU Usage', cpuPct + '%', (u.cpuTotal || 0) + ' cores', 'blue',
|
|
'<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>',
|
|
'<div class="progress-wrap"><div class="progress-bar ' + pct_class(cpuPct) + '" style="width:' + cpuPct + '%"></div></div>') +
|
|
stat_card('Memory', fmt_bytes(u.memUsed), 'of ' + fmt_bytes(u.memTotal), 'purple',
|
|
'<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>',
|
|
'<div class="progress-wrap"><div class="progress-bar ' + pct_class(memPct) + '" style="width:' + memPct + '%"></div></div>') +
|
|
stat_card('Disk', fmt_bytes(u.diskUsed), 'of ' + fmt_bytes(u.diskTotal), 'yellow',
|
|
'<path d="M22 12H2"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
|
|
'<div class="progress-wrap"><div class="progress-bar ' + pct_class(diskPct) + '" style="width:' + diskPct + '%"></div></div>') +
|
|
stat_card('Connections', connTotal, 'TCP ' + ((d.conntrack||{}).tcp||0) + ' / UDP ' + ((d.conntrack||{}).udp||0), 'green',
|
|
'<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>',
|
|
'') +
|
|
stat_card('Clients', clientCount, accessCount + ' AP + ' + vpnCount + ' VPN', 'teal',
|
|
'<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>',
|
|
'') +
|
|
stat_card('Links', linkCount, 'inter-output', 'red',
|
|
'<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
|
|
'');
|
|
|
|
// Node info
|
|
const w = d.worker || {};
|
|
const v = d.version || {};
|
|
document.getElementById('node-info-table').innerHTML =
|
|
node_row('Alias', w.alias || '-') +
|
|
node_row('UUID', '<span class="td-mono">' + (w.uuid || '-') + '</span>') +
|
|
node_row('Uptime', fmt_duration(w.uptime)) +
|
|
node_row('Protocol', w.protocol || '-') +
|
|
node_row('Version', (v.version || '-') + ' (' + (v.date || '') + ')') +
|
|
node_row('Expires', v.expire || '-') +
|
|
node_row('Users', (d.userLen || 0) + ' registered');
|
|
|
|
// Conntrack
|
|
const ct = d.conntrack || {};
|
|
document.getElementById('conntrack-grid').innerHTML =
|
|
ct_card(ct.total || 0, 'Total') +
|
|
ct_card(ct.tcp || 0, 'TCP') +
|
|
ct_card(ct.udp || 0, 'UDP') +
|
|
ct_card(ct.icmp || 0, 'ICMP');
|
|
|
|
// Devices
|
|
const devs = d.device || [];
|
|
document.getElementById('devices-count').textContent = devs.length;
|
|
const devBody = document.getElementById('devices-tbody');
|
|
if (devs.length === 0) {
|
|
devBody.innerHTML = '<tr class="empty-row"><td colspan="8">No devices found</td></tr>';
|
|
} else {
|
|
devBody.innerHTML = devs.map(dev => {
|
|
const addrs = (dev.address || []).join('<br>') || '-';
|
|
return '<tr>' +
|
|
'<td>' + (dev.network || '-') + '</td>' +
|
|
'<td class="td-mono">' + (dev.name || '-') + '</td>' +
|
|
'<td>' + state_badge(dev.state) + '</td>' +
|
|
'<td>' + (dev.mtu || '-') + '</td>' +
|
|
'<td class="td-mono td-muted">' + (dev.mac || '-') + '</td>' +
|
|
'<td class="td-mono" style="font-size:12px;">' + addrs + '</td>' +
|
|
'<td>' + fmt_bytes(dev.rxBytes||dev.recv||0) + ' / ' + fmt_bytes(dev.txBytes||dev.send||0) + '</td>' +
|
|
'<td>' + fmt_bits(dev.rxSpeed||0) + ' / ' + fmt_bits(dev.txSpeed||0) + '</td>' +
|
|
'</tr>';
|
|
}).join('');
|
|
}
|
|
}
|
|
|
|
function stat_card(label, value, sub, color, svg_path, extra) {
|
|
return '<div class="stat-card">' +
|
|
'<div class="stat-icon ' + color + '">' +
|
|
'<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">' + svg_path + '</svg>' +
|
|
'</div>' +
|
|
'<div class="stat-body">' +
|
|
'<div class="stat-label">' + label + '</div>' +
|
|
'<div class="stat-value">' + value + '</div>' +
|
|
'<div class="stat-sub">' + sub + '</div>' +
|
|
extra +
|
|
'</div>' +
|
|
'</div>';
|
|
}
|
|
|
|
function node_row(k, v) {
|
|
return '<tr><td>' + k + '</td><td>' + v + '</td></tr>';
|
|
}
|
|
|
|
function ct_card(num, label) {
|
|
return '<div class="ct-card"><div class="ct-num">' + num + '</div><div class="ct-label">' + label + '</div></div>';
|
|
}
|
|
|
|
function sort_by_device(items) {
|
|
return [...items].sort((a, b) => {
|
|
const deviceA = ((a && a.device) || '').toLowerCase();
|
|
const deviceB = ((b && b.device) || '').toLowerCase();
|
|
if (deviceA === deviceB) return 0;
|
|
if (!deviceA) return 1;
|
|
if (!deviceB) return -1;
|
|
return deviceA.localeCompare(deviceB, undefined, { numeric: true });
|
|
});
|
|
}
|
|
|
|
// =====================
|
|
// Clients
|
|
// =====================
|
|
async function load_clients() {
|
|
let access = [];
|
|
let clients = [];
|
|
let outputs = [];
|
|
let devices = [];
|
|
try {
|
|
const [accessData, clientData, outputData, deviceData] = await Promise.all([
|
|
api_get('/api/point'),
|
|
api_get('/api/vpn/client'),
|
|
api_get('/api/output'),
|
|
api_get('/api/device')
|
|
]);
|
|
access = Array.isArray(accessData) ? accessData : [];
|
|
clients = Array.isArray(clientData) ? clientData : [];
|
|
outputs = Array.isArray(outputData) ? outputData : [];
|
|
devices = Array.isArray(deviceData) ? deviceData : [];
|
|
} catch(e) {
|
|
show_toast('Failed to load clients: ' + e.message, 'error');
|
|
}
|
|
|
|
const deviceSpeedMap = {};
|
|
devices.forEach(dev => {
|
|
if (!dev || !dev.name) return;
|
|
deviceSpeedMap[dev.name] = {
|
|
rxSpeed: dev.rxSpeed || 0,
|
|
txSpeed: dev.txSpeed || 0
|
|
};
|
|
});
|
|
|
|
access = sort_by_device(access);
|
|
outputs = sort_by_device(outputs);
|
|
|
|
document.getElementById('access-count').textContent = access.length;
|
|
const ab = document.getElementById('access-tbody');
|
|
if (access.length === 0) {
|
|
ab.innerHTML = '<tr class="empty-row"><td colspan="9">No access clients connected</td></tr>';
|
|
} else {
|
|
ab.innerHTML = access.map(a => {
|
|
const devSpeed = deviceSpeedMap[a.device] || {};
|
|
return '<tr>' +
|
|
'<td>' + (a.network || '-') + '</td>' +
|
|
'<td>' + (a.user || '-') + '</td>' +
|
|
'<td>' + (a.alias || '-') + '</td>' +
|
|
'<td class="td-mono">' + (a.device || '-') + '</td>' +
|
|
'<td class="td-mono">' + (a.remote || '-') + '</td>' +
|
|
'<td><span class="badge badge-blue">' + (a.protocol || '-') + '</span></td>' +
|
|
'<td>' + fmt_bits(devSpeed.rxSpeed||0) + ' / ' + fmt_bits(devSpeed.txSpeed||0) + '</td>' +
|
|
'<td>' + state_badge(a.state) + '</td>' +
|
|
'<td>' + fmt_duration(a.aliveTime) + '</td>' +
|
|
'</tr>';
|
|
}).join('');
|
|
}
|
|
|
|
render_links_table(outputs, deviceSpeedMap);
|
|
|
|
document.getElementById('vpn-count').textContent = clients.length;
|
|
const vb = document.getElementById('vpn-tbody');
|
|
if (clients.length === 0) {
|
|
vb.innerHTML = '<tr class="empty-row"><td colspan="7">No VPN clients connected</td></tr>';
|
|
} else {
|
|
vb.innerHTML = clients.map(c => '<tr>' +
|
|
'<td class="td-mono">' + (c.device || '-') + '</td>' +
|
|
'<td class="td-mono">' + (c.address || '-') + '</td>' +
|
|
'<td class="td-mono">' + (c.remote || '-') + '</td>' +
|
|
'<td>' + (c.name || '-') + '</td>' +
|
|
'<td>' + fmt_bits(c.rxSpeed||0) + ' / ' + fmt_bits(c.txSpeed||0) + '</td>' +
|
|
'<td>' + state_badge(c.state) + '</td>' +
|
|
'<td>' + fmt_duration(c.aliveTime) + '</td>' +
|
|
'</tr>').join('');
|
|
}
|
|
}
|
|
|
|
function render_links_table(outputs, deviceSpeedMap) {
|
|
document.getElementById('links-count').textContent = outputs.length;
|
|
const lb = document.getElementById('links-tbody');
|
|
if (outputs.length === 0) {
|
|
lb.innerHTML = '<tr class="empty-row"><td colspan="7">No links configured</td></tr>';
|
|
} else {
|
|
lb.innerHTML = outputs.map(o => {
|
|
const devSpeed = deviceSpeedMap[o.device] || {};
|
|
const state = (o.rxBytes && o.rxBytes > 0) ? 'success' : (o.state || 'unknown');
|
|
return '<tr>' +
|
|
'<td>' + (o.network || '-') + '</td>' +
|
|
'<td class="td-mono">' + (o.device || '-') + '</td>' +
|
|
'<td><a href="https://' + (o.remote||'') + ':10000" target="_blank" class="td-mono" style="color:var(--primary);text-decoration:none;">' + (o.remote || '-') + '</a></td>' +
|
|
'<td><span class="badge badge-blue">' + (o.protocol || '-') + '</span></td>' +
|
|
'<td>' + fmt_bits(devSpeed.rxSpeed||0) + ' / ' + fmt_bits(devSpeed.txSpeed||0) + '</td>' +
|
|
'<td>' + state_badge(state) + '</td>' +
|
|
'<td>' + fmt_duration(o.aliveTime) + '</td>' +
|
|
'</tr>';
|
|
}).join('');
|
|
}
|
|
}
|
|
|
|
// =====================
|
|
// Routes
|
|
// =====================
|
|
async function load_routes() {
|
|
let routes = [];
|
|
try {
|
|
const [routeData] = await Promise.all([
|
|
api_get('/api/kernel/route'),
|
|
sync_kernel_neighbors()
|
|
]);
|
|
routes = Array.isArray(routeData) ? routeData : [];
|
|
} catch(e) {
|
|
show_toast('Failed to load kernel routes: ' + e.message, 'error');
|
|
}
|
|
|
|
const neighbors = Array.isArray(_kernelNeighbors) ? _kernelNeighbors : [];
|
|
|
|
document.getElementById('routes-count').textContent = routes.length;
|
|
const rb = document.getElementById('routes-tbody');
|
|
if (routes.length === 0) {
|
|
rb.innerHTML = '<tr class="empty-row"><td colspan="7">No routes found</td></tr>';
|
|
} else {
|
|
rb.innerHTML = routes.map(r => {
|
|
let nexthop = r.nexthop || '-';
|
|
let link = r.link || '-';
|
|
if (r.multipath && r.multipath.length > 0) {
|
|
nexthop = r.multipath.map(m => m.nexthop).join('<br>');
|
|
link = r.multipath.map(m => m.link).join('<br>');
|
|
}
|
|
return '<tr>' +
|
|
'<td>' + (r.table || '-') + '</td>' +
|
|
'<td><span class="badge badge-gray">' + (r.protocol || '-') + '</span></td>' +
|
|
'<td class="td-mono">' + (r.prefix || '-') + '</td>' +
|
|
'<td class="td-mono">' + nexthop + '</td>' +
|
|
'<td class="td-mono">' + link + '</td>' +
|
|
'<td class="td-mono">' + (r.source || '-') + '</td>' +
|
|
'<td>' + (r.metric || 0) + '</td>' +
|
|
'</tr>';
|
|
}).join('');
|
|
}
|
|
|
|
document.getElementById('neighbors-count').textContent = neighbors.length;
|
|
const nb = document.getElementById('neighbors-tbody');
|
|
if (neighbors.length === 0) {
|
|
nb.innerHTML = '<tr class="empty-row"><td colspan="4">No ARP entries found</td></tr>';
|
|
} else {
|
|
nb.innerHTML = neighbors.map(n => '<tr>' +
|
|
'<td class="td-mono">' + (n.address || '-') + '</td>' +
|
|
'<td class="td-mono td-muted">' + (n.hwaddr || '-') + '</td>' +
|
|
'<td class="td-mono">' + (n.link || '-') + '</td>' +
|
|
'<td>' + state_badge(n.state) + '</td>' +
|
|
'</tr>').join('');
|
|
}
|
|
}
|
|
|
|
async function sync_kernel_neighbors() {
|
|
const neighborData = await api_get('/api/kernel/neighbor');
|
|
_kernelNeighbors = Array.isArray(neighborData) ? neighborData : [];
|
|
return _kernelNeighbors;
|
|
}
|
|
|
|
// =====================
|
|
// Networks
|
|
// =====================
|
|
let _networksData = [];
|
|
|
|
function statusBadge(status, disabledText = 'Disabled') {
|
|
const value = (status || '').toLowerCase();
|
|
if (value === 'running') return '<span class="badge badge-green">Running</span>';
|
|
if (value === 'stopped') return '<span class="badge badge-red">Stopped</span>';
|
|
if (value === 'disabled') return '<span class="badge badge-gray">' + esc(disabledText) + '</span>';
|
|
return '<span class="badge badge-yellow">' + esc(status || '-') + '</span>';
|
|
}
|
|
|
|
async function load_networks() {
|
|
try {
|
|
const nets = await api_get('/api/network');
|
|
_networksData = Array.isArray(nets) ? nets : (nets ? [nets] : []);
|
|
const SPECIAL_NETS = new Set(['bgp', 'router', 'ipsec', 'ceci']);
|
|
const regularNets = _networksData.filter(n => !SPECIAL_NETS.has(n.name));
|
|
document.getElementById('networks-count').textContent = regularNets.length;
|
|
const nb = document.getElementById('networks-tbody');
|
|
if (regularNets.length === 0) {
|
|
nb.innerHTML = '<tr class="empty-row"><td colspan="7">No networks configured</td></tr>';
|
|
} else {
|
|
nb.innerHTML = regularNets.map((n, i) => {
|
|
// remap index to _networksData
|
|
i = _networksData.indexOf(n);
|
|
const cfg = n.config || {};
|
|
const subnet = (cfg.bridge && cfg.bridge.address) || '-';
|
|
const routeCount = Array.isArray(cfg.routes) ? cfg.routes.length : 0;
|
|
const outputCount = Array.isArray(cfg.outputs) ? cfg.outputs.length : 0;
|
|
const snat = cfg.snat || 'disable';
|
|
const ovpn = cfg.openvpn
|
|
? ((cfg.openvpn.protocol || '-') + (cfg.openvpn.listen || '-'))
|
|
: '';
|
|
const ovpnStatus = statusBadge(n.openvpnStatus, 'Disabled');
|
|
return '<tr>' +
|
|
'<td style="font-weight:600;">' + (n.name || '-') + '</td>' +
|
|
'<td class="td-muted td-mono" style="font-size:12px;">' + subnet + '</td>' +
|
|
'<td class="td-muted" style="font-size:12px;">' + routeCount + '</td>' +
|
|
'<td class="td-muted" style="font-size:12px;">' + outputCount + '</td>' +
|
|
'<td class="td-muted td-mono" style="font-size:12px;">' + snat + '</td>' +
|
|
'<td class="td-muted td-mono" style="font-size:12px;"><div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">' + (ovpn ? '<span>' + ovpn + '</span>' : '') + ovpnStatus + '</div></td>' +
|
|
'<td style="display:flex;gap:6px;">' +
|
|
'<button class="action-btn btn-detail" onclick="openNetModal(' + i + ')">Details</button>' +
|
|
'<button class="action-btn btn-save" onclick="saveNetwork(\'' + esc(n.name) + '\')">Save</button>' +
|
|
(SPECIAL_NETS.has(n.name) ? '' : '<button class="action-btn action-delete" onclick="removeNetwork(\'' + esc(n.name) + '\')">Remove</button>') +
|
|
'</td>' +
|
|
'</tr>';
|
|
}).join('');
|
|
}
|
|
load_vpn_clients();
|
|
load_special_nav();
|
|
} catch(e) {
|
|
show_toast('Failed to load networks: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function load_special_nav() {
|
|
const hasBgp = _networksData.some(n => n.name === 'bgp');
|
|
const hasRouter = _networksData.some(n => n.name === 'router');
|
|
const hasIpsec = _networksData.some(n => n.name === 'ipsec');
|
|
const hasCeci = _networksData.some(n => n.name === 'ceci');
|
|
|
|
document.getElementById('nav-bgp').style.display = hasBgp ? '' : 'none';
|
|
document.getElementById('nav-router').style.display = hasRouter ? '' : 'none';
|
|
document.getElementById('nav-ipsec').style.display = hasIpsec ? '' : 'none';
|
|
document.getElementById('nav-ceci').style.display = hasCeci ? '' : 'none';
|
|
|
|
}
|
|
|
|
async function restartIpsecTunnel(local, remote, protocol) {
|
|
try {
|
|
await api_put('/api/network/ipsec/tunnel/restart', { local, remote, protocol });
|
|
show_toast('Tunnel restarting', 'success');
|
|
setTimeout(load_ipsec, 1500);
|
|
} catch(e) {
|
|
show_toast('Failed to restart: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function load_bgp() {
|
|
try {
|
|
const bgp = await api_get('/api/network/bgp/global');
|
|
const g = document.getElementById('bgp-global');
|
|
if (bgp) {
|
|
document.getElementById('bgp-input-localas').value = bgp.localas || '';
|
|
document.getElementById('bgp-input-routerid').value = bgp.routerid || '';
|
|
const tb = document.getElementById('bgp-neighbors-tbody');
|
|
const neighbors = bgp.neighbors || [];
|
|
tb.innerHTML = neighbors.length === 0
|
|
? '<tr class="empty-row"><td colspan="6">No neighbors</td></tr>'
|
|
: neighbors.map(nb => {
|
|
const bc = nb.state === 'established' ? 'badge-green' : 'badge-gray';
|
|
const addr = esc(nb.address || '');
|
|
const adv = bgpPrefixTags(nb.advertis, addr, 'advertis') +
|
|
'<button onclick="bgpOpenAddPrefix(\'advertis\',\'' + addr + '\')" title="Add" style="background:none;border:none;cursor:pointer;padding:2px 4px;color:var(--text-muted);font-size:16px;line-height:1;">+</button>';
|
|
const rcv = bgpPrefixTags(nb.receives, addr, 'receives') +
|
|
'<button onclick="bgpOpenAddPrefix(\'receives\',\'' + addr + '\')" title="Add" style="background:none;border:none;cursor:pointer;padding:2px 4px;color:var(--text-muted);font-size:16px;line-height:1;">+</button>';
|
|
return '<tr>' +
|
|
'<td class="td-mono">' + addr + '</td>' +
|
|
'<td class="td-muted">' + (nb.remoteas || '-') + '</td>' +
|
|
'<td><span class="badge ' + bc + '">' + esc(nb.state || '-') + '</span></td>' +
|
|
'<td style="min-width:140px;">' + adv + '</td>' +
|
|
'<td style="min-width:140px;">' + rcv + '</td>' +
|
|
'<td><button class="action-btn btn-danger" onclick="bgpDelNeighbor(\'' + addr + '\',' + (nb.remoteas||0) + ')">Remove</button></td>' +
|
|
'</tr>';
|
|
}).join('');
|
|
}
|
|
} catch(e) {
|
|
// inputs remain empty on error
|
|
}
|
|
}
|
|
|
|
function bgpPrefixTags(list, neighbor, type) {
|
|
if (!list || list.length === 0) return '';
|
|
return list.map(p =>
|
|
'<div style="display:flex;align-items:center;gap:4px;font-family:monospace;font-size:12px;color:var(--text);padding:2px 0;">' +
|
|
esc(p) +
|
|
'<button onclick="bgpDelPrefix(\'' + type + '\',\'' + esc(neighbor) + '\',\'' + esc(p) + '\')" title="Remove" style="background:none;border:none;cursor:pointer;padding:0 0 0 3px;color:var(--text-muted);font-size:13px;line-height:1;">✕</button>' +
|
|
'</div>'
|
|
).join('');
|
|
}
|
|
|
|
let _bgpPrefixType = '';
|
|
function bgpOpenAddPrefix(type, neighbor) {
|
|
_bgpPrefixType = type;
|
|
document.getElementById('dlg-bgp-prefix-title').textContent = 'Add ' + (type === 'advertis' ? 'Advertised' : 'Received') + ' Prefix';
|
|
document.getElementById('bgp-prefix-neighbor').value = neighbor;
|
|
document.getElementById('bgp-prefix-value').value = '';
|
|
openAddDialog('bgp-prefix');
|
|
setTimeout(() => document.getElementById('bgp-prefix-value').focus(), 50);
|
|
}
|
|
|
|
async function bgpAddPrefix() {
|
|
const neighbor = document.getElementById('bgp-prefix-neighbor').value.trim();
|
|
const prefix = document.getElementById('bgp-prefix-value').value.trim();
|
|
if (!prefix) { show_toast('Prefix is required', 'error'); return; }
|
|
try {
|
|
await api_post('/api/network/bgp/' + _bgpPrefixType, { prefix, neighbor });
|
|
closeAddDialog('bgp-prefix');
|
|
await load_bgp();
|
|
show_toast('Prefix added', 'success');
|
|
} catch(e) {
|
|
show_toast('Failed: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function bgpDelPrefix(type, neighbor, prefix) {
|
|
try {
|
|
await api_delete('/api/network/bgp/' + type, { prefix, neighbor });
|
|
await load_bgp();
|
|
} catch(e) {
|
|
show_toast('Failed: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function bgpAddNeighbor() {
|
|
const address = document.getElementById('bgp-nei-address').value.trim();
|
|
const remoteas = parseInt(document.getElementById('bgp-nei-remoteas').value.trim(), 10);
|
|
const password = document.getElementById('bgp-nei-password').value;
|
|
if (!address) { show_toast('Address is required', 'error'); return; }
|
|
if (!remoteas) { show_toast('Remote AS is required', 'error'); return; }
|
|
try {
|
|
const body = { address, remoteas };
|
|
if (password) body.password = password;
|
|
await api_post('/api/network/bgp/neighbor', body);
|
|
document.getElementById('bgp-nei-address').value = '';
|
|
document.getElementById('bgp-nei-remoteas').value = '';
|
|
document.getElementById('bgp-nei-password').value = '';
|
|
closeAddDialog('bgp-neighbor');
|
|
await load_bgp();
|
|
show_toast('Neighbor added', 'success');
|
|
} catch(e) {
|
|
show_toast('Failed: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function bgpDelNeighbor(address, remoteas) {
|
|
const ok = await showConfirm('Remove neighbor ' + address + '?', 'Remove Neighbor');
|
|
if (!ok) return;
|
|
try {
|
|
await api_delete('/api/network/bgp/neighbor', { address, remoteas });
|
|
await load_bgp();
|
|
show_toast('Neighbor removed', 'success');
|
|
} catch(e) {
|
|
show_toast('Failed: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function bgpSaveGlobal() {
|
|
const localas = parseInt(document.getElementById('bgp-input-localas').value.trim(), 10);
|
|
const routerid = document.getElementById('bgp-input-routerid').value.trim();
|
|
if (!localas) { show_toast('Local AS is required', 'error'); return; }
|
|
try {
|
|
await api_post('/api/network/bgp/global', { localas, routerid });
|
|
show_toast('BGP global updated', 'success');
|
|
} catch(e) {
|
|
show_toast('Failed: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
let _routerIfaces = [];
|
|
let _routerSystemIfaces = [];
|
|
let _routerRedirects = [];
|
|
let _routerAddresses = [];
|
|
let _routerAddressDevice = '';
|
|
let _kernelNeighbors = [];
|
|
|
|
async function load_router() {
|
|
try {
|
|
const [ifaces, routerCfg] = await Promise.all([
|
|
api_get('/api/network/router/interface'),
|
|
api_get('/api/network/router').catch(() => ({}))
|
|
]);
|
|
_routerSystemIfaces = Array.isArray(ifaces) ? ifaces : [];
|
|
_routerIfaces = _routerSystemIfaces;
|
|
const cfgIfaces = (routerCfg && routerCfg.config && routerCfg.config.specifies && Array.isArray(routerCfg.config.specifies.interfaces))
|
|
? routerCfg.config.specifies.interfaces : [];
|
|
const cfgAddresses = (routerCfg && routerCfg.config && routerCfg.config.specifies && Array.isArray(routerCfg.config.specifies.addresses))
|
|
? routerCfg.config.specifies.addresses : [];
|
|
_routerAddresses = cfgAddresses.map(normalizeRouterAddress).filter(a => a.address);
|
|
const cfgAddressMap = {};
|
|
_routerAddresses.forEach((a, index) => {
|
|
const device = a.device || 'lo';
|
|
if (!cfgAddressMap[device]) cfgAddressMap[device] = [];
|
|
cfgAddressMap[device].push({ ...a, _routerIndex: index });
|
|
});
|
|
// build map of system ifaces by name
|
|
const sysMap = {};
|
|
_routerIfaces.forEach(f => { sysMap[f.name || f.device || ''] = f; });
|
|
// merge: configured ifaces first (all shown + deletable), then system-only ifaces
|
|
const cfgNames = new Set();
|
|
const merged = [];
|
|
cfgIfaces.forEach(c => {
|
|
const rawVlan = c.vlan || c.VLAN || 0;
|
|
const display = parseRouterIfaceDevice(c.device, rawVlan);
|
|
const vlan = display.vlan;
|
|
const id = vlan ? (display.device + '.' + vlan) : display.device;
|
|
cfgNames.add(id);
|
|
const sys = sysMap[id] || {};
|
|
merged.push({ name: id, _displayDevice: display.device, _device: c.device, _deleteVlan: rawVlan, address: routerDeviceAddresses(id, sys.address, c.address, cfgAddressMap), state: sys.state || '-', network: sys.network || '-', vlan, _canDel: true });
|
|
});
|
|
_routerIfaces.forEach(f => {
|
|
const name = f.name || f.device || '';
|
|
if (!cfgNames.has(name)) {
|
|
const display = parseRouterIfaceDevice(name, f.vlan || f.VLAN || 0);
|
|
const vlan = display.vlan;
|
|
const id = vlan ? (display.device + '.' + vlan) : display.device;
|
|
merged.push({ ...f, name: id, _displayDevice: display.device, _device: name, address: routerDeviceAddresses(id, f.address, '', cfgAddressMap), vlan, _canDel: false });
|
|
}
|
|
});
|
|
Object.keys(cfgAddressMap).forEach(device => {
|
|
if (!cfgNames.has(device) && !sysMap[device]) {
|
|
const display = parseRouterIfaceDevice(device, 0);
|
|
const vlan = display.vlan;
|
|
const id = vlan ? (display.device + '.' + vlan) : display.device;
|
|
merged.push({ name: id, _displayDevice: display.device, _device: device, address: routerDeviceAddresses(device, '', '', cfgAddressMap), state: '-', vlan, _canDel: false });
|
|
}
|
|
});
|
|
_routerIfaces = merged;
|
|
const tb = document.getElementById('router-iface-tbody');
|
|
tb.innerHTML = _routerIfaces.length === 0
|
|
? '<tr class="empty-row"><td colspan="6">No interfaces</td></tr>'
|
|
: _routerIfaces.map((f, i) =>
|
|
'<tr>' +
|
|
'<td class="td-mono" style="font-weight:600;">' + esc(f._displayDevice || f.name || f._device || '-') + '</td>' +
|
|
'<td class="td-muted">' + (f.vlan ? f.vlan : '-') + '</td>' +
|
|
'<td class="td-mono td-muted">' + esc(f.mac || '-') + '</td>' +
|
|
'<td>' + routerAddrTags(f, i) + '</td>' +
|
|
'<td><span class="badge ' + (f.state === 'up' ? 'badge-green' : 'badge-gray') + '">' + esc(f.state || '-') + '</span></td>' +
|
|
'<td style="display:flex;gap:4px;flex-wrap:wrap;">' +
|
|
(f._canDel ? '<button class="action-btn btn-danger" onclick="routerIfaceDel(' + i + ')">Remove</button>' : '') +
|
|
'</td>' +
|
|
'</tr>'
|
|
).join('');
|
|
} catch(e) {
|
|
document.getElementById('router-iface-tbody').innerHTML = '<tr class="empty-row"><td colspan="6">-</td></tr>';
|
|
}
|
|
await load_router_private();
|
|
await load_router_redirect();
|
|
}
|
|
|
|
function routerIfaceAdd() {
|
|
loadRouterIfaceDeviceOptions();
|
|
document.getElementById('router-iface-vlan').value = '0';
|
|
document.getElementById('router-iface-address').value = '';
|
|
openAddDialog('router-iface');
|
|
}
|
|
|
|
function routerIfaceCancel() {
|
|
closeAddDialog('router-iface');
|
|
}
|
|
|
|
function routerAddressAdd(i) {
|
|
const f = _routerIfaces[i];
|
|
if (!f) return;
|
|
_routerAddressDevice = f.name || f._device || f.device || '';
|
|
document.getElementById('router-address-device-label').textContent = _routerAddressDevice || '-';
|
|
document.getElementById('router-address-value').value = '';
|
|
openAddDialog('router-address');
|
|
}
|
|
|
|
function loadRouterIfaceDeviceOptions() {
|
|
const select = document.getElementById('router-iface-device');
|
|
const devices = _routerSystemIfaces.filter(d => (d.type || '').toLowerCase() !== 'vlan' && !parseRouterIfaceDevice(d.name || d.device || '', 0).vlan);
|
|
select.innerHTML = devices.length === 0
|
|
? '<option value="">No devices</option>'
|
|
: devices.map(d => '<option value="' + esc(d.name || d.device || '') + '">' + esc(d.name || d.device || '') + '</option>').join('');
|
|
}
|
|
|
|
function parseRouterIfaceDevice(device, vlan) {
|
|
const parsed = { device: (device || '').trim(), vlan: parseInt(vlan, 10) || 0 };
|
|
if (parsed.vlan) return parsed;
|
|
const match = parsed.device.match(/^(.+)\.(\d+)$/);
|
|
if (!match) return parsed;
|
|
const parsedVlan = parseInt(match[2], 10);
|
|
if (!parsedVlan) return parsed;
|
|
parsed.device = match[1];
|
|
parsed.vlan = parsedVlan;
|
|
return parsed;
|
|
}
|
|
|
|
async function routerIfaceSubmit() {
|
|
const deviceEl = document.getElementById('router-iface-device');
|
|
const vlanEl = document.getElementById('router-iface-vlan');
|
|
const device = deviceEl.value.trim();
|
|
const vlan = parseInt(vlanEl.value, 10) || 0;
|
|
const address = document.getElementById('router-iface-address').value.trim();
|
|
if (!device) { show_toast('Device is required', 'error'); return; }
|
|
if (!address) { show_toast('Address is required', 'error'); return; }
|
|
try {
|
|
await api_post('/api/network/router/interface', { device, vlan, address });
|
|
closeAddDialog('router-iface');
|
|
await load_router();
|
|
show_toast('Interface added', 'success');
|
|
} catch(e) {
|
|
show_toast('Failed: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
function routerAddrTags(f, index) {
|
|
const addrs = Array.isArray(f.address) ? f.address : (f.address ? [f.address] : []);
|
|
const values = addrs.map(a => {
|
|
const item = (typeof a === 'string') ? { address: a } : a;
|
|
const remove = Number.isInteger(item._routerIndex)
|
|
? '<button onclick="routerDelAddress(' + item._routerIndex + ')" title="Remove" style="background:none;border:none;cursor:pointer;padding:0 0 0 4px;color:var(--text-muted);font-size:13px;line-height:1;">✕</button>'
|
|
: '';
|
|
return '<div style="display:flex;align-items:center;gap:6px;font-family:monospace;font-size:12px;color:var(--text);padding:2px 0;">' + esc(item.address) + remove + '</div>';
|
|
}).join('');
|
|
return '<div style="display:flex;flex-direction:column;align-items:flex-start;gap:3px;">' +
|
|
values +
|
|
'<button onclick="routerAddressAdd(' + index + ')" title="Add Address" style="background:#21262d;border:1px solid var(--border);border-radius:6px;cursor:pointer;color:var(--text-secondary);font-size:13px;line-height:1;padding:2px 7px;">+</button>' +
|
|
'</div>';
|
|
}
|
|
|
|
async function routerIfaceDel(i) {
|
|
const f = _routerIfaces[i];
|
|
if (!f) return;
|
|
const device = f._device || f.name || f.device;
|
|
const vlan = Number.isInteger(f._deleteVlan) ? f._deleteVlan : (f.vlan || 0);
|
|
const label = f.name || (vlan ? (device + '.' + vlan) : device);
|
|
const ok = await showConfirm('Remove interface ' + label + '?', 'Remove Interface');
|
|
if (!ok) return;
|
|
try {
|
|
await api_delete('/api/network/router/interface', { device, vlan });
|
|
await load_router();
|
|
show_toast('Interface removed', 'success');
|
|
} catch(e) {
|
|
show_toast('Failed: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
function normalizeRouterAddress(a) {
|
|
if (typeof a === 'string') return { device: 'lo', address: a };
|
|
return {
|
|
device: a.device || a.Device || 'lo',
|
|
address: a.address || a.Address || ''
|
|
};
|
|
}
|
|
|
|
function pushRouterDeviceAddress(items, seen, value) {
|
|
if (!value) return;
|
|
const addrs = Array.isArray(value) ? value : [value];
|
|
addrs.forEach(a => {
|
|
const item = (typeof a === 'string') ? { address: a } : a;
|
|
if (!item.address) return;
|
|
if (seen[item.address]) {
|
|
if (Number.isInteger(item._routerIndex) && !Number.isInteger(seen[item.address]._routerIndex)) {
|
|
seen[item.address]._routerIndex = item._routerIndex;
|
|
seen[item.address].device = item.device;
|
|
}
|
|
return;
|
|
}
|
|
seen[item.address] = item;
|
|
items.push(item);
|
|
});
|
|
}
|
|
|
|
function routerDeviceAddresses(device, sysAddress, ifaceAddress, addressMap) {
|
|
const items = [];
|
|
const seen = {};
|
|
pushRouterDeviceAddress(items, seen, sysAddress);
|
|
pushRouterDeviceAddress(items, seen, ifaceAddress);
|
|
pushRouterDeviceAddress(items, seen, addressMap[device] || []);
|
|
return items;
|
|
}
|
|
|
|
async function routerAddAddress() {
|
|
const addressInput = document.getElementById('router-address-value');
|
|
const device = _routerAddressDevice;
|
|
const address = addressInput.value.trim();
|
|
if (!device) { show_toast('Device is required', 'error'); return; }
|
|
if (!address) { show_toast('Address is required', 'error'); return; }
|
|
try {
|
|
await api_post('/api/network/router/address', { device, address });
|
|
addressInput.value = '';
|
|
closeAddDialog('router-address');
|
|
await load_router();
|
|
show_toast('Address added', 'success');
|
|
} catch(e) {
|
|
show_toast('Failed: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function routerDelAddress(i) {
|
|
const value = _routerAddresses[i];
|
|
if (!value) return;
|
|
const ok = await showConfirm('Remove address ' + value.address + ' from ' + value.device + '?', 'Remove Address');
|
|
if (!ok) return;
|
|
try {
|
|
await api_delete('/api/network/router/address', { address: value.address });
|
|
await load_router();
|
|
show_toast('Address removed', 'success');
|
|
} catch(e) {
|
|
show_toast('Failed: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function load_router_private() {
|
|
const container = document.getElementById('router-private-tags');
|
|
try {
|
|
const data = await api_get('/api/network/router');
|
|
const privates = (data && data.config && data.config.specifies && Array.isArray(data.config.specifies.private)) ? data.config.specifies.private : [];
|
|
if (privates.length === 0) {
|
|
container.innerHTML = '<span style="color:var(--text-muted);font-size:13px;">No private subnets</span>';
|
|
} else {
|
|
container.innerHTML = privates.map(s =>
|
|
'<span style="display:inline-flex;align-items:center;gap:6px;background:#21262d;border:1px solid var(--border);border-radius:6px;padding:4px 10px;font-family:monospace;font-size:13px;color:var(--text);">' +
|
|
esc(s) +
|
|
'<button onclick="routerDelPrivate(\'' + esc(s) + '\')" title="Remove" style="background:none;border:none;cursor:pointer;padding:0;line-height:1;color:var(--text-muted);font-size:14px;">✕</button>' +
|
|
'</span>'
|
|
).join('');
|
|
}
|
|
} catch(e) {
|
|
container.innerHTML = '<span style="color:var(--text-muted);font-size:13px;">-</span>';
|
|
}
|
|
}
|
|
|
|
async function routerAddPrivate() {
|
|
const input = document.getElementById('router-private-subnet');
|
|
const subnet = input.value.trim();
|
|
if (!subnet) { show_toast('Enter a subnet', 'error'); return; }
|
|
try {
|
|
await api_post('/api/network/router/private', { subnet });
|
|
input.value = '';
|
|
closeAddDialog('router-private');
|
|
await load_router_private();
|
|
show_toast('Private subnet added', 'success');
|
|
} catch(e) {
|
|
show_toast('Failed: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function routerDelPrivate(subnet) {
|
|
const ok = await showConfirm('Remove private subnet ' + subnet + '?', 'Remove Subnet');
|
|
if (!ok) return;
|
|
try {
|
|
await api_delete('/api/network/router/private', { subnet });
|
|
await load_router_private();
|
|
show_toast('Private subnet removed', 'success');
|
|
} catch(e) {
|
|
show_toast('Failed: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function load_router_redirect() {
|
|
const tbody = document.getElementById('router-redirect-tbody');
|
|
try {
|
|
const [data] = await Promise.all([
|
|
api_get('/api/network/router'),
|
|
sync_kernel_neighbors().catch(() => _kernelNeighbors)
|
|
]);
|
|
const redirects = (data && data.config && data.config.specifies && Array.isArray(data.config.specifies.redirect))
|
|
? data.config.specifies.redirect : [];
|
|
_routerRedirects = redirects;
|
|
if (redirects.length === 0) {
|
|
tbody.innerHTML = '<tr class="empty-row"><td colspan="4">No redirect rules</td></tr>';
|
|
} else {
|
|
tbody.innerHTML = redirects.map((r, i) =>
|
|
'<tr>' +
|
|
'<td class="td-mono">' + esc(r.source || '-') + '</td>' +
|
|
'<td>' + routerRedirectNextHopCell(r.nexthop) + '</td>' +
|
|
'<td class="td-muted">' + (r.table || '-') + '</td>' +
|
|
'<td><button class="action-btn btn-danger" onclick="routerDelRedirect(' + i + ')">Remove</button></td>' +
|
|
'</tr>'
|
|
).join('');
|
|
}
|
|
} catch(e) {
|
|
_routerRedirects = [];
|
|
tbody.innerHTML = '<tr class="empty-row"><td colspan="4">-</td></tr>';
|
|
}
|
|
}
|
|
|
|
function routerRedirectNextHopCell(nexthop) {
|
|
return nextHopWithMacHtml(nexthop);
|
|
}
|
|
|
|
function nextHopWithMacHtml(nexthop) {
|
|
const hop = String(nexthop || '-');
|
|
const key = String(nexthop || '').trim();
|
|
if (!key) return '<span class="td-mono">-</span>';
|
|
const neighbor = _kernelNeighbors.find(n => String(n.address || '').trim() === key);
|
|
if (!neighbor || !neighbor.hwaddr) {
|
|
return '<span class="td-mono">' + esc(hop) + '</span>';
|
|
}
|
|
return '<div style="display:flex;flex-direction:column;gap:2px;">' +
|
|
'<span class="td-mono">' + esc(hop) + '</span>' +
|
|
'<span class="td-mono td-muted" style="font-size:12px;">' + esc(neighbor.hwaddr) + '</span>' +
|
|
'</div>';
|
|
}
|
|
|
|
async function routerAddRedirect() {
|
|
const source = document.getElementById('router-redirect-source').value.trim();
|
|
const nexthop = document.getElementById('router-redirect-nexthop').value.trim();
|
|
const table = parseInt(document.getElementById('router-redirect-table').value.trim(), 10);
|
|
if (!source) { show_toast('Source is required', 'error'); return; }
|
|
if (!nexthop) { show_toast('NextHop is required', 'error'); return; }
|
|
if (!table) { show_toast('Table is required', 'error'); return; }
|
|
if (_routerRedirects.some(r => (r.table || 0) === table)) {
|
|
show_toast('Table ' + table + ' is already in use', 'error');
|
|
return;
|
|
}
|
|
try {
|
|
await api_post('/api/network/router/redirect', { source, nexthop, table });
|
|
document.getElementById('router-redirect-source').value = '';
|
|
document.getElementById('router-redirect-nexthop').value = '';
|
|
document.getElementById('router-redirect-table').value = '';
|
|
closeAddDialog('router-redirect');
|
|
await load_router_redirect();
|
|
show_toast('Redirect added', 'success');
|
|
} catch(e) {
|
|
show_toast('Failed: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function routerDelRedirect(i) {
|
|
try {
|
|
const data = await api_get('/api/network/router');
|
|
const redirects = (data && data.config && data.config.specifies && Array.isArray(data.config.specifies.redirect))
|
|
? data.config.specifies.redirect : [];
|
|
const r = redirects[i];
|
|
if (!r) return;
|
|
const ok = await showConfirm('Remove redirect for ' + (r.source || '-') + '?', 'Remove Redirect');
|
|
if (!ok) return;
|
|
await api_delete('/api/network/router/redirect', { source: r.source, nexthop: r.nexthop, table: r.table });
|
|
await load_router_redirect();
|
|
show_toast('Redirect removed', 'success');
|
|
} catch(e) {
|
|
show_toast('Failed: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
let _ipsecTunnels = [];
|
|
|
|
function ipsecResetForm() {
|
|
document.getElementById('ipsec-tunnel-title').textContent = 'Add IPSec Tunnel';
|
|
document.getElementById('ipsec-tunnel-submit').textContent = 'Add';
|
|
document.getElementById('ipsec-edit-orig').value = '';
|
|
document.getElementById('ipsec-local').value = '%defaultroute';
|
|
document.getElementById('ipsec-localid').value = '';
|
|
document.getElementById('ipsec-localport').value = '4500';
|
|
document.getElementById('ipsec-remote').value = '';
|
|
document.getElementById('ipsec-remoteid').value = '';
|
|
document.getElementById('ipsec-remoteport').value = '4500';
|
|
document.getElementById('ipsec-secret').value = '';
|
|
}
|
|
|
|
function ipsecCancelEdit() {
|
|
ipsecResetForm();
|
|
closeAddDialog('ipsec-tunnel');
|
|
}
|
|
|
|
function ipsecOpenEdit(i) {
|
|
const t = _ipsecTunnels[i];
|
|
if (!t) return;
|
|
document.getElementById('ipsec-tunnel-title').textContent = 'Edit IPSec Tunnel';
|
|
document.getElementById('ipsec-tunnel-submit').textContent = 'Save';
|
|
document.getElementById('ipsec-edit-orig').value = JSON.stringify(t);
|
|
document.getElementById('ipsec-local').value = t.local || '';
|
|
document.getElementById('ipsec-localid').value = t.localid || '';
|
|
document.getElementById('ipsec-localport').value = t.localport || 4500;
|
|
document.getElementById('ipsec-remote').value = t.remote || '';
|
|
document.getElementById('ipsec-remoteid').value = t.remoteid || '';
|
|
document.getElementById('ipsec-remoteport').value = t.remoteport || 4500;
|
|
document.getElementById('ipsec-protocol').value = t.protocol || 'esp';
|
|
document.getElementById('ipsec-secret').value = t.secret || '';
|
|
openAddDialog('ipsec-tunnel');
|
|
}
|
|
|
|
async function ipsecAddTunnel() {
|
|
const local = document.getElementById('ipsec-local').value.trim();
|
|
const remote = document.getElementById('ipsec-remote').value.trim();
|
|
const protocol = document.getElementById('ipsec-protocol').value;
|
|
const secret = document.getElementById('ipsec-secret').value;
|
|
const localid = document.getElementById('ipsec-localid').value.trim();
|
|
const localport = parseInt(document.getElementById('ipsec-localport').value) || 4500;
|
|
const remoteid = document.getElementById('ipsec-remoteid').value.trim();
|
|
const remoteport = parseInt(document.getElementById('ipsec-remoteport').value) || 4500;
|
|
const origRaw = document.getElementById('ipsec-edit-orig').value;
|
|
const isEdit = !!origRaw;
|
|
if (!local) { show_toast('Local is required', 'error'); return; }
|
|
if (!localid) { show_toast('Local ID is required', 'error'); return; }
|
|
if (!remote) { show_toast('Remote is required', 'error'); return; }
|
|
if (!remoteid) { show_toast('Remote ID is required', 'error'); return; }
|
|
if (!isEdit && !secret) { show_toast('Secret is required', 'error'); return; }
|
|
try {
|
|
if (isEdit) {
|
|
const orig = JSON.parse(origRaw);
|
|
await api_delete('/api/network/ipsec/tunnel', { local: orig.local, remote: orig.remote, protocol: orig.protocol });
|
|
}
|
|
const body = { local, localid, localport, remote, remoteid, remoteport, protocol, secret: secret || '' };
|
|
await api_post('/api/network/ipsec/tunnel', body);
|
|
ipsecResetForm();
|
|
closeAddDialog('ipsec-tunnel');
|
|
await load_ipsec();
|
|
show_toast(isEdit ? 'Tunnel updated' : 'Tunnel added', 'success');
|
|
} catch(e) {
|
|
show_toast('Failed: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function ipsecDelTunnel(local, remote, protocol) {
|
|
const ok = await showConfirm('Remove tunnel ' + local + ' ↔ ' + remote + '?', 'Remove Tunnel');
|
|
if (!ok) return;
|
|
try {
|
|
await api_delete('/api/network/ipsec/tunnel', { local, remote, protocol });
|
|
await load_ipsec();
|
|
show_toast('Tunnel removed', 'success');
|
|
} catch(e) {
|
|
show_toast('Failed: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function load_ipsec() {
|
|
try {
|
|
const tunnels = await api_get('/api/network/ipsec/tunnel');
|
|
_ipsecTunnels = Array.isArray(tunnels) ? tunnels : [];
|
|
document.getElementById('ipsec-count').textContent = _ipsecTunnels.length;
|
|
const tb = document.getElementById('ipsec-tbody');
|
|
tb.innerHTML = _ipsecTunnels.length === 0
|
|
? '<tr class="empty-row"><td colspan="8">No tunnels</td></tr>'
|
|
: _ipsecTunnels.map((t, i) =>
|
|
'<tr>' +
|
|
'<td class="td-mono">' + esc(t.local || '-') + '</td>' +
|
|
'<td class="td-muted">' + esc(t.localid || '-') + '</td>' +
|
|
'<td class="td-mono">' + esc(t.remote || '-') + '</td>' +
|
|
'<td class="td-muted">' + esc(t.remoteid || '-') + '</td>' +
|
|
'<td><span class="badge badge-blue">' + esc(t.protocol || '-') + '</span></td>' +
|
|
'<td class="td-muted td-mono" style="font-size:12px;">' + esc(t.secret || '-') + '</td>' +
|
|
'<td><span class="badge ' + (t.state === 'up' ? 'badge-green' : 'badge-gray') + '">' + esc(t.state || '-') + '</span></td>' +
|
|
'<td style="white-space:nowrap;">' +
|
|
'<button class="action-btn btn-detail" onclick="restartIpsecTunnel(\'' + esc(t.local) + '\',\'' + esc(t.remote) + '\',\'' + esc(t.protocol) + '\')">Restart</button> ' +
|
|
'<button class="action-btn action-dark" onclick="ipsecOpenEdit(' + i + ')">Edit</button> ' +
|
|
'<button class="action-btn btn-danger" onclick="ipsecDelTunnel(\'' + esc(t.local) + '\',\'' + esc(t.remote) + '\',\'' + esc(t.protocol) + '\')">Remove</button>' +
|
|
'</td>' +
|
|
'</tr>'
|
|
).join('');
|
|
} catch(e) {
|
|
document.getElementById('ipsec-tbody').innerHTML = '<tr class="empty-row"><td colspan="8">-</td></tr>';
|
|
}
|
|
}
|
|
|
|
let _ceciTcpEntries = [];
|
|
|
|
async function load_ceci() {
|
|
const tbody = document.getElementById('ceci-tcp-tbody');
|
|
try {
|
|
const entries = await api_get('/api/network/ceci/tcp');
|
|
_ceciTcpEntries = Array.isArray(entries) ? entries : [];
|
|
document.getElementById('ceci-tcp-count').textContent = _ceciTcpEntries.length;
|
|
if (_ceciTcpEntries.length === 0) {
|
|
tbody.innerHTML = '<tr class="empty-row"><td colspan="5">No Ceci TCP entries</td></tr>';
|
|
return;
|
|
}
|
|
tbody.innerHTML = _ceciTcpEntries.map((item, i) =>
|
|
'<tr>' +
|
|
'<td><span class="badge badge-blue">' + esc(item.mode || 'tcp') + '</span></td>' +
|
|
'<td class="td-mono">' + esc(item.listen || '-') + '</td>' +
|
|
'<td>' + ceciTargetHtml(item.target) + '</td>' +
|
|
'<td>' + statusBadge(item.status, 'Not running') + '</td>' +
|
|
'<td style="white-space:nowrap;"><button class="action-btn action-dark" onclick="viewCeciTcpLog(\'' + esc(item.listen || '') + '\')">View Log</button> <button class="action-btn action-delete" onclick="ceciDelTcp(' + i + ')">Remove</button></td>' +
|
|
'</tr>'
|
|
).join('');
|
|
} catch(e) {
|
|
_ceciTcpEntries = [];
|
|
document.getElementById('ceci-tcp-count').textContent = '0';
|
|
tbody.innerHTML = '<tr class="empty-row"><td colspan="5">Failed to load Ceci entries</td></tr>';
|
|
show_toast('Failed to load ceci network: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
function ceciTargetHtml(targets) {
|
|
const list = Array.isArray(targets) ? targets.filter(Boolean) : [];
|
|
if (list.length === 0) return '<span class="td-muted">-</span>';
|
|
return list.map(target =>
|
|
'<div class="td-mono" style="font-size:12px;line-height:1.6;">' + esc(target) + '</div>'
|
|
).join('');
|
|
}
|
|
|
|
function ceciParseTargets(raw) {
|
|
return raw.split(/\r?\n|,/).map(v => v.trim()).filter(Boolean);
|
|
}
|
|
|
|
async function ceciAddTcp() {
|
|
const listen = document.getElementById('ceci-tcp-listen').value.trim();
|
|
const target = ceciParseTargets(document.getElementById('ceci-tcp-target').value);
|
|
if (!listen) { show_toast('Listen is required', 'error'); return; }
|
|
try {
|
|
await api_post('/api/network/ceci/tcp', { mode: 'tcp', listen, target });
|
|
document.getElementById('ceci-tcp-listen').value = '';
|
|
document.getElementById('ceci-tcp-target').value = '';
|
|
closeAddDialog('ceci-tcp');
|
|
await load_ceci();
|
|
show_toast('Ceci TCP entry added', 'success');
|
|
} catch(e) {
|
|
show_toast('Failed to add ceci tcp entry: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function ceciDelTcp(index) {
|
|
const item = _ceciTcpEntries[index];
|
|
if (!item) return;
|
|
const ok = await showConfirm('Remove ceci tcp entry ' + (item.listen || '-') + '?', 'Remove Ceci TCP');
|
|
if (!ok) return;
|
|
try {
|
|
await api_delete('/api/network/ceci/tcp', { mode: item.mode || 'tcp', listen: item.listen, target: item.target || [] });
|
|
await load_ceci();
|
|
show_toast('Ceci TCP entry removed', 'success');
|
|
} catch(e) {
|
|
show_toast('Failed to remove ceci tcp entry: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
function fmt_bytes(b) {
|
|
if (!b) return '0 B';
|
|
if (b < 1024) return b + ' B';
|
|
if (b < 1048576) return (b/1024).toFixed(1) + ' KB';
|
|
if (b < 1073741824) return (b/1048576).toFixed(1) + ' MB';
|
|
return (b/1073741824).toFixed(2) + ' GB';
|
|
}
|
|
|
|
function fmt_uptime(sec) {
|
|
if (!sec) return '-';
|
|
const h = Math.floor(sec/3600), m = Math.floor((sec%3600)/60), s = sec%60;
|
|
if (h > 0) return h + 'h ' + m + 'm';
|
|
if (m > 0) return m + 'm ' + s + 's';
|
|
return s + 's';
|
|
}
|
|
|
|
let _vpnClientsData = [];
|
|
let _vpnClientsNetworkFilter = '';
|
|
|
|
function render_vpn_clients_filter() {
|
|
const sel = document.getElementById('vpn-clients-network-filter');
|
|
if (!sel) return;
|
|
const networks = _vpnClientsData
|
|
.map(c => c && c.network ? String(c.network) : '')
|
|
.filter((name, index, arr) => name && arr.indexOf(name) === index)
|
|
.sort();
|
|
const current = _vpnClientsNetworkFilter;
|
|
sel.innerHTML = '<option value="">All Networks</option>' +
|
|
networks.map(name => '<option value="' + esc(name) + '">' + esc(name) + '</option>').join('');
|
|
sel.value = networks.includes(current) ? current : '';
|
|
_vpnClientsNetworkFilter = sel.value;
|
|
}
|
|
|
|
function get_filtered_vpn_clients() {
|
|
if (!_vpnClientsNetworkFilter) return _vpnClientsData.slice();
|
|
return _vpnClientsData.filter(c => (c && c.network ? String(c.network) : '') === _vpnClientsNetworkFilter);
|
|
}
|
|
|
|
function render_vpn_clients_table() {
|
|
const arr = get_filtered_vpn_clients();
|
|
document.getElementById('vpn-clients-count').textContent = arr.length;
|
|
const tb = document.getElementById('vpn-clients-tbody');
|
|
if (arr.length === 0) {
|
|
tb.innerHTML = '<tr class="empty-row"><td colspan="9">' + (_vpnClientsNetworkFilter ? 'No VPN clients in this network' : 'No VPN clients') + '</td></tr>';
|
|
return;
|
|
}
|
|
tb.innerHTML = arr.map(c => {
|
|
const state = c.state || '-';
|
|
const badgeCls = state === 'connected' ? 'badge-green' : 'badge-gray';
|
|
const net = esc(c.network || '');
|
|
const name = esc(c.name || '');
|
|
return '<tr>' +
|
|
'<td style="font-weight:600;" class="td-mono">' + (c.name || '-') + '</td>' +
|
|
'<td class="td-muted">' + (c.network || '-') + '</td>' +
|
|
'<td class="td-mono">' + (c.address || '-') + '</td>' +
|
|
'<td class="td-mono td-muted" style="font-size:12px;">' + (c.remote || '-') + '</td>' +
|
|
'<td><span class="badge ' + badgeCls + '">' + state + '</span></td>' +
|
|
'<td class="td-muted">' + fmt_bytes(c.rxBytes) + '</td>' +
|
|
'<td class="td-muted">' + fmt_bytes(c.txBytes) + '</td>' +
|
|
'<td class="td-muted">' + fmt_uptime(c.aliveTime) + '</td>' +
|
|
'<td style="display:flex;gap:4px;">' +
|
|
(c.remote ? '<button class="action-btn" style="background:rgba(245,158,11,.1);color:var(--warning);border:none;" onclick="killVpnClient(\'' + net + '\',\'' + name + '\')">Kick</button>' : '') +
|
|
(!c.remote ? '<button class="action-btn action-delete" onclick="removeVpnClient(\'' + net + '\',\'' + name + '\')">Remove</button>' : '') +
|
|
'</td>' +
|
|
'</tr>';
|
|
}).join('');
|
|
}
|
|
|
|
function onVpnClientsNetworkFilterChange() {
|
|
const sel = document.getElementById('vpn-clients-network-filter');
|
|
_vpnClientsNetworkFilter = sel ? sel.value : '';
|
|
render_vpn_clients_table();
|
|
}
|
|
|
|
async function load_vpn_clients() {
|
|
// Populate network selector
|
|
const netSel = document.getElementById('vpn-client-network');
|
|
if (netSel && _networksData.length > 0) {
|
|
const ovpnNets = _networksData.filter(n => n.config && n.config.openvpn);
|
|
netSel.innerHTML = ovpnNets.map(n => '<option value="' + esc(n.name) + '">' + n.name + '</option>').join('');
|
|
onVpnNetworkChange();
|
|
}
|
|
try {
|
|
// Fetch per-network to include statically configured clients
|
|
const results = await Promise.all(
|
|
_networksData.map(n =>
|
|
api_get('/api/vpn/client/' + encodeURIComponent(n.name))
|
|
.then(list => (Array.isArray(list) ? list : []).map(c => ({ ...c, network: c.network || n.name })))
|
|
.catch(() => [])
|
|
)
|
|
);
|
|
_vpnClientsData = results.flat().filter(Boolean);
|
|
render_vpn_clients_filter();
|
|
render_vpn_clients_table();
|
|
} catch(e) {
|
|
_vpnClientsData = [];
|
|
const tb = document.getElementById('vpn-clients-tbody');
|
|
if (tb) tb.innerHTML = '<tr class="empty-row"><td colspan="9">Failed to load VPN clients</td></tr>';
|
|
}
|
|
}
|
|
|
|
function onVpnNetworkChange() {
|
|
const netName = document.getElementById('vpn-client-network').value;
|
|
const addrEl = document.getElementById('vpn-client-address');
|
|
if (!addrEl) return;
|
|
const net = _networksData.find(n => n.name === netName);
|
|
const subnet = net && net.config && net.config.openvpn && net.config.openvpn.subnet;
|
|
if (subnet) {
|
|
// Derive base address from subnet (e.g. 10.0.0.0/24 → suggest 10.0.0.x)
|
|
const base = subnet.replace(/\/\d+$/, '').replace(/\.\d+$/, '');
|
|
addrEl.placeholder = base + '.x (subnet: ' + subnet + ')';
|
|
} else {
|
|
addrEl.placeholder = 'e.g. 10.0.0.2';
|
|
}
|
|
}
|
|
|
|
async function addVpnClient() {
|
|
const network = document.getElementById('vpn-client-network').value;
|
|
const name = document.getElementById('vpn-client-name').value.trim();
|
|
const address = document.getElementById('vpn-client-address').value.trim();
|
|
if (!network) { show_toast('Network is required', 'error'); return; }
|
|
if (!name) { show_toast('Name is required', 'error'); return; }
|
|
if (!address) { show_toast('Address is required', 'error'); return; }
|
|
try {
|
|
await api_post('/api/vpn/client/' + encodeURIComponent(network), { name, address });
|
|
document.getElementById('vpn-client-name').value = '';
|
|
document.getElementById('vpn-client-address').value = '';
|
|
closeAddDialog('vpnclient');
|
|
show_toast('VPN client added', 'success');
|
|
load_vpn_clients();
|
|
} catch(e) {
|
|
show_toast('Failed to add client: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function removeVpnClient(network, name) {
|
|
if (!await showConfirm('Remove VPN client "' + name + '" from network "' + network + '"?', 'Remove Client')) return;
|
|
try {
|
|
await api_delete('/api/vpn/client/' + encodeURIComponent(network), { name });
|
|
show_toast('Client removed', 'success');
|
|
load_vpn_clients();
|
|
} catch(e) {
|
|
show_toast('Failed to remove client: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function killVpnClient(network, name) {
|
|
if (!await showConfirm('Force disconnect "' + name + '" from network "' + network + '"?', 'Kick Client')) return;
|
|
try {
|
|
await api_post('/api/vpn/client/' + encodeURIComponent(network) + '/kill', { name });
|
|
show_toast('Client kicked', 'success');
|
|
load_vpn_clients();
|
|
} catch(e) {
|
|
show_toast('Failed to kick client: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
let _modalNetIndex = -1;
|
|
|
|
function openNetModal(i) {
|
|
_modalNetIndex = i;
|
|
const n = _networksData[i] || {};
|
|
const cfg = n.config || {};
|
|
const bridge = cfg.bridge || {};
|
|
const subnet = cfg.subnet || {};
|
|
const ovpn = cfg.openvpn || null;
|
|
const derivedSubnetNetmask = bridgeNetmask(bridge);
|
|
const subnetEditable = !!(bridge && bridge.address && derivedSubnetNetmask);
|
|
|
|
document.getElementById('net-modal-title').textContent = 'Network: ' + (n.name || '-');
|
|
|
|
// Config tab
|
|
const items = [
|
|
['Name', n.name], ['Bridge Address', bridge.address], ['Bridge Name', bridge.name],
|
|
['Namespace', cfg.namespace], ['MTU', bridge.mtu || cfg.mtu], ['MSS', bridge.tcpMss], ['Provider', cfg.provider],
|
|
].filter(([, v]) => v != null && v !== '');
|
|
document.getElementById('modal-base-info').innerHTML = items.map(([k, v]) =>
|
|
'<div class="detail-item"><span class="detail-key">' + k + '</span><span class="detail-val">' + v + '</span></div>'
|
|
).join('');
|
|
|
|
const snatValue = cfg.snat || 'disable';
|
|
const snatOptions = [
|
|
['disable', 'Disable'],
|
|
['enable', 'Enable'],
|
|
['local', 'Local'],
|
|
['openvpn', 'OpenVPN']
|
|
];
|
|
document.getElementById('modal-snat-section').innerHTML =
|
|
'<div style="display:flex;flex-direction:column;gap:12px;padding-top:16px;margin-top:16px;border-top:1px solid var(--border);">' +
|
|
'<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;">' +
|
|
'<div>' +
|
|
'<div class="detail-section-title" style="margin:0 0 6px;padding:0;border:none;">Subnet</div>' +
|
|
'<div style="font-size:13px;color:var(--text-muted);">' + esc(subnetEditable ? 'Update subnet pool for this virtual network.' : 'Configure bridge address first, then subnet can be edited.') + '</div>' +
|
|
'</div>' +
|
|
'<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">' +
|
|
'<button class="btn btn-ghost btn-sm"' + ((subnet.startAt || subnet.endAt || subnet.netmask) ? ' onclick="deleteNetworkSubnet()"' : ' disabled') + '>Delete</button>' +
|
|
'<button class="btn btn-dark btn-sm"' + (subnetEditable ? ' onclick="saveNetworkSubnet()"' : ' disabled title="Bridge address required"') + '>Apply</button>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;">' +
|
|
'<div class="form-group" style="margin:0;"><label>Start IP</label><input class="modal-input" id="network-subnet-start" type="text" title="Start IP" placeholder="Start IP" value="' + esc(subnet.startAt || '') + '"' + (subnetEditable ? '' : ' disabled') + ' style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);"></div>' +
|
|
'<div class="form-group" style="margin:0;"><label>End IP</label><input class="modal-input" id="network-subnet-end" type="text" title="End IP" placeholder="End IP" value="' + esc(subnet.endAt || '') + '"' + (subnetEditable ? '' : ' disabled') + ' style="width:100%;background:#21262d;border-color:var(--border);color:var(--text);"></div>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'<div style="padding-top:16px;margin-top:16px;border-top:1px solid var(--border);">' +
|
|
'<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;">' +
|
|
'<div>' +
|
|
'<div class="detail-section-title" style="margin:0 0 6px;padding:0;border:none;">SNAT</div>' +
|
|
'<div style="font-size:13px;color:var(--text-muted);">Configure subnet source NAT behavior for this network.</div>' +
|
|
'</div>' +
|
|
'<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">' +
|
|
'<select class="modal-input" id="network-snat-scope" style="min-width:140px;background:#21262d;border-color:var(--border);color:var(--text);">' +
|
|
snatOptions.map(([value, label]) =>
|
|
'<option value="' + value + '"' + (snatValue === value ? ' selected' : '') + '>' + label + '</option>'
|
|
).join('') +
|
|
'</select>' +
|
|
'<button class="btn btn-dark btn-sm" onclick="saveNetworkSNAT()">Apply</button>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'</div>';
|
|
document.getElementById('modal-snat-section').innerHTML +=
|
|
'<div style="padding-top:16px;margin-top:16px;border-top:1px solid var(--border);">' +
|
|
'<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;">' +
|
|
'<div>' +
|
|
'<div class="detail-section-title" style="margin:0 0 6px;padding:0;border:none;">TCP MSS</div>' +
|
|
'<div style="font-size:13px;color:var(--text-muted);">Clamp TCP MSS for bridge traffic on this network.</div>' +
|
|
'</div>' +
|
|
'<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">' +
|
|
'<input class="modal-input" id="network-mss-value" type="number" min="0" step="1" inputmode="numeric" placeholder="Disabled" value="' + esc(bridgeMssValue(bridge)) + '" style="width:120px;background:#21262d;border-color:var(--border);color:var(--text);">' +
|
|
'<button class="btn btn-dark btn-sm" onclick="saveNetworkMSS()">Apply</button>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'</div>';
|
|
|
|
let ovpnHtml = '';
|
|
if (ovpn) {
|
|
const ovpnItems = [
|
|
['Listen', ovpn.listen], ['Protocol', ovpn.protocol], ['Subnet', ovpn.subnet],
|
|
['Cipher', ovpn.cipher], ['Push Routes', ovpn.push ? ovpn.push.join(', ') : null],
|
|
].filter(([, v]) => v != null && v !== '');
|
|
ovpnHtml = '<div style="display:flex;align-items:center;justify-content:space-between;">' +
|
|
'<div class="detail-section-title" style="margin:0;padding:0;border:none;">OpenVPN</div>' +
|
|
'<div style="display:flex;gap:8px;align-items:center;">' +
|
|
'<button class="btn btn-sm btn-ghost" onclick="restartNetworkOpenVPN()" type="button">Restart</button>' +
|
|
'<button class="btn btn-sm btn-danger" onclick="disableNetworkOpenVPN()" type="button">Disable</button>' +
|
|
'<button class="btn btn-sm btn-ghost" onclick="viewOpenVPNLog(\'' + esc(n.name) + '\')" type="button">View Log</button>' +
|
|
'<button class="btn btn-sm btn-ghost" onclick="viewOpenVPNProfile(\'' + esc(n.name) + '\')" type="button">View .ovpn</button>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'<div class="detail-grid" style="margin-top:10px;">' +
|
|
ovpnItems.map(([k, v]) =>
|
|
'<div class="detail-item"><span class="detail-key">' + k + '</span><span class="detail-val">' + v + '</span></div>'
|
|
).join('') + '</div>';
|
|
} else {
|
|
ovpnHtml = '<div style="display:flex;align-items:center;justify-content:space-between;">' +
|
|
'<div class="detail-section-title" style="margin:0;padding:0;border:none;">OpenVPN</div>' +
|
|
'<button class="btn btn-sm btn-dark" onclick="openEnableNetworkOpenVPN()" type="button">Enable OpenVPN</button>' +
|
|
'</div>' +
|
|
'<div style="margin-top:10px;font-size:13px;color:var(--text-muted);">OpenVPN is disabled for this virtual network.</div>';
|
|
}
|
|
document.getElementById('modal-ovpn-section').innerHTML = ovpnHtml
|
|
? '<div style="padding-top:16px;margin-top:16px;border-top:1px solid var(--border);">' + ovpnHtml + '</div>'
|
|
: '';
|
|
|
|
document.getElementById('modal-json').textContent = JSON.stringify(n, null, 2);
|
|
document.getElementById('modal-yaml').textContent = 'Loading...';
|
|
switchNetDataFormat('yaml');
|
|
|
|
// Open modal on config tab
|
|
switchNetTab('config');
|
|
document.getElementById('net-modal').classList.remove('hidden');
|
|
|
|
// Load dynamic tabs
|
|
modalLoadRoutes(n.name);
|
|
modalLoadOutputs(n.name);
|
|
modalLoadYaml(n.name);
|
|
}
|
|
|
|
function openEnableNetworkOpenVPN() {
|
|
const n = _networksData[_modalNetIndex] || {};
|
|
const cfg = n.config || {};
|
|
const ovpn = cfg.openvpn || {};
|
|
document.getElementById('network-ovpn-listen').value = ovpn.listen || ':1194';
|
|
document.getElementById('network-ovpn-protocol').value = ovpn.protocol || 'tcp';
|
|
document.getElementById('network-ovpn-subnet').value = ovpn.subnet || '';
|
|
document.getElementById('network-ovpn-cipher').value = ovpn.cipher || '';
|
|
openAddDialog('network-openvpn');
|
|
setTimeout(() => document.getElementById('network-ovpn-listen').focus(), 50);
|
|
}
|
|
|
|
function bridgeMssValue(bridge) {
|
|
if (!bridge || bridge.tcpMss == null || bridge.tcpMss === '') return '';
|
|
return String(bridge.tcpMss);
|
|
}
|
|
|
|
function bridgeNetmask(bridge) {
|
|
if (!bridge || !bridge.address) return '';
|
|
const parts = String(bridge.address).split('/');
|
|
if (parts.length !== 2) return '';
|
|
const prefix = parseInt(parts[1], 10);
|
|
if (!Number.isFinite(prefix) || prefix < 0 || prefix > 32) return '';
|
|
let bits = prefix;
|
|
const octets = [];
|
|
for (let i = 0; i < 4; i++) {
|
|
const size = Math.max(0, Math.min(8, bits));
|
|
octets.push(size === 0 ? 0 : 256 - Math.pow(2, 8 - size));
|
|
bits -= size;
|
|
}
|
|
return octets.join('.');
|
|
}
|
|
|
|
async function saveNetworkMSS() {
|
|
const n = _networksData[_modalNetIndex];
|
|
if (!n) return;
|
|
const raw = document.getElementById('network-mss-value').value.trim();
|
|
try {
|
|
if (!raw) {
|
|
await api_delete('/api/network/' + encodeURIComponent(n.name) + '/mss');
|
|
} else {
|
|
const mss = parseInt(raw, 10);
|
|
if (!Number.isFinite(mss) || mss < 0) {
|
|
show_toast('MSS must be a non-negative integer', 'error');
|
|
return;
|
|
}
|
|
await api_post('/api/network/' + encodeURIComponent(n.name) + '/mss', { mss });
|
|
}
|
|
const updated = await api_get('/api/network/' + encodeURIComponent(n.name));
|
|
if (updated) _networksData[_modalNetIndex] = updated;
|
|
openNetModal(_modalNetIndex);
|
|
show_toast('Network MSS updated', 'success');
|
|
} catch(e) {
|
|
show_toast('Failed to update network MSS: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function saveNetworkSubnet() {
|
|
const n = _networksData[_modalNetIndex];
|
|
if (!n) return;
|
|
const bridge = (n.config || {}).bridge || {};
|
|
if (!bridge.address) {
|
|
show_toast('Bridge address is required before setting subnet', 'error');
|
|
return;
|
|
}
|
|
const startAt = document.getElementById('network-subnet-start').value.trim();
|
|
const endAt = document.getElementById('network-subnet-end').value.trim();
|
|
if (!startAt || !endAt) {
|
|
show_toast('Subnet start and end are required', 'error');
|
|
return;
|
|
}
|
|
try {
|
|
await api_post('/api/network/' + encodeURIComponent(n.name) + '/subnet', { startAt, endAt });
|
|
const updated = await api_get('/api/network/' + encodeURIComponent(n.name));
|
|
if (updated) _networksData[_modalNetIndex] = updated;
|
|
openNetModal(_modalNetIndex);
|
|
show_toast('Network subnet updated', 'success');
|
|
} catch(e) {
|
|
show_toast('Failed to update network subnet: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function deleteNetworkSubnet() {
|
|
const n = _networksData[_modalNetIndex];
|
|
if (!n) return;
|
|
if (!await showConfirm('Delete subnet settings for network "' + n.name + '"?', 'Delete Subnet')) return;
|
|
try {
|
|
await api_delete('/api/network/' + encodeURIComponent(n.name) + '/subnet');
|
|
const updated = await api_get('/api/network/' + encodeURIComponent(n.name));
|
|
if (updated) _networksData[_modalNetIndex] = updated;
|
|
openNetModal(_modalNetIndex);
|
|
show_toast('Network subnet deleted', 'success');
|
|
} catch(e) {
|
|
show_toast('Failed to delete network subnet: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function enableNetworkOpenVPN() {
|
|
const n = _networksData[_modalNetIndex];
|
|
if (!n) return;
|
|
const listen = document.getElementById('network-ovpn-listen').value.trim();
|
|
const protocol = document.getElementById('network-ovpn-protocol').value;
|
|
const subnet = document.getElementById('network-ovpn-subnet').value.trim();
|
|
const cipher = document.getElementById('network-ovpn-cipher').value.trim();
|
|
if (!listen) { show_toast('Listen is required', 'error'); return; }
|
|
try {
|
|
const body = { listen, protocol };
|
|
if (subnet) body.subnet = subnet;
|
|
if (cipher) body.cipher = cipher;
|
|
await api_post('/api/network/' + encodeURIComponent(n.name) + '/openvpn', body);
|
|
closeAddDialog('network-openvpn');
|
|
show_toast('OpenVPN enabled', 'success');
|
|
const updated = await api_get('/api/network/' + encodeURIComponent(n.name));
|
|
if (updated) _networksData[_modalNetIndex] = updated;
|
|
openNetModal(_modalNetIndex);
|
|
load_vpn_clients();
|
|
} catch(e) {
|
|
show_toast('Failed to enable OpenVPN: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function disableNetworkOpenVPN() {
|
|
const n = _networksData[_modalNetIndex];
|
|
if (!n) return;
|
|
if (!await showConfirm('Disable OpenVPN for network "' + n.name + '"?', 'Disable OpenVPN')) return;
|
|
try {
|
|
await api_delete('/api/network/' + encodeURIComponent(n.name) + '/openvpn');
|
|
show_toast('OpenVPN disabled', 'success');
|
|
const updated = await api_get('/api/network/' + encodeURIComponent(n.name));
|
|
if (updated) _networksData[_modalNetIndex] = updated;
|
|
openNetModal(_modalNetIndex);
|
|
load_vpn_clients();
|
|
} catch(e) {
|
|
show_toast('Failed to disable OpenVPN: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function restartNetworkOpenVPN() {
|
|
const n = _networksData[_modalNetIndex];
|
|
if (!n) return;
|
|
try {
|
|
await api_post('/api/network/' + encodeURIComponent(n.name) + '/openvpn/restart', {});
|
|
show_toast('OpenVPN restarting', 'success');
|
|
} catch(e) {
|
|
show_toast('Failed to restart OpenVPN: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function saveNetworkSNAT() {
|
|
const n = _networksData[_modalNetIndex];
|
|
if (!n) return;
|
|
const scope = document.getElementById('network-snat-scope').value;
|
|
try {
|
|
if (scope === 'disable') {
|
|
await api_delete('/api/network/' + encodeURIComponent(n.name) + '/snat');
|
|
} else {
|
|
await api_post('/api/network/' + encodeURIComponent(n.name) + '/snat', { scope });
|
|
}
|
|
const updated = await api_get('/api/network/' + encodeURIComponent(n.name));
|
|
if (updated) _networksData[_modalNetIndex] = updated;
|
|
openNetModal(_modalNetIndex);
|
|
show_toast('SNAT updated', 'success');
|
|
} catch(e) {
|
|
show_toast('Failed to update SNAT: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
function closeNetModal() {
|
|
document.getElementById('net-modal').classList.add('hidden');
|
|
_modalNetIndex = -1;
|
|
}
|
|
|
|
function switchNetTab(name) {
|
|
document.querySelectorAll('.modal-tab').forEach((t, i) => {
|
|
const tabs = ['config', 'routes', 'outputs', 'json'];
|
|
t.classList.toggle('active', tabs[i] === name);
|
|
});
|
|
document.querySelectorAll('.modal-tab-pane').forEach(p => p.classList.remove('active'));
|
|
document.getElementById('net-tab-' + name).classList.add('active');
|
|
}
|
|
|
|
function switchNetDataFormat(name) {
|
|
const jsonBtn = document.getElementById('net-data-tab-json');
|
|
const yamlBtn = document.getElementById('net-data-tab-yaml');
|
|
const jsonPre = document.getElementById('modal-json');
|
|
const yamlPre = document.getElementById('modal-yaml');
|
|
if (!jsonBtn || !yamlBtn || !jsonPre || !yamlPre) return;
|
|
|
|
const showJson = name !== 'yaml';
|
|
jsonBtn.className = showJson ? 'btn btn-sm btn-dark' : 'btn btn-sm btn-ghost';
|
|
yamlBtn.className = showJson ? 'btn btn-sm btn-ghost' : 'btn btn-sm btn-dark';
|
|
jsonPre.style.display = showJson ? '' : 'none';
|
|
yamlPre.style.display = showJson ? 'none' : '';
|
|
}
|
|
|
|
// Close modal on overlay click
|
|
document.addEventListener('click', function(e) {
|
|
if (e.target.id === 'net-modal') closeNetModal();
|
|
if (e.target.id === 'dlg-file-preview') closeFilePreview();
|
|
});
|
|
|
|
async function saveNetwork(name) {
|
|
try {
|
|
await api_put('/api/network/' + encodeURIComponent(name));
|
|
show_toast('Network "' + name + '" saved', 'success');
|
|
} catch(e) {
|
|
show_toast('Failed to save: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function modalSave() {
|
|
const n = _networksData[_modalNetIndex];
|
|
if (!n) return;
|
|
const activeTab = document.querySelector('#net-modal .modal-tab.active');
|
|
const tab = activeTab ? activeTab.textContent.trim().toLowerCase() : 'config';
|
|
try {
|
|
if (tab === 'routes') {
|
|
await api_put('/api/network/' + encodeURIComponent(n.name) + '/route');
|
|
} else if (tab === 'outputs') {
|
|
await api_put('/api/network/' + encodeURIComponent(n.name) + '/output');
|
|
} else {
|
|
await api_put('/api/network/' + encodeURIComponent(n.name));
|
|
}
|
|
show_toast('Network "' + n.name + '" saved', 'success');
|
|
} catch(e) {
|
|
show_toast('Failed to save: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function modalLoadYaml(netName) {
|
|
const el = document.getElementById('modal-yaml');
|
|
if (!el) return;
|
|
el.textContent = 'Loading...';
|
|
try {
|
|
el.textContent = await api_get_text('/api/network/' + encodeURIComponent(netName) + '?format=yaml');
|
|
} catch(e) {
|
|
el.textContent = 'Failed to load YAML: ' + e.message;
|
|
}
|
|
}
|
|
|
|
async function modalLoadRoutes(netName) {
|
|
const el = document.getElementById('modal-routes-list');
|
|
if (!el) return;
|
|
el.innerHTML = '<span style="color:var(--text-muted);font-size:13px;">Loading...</span>';
|
|
try {
|
|
await sync_kernel_neighbors().catch(() => _kernelNeighbors);
|
|
const routes = await api_get('/api/network/' + encodeURIComponent(netName) + '/route');
|
|
const arr = Array.isArray(routes) ? routes : [];
|
|
if (arr.length === 0) {
|
|
el.innerHTML = '<p style="color:var(--text-muted);font-size:13px;">No routes configured</p>';
|
|
} else {
|
|
el.innerHTML = '<table style="width:100%;">' +
|
|
'<thead><tr><th>Prefix</th><th>NextHop</th><th>Metric</th><th></th></tr></thead><tbody>' +
|
|
arr.map(r =>
|
|
'<tr>' +
|
|
'<td class="td-mono">' + (r.prefix || '-') + '</td>' +
|
|
'<td>' + nextHopWithMacHtml(r.nexthop) + '</td>' +
|
|
'<td class="td-muted">' + (r.metric || 0) + '</td>' +
|
|
'<td><button class="action-btn action-delete" onclick="modalDelRoute(\'' + esc(r.prefix) + '\')">Remove</button></td>' +
|
|
'</tr>'
|
|
).join('') + '</tbody></table>';
|
|
}
|
|
} catch(e) {
|
|
el.innerHTML = '<span style="color:var(--danger);font-size:13px;">Failed to load routes</span>';
|
|
}
|
|
}
|
|
|
|
async function modalAddRoute() {
|
|
const n = _networksData[_modalNetIndex];
|
|
if (!n) return;
|
|
const prefix = document.getElementById('network-route-prefix').value.trim();
|
|
const nexthop = document.getElementById('network-route-nexthop').value.trim();
|
|
if (!prefix) { show_toast('Prefix is required', 'error'); return; }
|
|
try {
|
|
await api_post('/api/network/' + encodeURIComponent(n.name) + '/route', { prefix, nexthop });
|
|
document.getElementById('network-route-prefix').value = '';
|
|
document.getElementById('network-route-nexthop').value = '';
|
|
closeAddDialog('network-route');
|
|
show_toast('Route added', 'success');
|
|
modalLoadRoutes(n.name);
|
|
} catch(e) {
|
|
show_toast('Failed to add route: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function modalDelRoute(prefix) {
|
|
const n = _networksData[_modalNetIndex];
|
|
if (!n || !await showConfirm('Remove route "' + prefix + '"?')) return;
|
|
try {
|
|
await api_delete('/api/network/' + encodeURIComponent(n.name) + '/route', { prefix });
|
|
show_toast('Route removed', 'success');
|
|
modalLoadRoutes(n.name);
|
|
} catch(e) {
|
|
show_toast('Failed to remove route: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function modalLoadOutputs(netName) {
|
|
const el = document.getElementById('modal-outputs-list');
|
|
if (!el) return;
|
|
el.innerHTML = '<span style="color:var(--text-muted);font-size:13px;">Loading...</span>';
|
|
try {
|
|
const outputs = await api_get('/api/network/' + encodeURIComponent(netName) + '/output');
|
|
const arr = Array.isArray(outputs) ? outputs : [];
|
|
if (arr.length === 0) {
|
|
el.innerHTML = '<p style="color:var(--text-muted);font-size:13px;">No outputs configured</p>';
|
|
} else {
|
|
el.innerHTML = '<table style="width:100%;">' +
|
|
'<thead><tr><th>Protocol</th><th>Remote</th><th>Segment / Secret</th><th>Device</th><th>State</th><th>RX/TX</th><th></th></tr></thead><tbody>' +
|
|
arr.map(o => {
|
|
const state = o.state || '-';
|
|
const bc = state === 'up' ? 'badge-green' : state === 'down' ? 'badge-red' : 'badge-gray';
|
|
const device = outputDeviceName(o);
|
|
const deleteKey = outputDeleteKey(o);
|
|
const detail = (o.protocol === 'tcp' || o.protocol === 'udp')
|
|
? '<div class="td-mono" style="font-size:12px;line-height:1.5;">' +
|
|
'<div>' + esc(o.secret || '-') + '</div>' +
|
|
'<div>' + esc(o.crypt || '-') + '</div>' +
|
|
'</div>'
|
|
: (o.protocol === 'gre' || o.protocol === 'vxlan')
|
|
? '<div class="td-mono td-muted" style="font-size:12px;line-height:1.5;">' + (o.segment || '-') + '</div>'
|
|
: '<div class="td-mono td-muted" style="font-size:12px;line-height:1.5;">-</div>';
|
|
return '<tr>' +
|
|
'<td><span class="badge badge-blue">' + (o.protocol || '-') + '</span></td>' +
|
|
'<td class="td-mono">' + (o.remote || '-') + '</td>' +
|
|
'<td>' + detail + '</td>' +
|
|
'<td class="td-mono td-muted">' + (device || '-') + '</td>' +
|
|
'<td><span class="badge ' + bc + '">' + state + '</span></td>' +
|
|
'<td class="td-muted" style="font-size:12px;">' + fmt_bytes(o.rxBytes) + ' / ' + fmt_bytes(o.txBytes) + '</td>' +
|
|
'<td><button class="action-btn action-delete" onclick="modalDelOutput(\'' + esc(deleteKey || '') + '\', \'' + esc(device || deleteKey || '') + '\')">Remove</button></td>' +
|
|
'</tr>';
|
|
}).join('') + '</tbody></table>';
|
|
}
|
|
} catch(e) {
|
|
if (el) el.innerHTML = '<span style="color:var(--danger);font-size:13px;">Failed to load outputs</span>';
|
|
}
|
|
}
|
|
|
|
async function modalAddOutput() {
|
|
const n = _networksData[_modalNetIndex];
|
|
if (!n) return;
|
|
const protocol = document.getElementById('network-out-proto').value;
|
|
const remote = document.getElementById('network-out-remote').value.trim();
|
|
const segment = parseInt(document.getElementById('network-out-segment').value.trim(), 10) || 0;
|
|
const secret = document.getElementById('network-out-secret').value.trim();
|
|
const crypt = document.getElementById('network-out-crypt').value.trim();
|
|
if (!remote) { show_toast('Remote is required', 'error'); return; }
|
|
if (protocol === 'tcp' || protocol === 'udp') {
|
|
if (!secret) { show_toast('Secret is required for ' + protocol + ' output', 'error'); return; }
|
|
if (!crypt) { show_toast('Crypt is required for ' + protocol + ' output', 'error'); return; }
|
|
}
|
|
if (protocol === 'gre' || protocol === 'vxlan') {
|
|
if (!segment) { show_toast('Segment is required for ' + protocol + ' output', 'error'); return; }
|
|
}
|
|
try {
|
|
const body = { protocol, remote };
|
|
if (segment) body.segment = segment;
|
|
if (secret) body.secret = secret;
|
|
if (crypt) body.crypt = crypt;
|
|
await api_post('/api/network/' + encodeURIComponent(n.name) + '/output', body);
|
|
document.getElementById('network-out-remote').value = '';
|
|
document.getElementById('network-out-segment').value = '';
|
|
document.getElementById('network-out-secret').value = '';
|
|
document.getElementById('network-out-crypt').value = '';
|
|
closeAddDialog('network-output');
|
|
show_toast('Output added', 'success');
|
|
modalLoadOutputs(n.name);
|
|
} catch(e) {
|
|
show_toast('Failed to add output: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function modalDelOutput(device, label) {
|
|
const n = _networksData[_modalNetIndex];
|
|
const text = label || device;
|
|
if (!n || !device || !await showConfirm('Remove output "' + text + '"?')) return;
|
|
try {
|
|
await api_delete('/api/network/' + encodeURIComponent(n.name) + '/output', { device });
|
|
show_toast('Output removed', 'success');
|
|
modalLoadOutputs(n.name);
|
|
} catch(e) {
|
|
show_toast('Failed to remove output: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
function outputDeviceName(output) {
|
|
if (!output) return '';
|
|
return output.device || '';
|
|
}
|
|
|
|
function outputDeleteKey(output) {
|
|
if (!output) return '';
|
|
const protocol = String(output.protocol || '').trim();
|
|
if (protocol === 'tcp' || protocol === 'udp' || protocol === 'tls' || protocol === 'wss') {
|
|
const secret = String(output.secret || '');
|
|
const user = secret.split(':', 1)[0] || '';
|
|
if (output.remote && user) {
|
|
return protocol + ':' + output.remote + ':' + user;
|
|
}
|
|
}
|
|
if (protocol === 'gre' && output.segment) return 'xgi' + output.segment;
|
|
if (protocol === 'vxlan' && output.segment) return 'xei' + output.segment;
|
|
if (output.device) return output.device;
|
|
if (output.segment) return String(output.remote || '') + '.' + output.segment;
|
|
return output.remote || '';
|
|
}
|
|
|
|
async function addNetwork() {
|
|
const name = document.getElementById('net-name').value.trim();
|
|
const addr = document.getElementById('net-address').value.trim();
|
|
const ns = document.getElementById('net-namespace').value.trim();
|
|
if (!name) { show_toast('Network name is required', 'error'); return; }
|
|
try {
|
|
const body = { name, config: { name, bridge: addr ? { address: addr } : undefined, namespace: ns || undefined } };
|
|
await api_post('/api/network', body);
|
|
closeAddDialog('network');
|
|
show_toast('Network "' + name + '" added', 'success');
|
|
document.getElementById('net-name').value = '';
|
|
document.getElementById('net-address').value = '';
|
|
document.getElementById('net-namespace').value = '';
|
|
await load_networks();
|
|
} catch(e) {
|
|
show_toast('Failed to add network: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function removeNetwork(name) {
|
|
if (!await showConfirm('Remove network "' + name + '"? This cannot be undone.')) return;
|
|
try {
|
|
await api_delete('/api/network/' + encodeURIComponent(name));
|
|
show_toast('Network "' + name + '" removed', 'success');
|
|
await load_networks();
|
|
} catch(e) {
|
|
show_toast('Failed to remove network: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
// =====================
|
|
// About
|
|
function about_row(k, v) {
|
|
return '<tr>' +
|
|
'<td class="about-table-key">' + k + '</td>' +
|
|
'<td class="about-table-val">' + v + '</td>' +
|
|
'</tr>';
|
|
}
|
|
|
|
function about_stat(label, value) {
|
|
return '<div class="about-stat">' +
|
|
'<div class="about-stat-label">' + label + '</div>' +
|
|
'<div class="about-stat-value">' + value + '</div>' +
|
|
'</div>';
|
|
}
|
|
|
|
async function load_about() {
|
|
try {
|
|
const [v, idx] = await Promise.all([
|
|
api_get('/api/version'),
|
|
api_get('/api/index').catch(() => ({}))
|
|
]);
|
|
const w = (idx && idx.worker) || {};
|
|
document.getElementById('about-stats').innerHTML = [
|
|
about_stat('Version', v.version ? '<span class="td-mono">' + esc(v.version) + '</span>' : '-'),
|
|
about_stat('Node Alias', esc(w.alias || '-')),
|
|
about_stat('Protocol', esc(w.protocol || '-')),
|
|
about_stat('Uptime', fmt_duration(w.uptime))
|
|
].join('');
|
|
const rows = [
|
|
about_row('Version', v.version ? '<span class="td-mono">' + esc(v.version) + '</span>' : '-'),
|
|
about_row('Build Date', esc(v.date || '-')),
|
|
about_row('Commit', v.commit ? '<span class="td-mono" style="font-size:12px;">' + esc(v.commit) + '</span>' : '-'),
|
|
about_row('License Expires', esc(v.expire || '-')),
|
|
about_row('Node Alias', esc(w.alias || '-')),
|
|
about_row('UUID', w.uuid ? '<span class="td-mono" style="font-size:12px;">' + esc(w.uuid) + '</span>' : '-'),
|
|
about_row('Protocol', esc(w.protocol || '-')),
|
|
about_row('Uptime', fmt_duration(w.uptime)),
|
|
].join('');
|
|
document.getElementById('about-info-tbody').innerHTML = rows;
|
|
} catch(e) {
|
|
document.getElementById('about-stats').innerHTML = '';
|
|
document.getElementById('about-info-tbody').innerHTML =
|
|
'<tr><td colspan="2" style="padding:16px;color:var(--text-muted);">Failed to load info</td></tr>';
|
|
}
|
|
}
|
|
|
|
// Users
|
|
// =====================
|
|
let _usersData = [];
|
|
let _usersNetworkFilter = '';
|
|
|
|
function render_users_filter() {
|
|
const sel = document.getElementById('users-network-filter');
|
|
if (!sel) return;
|
|
const networks = _usersData
|
|
.map(u => u && u.network ? String(u.network) : '')
|
|
.filter((name, index, arr) => name && arr.indexOf(name) === index)
|
|
.sort();
|
|
const current = _usersNetworkFilter;
|
|
sel.innerHTML = '<option value="">All Networks</option>' +
|
|
networks.map(name => '<option value="' + esc(name) + '">' + esc(name) + '</option>').join('');
|
|
sel.value = networks.includes(current) ? current : '';
|
|
_usersNetworkFilter = sel.value;
|
|
}
|
|
|
|
function get_filtered_users() {
|
|
if (!_usersNetworkFilter) return _usersData.slice();
|
|
return _usersData.filter(u => (u && u.network ? String(u.network) : '') === _usersNetworkFilter);
|
|
}
|
|
|
|
function render_users_table() {
|
|
const users = get_filtered_users();
|
|
document.getElementById('users-count').textContent = users.length;
|
|
const ub = document.getElementById('users-tbody');
|
|
if (users.length === 0) {
|
|
ub.innerHTML = '<tr class="empty-row"><td colspan="5">' + (_usersNetworkFilter ? 'No users found in this network' : 'No users found') + '</td></tr>';
|
|
return;
|
|
}
|
|
ub.innerHTML = users.map(u => {
|
|
const originalIndex = _usersData.indexOf(u);
|
|
const fullName = (u.name || '-') + ((u && u.network) ? ('@' + u.network) : '');
|
|
return '<tr>' +
|
|
'<td style="font-weight:600;">' + esc(fullName) + '</td>' +
|
|
'<td>' + role_badge(u.role) + '</td>' +
|
|
'<td class="td-mono td-muted" style="font-size:12px;">' + (u.password ? esc(u.password.substring(0,4)) + '••••••••' : '-') + '</td>' +
|
|
'<td class="td-muted">' + esc(u.leaseTime || '-') + '</td>' +
|
|
'<td style="white-space:nowrap;">' +
|
|
'<button class="action-btn action-dark" onclick="viewUserAccess(\'' + esc(u.name) + '@' + esc(u.network) + '\')">View .yaml</button> ' +
|
|
'<button class="action-btn action-dark" onclick="openEditUser(' + originalIndex + ')">Edit</button> ' +
|
|
'<button class="action-btn action-delete" onclick="removeUser(\'' + esc(u.name) + '@' + esc(u.network) + '\')">Remove</button>' +
|
|
'</td>' +
|
|
'</tr>';
|
|
}).join('');
|
|
}
|
|
|
|
function onUsersNetworkFilterChange() {
|
|
const sel = document.getElementById('users-network-filter');
|
|
_usersNetworkFilter = sel ? sel.value : '';
|
|
render_users_table();
|
|
}
|
|
|
|
async function load_users() {
|
|
try {
|
|
const users = await api_get('/api/user');
|
|
_usersData = Array.isArray(users) ? users : [];
|
|
render_users_filter();
|
|
render_users_table();
|
|
} catch(e) {
|
|
show_toast('Failed to load users: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
function downloadUserAccess(fullName) {
|
|
if (!fullName) return;
|
|
const url = State.baseUrl + '/api/user/' + encodeURIComponent(fullName) + '/access?token=' + encodeURIComponent(State.token);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = '';
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
}
|
|
|
|
function viewUserAccess(fullName) {
|
|
if (!fullName) return;
|
|
const filename = fullName.split('@')[0] + '.yaml';
|
|
const url = State.baseUrl + '/api/user/' + encodeURIComponent(fullName) + '/access?token=' + encodeURIComponent(State.token);
|
|
openTextPreview(url, filename, function() { downloadUserAccess(fullName); }, [{
|
|
id: 'output',
|
|
label: 'Output',
|
|
load: function() { return loadUserOutputParams(fullName); }
|
|
}], 'Access').catch(e => show_toast('Failed to view access file: ' + e.message, 'error'));
|
|
}
|
|
|
|
async function loadUserOutputParams(fullName) {
|
|
const accessUrl = State.baseUrl + '/api/user/' + encodeURIComponent(fullName) + '/access?token=' + encodeURIComponent(State.token);
|
|
const accessText = await fetch(accessUrl, { method: 'GET' }).then(async function(resp) {
|
|
if (!resp.ok) throw new Error('Failed to load access YAML: HTTP ' + resp.status + ': ' + resp.statusText);
|
|
return await resp.text();
|
|
});
|
|
const protocol = parseYamlScalar(accessText, 'protocol');
|
|
const remote = parseYamlScalar(accessText, 'connection');
|
|
const fallback = parseYamlScalar(accessText, 'fallback');
|
|
const username = parseYamlScalar(accessText, 'username');
|
|
const password = parseYamlScalar(accessText, 'password');
|
|
const cryptAlgo = parseYamlScalar(accessText, 'algorithm');
|
|
const cryptKey = parseYamlScalar(accessText, 'secret');
|
|
const userParts = String(username || '').split('@');
|
|
const userNameOnly = userParts[0] || '';
|
|
const network = userParts.length > 1 ? userParts.slice(1).join('@') : '';
|
|
const secret = userNameOnly || password ? (userNameOnly + ':' + (password || '')) : '';
|
|
const crypt = cryptAlgo || cryptKey ? ((cryptAlgo || '') + ':' + (cryptKey || '')) : '';
|
|
const lines = [
|
|
'protocol: ' + (protocol || '-'),
|
|
'remote: ' + (remote || '-')
|
|
];
|
|
if (network) lines.push('network: ' + network);
|
|
if (secret) lines.push('secret: ' + secret);
|
|
if (crypt) lines.push('crypt: ' + crypt);
|
|
if (fallback) lines.push('fallback: ' + fallback);
|
|
return lines.join('\n') + '\n';
|
|
}
|
|
|
|
function parseYamlScalar(text, key) {
|
|
const pattern = new RegExp('^\\s*' + key.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&') + '\\s*:\\s*(.+?)\\s*$', 'mi');
|
|
const match = String(text || '').match(pattern);
|
|
if (!match) return '';
|
|
return String(match[1] || '').replace(/^['"]|['"]$/g, '');
|
|
}
|
|
|
|
async function copyTextToClipboard(text) {
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
await navigator.clipboard.writeText(text);
|
|
return;
|
|
}
|
|
const area = document.createElement('textarea');
|
|
area.value = text;
|
|
area.setAttribute('readonly', '');
|
|
area.style.position = 'fixed';
|
|
area.style.opacity = '0';
|
|
document.body.appendChild(area);
|
|
area.focus();
|
|
area.select();
|
|
const ok = document.execCommand('copy');
|
|
document.body.removeChild(area);
|
|
if (!ok) throw new Error('clipboard unavailable');
|
|
}
|
|
|
|
function downloadOpenVPNProfile(networkName) {
|
|
if (!networkName) return;
|
|
const url = State.baseUrl + '/api/network/' + encodeURIComponent(networkName) + '/ovpn?token=' + encodeURIComponent(State.token);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = networkName + '.ovpn';
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
}
|
|
|
|
function viewOpenVPNProfile(networkName) {
|
|
if (!networkName) return;
|
|
const filename = networkName + '.ovpn';
|
|
const url = State.baseUrl + '/api/network/' + encodeURIComponent(networkName) + '/ovpn?token=' + encodeURIComponent(State.token);
|
|
openTextPreview(url, filename, function() { downloadOpenVPNProfile(networkName); }).catch(e => show_toast('Failed to view OpenVPN profile: ' + e.message, 'error'));
|
|
}
|
|
|
|
function viewOpenVPNLog(networkName) {
|
|
if (!networkName) return;
|
|
const filename = networkName + '-openvpn.log';
|
|
const url = State.baseUrl + '/api/network/' + encodeURIComponent(networkName) + '/openvpn/log?token=' + encodeURIComponent(State.token);
|
|
openTextPreview(url, filename).catch(e => show_toast('Failed to view OpenVPN log: ' + e.message, 'error'));
|
|
}
|
|
|
|
function viewCeciTcpLog(listen) {
|
|
if (!listen) return;
|
|
const filename = 'ceci-' + listen.replace(/[^\w.-]+/g, '_') + '.log';
|
|
const url = State.baseUrl + '/api/network/ceci/tcp/log?listen=' + encodeURIComponent(listen) + '&token=' + encodeURIComponent(State.token);
|
|
openTextPreview(url, filename).catch(e => show_toast('Failed to view Ceci log: ' + e.message, 'error'));
|
|
}
|
|
|
|
function userResetForm() {
|
|
document.getElementById('user-dialog-title').textContent = 'Add User';
|
|
document.getElementById('user-dialog-submit').textContent = 'Add';
|
|
document.getElementById('user-pass-hint').textContent = '(auto-generated if empty)';
|
|
document.getElementById('user-name').readOnly = false;
|
|
document.getElementById('user-name').value = '';
|
|
document.getElementById('user-password').value = '';
|
|
document.getElementById('user-role').value = 'guest';
|
|
document.getElementById('user-lease').value = '';
|
|
}
|
|
|
|
function userCancelEdit() {
|
|
userResetForm();
|
|
closeAddDialog('user');
|
|
}
|
|
|
|
function openEditUser(i) {
|
|
const u = _usersData[i];
|
|
if (!u) return;
|
|
document.getElementById('user-dialog-title').textContent = 'Edit User';
|
|
document.getElementById('user-dialog-submit').textContent = 'Save';
|
|
document.getElementById('user-pass-hint').textContent = '(leave blank to keep current)';
|
|
document.getElementById('user-name').value = (u.name || '') + '@' + (u.network || '');
|
|
document.getElementById('user-name').readOnly = true;
|
|
document.getElementById('user-password').value = u.password || '';
|
|
document.getElementById('user-role').value = u.role || 'guest';
|
|
document.getElementById('user-lease').value = u.leaseTime ? u.leaseTime.substring(0, 10) : '';
|
|
openAddDialog('user');
|
|
}
|
|
|
|
async function addUser() {
|
|
const name = document.getElementById('user-name').value.trim();
|
|
const pass = document.getElementById('user-password').value.trim() || gen_password();
|
|
const role = document.getElementById('user-role').value;
|
|
const leaseDate = document.getElementById('user-lease').value;
|
|
|
|
if (!name || !name.includes('@')) { show_toast('Username must be in format user@network', 'error'); return; }
|
|
|
|
const parts = name.split('@');
|
|
const username = parts[0];
|
|
const network = parts[1];
|
|
|
|
const body = {
|
|
name: username,
|
|
network: network,
|
|
password: pass,
|
|
role: role,
|
|
leaseTime: leaseDate ? leaseDate + 'T00' : undefined
|
|
};
|
|
|
|
try {
|
|
await api_post('/api/user/' + encodeURIComponent(name), body);
|
|
userResetForm();
|
|
closeAddDialog('user');
|
|
show_toast('User "' + name + '" saved', 'success');
|
|
await load_users();
|
|
} catch(e) {
|
|
show_toast('Failed to add user: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function removeUser(fullName) {
|
|
if (!await showConfirm('Remove user "' + fullName + '"?')) return;
|
|
try {
|
|
await api_delete('/api/user/' + encodeURIComponent(fullName));
|
|
show_toast('User "' + fullName + '" removed', 'success');
|
|
await load_users();
|
|
} catch(e) {
|
|
show_toast('Failed to remove user: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
// =====================
|
|
// Escape for HTML attrs
|
|
// =====================
|
|
function openAddDialog(type) {
|
|
document.getElementById('dlg-' + type).classList.remove('hidden');
|
|
if (type === 'vpnclient') {
|
|
onVpnNetworkChange();
|
|
} else if (type === 'router-redirect') {
|
|
const sourceInput = document.getElementById('router-redirect-source');
|
|
const sourceList = document.getElementById('router-redirect-source-list');
|
|
const ovpnSubnets = _networksData
|
|
.map(n => n && n.config && n.config.openvpn ? n.config.openvpn.subnet : '')
|
|
.filter((subnet, index, arr) => subnet && arr.indexOf(subnet) === index);
|
|
if (sourceList) {
|
|
sourceList.innerHTML = ovpnSubnets.map(subnet => '<option value="' + esc(subnet) + '"></option>').join('');
|
|
}
|
|
const usedTables = _routerRedirects
|
|
.map(r => r && r.table ? String(r.table) : '')
|
|
.filter((table, index, arr) => table && arr.indexOf(table) === index)
|
|
.sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
|
|
const tableList = document.getElementById('router-redirect-table-list');
|
|
const tableHint = document.getElementById('router-redirect-table-hint');
|
|
if (tableList) {
|
|
const freeTables = [];
|
|
for (let t = 100; t <= 250 && freeTables.length < 12; t++) {
|
|
if (!usedTables.includes(String(t))) freeTables.push(String(t));
|
|
}
|
|
tableList.innerHTML = freeTables.map(table => '<option value="' + table + '"></option>').join('');
|
|
}
|
|
document.getElementById('router-redirect-source').value = '';
|
|
document.getElementById('router-redirect-nexthop').value = '';
|
|
document.getElementById('router-redirect-table').value = '';
|
|
if (sourceInput && ovpnSubnets.length > 0) {
|
|
sourceInput.value = ovpnSubnets[0];
|
|
sourceInput.placeholder = 'Select OpenVPN subnet or enter custom CIDR';
|
|
} else if (sourceInput) {
|
|
sourceInput.placeholder = 'e.g. 10.0.0.0/24';
|
|
}
|
|
if (tableHint) {
|
|
tableHint.textContent = usedTables.length > 0
|
|
? 'Used table IDs: ' + usedTables.join(', ')
|
|
: 'No table IDs are currently in use.';
|
|
}
|
|
setTimeout(() => document.getElementById('router-redirect-source').focus(), 50);
|
|
} else if (type === 'network-route') {
|
|
document.getElementById('network-route-prefix').value = '';
|
|
document.getElementById('network-route-nexthop').value = '';
|
|
setTimeout(() => document.getElementById('network-route-prefix').focus(), 50);
|
|
} else if (type === 'network-output') {
|
|
document.getElementById('network-out-proto').value = 'tcp';
|
|
document.getElementById('network-out-remote').value = '';
|
|
document.getElementById('network-out-segment').value = '';
|
|
document.getElementById('network-out-secret').value = '';
|
|
document.getElementById('network-out-crypt').value = '';
|
|
onNetworkOutputProtocolChange();
|
|
setTimeout(() => document.getElementById('network-out-remote').focus(), 50);
|
|
} else if (type === 'ceci-tcp') {
|
|
document.getElementById('ceci-tcp-listen').value = '';
|
|
document.getElementById('ceci-tcp-target').value = '';
|
|
setTimeout(() => document.getElementById('ceci-tcp-listen').focus(), 50);
|
|
}
|
|
}
|
|
function closeAddDialog(type) {
|
|
document.getElementById('dlg-' + type).classList.add('hidden');
|
|
}
|
|
|
|
function onNetworkOutputProtocolChange() {
|
|
const protocol = document.getElementById('network-out-proto').value;
|
|
const remoteInput = document.getElementById('network-out-remote');
|
|
const segmentGroup = document.getElementById('network-out-segment-group');
|
|
const secretGroup = document.getElementById('network-out-secret-group');
|
|
const cryptGroup = document.getElementById('network-out-crypt-group');
|
|
const secretLabel = secretGroup ? secretGroup.querySelector('label') : null;
|
|
if (protocol === 'tcp' || protocol === 'udp') {
|
|
if (remoteInput) remoteInput.placeholder = 'e.g. 1.2.3.4:4500';
|
|
if (segmentGroup) segmentGroup.style.display = 'none';
|
|
const segmentInput = document.getElementById('network-out-segment');
|
|
if (segmentInput) segmentInput.value = '';
|
|
if (secretLabel) secretLabel.innerHTML = 'Secret';
|
|
if (cryptGroup) cryptGroup.style.display = '';
|
|
if (secretGroup) secretGroup.style.display = '';
|
|
} else if (protocol === 'gre' || protocol === 'vxlan') {
|
|
if (remoteInput) remoteInput.placeholder = 'e.g. 1.2.3.4';
|
|
if (segmentGroup) segmentGroup.style.display = '';
|
|
if (secretGroup) secretGroup.style.display = 'none';
|
|
if (cryptGroup) cryptGroup.style.display = 'none';
|
|
const secretInput = document.getElementById('network-out-secret');
|
|
const cryptInput = document.getElementById('network-out-crypt');
|
|
if (secretInput) secretInput.value = '';
|
|
if (cryptInput) cryptInput.value = '';
|
|
}
|
|
}
|
|
|
|
let _confirmResolve = null;
|
|
function showConfirm(msg, title) {
|
|
document.getElementById('confirm-title').textContent = title || 'Confirm Delete';
|
|
document.getElementById('confirm-msg').textContent = msg;
|
|
document.getElementById('confirm-dialog').classList.remove('hidden');
|
|
return new Promise(resolve => { _confirmResolve = resolve; });
|
|
}
|
|
function confirmResolve(result) {
|
|
document.getElementById('confirm-dialog').classList.add('hidden');
|
|
if (_confirmResolve) { _confirmResolve(result); _confirmResolve = null; }
|
|
}
|
|
|
|
function esc(s) {
|
|
return (s || '').replace(/'/g, "\\'").replace(/"/g, '"');
|
|
}
|
|
|
|
// =====================
|
|
// Init
|
|
// =====================
|
|
(function init() {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const tokenFromQuery = params.get('token');
|
|
State.token = tokenFromQuery || '';
|
|
State.baseUrl = window.location.origin;
|
|
show_dashboard();
|
|
load_overview();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|