1184 lines
46 KiB
HTML
1184 lines
46 KiB
HTML
|
|
<!DOCTYPE html>
|
||
|
|
<html lang="en">
|
||
|
|
<head>
|
||
|
|
<meta charset="UTF-8">
|
||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
|
|
<title>AUTARCH DNS — Command</title>
|
||
|
|
<style>
|
||
|
|
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Oswald:wght@300;400;500;600;700&display=swap');
|
||
|
|
|
||
|
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||
|
|
|
||
|
|
:root {
|
||
|
|
--bg-deep: #06090c;
|
||
|
|
--bg-panel: #0b1117;
|
||
|
|
--bg-surface: #101820;
|
||
|
|
--bg-elevated: #162030;
|
||
|
|
--border: #1a2a3a;
|
||
|
|
--border-bright: #1e3a50;
|
||
|
|
--green-dim: #1a3a2a;
|
||
|
|
--green: #00d672;
|
||
|
|
--green-bright: #00ff88;
|
||
|
|
--green-glow: rgba(0,214,114,0.15);
|
||
|
|
--amber: #ffb020;
|
||
|
|
--amber-dim: #3a2a10;
|
||
|
|
--red: #ff3a4a;
|
||
|
|
--red-dim: #3a1a1a;
|
||
|
|
--cyan: #00c8ff;
|
||
|
|
--cyan-dim: #0a2a3a;
|
||
|
|
--text: #c8d8e8;
|
||
|
|
--text-dim: #607888;
|
||
|
|
--text-bright: #e8f0f8;
|
||
|
|
--mono: 'JetBrains Mono', 'Consolas', monospace;
|
||
|
|
--display: 'Oswald', 'Impact', sans-serif;
|
||
|
|
--radius: 2px;
|
||
|
|
--sidebar-w: 220px;
|
||
|
|
}
|
||
|
|
|
||
|
|
html,body{height:100%;overflow:hidden}
|
||
|
|
body{
|
||
|
|
font-family:var(--mono);
|
||
|
|
font-size:12px;
|
||
|
|
color:var(--text);
|
||
|
|
background:var(--bg-deep);
|
||
|
|
line-height:1.5;
|
||
|
|
-webkit-font-smoothing:antialiased;
|
||
|
|
}
|
||
|
|
|
||
|
|
::selection{background:var(--green);color:var(--bg-deep)}
|
||
|
|
::-webkit-scrollbar{width:6px;height:6px}
|
||
|
|
::-webkit-scrollbar-track{background:var(--bg-panel)}
|
||
|
|
::-webkit-scrollbar-thumb{background:var(--border-bright);border-radius:3px}
|
||
|
|
::-webkit-scrollbar-thumb:hover{background:var(--green-dim)}
|
||
|
|
|
||
|
|
/* ═══ LOGIN ═══ */
|
||
|
|
#login-screen{
|
||
|
|
position:fixed;inset:0;z-index:9999;
|
||
|
|
background:var(--bg-deep);
|
||
|
|
display:flex;align-items:center;justify-content:center;
|
||
|
|
flex-direction:column;
|
||
|
|
}
|
||
|
|
#login-screen.hidden{display:none}
|
||
|
|
|
||
|
|
.login-bg{
|
||
|
|
position:absolute;inset:0;overflow:hidden;opacity:0.08;
|
||
|
|
background:
|
||
|
|
repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,214,114,0.03) 2px,rgba(0,214,114,0.03) 4px),
|
||
|
|
repeating-linear-gradient(90deg,transparent,transparent 40px,rgba(0,214,114,0.02) 40px,rgba(0,214,114,0.02) 41px);
|
||
|
|
}
|
||
|
|
|
||
|
|
.login-box{
|
||
|
|
position:relative;z-index:1;
|
||
|
|
width:380px;
|
||
|
|
background:var(--bg-panel);
|
||
|
|
border:1px solid var(--border-bright);
|
||
|
|
padding:0;
|
||
|
|
}
|
||
|
|
.login-header{
|
||
|
|
background:var(--bg-surface);
|
||
|
|
border-bottom:1px solid var(--border-bright);
|
||
|
|
padding:20px 28px 16px;
|
||
|
|
text-align:center;
|
||
|
|
}
|
||
|
|
.login-header h1{
|
||
|
|
font-family:var(--display);
|
||
|
|
font-weight:700;
|
||
|
|
font-size:28px;
|
||
|
|
letter-spacing:6px;
|
||
|
|
text-transform:uppercase;
|
||
|
|
color:var(--green);
|
||
|
|
margin-bottom:4px;
|
||
|
|
}
|
||
|
|
.login-header .sub{
|
||
|
|
font-size:10px;
|
||
|
|
letter-spacing:3px;
|
||
|
|
color:var(--text-dim);
|
||
|
|
text-transform:uppercase;
|
||
|
|
}
|
||
|
|
.login-body{padding:28px}
|
||
|
|
.login-body label{
|
||
|
|
display:block;
|
||
|
|
font-size:9px;
|
||
|
|
letter-spacing:2px;
|
||
|
|
text-transform:uppercase;
|
||
|
|
color:var(--text-dim);
|
||
|
|
margin-bottom:6px;
|
||
|
|
}
|
||
|
|
.login-body input{
|
||
|
|
width:100%;
|
||
|
|
background:var(--bg-deep);
|
||
|
|
border:1px solid var(--border);
|
||
|
|
color:var(--text-bright);
|
||
|
|
font-family:var(--mono);
|
||
|
|
font-size:13px;
|
||
|
|
padding:10px 12px;
|
||
|
|
margin-bottom:18px;
|
||
|
|
outline:none;
|
||
|
|
transition:border-color 0.2s;
|
||
|
|
}
|
||
|
|
.login-body input:focus{border-color:var(--green)}
|
||
|
|
.login-btn{
|
||
|
|
width:100%;
|
||
|
|
background:var(--green);
|
||
|
|
color:var(--bg-deep);
|
||
|
|
border:none;
|
||
|
|
font-family:var(--display);
|
||
|
|
font-size:14px;
|
||
|
|
font-weight:600;
|
||
|
|
letter-spacing:3px;
|
||
|
|
text-transform:uppercase;
|
||
|
|
padding:12px;
|
||
|
|
cursor:pointer;
|
||
|
|
transition:all 0.15s;
|
||
|
|
}
|
||
|
|
.login-btn:hover{background:var(--green-bright);transform:translateY(-1px)}
|
||
|
|
.login-btn:active{transform:translateY(0)}
|
||
|
|
.login-error{
|
||
|
|
color:var(--red);
|
||
|
|
font-size:11px;
|
||
|
|
text-align:center;
|
||
|
|
margin-top:12px;
|
||
|
|
min-height:16px;
|
||
|
|
}
|
||
|
|
.login-footer{
|
||
|
|
border-top:1px solid var(--border);
|
||
|
|
padding:12px 28px;
|
||
|
|
font-size:9px;
|
||
|
|
color:var(--text-dim);
|
||
|
|
text-align:center;
|
||
|
|
letter-spacing:1px;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ═══ APP SHELL ═══ */
|
||
|
|
#app{display:none;height:100vh;flex-direction:column}
|
||
|
|
#app.active{display:flex}
|
||
|
|
|
||
|
|
/* Top bar */
|
||
|
|
.topbar{
|
||
|
|
height:36px;
|
||
|
|
background:var(--bg-panel);
|
||
|
|
border-bottom:1px solid var(--border);
|
||
|
|
display:flex;
|
||
|
|
align-items:center;
|
||
|
|
padding:0 16px;
|
||
|
|
flex-shrink:0;
|
||
|
|
gap:12px;
|
||
|
|
}
|
||
|
|
.topbar-brand{
|
||
|
|
font-family:var(--display);
|
||
|
|
font-weight:600;
|
||
|
|
font-size:13px;
|
||
|
|
letter-spacing:4px;
|
||
|
|
color:var(--green);
|
||
|
|
text-transform:uppercase;
|
||
|
|
}
|
||
|
|
.topbar-sep{width:1px;height:16px;background:var(--border-bright)}
|
||
|
|
.topbar-status{
|
||
|
|
display:flex;align-items:center;gap:6px;
|
||
|
|
font-size:10px;color:var(--text-dim);
|
||
|
|
}
|
||
|
|
.status-dot{
|
||
|
|
width:6px;height:6px;border-radius:50%;
|
||
|
|
background:var(--green);
|
||
|
|
box-shadow:0 0 6px var(--green);
|
||
|
|
animation:pulse 2s infinite;
|
||
|
|
}
|
||
|
|
@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.4}}
|
||
|
|
.topbar-right{margin-left:auto;display:flex;align-items:center;gap:12px}
|
||
|
|
.topbar-user{font-size:10px;color:var(--text-dim);letter-spacing:1px;text-transform:uppercase}
|
||
|
|
.topbar-logout{
|
||
|
|
background:none;border:1px solid var(--border);
|
||
|
|
color:var(--text-dim);font-family:var(--mono);font-size:10px;
|
||
|
|
padding:3px 10px;cursor:pointer;letter-spacing:1px;
|
||
|
|
transition:all 0.15s;
|
||
|
|
}
|
||
|
|
.topbar-logout:hover{border-color:var(--red);color:var(--red)}
|
||
|
|
|
||
|
|
/* Layout */
|
||
|
|
.main-layout{display:flex;flex:1;overflow:hidden}
|
||
|
|
|
||
|
|
/* Sidebar */
|
||
|
|
.sidebar{
|
||
|
|
width:var(--sidebar-w);
|
||
|
|
background:var(--bg-panel);
|
||
|
|
border-right:1px solid var(--border);
|
||
|
|
display:flex;flex-direction:column;
|
||
|
|
flex-shrink:0;
|
||
|
|
overflow-y:auto;
|
||
|
|
}
|
||
|
|
.nav-section{
|
||
|
|
padding:16px 0 8px 16px;
|
||
|
|
font-size:9px;letter-spacing:2px;
|
||
|
|
text-transform:uppercase;
|
||
|
|
color:var(--text-dim);
|
||
|
|
}
|
||
|
|
.nav-item{
|
||
|
|
display:flex;align-items:center;gap:10px;
|
||
|
|
padding:8px 16px;
|
||
|
|
cursor:pointer;
|
||
|
|
font-size:11px;
|
||
|
|
color:var(--text-dim);
|
||
|
|
border-left:2px solid transparent;
|
||
|
|
transition:all 0.12s;
|
||
|
|
}
|
||
|
|
.nav-item:hover{color:var(--text);background:rgba(0,214,114,0.03)}
|
||
|
|
.nav-item.active{
|
||
|
|
color:var(--green);
|
||
|
|
border-left-color:var(--green);
|
||
|
|
background:rgba(0,214,114,0.05);
|
||
|
|
}
|
||
|
|
.nav-icon{
|
||
|
|
width:16px;height:16px;
|
||
|
|
display:flex;align-items:center;justify-content:center;
|
||
|
|
font-size:12px;opacity:0.7;
|
||
|
|
}
|
||
|
|
.nav-item.active .nav-icon{opacity:1}
|
||
|
|
|
||
|
|
/* Content */
|
||
|
|
.content{flex:1;overflow-y:auto;padding:20px;background:var(--bg-deep)}
|
||
|
|
|
||
|
|
.page{display:none}
|
||
|
|
.page.active{display:block;animation:fadeIn 0.2s ease}
|
||
|
|
@keyframes fadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
|
||
|
|
|
||
|
|
/* ═══ COMPONENTS ═══ */
|
||
|
|
|
||
|
|
/* Section header */
|
||
|
|
.section-hdr{
|
||
|
|
font-family:var(--display);
|
||
|
|
font-size:18px;font-weight:500;
|
||
|
|
letter-spacing:3px;text-transform:uppercase;
|
||
|
|
color:var(--text-bright);
|
||
|
|
margin-bottom:16px;
|
||
|
|
padding-bottom:8px;
|
||
|
|
border-bottom:1px solid var(--border);
|
||
|
|
}
|
||
|
|
.section-hdr .accent{color:var(--green)}
|
||
|
|
|
||
|
|
/* Cards */
|
||
|
|
.card{
|
||
|
|
background:var(--bg-panel);
|
||
|
|
border:1px solid var(--border);
|
||
|
|
margin-bottom:16px;
|
||
|
|
}
|
||
|
|
.card-head{
|
||
|
|
padding:10px 16px;
|
||
|
|
background:var(--bg-surface);
|
||
|
|
border-bottom:1px solid var(--border);
|
||
|
|
font-size:10px;letter-spacing:2px;
|
||
|
|
text-transform:uppercase;
|
||
|
|
color:var(--text-dim);
|
||
|
|
display:flex;align-items:center;justify-content:space-between;
|
||
|
|
}
|
||
|
|
.card-body{padding:16px}
|
||
|
|
|
||
|
|
/* Stat grid */
|
||
|
|
.stat-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-bottom:20px}
|
||
|
|
.stat-card{
|
||
|
|
background:var(--bg-panel);
|
||
|
|
border:1px solid var(--border);
|
||
|
|
padding:14px 16px;
|
||
|
|
}
|
||
|
|
.stat-label{
|
||
|
|
font-size:9px;letter-spacing:2px;
|
||
|
|
text-transform:uppercase;color:var(--text-dim);
|
||
|
|
margin-bottom:6px;
|
||
|
|
}
|
||
|
|
.stat-value{
|
||
|
|
font-size:22px;font-weight:300;
|
||
|
|
color:var(--green-bright);
|
||
|
|
font-variant-numeric:tabular-nums;
|
||
|
|
}
|
||
|
|
.stat-value.amber{color:var(--amber)}
|
||
|
|
.stat-value.cyan{color:var(--cyan)}
|
||
|
|
.stat-value.red{color:var(--red)}
|
||
|
|
.stat-value.white{color:var(--text-bright)}
|
||
|
|
|
||
|
|
/* Tables */
|
||
|
|
.tbl{width:100%;border-collapse:collapse;font-size:11px}
|
||
|
|
.tbl th{
|
||
|
|
text-align:left;
|
||
|
|
padding:8px 12px;
|
||
|
|
font-size:9px;letter-spacing:1.5px;
|
||
|
|
text-transform:uppercase;
|
||
|
|
color:var(--text-dim);
|
||
|
|
background:var(--bg-surface);
|
||
|
|
border-bottom:1px solid var(--border);
|
||
|
|
font-weight:500;
|
||
|
|
position:sticky;top:0;z-index:1;
|
||
|
|
}
|
||
|
|
.tbl td{
|
||
|
|
padding:6px 12px;
|
||
|
|
border-bottom:1px solid var(--border);
|
||
|
|
color:var(--text);
|
||
|
|
max-width:300px;
|
||
|
|
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
|
||
|
|
}
|
||
|
|
.tbl tr:hover td{background:rgba(0,214,114,0.02)}
|
||
|
|
.tbl .mono{font-variant-numeric:tabular-nums}
|
||
|
|
|
||
|
|
/* Badges */
|
||
|
|
.badge{
|
||
|
|
display:inline-block;
|
||
|
|
padding:1px 6px;
|
||
|
|
font-size:9px;letter-spacing:1px;
|
||
|
|
text-transform:uppercase;
|
||
|
|
border:1px solid;
|
||
|
|
}
|
||
|
|
.badge-green{color:var(--green);border-color:var(--green-dim);background:rgba(0,214,114,0.05)}
|
||
|
|
.badge-amber{color:var(--amber);border-color:var(--amber-dim);background:rgba(255,176,32,0.05)}
|
||
|
|
.badge-red{color:var(--red);border-color:var(--red-dim);background:rgba(255,58,74,0.05)}
|
||
|
|
.badge-cyan{color:var(--cyan);border-color:var(--cyan-dim);background:rgba(0,200,255,0.05)}
|
||
|
|
|
||
|
|
/* Buttons */
|
||
|
|
.btn{
|
||
|
|
font-family:var(--mono);font-size:10px;
|
||
|
|
letter-spacing:1px;text-transform:uppercase;
|
||
|
|
padding:6px 14px;cursor:pointer;
|
||
|
|
border:1px solid var(--border-bright);
|
||
|
|
background:var(--bg-surface);
|
||
|
|
color:var(--text);
|
||
|
|
transition:all 0.12s;
|
||
|
|
}
|
||
|
|
.btn:hover{border-color:var(--green);color:var(--green)}
|
||
|
|
.btn-primary{background:var(--green);color:var(--bg-deep);border-color:var(--green);font-weight:600}
|
||
|
|
.btn-primary:hover{background:var(--green-bright)}
|
||
|
|
.btn-danger{border-color:var(--red-dim);color:var(--red)}
|
||
|
|
.btn-danger:hover{background:var(--red-dim);border-color:var(--red)}
|
||
|
|
.btn-sm{padding:3px 8px;font-size:9px}
|
||
|
|
.btn-group{display:flex;gap:6px;flex-wrap:wrap}
|
||
|
|
|
||
|
|
/* Inputs */
|
||
|
|
.input{
|
||
|
|
font-family:var(--mono);font-size:12px;
|
||
|
|
background:var(--bg-deep);
|
||
|
|
border:1px solid var(--border);
|
||
|
|
color:var(--text-bright);
|
||
|
|
padding:8px 12px;
|
||
|
|
outline:none;width:100%;
|
||
|
|
transition:border-color 0.15s;
|
||
|
|
}
|
||
|
|
.input:focus{border-color:var(--green)}
|
||
|
|
.input::placeholder{color:var(--text-dim)}
|
||
|
|
.input-sm{padding:5px 8px;font-size:11px}
|
||
|
|
|
||
|
|
.form-row{display:flex;gap:8px;margin-bottom:10px;align-items:flex-end}
|
||
|
|
.form-group{flex:1}
|
||
|
|
.form-label{
|
||
|
|
display:block;
|
||
|
|
font-size:9px;letter-spacing:1.5px;
|
||
|
|
text-transform:uppercase;
|
||
|
|
color:var(--text-dim);
|
||
|
|
margin-bottom:4px;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Toggle */
|
||
|
|
.toggle-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--border)}
|
||
|
|
.toggle-row:last-child{border-bottom:none}
|
||
|
|
.toggle-info{flex:1}
|
||
|
|
.toggle-label{font-size:12px;color:var(--text-bright)}
|
||
|
|
.toggle-desc{font-size:10px;color:var(--text-dim);margin-top:2px}
|
||
|
|
.toggle{position:relative;width:40px;height:20px;flex-shrink:0}
|
||
|
|
.toggle input{opacity:0;width:0;height:0}
|
||
|
|
.toggle-track{
|
||
|
|
position:absolute;inset:0;
|
||
|
|
background:var(--bg-deep);
|
||
|
|
border:1px solid var(--border-bright);
|
||
|
|
cursor:pointer;transition:all 0.2s;
|
||
|
|
}
|
||
|
|
.toggle-track::after{
|
||
|
|
content:'';position:absolute;
|
||
|
|
left:2px;top:2px;width:14px;height:14px;
|
||
|
|
background:var(--text-dim);transition:all 0.2s;
|
||
|
|
}
|
||
|
|
.toggle input:checked+.toggle-track{border-color:var(--green);background:var(--green-dim)}
|
||
|
|
.toggle input:checked+.toggle-track::after{transform:translateX(20px);background:var(--green)}
|
||
|
|
|
||
|
|
/* NS grid */
|
||
|
|
.ns-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||
|
|
.ns-slot{
|
||
|
|
background:var(--bg-deep);
|
||
|
|
border:1px solid var(--border);
|
||
|
|
padding:10px 12px;
|
||
|
|
}
|
||
|
|
.ns-slot-label{
|
||
|
|
font-size:9px;letter-spacing:2px;
|
||
|
|
text-transform:uppercase;
|
||
|
|
color:var(--text-dim);
|
||
|
|
margin-bottom:6px;
|
||
|
|
}
|
||
|
|
.ns-slot input{
|
||
|
|
width:100%;background:transparent;
|
||
|
|
border:none;border-bottom:1px solid var(--border);
|
||
|
|
color:var(--green-bright);
|
||
|
|
font-family:var(--mono);font-size:13px;
|
||
|
|
padding:4px 0;outline:none;
|
||
|
|
}
|
||
|
|
.ns-slot input:focus{border-bottom-color:var(--green)}
|
||
|
|
.ns-slot input::placeholder{color:var(--text-dim)}
|
||
|
|
|
||
|
|
/* Empty state */
|
||
|
|
.empty{
|
||
|
|
text-align:center;padding:40px;
|
||
|
|
color:var(--text-dim);font-size:11px;
|
||
|
|
letter-spacing:1px;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Scan line overlay */
|
||
|
|
.scanline{
|
||
|
|
pointer-events:none;position:fixed;inset:0;z-index:10000;
|
||
|
|
background:repeating-linear-gradient(
|
||
|
|
0deg,
|
||
|
|
transparent, transparent 2px,
|
||
|
|
rgba(0,0,0,0.03) 2px, rgba(0,0,0,0.03) 4px
|
||
|
|
);
|
||
|
|
opacity:0.5;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Toast */
|
||
|
|
#toast{
|
||
|
|
position:fixed;bottom:20px;right:20px;z-index:99999;
|
||
|
|
background:var(--bg-elevated);
|
||
|
|
border:1px solid var(--green);
|
||
|
|
color:var(--green);
|
||
|
|
padding:10px 18px;
|
||
|
|
font-size:11px;letter-spacing:1px;
|
||
|
|
transform:translateY(80px);opacity:0;
|
||
|
|
transition:all 0.3s cubic-bezier(0.22,1,0.36,1);
|
||
|
|
}
|
||
|
|
#toast.show{transform:translateY(0);opacity:1}
|
||
|
|
#toast.error{border-color:var(--red);color:var(--red)}
|
||
|
|
|
||
|
|
/* Modal */
|
||
|
|
.modal-overlay{
|
||
|
|
position:fixed;inset:0;z-index:50000;
|
||
|
|
background:rgba(6,9,12,0.85);
|
||
|
|
display:none;align-items:center;justify-content:center;
|
||
|
|
}
|
||
|
|
.modal-overlay.active{display:flex}
|
||
|
|
.modal{
|
||
|
|
background:var(--bg-panel);
|
||
|
|
border:1px solid var(--border-bright);
|
||
|
|
width:480px;max-width:90vw;max-height:80vh;overflow-y:auto;
|
||
|
|
}
|
||
|
|
.modal-head{
|
||
|
|
padding:14px 20px;
|
||
|
|
background:var(--bg-surface);
|
||
|
|
border-bottom:1px solid var(--border);
|
||
|
|
font-family:var(--display);
|
||
|
|
font-size:14px;letter-spacing:2px;
|
||
|
|
text-transform:uppercase;
|
||
|
|
display:flex;align-items:center;justify-content:space-between;
|
||
|
|
}
|
||
|
|
.modal-close{
|
||
|
|
background:none;border:none;color:var(--text-dim);
|
||
|
|
font-size:18px;cursor:pointer;line-height:1;
|
||
|
|
}
|
||
|
|
.modal-close:hover{color:var(--red)}
|
||
|
|
.modal-body{padding:20px}
|
||
|
|
|
||
|
|
/* Loading shimmer */
|
||
|
|
@keyframes shimmer{0%{background-position:-200% 0}100%{background-position:200% 0}}
|
||
|
|
.loading{
|
||
|
|
background:linear-gradient(90deg,var(--bg-panel) 25%,var(--bg-surface) 50%,var(--bg-panel) 75%);
|
||
|
|
background-size:200% 100%;
|
||
|
|
animation:shimmer 1.5s infinite;
|
||
|
|
height:20px;margin:8px 0;
|
||
|
|
}
|
||
|
|
</style>
|
||
|
|
</head>
|
||
|
|
<body>
|
||
|
|
|
||
|
|
<div class="scanline"></div>
|
||
|
|
|
||
|
|
<!-- ═══ LOGIN SCREEN ═══ -->
|
||
|
|
<div id="login-screen">
|
||
|
|
<div class="login-bg"></div>
|
||
|
|
<div class="login-box">
|
||
|
|
<div class="login-header">
|
||
|
|
<h1>Autarch</h1>
|
||
|
|
<div class="sub">DNS Command Interface</div>
|
||
|
|
</div>
|
||
|
|
<div class="login-body">
|
||
|
|
<label>Operator ID</label>
|
||
|
|
<input type="text" id="login-user" autocomplete="username" spellcheck="false">
|
||
|
|
<label>Access Code</label>
|
||
|
|
<input type="password" id="login-pass" autocomplete="current-password">
|
||
|
|
<button class="login-btn" onclick="doLogin()">Authenticate</button>
|
||
|
|
<div class="login-error" id="login-error"></div>
|
||
|
|
</div>
|
||
|
|
<div class="login-footer">SETEC LABORATORIES — AUTHORIZED PERSONNEL ONLY</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- ═══ APP ═══ -->
|
||
|
|
<div id="app">
|
||
|
|
<div class="topbar">
|
||
|
|
<div class="topbar-brand">Autarch DNS</div>
|
||
|
|
<div class="topbar-sep"></div>
|
||
|
|
<div class="topbar-status">
|
||
|
|
<div class="status-dot" id="status-dot"></div>
|
||
|
|
<span id="topbar-uptime">--</span>
|
||
|
|
</div>
|
||
|
|
<div class="topbar-sep"></div>
|
||
|
|
<div class="topbar-status">
|
||
|
|
<span id="topbar-queries">0</span> queries
|
||
|
|
</div>
|
||
|
|
<div class="topbar-right">
|
||
|
|
<span class="topbar-user" id="topbar-user">admin</span>
|
||
|
|
<button class="topbar-logout" onclick="doLogout()">Logout</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="main-layout">
|
||
|
|
<div class="sidebar">
|
||
|
|
<div class="nav-section">Operations</div>
|
||
|
|
<div class="nav-item active" data-page="dashboard" onclick="nav(this)">
|
||
|
|
<div class="nav-icon">■</div> Dashboard
|
||
|
|
</div>
|
||
|
|
<div class="nav-item" data-page="querylog" onclick="nav(this)">
|
||
|
|
<div class="nav-icon">▶</div> Query Log
|
||
|
|
</div>
|
||
|
|
<div class="nav-item" data-page="analytics" onclick="nav(this)">
|
||
|
|
<div class="nav-icon">▲</div> Analytics
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="nav-section">DNS Management</div>
|
||
|
|
<div class="nav-item" data-page="zones" onclick="nav(this)">
|
||
|
|
<div class="nav-icon">♦</div> Zones & Records
|
||
|
|
</div>
|
||
|
|
<div class="nav-item" data-page="nameservers" onclick="nav(this)">
|
||
|
|
<div class="nav-icon">★</div> Nameservers
|
||
|
|
</div>
|
||
|
|
<div class="nav-item" data-page="cache" onclick="nav(this)">
|
||
|
|
<div class="nav-icon">●</div> Cache
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="nav-section">Security</div>
|
||
|
|
<div class="nav-item" data-page="blocklist" onclick="nav(this)">
|
||
|
|
<div class="nav-icon">✖</div> Blocklist
|
||
|
|
</div>
|
||
|
|
<div class="nav-item" data-page="hosts" onclick="nav(this)">
|
||
|
|
<div class="nav-icon">☷</div> Hosts
|
||
|
|
</div>
|
||
|
|
<div class="nav-item" data-page="encryption" onclick="nav(this)">
|
||
|
|
<div class="nav-icon">◆</div> Encryption
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="nav-section">System</div>
|
||
|
|
<div class="nav-item" data-page="config" onclick="nav(this)">
|
||
|
|
<div class="nav-icon">⚙</div> Configuration
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="content">
|
||
|
|
|
||
|
|
<!-- DASHBOARD -->
|
||
|
|
<div class="page active" id="page-dashboard">
|
||
|
|
<div class="section-hdr">System <span class="accent">Dashboard</span></div>
|
||
|
|
<div class="stat-grid" id="dash-stats"></div>
|
||
|
|
<div class="card">
|
||
|
|
<div class="card-head">Recent Queries</div>
|
||
|
|
<div class="card-body" style="max-height:320px;overflow-y:auto;padding:0">
|
||
|
|
<table class="tbl"><thead><tr>
|
||
|
|
<th>Time</th><th>Client</th><th>Name</th><th>Type</th><th>Result</th><th>Latency</th>
|
||
|
|
</tr></thead><tbody id="dash-recent"></tbody></table>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- QUERY LOG -->
|
||
|
|
<div class="page" id="page-querylog">
|
||
|
|
<div class="section-hdr">Query <span class="accent">Log</span></div>
|
||
|
|
<div class="btn-group" style="margin-bottom:12px">
|
||
|
|
<button class="btn" onclick="loadQueryLog()">Refresh</button>
|
||
|
|
<button class="btn btn-danger" onclick="clearQueryLog()">Clear Log</button>
|
||
|
|
</div>
|
||
|
|
<div class="card"><div class="card-body" style="max-height:calc(100vh - 200px);overflow-y:auto;padding:0">
|
||
|
|
<table class="tbl"><thead><tr>
|
||
|
|
<th>Timestamp</th><th>Client</th><th>Name</th><th>Type</th><th>Rcode</th><th>Answers</th><th>Latency</th><th>Source</th>
|
||
|
|
</tr></thead><tbody id="qlog-body"></tbody></table>
|
||
|
|
</div></div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- ANALYTICS -->
|
||
|
|
<div class="page" id="page-analytics">
|
||
|
|
<div class="section-hdr">Query <span class="accent">Analytics</span></div>
|
||
|
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
|
||
|
|
<div class="card">
|
||
|
|
<div class="card-head">Top Domains</div>
|
||
|
|
<div class="card-body" style="max-height:400px;overflow-y:auto;padding:0">
|
||
|
|
<table class="tbl"><thead><tr><th>Domain</th><th>Queries</th></tr></thead>
|
||
|
|
<tbody id="analytics-domains"></tbody></table>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="card">
|
||
|
|
<div class="card-head">Query Types</div>
|
||
|
|
<div class="card-body" id="analytics-types" style="padding:12px"></div>
|
||
|
|
</div>
|
||
|
|
<div class="card" style="grid-column:1/-1">
|
||
|
|
<div class="card-head">Clients</div>
|
||
|
|
<div class="card-body" style="max-height:300px;overflow-y:auto;padding:0">
|
||
|
|
<table class="tbl"><thead><tr><th>Client IP</th><th>Queries</th></tr></thead>
|
||
|
|
<tbody id="analytics-clients"></tbody></table>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- ZONES -->
|
||
|
|
<div class="page" id="page-zones">
|
||
|
|
<div class="section-hdr">Zones & <span class="accent">Records</span></div>
|
||
|
|
<div class="form-row" style="margin-bottom:16px">
|
||
|
|
<div class="form-group">
|
||
|
|
<input class="input" id="zone-new-domain" placeholder="example.com">
|
||
|
|
</div>
|
||
|
|
<button class="btn btn-primary" onclick="createZone()">Create Zone</button>
|
||
|
|
</div>
|
||
|
|
<div id="zones-list"></div>
|
||
|
|
<div id="zone-detail" style="display:none">
|
||
|
|
<div class="card">
|
||
|
|
<div class="card-head">
|
||
|
|
<span>Zone: <span id="zone-detail-name" style="color:var(--green)"></span></span>
|
||
|
|
<div class="btn-group">
|
||
|
|
<button class="btn btn-sm" onclick="closeZoneDetail()">Back</button>
|
||
|
|
<button class="btn btn-sm btn-danger" onclick="deleteCurrentZone()">Delete Zone</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="card-body">
|
||
|
|
<div class="form-row">
|
||
|
|
<div class="form-group"><div class="form-label">Name</div><input class="input input-sm" id="rec-name" placeholder="subdomain.example.com."></div>
|
||
|
|
<div class="form-group" style="max-width:100px"><div class="form-label">Type</div>
|
||
|
|
<select class="input input-sm" id="rec-type"><option>A</option><option>AAAA</option><option>CNAME</option><option>MX</option><option>TXT</option><option>NS</option><option>SRV</option><option>PTR</option></select>
|
||
|
|
</div>
|
||
|
|
<div class="form-group"><div class="form-label">Value</div><input class="input input-sm" id="rec-value" placeholder="1.2.3.4"></div>
|
||
|
|
<div class="form-group" style="max-width:80px"><div class="form-label">TTL</div><input class="input input-sm" id="rec-ttl" value="300" type="number"></div>
|
||
|
|
<button class="btn btn-primary btn-sm" onclick="addRecord()" style="margin-bottom:0">Add</button>
|
||
|
|
</div>
|
||
|
|
<div style="max-height:400px;overflow-y:auto">
|
||
|
|
<table class="tbl"><thead><tr><th>Name</th><th>Type</th><th>Value</th><th>TTL</th><th></th></tr></thead>
|
||
|
|
<tbody id="records-body"></tbody></table>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- NAMESERVERS -->
|
||
|
|
<div class="page" id="page-nameservers">
|
||
|
|
<div class="section-hdr">Nameserver <span class="accent">Configuration</span></div>
|
||
|
|
<div class="card">
|
||
|
|
<div class="card-head">Primary Nameserver Identity</div>
|
||
|
|
<div class="card-body">
|
||
|
|
<div class="form-row">
|
||
|
|
<div class="form-group">
|
||
|
|
<div class="form-label">Primary NS Hostname</div>
|
||
|
|
<input class="input" id="ns-primary" placeholder="ns1.example.com">
|
||
|
|
</div>
|
||
|
|
<div class="form-group">
|
||
|
|
<div class="form-label">Domain</div>
|
||
|
|
<input class="input" id="ns-domain" placeholder="example.com">
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="card">
|
||
|
|
<div class="card-head">Upstream Nameservers (Forwarders / Fallback)</div>
|
||
|
|
<div class="card-body">
|
||
|
|
<div class="ns-grid" id="ns-grid"></div>
|
||
|
|
<div style="margin-top:14px;display:flex;justify-content:flex-end">
|
||
|
|
<button class="btn btn-primary" onclick="saveNameservers()">Save Nameservers</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- CACHE -->
|
||
|
|
<div class="page" id="page-cache">
|
||
|
|
<div class="section-hdr">DNS <span class="accent">Cache</span></div>
|
||
|
|
<div class="btn-group" style="margin-bottom:12px">
|
||
|
|
<button class="btn" onclick="loadCache()">Refresh</button>
|
||
|
|
<button class="btn btn-danger" onclick="flushCache()">Flush All</button>
|
||
|
|
</div>
|
||
|
|
<div class="card"><div class="card-body" style="max-height:calc(100vh - 220px);overflow-y:auto;padding:0">
|
||
|
|
<table class="tbl"><thead><tr>
|
||
|
|
<th>Name</th><th>Type</th><th>TTL</th><th>Answers</th><th>Expires</th><th></th>
|
||
|
|
</tr></thead><tbody id="cache-body"></tbody></table>
|
||
|
|
</div></div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- BLOCKLIST -->
|
||
|
|
<div class="page" id="page-blocklist">
|
||
|
|
<div class="section-hdr">Domain <span class="accent">Blocklist</span></div>
|
||
|
|
<div class="form-row" style="margin-bottom:12px">
|
||
|
|
<div class="form-group">
|
||
|
|
<input class="input" id="block-domain" placeholder="malware.example.com">
|
||
|
|
</div>
|
||
|
|
<button class="btn btn-primary" onclick="addBlock()">Block</button>
|
||
|
|
<button class="btn" onclick="loadBlocklist()">Refresh</button>
|
||
|
|
</div>
|
||
|
|
<div class="card">
|
||
|
|
<div class="card-head"><span>Blocked Domains</span><span id="block-count">0</span></div>
|
||
|
|
<div class="card-body" style="max-height:calc(100vh - 260px);overflow-y:auto;padding:0">
|
||
|
|
<table class="tbl"><thead><tr><th>Domain</th><th></th></tr></thead>
|
||
|
|
<tbody id="block-body"></tbody></table>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- HOSTS -->
|
||
|
|
<div class="page" id="page-hosts">
|
||
|
|
<div class="section-hdr">Hosts <span class="accent">File</span></div>
|
||
|
|
<div class="form-row" style="margin-bottom:12px">
|
||
|
|
<div class="form-group" style="max-width:160px">
|
||
|
|
<input class="input input-sm" id="host-ip" placeholder="IP Address">
|
||
|
|
</div>
|
||
|
|
<div class="form-group">
|
||
|
|
<input class="input input-sm" id="host-name" placeholder="hostname.local">
|
||
|
|
</div>
|
||
|
|
<button class="btn btn-primary btn-sm" onclick="addHost()">Add</button>
|
||
|
|
<button class="btn btn-sm" onclick="loadHosts()">Refresh</button>
|
||
|
|
</div>
|
||
|
|
<div class="card"><div class="card-body" style="max-height:calc(100vh - 240px);overflow-y:auto;padding:0">
|
||
|
|
<table class="tbl"><thead><tr><th>IP</th><th>Hostname</th><th>Aliases</th><th></th></tr></thead>
|
||
|
|
<tbody id="hosts-body"></tbody></table>
|
||
|
|
</div></div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- ENCRYPTION -->
|
||
|
|
<div class="page" id="page-encryption">
|
||
|
|
<div class="section-hdr">Transport <span class="accent">Encryption</span></div>
|
||
|
|
<div class="card">
|
||
|
|
<div class="card-head">Upstream Encryption Mode</div>
|
||
|
|
<div class="card-body">
|
||
|
|
<div class="toggle-row">
|
||
|
|
<div class="toggle-info">
|
||
|
|
<div class="toggle-label">DNS-over-TLS (DoT)</div>
|
||
|
|
<div class="toggle-desc">Encrypt upstream queries via TLS on port 853</div>
|
||
|
|
</div>
|
||
|
|
<label class="toggle"><input type="checkbox" id="enc-dot" onchange="saveEncryption()"><span class="toggle-track"></span></label>
|
||
|
|
</div>
|
||
|
|
<div class="toggle-row">
|
||
|
|
<div class="toggle-info">
|
||
|
|
<div class="toggle-label">DNS-over-HTTPS (DoH)</div>
|
||
|
|
<div class="toggle-desc">Encrypt upstream queries via HTTPS (RFC 8484)</div>
|
||
|
|
</div>
|
||
|
|
<label class="toggle"><input type="checkbox" id="enc-doh" onchange="saveEncryption()"><span class="toggle-track"></span></label>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="card">
|
||
|
|
<div class="card-head">Encryption Status</div>
|
||
|
|
<div class="card-body" id="enc-status"></div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- CONFIG -->
|
||
|
|
<div class="page" id="page-config">
|
||
|
|
<div class="section-hdr">Server <span class="accent">Configuration</span></div>
|
||
|
|
<div class="card">
|
||
|
|
<div class="card-head">Settings</div>
|
||
|
|
<div class="card-body" id="config-body"></div>
|
||
|
|
</div>
|
||
|
|
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:8px">
|
||
|
|
<button class="btn btn-primary" onclick="saveConfig()">Save Configuration</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div id="toast"></div>
|
||
|
|
|
||
|
|
<div class="modal-overlay" id="modal">
|
||
|
|
<div class="modal">
|
||
|
|
<div class="modal-head"><span id="modal-title">Modal</span><button class="modal-close" onclick="closeModal()">×</button></div>
|
||
|
|
<div class="modal-body" id="modal-body"></div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<script>
|
||
|
|
/* ═══ CORE ═══ */
|
||
|
|
let currentPage='dashboard', currentZone=null, toastTimer=null;
|
||
|
|
|
||
|
|
async function api(path,opts={}){
|
||
|
|
try{
|
||
|
|
const r=await fetch(path,opts);
|
||
|
|
if(r.status===401){showLogin();return null}
|
||
|
|
return await r.json();
|
||
|
|
}catch(e){toast('Connection error','error');return null}
|
||
|
|
}
|
||
|
|
const post=(p,b)=>api(p,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)});
|
||
|
|
const put=(p,b)=>api(p,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)});
|
||
|
|
const del=(p,b)=>api(p,{method:'DELETE',headers:{'Content-Type':'application/json'},body:b?JSON.stringify(b):undefined});
|
||
|
|
|
||
|
|
function toast(msg,type='success'){
|
||
|
|
const t=document.getElementById('toast');
|
||
|
|
t.textContent=msg;t.className=type==='error'?'error':'';
|
||
|
|
clearTimeout(toastTimer);
|
||
|
|
requestAnimationFrame(()=>{t.classList.add('show');toastTimer=setTimeout(()=>t.classList.remove('show'),3000)});
|
||
|
|
}
|
||
|
|
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||
|
|
|
||
|
|
/* ═══ AUTH ═══ */
|
||
|
|
async function checkAuth(){
|
||
|
|
const r=await api('/auth/check');
|
||
|
|
if(r&&r.ok){
|
||
|
|
document.getElementById('topbar-user').textContent=r.username;
|
||
|
|
hideLogin();loadPage('dashboard');
|
||
|
|
}else{showLogin()}
|
||
|
|
}
|
||
|
|
function showLogin(){document.getElementById('login-screen').classList.remove('hidden');document.getElementById('app').classList.remove('active')}
|
||
|
|
function hideLogin(){document.getElementById('login-screen').classList.add('hidden');document.getElementById('app').classList.add('active')}
|
||
|
|
|
||
|
|
async function doLogin(){
|
||
|
|
const u=document.getElementById('login-user').value;
|
||
|
|
const p=document.getElementById('login-pass').value;
|
||
|
|
const r=await post('/auth/login',{username:u,password:p});
|
||
|
|
if(r&&r.ok){hideLogin();document.getElementById('topbar-user').textContent=r.username;loadPage('dashboard')}
|
||
|
|
else{document.getElementById('login-error').textContent=r?r.error:'Connection failed'}
|
||
|
|
}
|
||
|
|
async function doLogout(){await api('/auth/logout');showLogin()}
|
||
|
|
|
||
|
|
document.getElementById('login-pass').addEventListener('keydown',e=>{if(e.key==='Enter')doLogin()});
|
||
|
|
document.getElementById('login-user').addEventListener('keydown',e=>{if(e.key==='Enter')document.getElementById('login-pass').focus()});
|
||
|
|
|
||
|
|
/* ═══ NAVIGATION ═══ */
|
||
|
|
function nav(el){
|
||
|
|
const page=el.dataset.page;
|
||
|
|
document.querySelectorAll('.nav-item').forEach(n=>n.classList.remove('active'));
|
||
|
|
el.classList.add('active');
|
||
|
|
document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
|
||
|
|
document.getElementById('page-'+page).classList.add('active');
|
||
|
|
currentPage=page;
|
||
|
|
loadPage(page);
|
||
|
|
}
|
||
|
|
function loadPage(page){
|
||
|
|
switch(page){
|
||
|
|
case'dashboard':loadDashboard();break;
|
||
|
|
case'querylog':loadQueryLog();break;
|
||
|
|
case'analytics':loadAnalytics();break;
|
||
|
|
case'zones':loadZones();break;
|
||
|
|
case'nameservers':loadNameservers();break;
|
||
|
|
case'cache':loadCache();break;
|
||
|
|
case'blocklist':loadBlocklist();break;
|
||
|
|
case'hosts':loadHosts();break;
|
||
|
|
case'encryption':loadEncryption();break;
|
||
|
|
case'config':loadConfig();break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ═══ DASHBOARD ═══ */
|
||
|
|
async function loadDashboard(){
|
||
|
|
const[s,m]=await Promise.all([api('/api/status'),api('/api/metrics')]);
|
||
|
|
if(!s||!m)return;
|
||
|
|
const met=m.metrics||m;
|
||
|
|
document.getElementById('topbar-uptime').textContent=s.uptime||'--';
|
||
|
|
document.getElementById('topbar-queries').textContent=(met.total_queries||0).toLocaleString();
|
||
|
|
|
||
|
|
document.getElementById('dash-stats').innerHTML=`
|
||
|
|
<div class="stat-card"><div class="stat-label">Total Queries</div><div class="stat-value">${(met.total_queries||0).toLocaleString()}</div></div>
|
||
|
|
<div class="stat-card"><div class="stat-label">Cache Hits</div><div class="stat-value cyan">${(met.cache_hits||0).toLocaleString()}</div></div>
|
||
|
|
<div class="stat-card"><div class="stat-label">Cache Misses</div><div class="stat-value amber">${(met.cache_misses||0).toLocaleString()}</div></div>
|
||
|
|
<div class="stat-card"><div class="stat-label">Local Answers</div><div class="stat-value white">${(met.local_answers||0).toLocaleString()}</div></div>
|
||
|
|
<div class="stat-card"><div class="stat-label">Resolved</div><div class="stat-value">${(met.resolved||0).toLocaleString()}</div></div>
|
||
|
|
<div class="stat-card"><div class="stat-label">Blocked</div><div class="stat-value red">${(met.blocked||0).toLocaleString()}</div></div>
|
||
|
|
<div class="stat-card"><div class="stat-label">Failed</div><div class="stat-value red">${(met.failed||0).toLocaleString()}</div></div>
|
||
|
|
<div class="stat-card"><div class="stat-label">Cache Size</div><div class="stat-value cyan">${(m.cache_size||s.cache_size||0)}</div></div>
|
||
|
|
<div class="stat-card"><div class="stat-label">Active Zones</div><div class="stat-value white">${s.zones||0}</div></div>
|
||
|
|
<div class="stat-card"><div class="stat-label">Uptime</div><div class="stat-value white" style="font-size:14px">${s.uptime||'--'}</div></div>
|
||
|
|
`;
|
||
|
|
|
||
|
|
const ql=await api('/api/querylog?limit=20');
|
||
|
|
const tb=document.getElementById('dash-recent');
|
||
|
|
if(ql&&ql.entries){
|
||
|
|
tb.innerHTML=ql.entries.map(e=>`<tr>
|
||
|
|
<td class="mono">${esc(e.timestamp?.substring(11,19)||'')}</td>
|
||
|
|
<td>${esc(e.client?.split(':')[0]||'')}</td>
|
||
|
|
<td>${esc(e.name||'')}</td>
|
||
|
|
<td><span class="badge badge-cyan">${esc(e.type||'')}</span></td>
|
||
|
|
<td><span class="badge ${e.rcode==='NOERROR'?'badge-green':e.rcode==='NXDOMAIN'?'badge-amber':'badge-red'}">${esc(e.rcode||'')}</span></td>
|
||
|
|
<td class="mono">${esc(e.latency||'')}</td>
|
||
|
|
</tr>`).join('');
|
||
|
|
}else{tb.innerHTML='<tr><td colspan="6" class="empty">No queries yet</td></tr>'}
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ═══ QUERY LOG ═══ */
|
||
|
|
async function loadQueryLog(){
|
||
|
|
const r=await api('/api/querylog?limit=500');
|
||
|
|
const tb=document.getElementById('qlog-body');
|
||
|
|
if(r&&r.entries&&r.entries.length){
|
||
|
|
tb.innerHTML=r.entries.map(e=>`<tr>
|
||
|
|
<td class="mono">${esc(e.timestamp||'')}</td>
|
||
|
|
<td>${esc(e.client||'')}</td>
|
||
|
|
<td>${esc(e.name||'')}</td>
|
||
|
|
<td><span class="badge badge-cyan">${esc(e.type||'')}</span></td>
|
||
|
|
<td><span class="badge ${e.rcode==='NOERROR'?'badge-green':'badge-amber'}">${esc(e.rcode||'')}</span></td>
|
||
|
|
<td>${e.answers||0}</td>
|
||
|
|
<td class="mono">${esc(e.latency||'')}</td>
|
||
|
|
<td><span class="badge ${e.source==='cache'?'badge-cyan':e.source==='local'?'badge-green':e.source==='blocked'?'badge-red':'badge-amber'}">${esc(e.source||'')}</span></td>
|
||
|
|
</tr>`).join('');
|
||
|
|
}else{tb.innerHTML='<tr><td colspan="8" class="empty">No log entries</td></tr>'}
|
||
|
|
}
|
||
|
|
async function clearQueryLog(){await del('/api/querylog');loadQueryLog();toast('Query log cleared')}
|
||
|
|
|
||
|
|
/* ═══ ANALYTICS ═══ */
|
||
|
|
async function loadAnalytics(){
|
||
|
|
const[d,t,c]=await Promise.all([api('/api/stats/top-domains?limit=30'),api('/api/stats/query-types'),api('/api/stats/clients')]);
|
||
|
|
const db=document.getElementById('analytics-domains');
|
||
|
|
if(d&&d.domains&&d.domains.length){
|
||
|
|
const max=d.domains[0]?.count||1;
|
||
|
|
db.innerHTML=d.domains.map(e=>`<tr><td>${esc(e.domain||'')}</td><td class="mono"><div style="display:flex;align-items:center;gap:8px"><span>${e.count}</span><div style="height:4px;background:var(--green);width:${(e.count/max*100).toFixed(0)}px;max-width:120px;opacity:0.5"></div></div></td></tr>`).join('');
|
||
|
|
}else{db.innerHTML='<tr><td colspan="2" class="empty">No data</td></tr>'}
|
||
|
|
|
||
|
|
const tp=document.getElementById('analytics-types');
|
||
|
|
if(t&&t.types){
|
||
|
|
const entries=Object.entries(t.types).sort((a,b)=>b[1]-a[1]);
|
||
|
|
const max=entries[0]?.[1]||1;
|
||
|
|
tp.innerHTML=entries.map(([k,v])=>`<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">
|
||
|
|
<span style="width:50px;text-align:right;color:var(--cyan);font-size:11px">${esc(k)}</span>
|
||
|
|
<div style="flex:1;height:18px;background:var(--bg-deep);position:relative"><div style="height:100%;width:${(v/max*100).toFixed(1)}%;background:linear-gradient(90deg,var(--cyan-dim),rgba(0,200,255,0.3))"></div></div>
|
||
|
|
<span class="mono" style="width:50px;font-size:11px">${v}</span>
|
||
|
|
</div>`).join('');
|
||
|
|
}
|
||
|
|
|
||
|
|
const cb=document.getElementById('analytics-clients');
|
||
|
|
if(c&&c.clients){
|
||
|
|
const entries=Object.entries(c.clients).sort((a,b)=>b[1]-a[1]);
|
||
|
|
cb.innerHTML=entries.map(([k,v])=>`<tr><td>${esc(k)}</td><td class="mono">${v}</td></tr>`).join('');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ═══ ZONES ═══ */
|
||
|
|
async function loadZones(){
|
||
|
|
document.getElementById('zone-detail').style.display='none';
|
||
|
|
document.getElementById('zones-list').style.display='block';
|
||
|
|
const r=await api('/api/zones');
|
||
|
|
const c=document.getElementById('zones-list');
|
||
|
|
if(r&&r.zones&&r.zones.length){
|
||
|
|
c.innerHTML=r.zones.map(z=>`<div class="card" style="cursor:pointer" onclick="openZone('${esc(z.domain)}')">
|
||
|
|
<div class="card-body" style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px">
|
||
|
|
<div><span style="color:var(--green);font-size:13px;font-weight:500">${esc(z.domain)}</span>
|
||
|
|
<span style="color:var(--text-dim);margin-left:12px">${z.records} records</span></div>
|
||
|
|
<span class="badge badge-green">${z.dnssec?'DNSSEC':'Active'}</span>
|
||
|
|
</div>
|
||
|
|
</div>`).join('');
|
||
|
|
}else{c.innerHTML='<div class="empty">No zones configured. Create one above.</div>'}
|
||
|
|
}
|
||
|
|
async function createZone(){
|
||
|
|
const d=document.getElementById('zone-new-domain').value.trim();
|
||
|
|
if(!d)return;
|
||
|
|
const r=await post('/api/zones',{domain:d});
|
||
|
|
if(r&&r.ok){toast('Zone created: '+d);document.getElementById('zone-new-domain').value='';loadZones()}
|
||
|
|
else toast(r?.error||'Failed','error');
|
||
|
|
}
|
||
|
|
async function openZone(domain){
|
||
|
|
currentZone=domain;
|
||
|
|
document.getElementById('zones-list').style.display='none';
|
||
|
|
document.getElementById('zone-detail').style.display='block';
|
||
|
|
document.getElementById('zone-detail-name').textContent=domain;
|
||
|
|
const r=await api('/api/zones/'+encodeURIComponent(domain));
|
||
|
|
const tb=document.getElementById('records-body');
|
||
|
|
if(r&&r.zone&&r.zone.records){
|
||
|
|
tb.innerHTML=r.zone.records.map(rec=>`<tr>
|
||
|
|
<td>${esc(rec.name||'')}</td>
|
||
|
|
<td><span class="badge badge-cyan">${esc(rec.type||'')}</span></td>
|
||
|
|
<td>${esc(rec.value||'')}</td>
|
||
|
|
<td class="mono">${rec.ttl||300}</td>
|
||
|
|
<td><button class="btn btn-danger btn-sm" onclick="deleteRecord('${esc(rec.id)}')">Del</button></td>
|
||
|
|
</tr>`).join('');
|
||
|
|
}else{tb.innerHTML='<tr><td colspan="5" class="empty">No records</td></tr>'}
|
||
|
|
}
|
||
|
|
function closeZoneDetail(){currentZone=null;loadZones()}
|
||
|
|
async function addRecord(){
|
||
|
|
if(!currentZone)return;
|
||
|
|
const rec={name:document.getElementById('rec-name').value,type:document.getElementById('rec-type').value,
|
||
|
|
value:document.getElementById('rec-value').value,ttl:parseInt(document.getElementById('rec-ttl').value)||300};
|
||
|
|
if(!rec.name||!rec.value)return toast('Name and value required','error');
|
||
|
|
const r=await post('/api/zones/'+encodeURIComponent(currentZone)+'/records',rec);
|
||
|
|
if(r&&r.ok){toast('Record added');openZone(currentZone);document.getElementById('rec-name').value='';document.getElementById('rec-value').value=''}
|
||
|
|
else toast(r?.error||'Failed','error');
|
||
|
|
}
|
||
|
|
async function deleteRecord(id){
|
||
|
|
if(!currentZone)return;
|
||
|
|
const r=await del('/api/zones/'+encodeURIComponent(currentZone)+'/records/'+id);
|
||
|
|
if(r&&r.ok){toast('Record deleted');openZone(currentZone)}
|
||
|
|
}
|
||
|
|
async function deleteCurrentZone(){
|
||
|
|
if(!currentZone||!confirm('Delete zone '+currentZone+'?'))return;
|
||
|
|
await del('/api/zones/'+encodeURIComponent(currentZone));
|
||
|
|
toast('Zone deleted');closeZoneDetail();
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ═══ NAMESERVERS ═══ */
|
||
|
|
const nsLabels=['Primary','Secondary','Tertiary','Quaternary','Quinary','Senary'];
|
||
|
|
async function loadNameservers(){
|
||
|
|
const r=await api('/webui/nameservers');
|
||
|
|
const ns=r?.nameservers||{};
|
||
|
|
const grid=document.getElementById('ns-grid');
|
||
|
|
const servers=ns.nameservers||['','','','','',''];
|
||
|
|
grid.innerHTML=servers.map((s,i)=>`<div class="ns-slot">
|
||
|
|
<div class="ns-slot-label">NS ${i+1} — ${nsLabels[i]||'Slot '+(i+1)}</div>
|
||
|
|
<input id="ns-${i}" value="${esc(s||'')}" placeholder="IP or hostname:port" spellcheck="false">
|
||
|
|
</div>`).join('');
|
||
|
|
document.getElementById('ns-primary').value=ns.primary||'';
|
||
|
|
document.getElementById('ns-domain').value=ns.domain||'';
|
||
|
|
}
|
||
|
|
async function saveNameservers(){
|
||
|
|
const servers=[];
|
||
|
|
for(let i=0;i<6;i++)servers.push(document.getElementById('ns-'+i)?.value||'');
|
||
|
|
const cfg={nameservers:servers,primary:document.getElementById('ns-primary').value,domain:document.getElementById('ns-domain').value};
|
||
|
|
const r=await put('/webui/nameservers',cfg);
|
||
|
|
if(r&&r.ok)toast('Nameservers saved');else toast('Failed','error');
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ═══ CACHE ═══ */
|
||
|
|
async function loadCache(){
|
||
|
|
const r=await api('/api/cache');
|
||
|
|
const tb=document.getElementById('cache-body');
|
||
|
|
if(r&&r.entries&&r.entries.length){
|
||
|
|
tb.innerHTML=r.entries.map(e=>`<tr>
|
||
|
|
<td>${esc(e.name||'')}</td>
|
||
|
|
<td><span class="badge badge-cyan">${esc(e.type||'')}</span></td>
|
||
|
|
<td class="mono">${e.ttl_remaining||0}s</td>
|
||
|
|
<td>${e.answers||0}</td>
|
||
|
|
<td class="mono">${esc(e.expires_at?.substring(11,19)||'')}</td>
|
||
|
|
<td><button class="btn btn-danger btn-sm" onclick="flushEntry('${esc(e.key)}')">Flush</button></td>
|
||
|
|
</tr>`).join('');
|
||
|
|
}else{tb.innerHTML='<tr><td colspan="6" class="empty">Cache empty</td></tr>'}
|
||
|
|
}
|
||
|
|
async function flushCache(){await del('/api/cache');loadCache();toast('Cache flushed')}
|
||
|
|
async function flushEntry(key){await del('/api/cache?key='+encodeURIComponent(key));loadCache();toast('Entry flushed')}
|
||
|
|
|
||
|
|
/* ═══ BLOCKLIST ═══ */
|
||
|
|
async function loadBlocklist(){
|
||
|
|
const r=await api('/api/blocklist');
|
||
|
|
document.getElementById('block-count').textContent=r?.count||0;
|
||
|
|
const tb=document.getElementById('block-body');
|
||
|
|
if(r&&r.domains&&r.domains.length){
|
||
|
|
tb.innerHTML=r.domains.map(d=>`<tr>
|
||
|
|
<td>${esc(d)}</td>
|
||
|
|
<td style="width:60px"><button class="btn btn-danger btn-sm" onclick="removeBlock('${esc(d)}')">Remove</button></td>
|
||
|
|
</tr>`).join('');
|
||
|
|
}else{tb.innerHTML='<tr><td colspan="2" class="empty">No blocked domains</td></tr>'}
|
||
|
|
}
|
||
|
|
async function addBlock(){
|
||
|
|
const d=document.getElementById('block-domain').value.trim();
|
||
|
|
if(!d)return;
|
||
|
|
await post('/api/blocklist',{domain:d});
|
||
|
|
document.getElementById('block-domain').value='';
|
||
|
|
loadBlocklist();toast('Blocked: '+d);
|
||
|
|
}
|
||
|
|
async function removeBlock(d){await del('/api/blocklist',{domain:d});loadBlocklist();toast('Unblocked: '+d)}
|
||
|
|
|
||
|
|
/* ═══ HOSTS ═══ */
|
||
|
|
async function loadHosts(){
|
||
|
|
const r=await api('/api/hosts');
|
||
|
|
const tb=document.getElementById('hosts-body');
|
||
|
|
const entries=r?.entries||r?.hosts||[];
|
||
|
|
if(entries.length){
|
||
|
|
tb.innerHTML=entries.map(h=>`<tr>
|
||
|
|
<td class="mono">${esc(h.ip||'')}</td>
|
||
|
|
<td>${esc(h.hostname||'')}</td>
|
||
|
|
<td style="color:var(--text-dim)">${(h.aliases||[]).join(', ')}</td>
|
||
|
|
<td style="width:60px"><button class="btn btn-danger btn-sm" onclick="removeHost('${esc(h.hostname)}')">Del</button></td>
|
||
|
|
</tr>`).join('');
|
||
|
|
}else{tb.innerHTML='<tr><td colspan="4" class="empty">No host entries</td></tr>'}
|
||
|
|
}
|
||
|
|
async function addHost(){
|
||
|
|
const ip=document.getElementById('host-ip').value.trim();
|
||
|
|
const name=document.getElementById('host-name').value.trim();
|
||
|
|
if(!ip||!name)return toast('IP and hostname required','error');
|
||
|
|
await post('/api/hosts',{ip,hostname:name});
|
||
|
|
document.getElementById('host-ip').value='';document.getElementById('host-name').value='';
|
||
|
|
loadHosts();toast('Added: '+name);
|
||
|
|
}
|
||
|
|
async function removeHost(name){await del('/api/hosts',{hostname:name});loadHosts();toast('Removed: '+name)}
|
||
|
|
|
||
|
|
/* ═══ ENCRYPTION ═══ */
|
||
|
|
async function loadEncryption(){
|
||
|
|
const r=await api('/api/encryption');
|
||
|
|
if(!r)return;
|
||
|
|
document.getElementById('enc-dot').checked=!!r.dot_enabled;
|
||
|
|
document.getElementById('enc-doh').checked=!!r.doh_enabled;
|
||
|
|
const mode=r.preferred_mode||'plain';
|
||
|
|
let html=`<div style="margin-bottom:12px"><span class="badge ${mode==='doh'?'badge-green':mode==='dot'?'badge-cyan':'badge-amber'}">${mode.toUpperCase()}</span> <span style="color:var(--text-dim)">current preferred mode</span></div>`;
|
||
|
|
if(r.doh_servers){
|
||
|
|
html+='<div class="form-label" style="margin:12px 0 6px">Known DoH Endpoints</div>';
|
||
|
|
Object.entries(r.doh_servers).forEach(([ip,url])=>{html+=`<div style="font-size:11px;padding:3px 0"><span class="mono" style="color:var(--cyan)">${esc(ip)}</span> → <span style="color:var(--text-dim)">${esc(url)}</span></div>`});
|
||
|
|
}
|
||
|
|
document.getElementById('enc-status').innerHTML=html;
|
||
|
|
}
|
||
|
|
async function saveEncryption(){
|
||
|
|
const dot=document.getElementById('enc-dot').checked;
|
||
|
|
const doh=document.getElementById('enc-doh').checked;
|
||
|
|
await put('/api/config',{enable_dot:dot,enable_doh:doh});
|
||
|
|
toast('Encryption updated');
|
||
|
|
setTimeout(loadEncryption,500);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ═══ CONFIG ═══ */
|
||
|
|
const configFields=[
|
||
|
|
{key:'listen_dns',label:'DNS Listen',type:'text'},
|
||
|
|
{key:'listen_api',label:'API Listen',type:'text'},
|
||
|
|
{key:'cache_ttl',label:'Cache TTL (sec)',type:'number'},
|
||
|
|
{key:'rate_limit',label:'Rate Limit (qps/ip)',type:'number'},
|
||
|
|
{key:'max_udp_size',label:'Max UDP Size',type:'number'},
|
||
|
|
{key:'querylog_max',label:'Query Log Max',type:'number'},
|
||
|
|
{key:'negative_cache_ttl',label:'Negative Cache TTL',type:'number'},
|
||
|
|
{key:'servfail_cache_ttl',label:'SERVFAIL Cache TTL',type:'number'},
|
||
|
|
{key:'log_queries',label:'Log Queries',type:'bool'},
|
||
|
|
{key:'refuse_any',label:'Refuse ANY Queries',type:'bool'},
|
||
|
|
{key:'minimal_responses',label:'Minimal Responses',type:'bool'},
|
||
|
|
{key:'prefetch_enabled',label:'Prefetch Enabled',type:'bool'},
|
||
|
|
{key:'enable_dot',label:'DNS-over-TLS',type:'bool'},
|
||
|
|
{key:'enable_doh',label:'DNS-over-HTTPS',type:'bool'},
|
||
|
|
];
|
||
|
|
let currentConfig={};
|
||
|
|
async function loadConfig(){
|
||
|
|
const r=await api('/api/config');
|
||
|
|
if(!r||!r.config)return;
|
||
|
|
currentConfig=r.config;
|
||
|
|
const c=document.getElementById('config-body');
|
||
|
|
c.innerHTML=configFields.map(f=>{
|
||
|
|
const v=currentConfig[f.key];
|
||
|
|
if(f.type==='bool'){
|
||
|
|
return`<div class="toggle-row"><div class="toggle-info"><div class="toggle-label">${f.label}</div><div class="toggle-desc">${f.key}</div></div>
|
||
|
|
<label class="toggle"><input type="checkbox" id="cfg-${f.key}" ${v?'checked':''}><span class="toggle-track"></span></label></div>`;
|
||
|
|
}
|
||
|
|
return`<div class="form-row"><div class="form-group"><div class="form-label">${f.label} <span style="color:var(--text-dim)">(${f.key})</span></div>
|
||
|
|
<input class="input input-sm" id="cfg-${f.key}" value="${v!==undefined?v:''}" type="${f.type}"></div></div>`;
|
||
|
|
}).join('');
|
||
|
|
}
|
||
|
|
async function saveConfig(){
|
||
|
|
const updates={};
|
||
|
|
configFields.forEach(f=>{
|
||
|
|
const el=document.getElementById('cfg-'+f.key);
|
||
|
|
if(!el)return;
|
||
|
|
if(f.type==='bool')updates[f.key]=el.checked;
|
||
|
|
else if(f.type==='number')updates[f.key]=parseInt(el.value)||0;
|
||
|
|
else updates[f.key]=el.value;
|
||
|
|
});
|
||
|
|
updates.upstream=currentConfig.upstream||[];
|
||
|
|
const r=await put('/api/config',updates);
|
||
|
|
if(r&&r.ok)toast('Configuration saved');else toast('Failed','error');
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ═══ MODAL ═══ */
|
||
|
|
function openModal(title,html){
|
||
|
|
document.getElementById('modal-title').textContent=title;
|
||
|
|
document.getElementById('modal-body').innerHTML=html;
|
||
|
|
document.getElementById('modal').classList.add('active');
|
||
|
|
}
|
||
|
|
function closeModal(){document.getElementById('modal').classList.remove('active')}
|
||
|
|
|
||
|
|
/* ═══ INIT ═══ */
|
||
|
|
checkAuth();
|
||
|
|
|
||
|
|
// Auto-refresh dashboard every 10s
|
||
|
|
setInterval(()=>{if(currentPage==='dashboard')loadDashboard()},10000);
|
||
|
|
</script>
|
||
|
|
</body>
|
||
|
|
</html>
|