mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 22:07:24 +00:00
- Add hybrid-skin.css: Glass Morphism + Matrix Terminal universal theme - Update sidebar.js v2.23.0: - Remove light/dark theme switching (single hybrid theme) - Add global status bar (bottom) with health metrics - Add global menu bar (top) replacing individual page headers - Load health data from /api/v1/hub/health endpoint - Update sidebar.css: Transparent header/footer to fix white corners - Update design-tokens.css: Add .hybrid-skin to dark theme selector - Update index.html files: Apply hybrid-skin body class - Add SOC dashboard (soc/index.html): Reference implementation - Add Health Doctor Pattern documentation - Update module APIs with /health endpoints Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1578 lines
69 KiB
HTML
1578 lines
69 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>SecuBox - HAProxy + WAF Dashboard</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link href="https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&display=swap" rel="stylesheet">
|
|
<link rel="stylesheet" href="/shared/crt-light.css">
|
|
<link rel="stylesheet" href="/shared/sidebar-light.css">
|
|
<style>
|
|
:root {
|
|
/* P31 Phosphor spectrum */
|
|
--p31-peak: #00dd44;
|
|
--p31-hot: #00ff55;
|
|
--p31-mid: #009933;
|
|
--p31-dim: #006622;
|
|
--p31-ghost: #003311;
|
|
--p31-decay: #ffb347;
|
|
--p31-decay-dim: #cc7722;
|
|
/* Tube glass */
|
|
--tube-light: #e8f5e9;
|
|
--tube-pale: #c8e6c9;
|
|
--tube-soft: #a5d6a7;
|
|
/* Legacy mappings */
|
|
--bg-dark: var(--tube-light);
|
|
--bg-card: var(--tube-pale);
|
|
--bg-sidebar: var(--tube-black);
|
|
--border: var(--tube-soft);
|
|
--text: var(--tube-dark);
|
|
--text-dim: var(--p31-dim);
|
|
--primary: var(--p31-peak);
|
|
--cyan: var(--p31-peak);
|
|
--green: var(--p31-peak);
|
|
--red: #ff4466;
|
|
--yellow: var(--p31-decay);
|
|
--orange: var(--p31-decay-dim);
|
|
--purple: #a371f7;
|
|
/* Bloom effects */
|
|
--bloom-text: 0 0 2px var(--p31-peak), 0 0 6px var(--p31-peak), 0 0 14px rgba(51,255,102,0.5);
|
|
--bloom-soft: 0 0 6px var(--p31-peak), 0 0 14px rgba(51,255,102,0.5);
|
|
--bloom-amber: 0 0 3px var(--p31-decay), 0 0 10px rgba(255,179,71,0.4);
|
|
}
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body {
|
|
font-family: 'Courier Prime', 'Courier New', monospace;
|
|
background: var(--tube-light);
|
|
background-image: radial-gradient(ellipse at 50% 40%, rgba(51,255,102,0.025) 0%, transparent 70%);
|
|
color: var(--tube-dark);
|
|
display: flex;
|
|
min-height: 100vh;
|
|
}
|
|
.sidebar { width: 220px; background: var(--bg-sidebar); border-right: 1px solid var(--border); position: fixed; height: 100vh; overflow-y: auto; }
|
|
.main { flex: 1; margin-left: 220px; padding: 1.5rem; }
|
|
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; }
|
|
.header h1 { font-size: 1.5rem; }
|
|
.btn { padding: 0.5rem 1rem; border-radius: 6px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text); cursor: pointer; font-size: 0.9rem; }
|
|
.btn:hover { background: var(--bg-dark); }
|
|
.btn.primary { background: rgba(88,166,255,0.1); border-color: var(--primary); color: var(--primary); }
|
|
.btn.success { background: rgba(63,185,80,0.1); border-color: var(--green); color: var(--green); }
|
|
.btn.danger { background: rgba(248,81,73,0.1); border-color: var(--red); color: var(--red); }
|
|
.btn-group { display: flex; gap: 0.5rem; }
|
|
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
|
|
.stat-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; text-align: center; }
|
|
.stat-card .value { font-size: 1.5rem; font-weight: bold; color: var(--primary); }
|
|
.stat-card .label { font-size: 0.75rem; color: var(--text-dim); margin-top: 0.25rem; }
|
|
.stat-card.running .value { color: var(--green); }
|
|
.stat-card.stopped .value { color: var(--red); }
|
|
.stat-card.waf .value { color: var(--orange); }
|
|
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem; margin-bottom: 1.5rem; }
|
|
.card h2 { margin-bottom: 1rem; color: var(--cyan); display: flex; align-items: center; gap: 0.5rem; }
|
|
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
|
th, td { padding: 0.6rem; text-align: left; border-bottom: 1px solid var(--border); }
|
|
th { color: var(--text-dim); font-size: 0.8rem; }
|
|
.badge { padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.7rem; font-weight: 600; }
|
|
.badge.up { background: rgba(63,185,80,0.2); color: var(--green); }
|
|
.badge.down { background: rgba(248,81,73,0.2); color: var(--red); }
|
|
.badge.waf { background: rgba(240,136,62,0.2); color: var(--orange); }
|
|
.badge.bypass { background: rgba(139,148,158,0.2); color: var(--text-dim); }
|
|
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
|
|
.waf-panel { background: linear-gradient(135deg, rgba(240,136,62,0.1), rgba(248,81,73,0.1)); border: 1px solid var(--orange); }
|
|
.waf-panel h2 { color: var(--orange); }
|
|
.toggle { position: relative; width: 50px; height: 26px; display: inline-block; }
|
|
.toggle input { opacity: 0; width: 0; height: 0; }
|
|
.toggle .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background: var(--border); border-radius: 26px; transition: 0.3s; }
|
|
.toggle .slider:before { position: absolute; content: ""; height: 20px; width: 20px; left: 3px; bottom: 3px; background: var(--text); border-radius: 50%; transition: 0.3s; }
|
|
.toggle input:checked + .slider { background: var(--green); }
|
|
.toggle input:checked + .slider:before { transform: translateX(24px); }
|
|
.waf-status { display: flex; align-items: center; gap: 1rem; padding: 1rem; background: var(--bg-dark); border-radius: 6px; margin-bottom: 1rem; }
|
|
.waf-status .indicator { width: 12px; height: 12px; border-radius: 50%; }
|
|
.waf-status .indicator.active { background: var(--green); box-shadow: 0 0 8px var(--green); }
|
|
.waf-status .indicator.inactive { background: var(--red); }
|
|
.tabs { display: flex; gap: 0; margin-bottom: 1rem; border-bottom: 1px solid var(--border); }
|
|
.tab { padding: 0.75rem 1.25rem; cursor: pointer; color: var(--text-dim); border-bottom: 2px solid transparent; }
|
|
.tab:hover { color: var(--text); }
|
|
.tab.active { color: var(--primary); border-bottom-color: var(--primary); }
|
|
.tab-content { display: none; }
|
|
.tab-content.active { display: block; }
|
|
@media (max-width: 900px) { .sidebar { display: none; } .main { margin-left: 0; } .grid-2 { grid-template-columns: 1fr; } }
|
|
|
|
/* CRT Enhancements */
|
|
.main { margin-left: 220px; padding: 1.5rem; }
|
|
|
|
.header h1 {
|
|
color: var(--p31-hot);
|
|
text-shadow: var(--bloom-text);
|
|
font-weight: 700;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.badge {
|
|
font-family: 'Courier Prime', monospace;
|
|
letter-spacing: 0.1em;
|
|
text-transform: uppercase;
|
|
border: 1px solid;
|
|
}
|
|
.badge-blue, .badge-primary { background: rgba(51,255,102,0.1); border-color: var(--p31-peak); color: var(--p31-peak); }
|
|
.badge-green { background: rgba(51,255,102,0.15); border-color: var(--p31-peak); color: var(--p31-peak); text-shadow: var(--bloom-soft); }
|
|
.badge-cyan { background: rgba(51,255,102,0.1); border-color: var(--p31-mid); color: var(--p31-mid); }
|
|
.badge-purple { background: rgba(163,113,247,0.1); border-color: #a371f7; color: #a371f7; }
|
|
.badge-yellow, .badge-amber { background: rgba(255,179,71,0.1); border-color: var(--p31-decay); color: var(--p31-decay); }
|
|
.badge-red { background: rgba(255,68,102,0.1); border-color: #ff4466; color: #ff4466; }
|
|
|
|
.btn {
|
|
font-family: 'Courier Prime', monospace;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
border: 1px solid var(--tube-soft);
|
|
background: var(--tube-pale);
|
|
color: var(--p31-mid);
|
|
transition: all 0.2s;
|
|
}
|
|
.btn:hover {
|
|
border-color: var(--p31-dim);
|
|
color: var(--p31-peak);
|
|
text-shadow: var(--bloom-soft);
|
|
box-shadow: 0 0 12px rgba(51,255,102,0.1);
|
|
}
|
|
.btn-green, .btn-primary, .btn.primary, .btn.success {
|
|
border-color: var(--p31-peak);
|
|
color: var(--p31-peak);
|
|
background: rgba(51,255,102,0.1);
|
|
}
|
|
.btn-green:hover, .btn-primary:hover, .btn.primary:hover, .btn.success:hover {
|
|
background: rgba(51,255,102,0.2);
|
|
box-shadow: 0 0 16px rgba(51,255,102,0.2);
|
|
}
|
|
.btn-red, .btn.danger {
|
|
border-color: #ff4466;
|
|
color: #ff4466;
|
|
background: rgba(255,68,102,0.1);
|
|
}
|
|
.btn-red:hover, .btn.danger:hover {
|
|
background: rgba(255,68,102,0.2);
|
|
}
|
|
.btn-blue {
|
|
border-color: var(--p31-mid);
|
|
color: var(--p31-mid);
|
|
background: rgba(51,255,102,0.05);
|
|
}
|
|
.btn-blue:hover {
|
|
border-color: var(--p31-peak);
|
|
color: var(--p31-peak);
|
|
}
|
|
|
|
.card {
|
|
background: var(--tube-pale);
|
|
border: 1px solid var(--tube-soft);
|
|
box-shadow: inset 0 0 20px rgba(0,0,0,0.3);
|
|
}
|
|
.card:hover {
|
|
border-color: var(--p31-dim);
|
|
box-shadow: 0 0 8px rgba(51,255,102,0.05), inset 0 0 20px rgba(0,0,0,0.3);
|
|
}
|
|
.card-title, .card-header h2 {
|
|
color: var(--p31-decay);
|
|
text-shadow: var(--bloom-amber);
|
|
letter-spacing: 0.1em;
|
|
text-transform: uppercase;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.stat-card {
|
|
background: var(--tube-pale);
|
|
border: 1px solid var(--tube-soft);
|
|
}
|
|
.stat-value, .stat-card .value {
|
|
text-shadow: 0 0 10px currentColor;
|
|
}
|
|
.stat-label, .stat-card .label {
|
|
color: var(--p31-dim);
|
|
letter-spacing: 0.15em;
|
|
}
|
|
.stat-card.cyan .value, .stat-card.green .value { color: var(--p31-peak); }
|
|
.stat-card.yellow .value { color: var(--p31-decay); }
|
|
.stat-card.red .value { color: #ff4466; }
|
|
|
|
table, .table {
|
|
font-family: 'Courier Prime', monospace;
|
|
}
|
|
th, .table th {
|
|
color: var(--p31-decay);
|
|
text-shadow: var(--bloom-amber);
|
|
letter-spacing: 0.15em;
|
|
border-bottom: 1px solid var(--p31-ghost);
|
|
}
|
|
td, .table td {
|
|
border-bottom: 1px solid var(--p31-ghost);
|
|
color: var(--p31-mid);
|
|
}
|
|
tr:hover, .table tr:hover {
|
|
background: rgba(51,255,102,0.03);
|
|
}
|
|
|
|
.status-dot.active, .status-running {
|
|
color: var(--p31-peak);
|
|
text-shadow: var(--bloom-soft);
|
|
}
|
|
.status-dot.active {
|
|
background: var(--p31-peak);
|
|
box-shadow: 0 0 8px var(--p31-peak);
|
|
}
|
|
|
|
.toast {
|
|
background: var(--tube-pale);
|
|
border: 1px solid var(--p31-dim);
|
|
font-family: 'Courier Prime', monospace;
|
|
}
|
|
.toast-success { border-color: var(--p31-peak); }
|
|
.toast-error { border-color: #ff4466; }
|
|
|
|
.modal {
|
|
background: rgba(0,0,0,0.85);
|
|
}
|
|
.modal-content {
|
|
background: var(--tube-pale);
|
|
border: 1px solid var(--p31-dim);
|
|
box-shadow: 0 0 30px rgba(51,255,102,0.1);
|
|
}
|
|
.modal-title {
|
|
color: var(--p31-decay);
|
|
text-shadow: var(--bloom-amber);
|
|
letter-spacing: 0.1em;
|
|
}
|
|
|
|
.tabs {
|
|
background: var(--tube-pale);
|
|
border-bottom: 1px solid var(--p31-ghost);
|
|
}
|
|
.tab {
|
|
color: var(--p31-dim);
|
|
letter-spacing: 0.1em;
|
|
text-transform: uppercase;
|
|
font-size: 0.8rem;
|
|
}
|
|
.tab:hover { color: var(--p31-mid); }
|
|
.tab.active {
|
|
color: var(--p31-peak);
|
|
text-shadow: var(--bloom-soft);
|
|
border-bottom-color: var(--p31-peak);
|
|
}
|
|
|
|
input, select, textarea {
|
|
font-family: 'Courier Prime', monospace;
|
|
background: var(--tube-light);
|
|
border: 1px solid var(--tube-soft);
|
|
color: var(--p31-mid);
|
|
padding: 0.5rem;
|
|
}
|
|
input:focus, select:focus, textarea:focus {
|
|
outline: none;
|
|
border-color: var(--p31-dim);
|
|
box-shadow: 0 0 8px rgba(51,255,102,0.1);
|
|
}
|
|
|
|
/* Scrollbar */
|
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
|
::-webkit-scrollbar-track { background: var(--tube-light); }
|
|
::-webkit-scrollbar-thumb { background: var(--p31-dim); }
|
|
::-webkit-scrollbar-thumb:hover { background: var(--p31-mid); }
|
|
|
|
/* Empty states */
|
|
.empty { color: var(--p31-dim); }
|
|
|
|
@media (max-width: 768px) {
|
|
.main { margin-left: 0; }
|
|
}
|
|
|
|
/* Modal Styles */
|
|
.modal {
|
|
display: none;
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.85);
|
|
z-index: 1000;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.modal.active {
|
|
display: flex;
|
|
}
|
|
.modal-content {
|
|
background: var(--tube-pale);
|
|
border: 1px solid var(--p31-dim);
|
|
border-radius: 8px;
|
|
box-shadow: 0 0 30px rgba(51, 255, 102, 0.1);
|
|
max-width: 600px;
|
|
width: 90%;
|
|
max-height: 90vh;
|
|
overflow-y: auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.modal-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 1.5rem;
|
|
border-bottom: 1px solid var(--p31-ghost);
|
|
}
|
|
.modal-title {
|
|
color: var(--p31-decay);
|
|
text-shadow: var(--bloom-amber);
|
|
letter-spacing: 0.1em;
|
|
margin: 0;
|
|
font-size: 1.1rem;
|
|
}
|
|
.modal-close {
|
|
background: none;
|
|
border: none;
|
|
font-size: 1.5rem;
|
|
color: var(--p31-dim);
|
|
cursor: pointer;
|
|
padding: 0;
|
|
width: 30px;
|
|
height: 30px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.modal-close:hover {
|
|
color: var(--p31-peak);
|
|
text-shadow: var(--bloom-soft);
|
|
}
|
|
#modal-body {
|
|
padding: 1.5rem;
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
}
|
|
.modal-footer {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 0.5rem;
|
|
padding: 1rem 1.5rem;
|
|
border-top: 1px solid var(--p31-ghost);
|
|
}
|
|
.modal-footer .btn {
|
|
margin: 0;
|
|
}
|
|
|
|
/* Form Styles */
|
|
.form-group {
|
|
margin-bottom: 1rem;
|
|
}
|
|
.form-group label {
|
|
display: block;
|
|
margin-bottom: 0.5rem;
|
|
font-size: 0.85rem;
|
|
color: var(--p31-mid);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.1em;
|
|
}
|
|
.form-group input,
|
|
.form-group select {
|
|
width: 100%;
|
|
}
|
|
.form-row {
|
|
display: flex;
|
|
gap: 1rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.form-row > * {
|
|
flex: 1;
|
|
}
|
|
.form-check {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.form-check input[type="checkbox"] {
|
|
width: auto;
|
|
margin: 0;
|
|
}
|
|
.form-check label {
|
|
font-size: 0.9rem;
|
|
color: var(--p31-mid);
|
|
margin: 0;
|
|
text-transform: none;
|
|
}
|
|
|
|
/* Toast Styles */
|
|
#toast-container {
|
|
position: fixed;
|
|
bottom: 2rem;
|
|
right: 2rem;
|
|
z-index: 2000;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
}
|
|
.toast {
|
|
background: var(--tube-pale);
|
|
border: 2px solid var(--p31-dim);
|
|
border-radius: 6px;
|
|
padding: 1rem 1.25rem;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
|
font-family: 'Courier Prime', monospace;
|
|
max-width: 400px;
|
|
animation: slideIn 0.3s ease-out;
|
|
}
|
|
@keyframes slideIn {
|
|
from {
|
|
transform: translateX(400px);
|
|
opacity: 0;
|
|
}
|
|
to {
|
|
transform: translateX(0);
|
|
opacity: 1;
|
|
}
|
|
}
|
|
@keyframes slideOut {
|
|
from {
|
|
transform: translateX(0);
|
|
opacity: 1;
|
|
}
|
|
to {
|
|
transform: translateX(400px);
|
|
opacity: 0;
|
|
}
|
|
}
|
|
.toast.removing {
|
|
animation: slideOut 0.3s ease-out forwards;
|
|
}
|
|
.toast-success {
|
|
border-color: var(--p31-peak);
|
|
color: var(--p31-peak);
|
|
}
|
|
.toast-success::before {
|
|
content: "✓ ";
|
|
font-weight: bold;
|
|
}
|
|
.toast-error {
|
|
border-color: #ff4466;
|
|
color: #ff4466;
|
|
}
|
|
.toast-error::before {
|
|
content: "✕ ";
|
|
font-weight: bold;
|
|
}
|
|
.toast-info {
|
|
border-color: var(--p31-mid);
|
|
color: var(--p31-mid);
|
|
}
|
|
.toast-warning {
|
|
border-color: var(--p31-decay);
|
|
color: var(--p31-decay);
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.modal-content {
|
|
width: 95%;
|
|
}
|
|
#toast-container {
|
|
bottom: 1rem;
|
|
right: 1rem;
|
|
}
|
|
.toast {
|
|
max-width: 280px;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body class="crt-light">
|
|
<nav class="sidebar" id="sidebar"></nav>
|
|
<script src="/shared/sidebar.js"></script>
|
|
|
|
<main class="main">
|
|
<header class="header">
|
|
<h1>HAProxy + WAF Dashboard</h1>
|
|
<div class="btn-group">
|
|
<button class="btn" onclick="refresh()">Refresh</button>
|
|
<button class="btn primary" onclick="generateConfig()">Generate Config</button>
|
|
<button class="btn success" onclick="reload()">Reload</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="stats-row">
|
|
<div class="stat-card" id="statusCard">
|
|
<div class="value" id="status">-</div>
|
|
<div class="label">HAProxy Status</div>
|
|
</div>
|
|
<div class="stat-card waf" id="wafCard">
|
|
<div class="value" id="wafStatus">-</div>
|
|
<div class="label">WAF Status</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="value" id="vhosts-count">-</div>
|
|
<div class="label">VHosts</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="value" id="backends-count">-</div>
|
|
<div class="label">Backends</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="value" id="requests">-</div>
|
|
<div class="label">Total Requests</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="value" id="bans">-</div>
|
|
<div class="label">Active Bans</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- WAF Control Panel -->
|
|
<div class="card waf-panel">
|
|
<h2>WAF Integration</h2>
|
|
<div class="waf-status">
|
|
<div class="indicator" id="wafIndicator"></div>
|
|
<span id="wafLabel">WAF Protection</span>
|
|
<label class="toggle" style="margin-left: auto;">
|
|
<input type="checkbox" id="wafToggle" onchange="toggleWAF(this.checked)">
|
|
<span class="slider"></span>
|
|
</label>
|
|
</div>
|
|
<p style="color: var(--text-dim); font-size: 0.85rem; margin-bottom: 1rem;">
|
|
When enabled, all HTTP traffic is inspected by the WAF before reaching backends.
|
|
Traffic flow: Client → HAProxy → WAF Inspector → Backend
|
|
</p>
|
|
<div class="btn-group">
|
|
<button class="btn" onclick="syncRoutes()">Sync WAF Routes</button>
|
|
<button class="btn" onclick="window.location='/waf/'">Open WAF Dashboard</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tabbed Content -->
|
|
<div class="card">
|
|
<div class="tabs">
|
|
<div class="tab active" data-tab="components" onclick="switchTab('components')">Components</div>
|
|
<div class="tab" data-tab="access" onclick="switchTab('access')">Access</div>
|
|
<div class="tab" data-tab="vhosts" onclick="switchTab('vhosts')">VHosts</div>
|
|
<div class="tab" data-tab="backends" onclick="switchTab('backends')">Backends</div>
|
|
<div class="tab" data-tab="stats" onclick="switchTab('stats')">Live Stats</div>
|
|
<div class="tab" data-tab="certs" onclick="switchTab('certs')">Certificates</div>
|
|
<div class="tab" data-tab="actions" onclick="switchTab('actions')">Actions</div>
|
|
</div>
|
|
|
|
<div id="components" class="tab-content active">
|
|
<table>
|
|
<thead><tr><th>Component</th><th>Description</th><th>Status</th></tr></thead>
|
|
<tbody id="components-list"><tr><td colspan="3">Loading...</td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div id="access" class="tab-content">
|
|
<div id="access-list" style="display: grid; gap: 0.5rem;"></div>
|
|
</div>
|
|
|
|
<div id="vhosts" class="tab-content">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
|
<h3 style="margin:0;color:var(--p31-decay)">Virtual Hosts</h3>
|
|
<button class="btn primary" onclick="showAddVhostModal()">+ Add VHost</button>
|
|
</div>
|
|
<table>
|
|
<thead><tr><th>Domain</th><th>Backend</th><th>SSL</th><th>WAF</th><th>Actions</th></tr></thead>
|
|
<tbody id="vhost-list"><tr><td colspan="5">Loading...</td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div id="backends" class="tab-content">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
|
<h3 style="margin:0;color:var(--p31-decay)">Backends</h3>
|
|
<button class="btn primary" onclick="showAddBackendModal()">+ Add Backend</button>
|
|
</div>
|
|
<table>
|
|
<thead><tr><th>Name</th><th>Mode</th><th>Balance</th><th>Servers</th><th>Actions</th></tr></thead>
|
|
<tbody id="backend-list"><tr><td colspan="5">Loading...</td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div id="stats" class="tab-content">
|
|
<table>
|
|
<thead><tr><th>Name</th><th>Type</th><th>Status</th><th>Sessions</th><th>Bytes In</th><th>Bytes Out</th></tr></thead>
|
|
<tbody id="stats-list"><tr><td colspan="6">Loading...</td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div id="certs" class="tab-content">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
|
<h3 style="margin:0;color:var(--p31-decay)">SSL Certificates</h3>
|
|
<button class="btn primary" onclick="showRequestCertModal()">+ Request Certificate</button>
|
|
</div>
|
|
<table>
|
|
<thead><tr><th>Certificate</th><th>Path</th><th>Actions</th></tr></thead>
|
|
<tbody id="cert-list"><tr><td colspan="3">Loading...</td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div id="actions" class="tab-content">
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
|
|
<div style="background: var(--bg-dark); padding: 1rem; border-radius: 6px;">
|
|
<h3 style="margin-bottom: 0.5rem;">Migration</h3>
|
|
<p style="color: var(--text-dim); font-size: 0.85rem; margin-bottom: 1rem;">Import vhosts and certificates from OpenWrt SecuBox</p>
|
|
<button class="btn" onclick="showMigrate()">Migrate from OpenWrt</button>
|
|
</div>
|
|
<div style="background: var(--bg-dark); padding: 1rem; border-radius: 6px;">
|
|
<h3 style="margin-bottom: 0.5rem;">Service Control</h3>
|
|
<p style="color: var(--text-dim); font-size: 0.85rem; margin-bottom: 1rem;">Manage HAProxy service</p>
|
|
<div class="btn-group">
|
|
<button class="btn success" onclick="restart()">Restart</button>
|
|
<button class="btn primary" onclick="reload()">Reload Config</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- Migrate Modal -->
|
|
<div id="migrate-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:100;align-items:center;justify-content:center;">
|
|
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:1.5rem;min-width:400px;">
|
|
<h3 style="margin-bottom:1rem;">Migrate from OpenWrt</h3>
|
|
<div style="margin-bottom:1rem;">
|
|
<label style="display:block;margin-bottom:0.25rem;font-size:0.8rem;color:var(--text-dim);">Source IP</label>
|
|
<input type="text" id="migrate-source" value="192.168.255.1" style="width:100%;padding:0.5rem;border:1px solid var(--border);border-radius:4px;background:var(--bg-dark);color:var(--text);">
|
|
</div>
|
|
<div style="display:flex;justify-content:flex-end;gap:0.5rem;">
|
|
<button class="btn" onclick="hideMigrate()">Cancel</button>
|
|
<button class="btn primary" onclick="doMigrate()">Start Migration</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Generic Modal -->
|
|
<div id="modal" class="modal">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h3 id="modal-title"></h3>
|
|
<button class="modal-close" onclick="closeModal()">×</button>
|
|
</div>
|
|
<div id="modal-body"></div>
|
|
<div id="modal-footer" class="modal-footer"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Toast Container -->
|
|
<div id="toast-container"></div>
|
|
|
|
<script>
|
|
const API = '/api/v1/haproxy';
|
|
const token = () => localStorage.getItem('sbx_token');
|
|
const headers = () => ({ 'Content-Type': 'application/json', ...(token() ? { 'Authorization': 'Bearer ' + token() } : {}) });
|
|
|
|
/* Modal Functions */
|
|
function openModal(title, body, footer = '') {
|
|
document.getElementById('modal-title').textContent = title;
|
|
document.getElementById('modal-body').innerHTML = body;
|
|
document.getElementById('modal-footer').innerHTML = footer || '';
|
|
document.getElementById('modal').classList.add('active');
|
|
}
|
|
|
|
function closeModal() {
|
|
document.getElementById('modal').classList.remove('active');
|
|
document.getElementById('modal-title').textContent = '';
|
|
document.getElementById('modal-body').innerHTML = '';
|
|
document.getElementById('modal-footer').innerHTML = '';
|
|
}
|
|
|
|
/* Keyboard/Backdrop Event Handlers */
|
|
document.getElementById('modal')?.addEventListener('click', (e) => {
|
|
if (e.target.id === 'modal') closeModal();
|
|
});
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') closeModal();
|
|
});
|
|
|
|
/* Toast Functions */
|
|
function showToast(message, type = 'info', duration = 3000) {
|
|
const container = document.getElementById('toast-container');
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast toast-${type}`;
|
|
toast.textContent = message;
|
|
container.appendChild(toast);
|
|
|
|
if (duration > 0) {
|
|
setTimeout(() => {
|
|
toast.classList.add('removing');
|
|
setTimeout(() => toast.remove(), 300);
|
|
}, duration);
|
|
}
|
|
return toast;
|
|
}
|
|
|
|
function showSuccess(message, duration = 3000) {
|
|
return showToast(message, 'success', duration);
|
|
}
|
|
|
|
function showError(message, duration = 3000) {
|
|
return showToast(message, 'error', duration);
|
|
}
|
|
|
|
function showInfo(message, duration = 3000) {
|
|
return showToast(message, 'info', duration);
|
|
}
|
|
|
|
function showWarning(message, duration = 3000) {
|
|
return showToast(message, 'warning', duration);
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════ */
|
|
/* Form Validation */
|
|
/* ══════════════════════════════════════════════════════════════ */
|
|
function validateForm(fields) {
|
|
for (const { id, rules } of fields) {
|
|
const el = document.getElementById(id);
|
|
if (!el) continue;
|
|
const val = el.value?.trim() || '';
|
|
|
|
if (rules.required && !val) {
|
|
showToast(rules.requiredMsg || `${id} is required`, 'error');
|
|
el.focus();
|
|
return false;
|
|
}
|
|
if (rules.pattern && val && !rules.pattern.test(val)) {
|
|
showToast(rules.patternMsg || `Invalid ${id} format`, 'error');
|
|
el.focus();
|
|
return false;
|
|
}
|
|
if (rules.min !== undefined && val && parseInt(val) < rules.min) {
|
|
showToast(rules.minMsg || `${id} must be at least ${rules.min}`, 'error');
|
|
el.focus();
|
|
return false;
|
|
}
|
|
if (rules.max !== undefined && val && parseInt(val) > rules.max) {
|
|
showToast(rules.maxMsg || `${id} must be at most ${rules.max}`, 'error');
|
|
el.focus();
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function api(path, opts = {}) {
|
|
try {
|
|
const res = await fetch(API + path, { ...opts, headers: headers() });
|
|
if (res.status === 401) { window.location = '/login.html'; return {}; }
|
|
const ct = res.headers.get('content-type') || '';
|
|
if (!ct.includes('application/json')) {
|
|
console.warn('[API] Non-JSON response for', path, ':', ct);
|
|
return {};
|
|
}
|
|
return res.json();
|
|
} catch (e) { console.warn('[API] Error:', path, e); return {}; }
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════ */
|
|
/* Enhanced API Call with Error Handling */
|
|
/* ══════════════════════════════════════════════════════════════ */
|
|
async function apiCall(path, opts = {}) {
|
|
try {
|
|
const res = await fetch(API + path, { ...opts, headers: headers() });
|
|
|
|
if (res.status === 401) {
|
|
window.location = '/login.html';
|
|
return { error: 'Unauthorized' };
|
|
}
|
|
|
|
const ct = res.headers.get('content-type') || '';
|
|
if (!ct.includes('application/json')) {
|
|
console.warn('[API] Non-JSON response for', path, ':', ct);
|
|
return { error: 'Non-JSON response' };
|
|
}
|
|
|
|
const data = await res.json();
|
|
|
|
if (!res.ok || data.error) {
|
|
const msg = data.error || data.detail || `Error ${res.status}`;
|
|
showToast(msg, 'error');
|
|
return { error: msg };
|
|
}
|
|
|
|
return data;
|
|
} catch (e) {
|
|
console.warn('[API] Error:', path, e);
|
|
showToast('Network error: ' + e.message, 'error');
|
|
return { error: e.message };
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// VHost CRUD
|
|
// ══════════════════════════════════════════════════════════════
|
|
let cachedBackends = [];
|
|
|
|
async function loadBackendOptions() {
|
|
const d = await api('/backends');
|
|
cachedBackends = (d.backends || []).filter(b => b.type !== 'waf');
|
|
return cachedBackends.map(b =>
|
|
`<option value="${b.name}">${b.name}</option>`
|
|
).join('');
|
|
}
|
|
|
|
async function showAddVhostModal() {
|
|
const backendOpts = await loadBackendOptions();
|
|
const body = `
|
|
<div class="form-group">
|
|
<label>Domain</label>
|
|
<input type="text" id="vhost-domain" placeholder="example.com">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Backend</label>
|
|
<select id="vhost-backend">
|
|
<option value="">-- Select Backend --</option>
|
|
${backendOpts}
|
|
</select>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-check">
|
|
<input type="checkbox" id="vhost-ssl" checked>
|
|
<label>Enable SSL</label>
|
|
</div>
|
|
<div class="form-check">
|
|
<input type="checkbox" id="vhost-ssl-redirect" checked>
|
|
<label>Force HTTPS</label>
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-check">
|
|
<input type="checkbox" id="vhost-acme" checked>
|
|
<label>Auto-renew (ACME)</label>
|
|
</div>
|
|
<div class="form-check">
|
|
<input type="checkbox" id="vhost-waf-bypass">
|
|
<label>Bypass WAF</label>
|
|
</div>
|
|
</div>
|
|
<div class="form-check">
|
|
<input type="checkbox" id="vhost-enabled" checked>
|
|
<label>Enabled</label>
|
|
</div>
|
|
`;
|
|
const footer = `
|
|
<button class="btn" onclick="closeModal()">Cancel</button>
|
|
<button class="btn primary" onclick="saveVhost(false)">Add VHost</button>
|
|
`;
|
|
openModal('Add Virtual Host', body, footer);
|
|
}
|
|
|
|
async function showEditVhostModal(vhost) {
|
|
const backendOpts = await loadBackendOptions();
|
|
const body = `
|
|
<input type="hidden" id="vhost-original-name" value="${vhost.name || vhost.domain}">
|
|
<div class="form-group">
|
|
<label>Domain</label>
|
|
<input type="text" id="vhost-domain" value="${vhost.domain}">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Backend</label>
|
|
<select id="vhost-backend">
|
|
<option value="">-- Select Backend --</option>
|
|
${backendOpts}
|
|
</select>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-check">
|
|
<input type="checkbox" id="vhost-ssl" ${vhost.ssl ? 'checked' : ''}>
|
|
<label>Enable SSL</label>
|
|
</div>
|
|
<div class="form-check">
|
|
<input type="checkbox" id="vhost-ssl-redirect" ${vhost.ssl_redirect ? 'checked' : ''}>
|
|
<label>Force HTTPS</label>
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-check">
|
|
<input type="checkbox" id="vhost-acme" ${vhost.acme ? 'checked' : ''}>
|
|
<label>Auto-renew (ACME)</label>
|
|
</div>
|
|
<div class="form-check">
|
|
<input type="checkbox" id="vhost-waf-bypass" ${vhost.waf_bypass ? 'checked' : ''}>
|
|
<label>Bypass WAF</label>
|
|
</div>
|
|
</div>
|
|
<div class="form-check">
|
|
<input type="checkbox" id="vhost-enabled" ${vhost.enabled !== false ? 'checked' : ''}>
|
|
<label>Enabled</label>
|
|
</div>
|
|
`;
|
|
const footer = `
|
|
<button class="btn" onclick="closeModal()">Cancel</button>
|
|
<button class="btn primary" onclick="saveVhost(true)">Save Changes</button>
|
|
`;
|
|
openModal('Edit Virtual Host', body, footer);
|
|
document.getElementById('vhost-backend').value = vhost.backend || '';
|
|
}
|
|
|
|
async function saveVhost(isEdit) {
|
|
const valid = validateForm([
|
|
{ id: 'vhost-domain', rules: { required: true, requiredMsg: 'Domain is required', pattern: /^[a-zA-Z0-9*][-a-zA-Z0-9.]*\.[a-zA-Z]{2,}$/, patternMsg: 'Invalid domain format' }},
|
|
{ id: 'vhost-backend', rules: { required: true, requiredMsg: 'Please select a backend' }}
|
|
]);
|
|
if (!valid) return;
|
|
|
|
const data = {
|
|
domain: document.getElementById('vhost-domain').value.trim(),
|
|
backend: document.getElementById('vhost-backend').value,
|
|
ssl: document.getElementById('vhost-ssl').checked,
|
|
ssl_redirect: document.getElementById('vhost-ssl-redirect').checked,
|
|
acme: document.getElementById('vhost-acme').checked,
|
|
waf_bypass: document.getElementById('vhost-waf-bypass').checked,
|
|
enabled: document.getElementById('vhost-enabled').checked
|
|
};
|
|
|
|
let res;
|
|
if (isEdit) {
|
|
const origName = document.getElementById('vhost-original-name').value;
|
|
res = await apiCall(`/vhost/${encodeURIComponent(origName)}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data)
|
|
});
|
|
} else {
|
|
res = await apiCall('/vhost', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data)
|
|
});
|
|
}
|
|
|
|
if (!res.error) {
|
|
closeModal();
|
|
showToast(isEdit ? 'VHost updated' : 'VHost created', 'success');
|
|
loadVhosts();
|
|
loadStatus();
|
|
}
|
|
}
|
|
|
|
async function deleteVhost(name) {
|
|
if (!confirm(`Delete vhost "${name}"?\n\nThis will remove the domain routing.`)) return;
|
|
|
|
const res = await apiCall(`/vhost/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
|
if (!res.error) {
|
|
showToast(`VHost "${name}" deleted`, 'success');
|
|
loadVhosts();
|
|
loadStatus();
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// Backend CRUD
|
|
// ══════════════════════════════════════════════════════════════
|
|
function showAddBackendModal() {
|
|
const body = `
|
|
<div class="form-group">
|
|
<label>Name</label>
|
|
<input type="text" id="backend-name" placeholder="my-backend">
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Mode</label>
|
|
<select id="backend-mode">
|
|
<option value="http">HTTP</option>
|
|
<option value="tcp">TCP</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Balance</label>
|
|
<select id="backend-balance">
|
|
<option value="roundrobin">Round Robin</option>
|
|
<option value="leastconn">Least Connections</option>
|
|
<option value="source">Source IP</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="form-check">
|
|
<input type="checkbox" id="backend-health" checked>
|
|
<label>Enable Health Checks</label>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Health Check URI</label>
|
|
<input type="text" id="backend-health-uri" value="/health" placeholder="/health">
|
|
</div>
|
|
`;
|
|
const footer = `
|
|
<button class="btn" onclick="closeModal()">Cancel</button>
|
|
<button class="btn primary" onclick="saveBackend(false)">Add Backend</button>
|
|
`;
|
|
openModal('Add Backend', body, footer);
|
|
}
|
|
|
|
async function showEditBackendModal(backend) {
|
|
const body = `
|
|
<input type="hidden" id="backend-original-name" value="${backend.name}">
|
|
<div class="form-group">
|
|
<label>Name</label>
|
|
<input type="text" id="backend-name" value="${backend.name}">
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Mode</label>
|
|
<select id="backend-mode">
|
|
<option value="http" ${backend.mode === 'http' ? 'selected' : ''}>HTTP</option>
|
|
<option value="tcp" ${backend.mode === 'tcp' ? 'selected' : ''}>TCP</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Balance</label>
|
|
<select id="backend-balance">
|
|
<option value="roundrobin" ${backend.balance === 'roundrobin' ? 'selected' : ''}>Round Robin</option>
|
|
<option value="leastconn" ${backend.balance === 'leastconn' ? 'selected' : ''}>Least Connections</option>
|
|
<option value="source" ${backend.balance === 'source' ? 'selected' : ''}>Source IP</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="form-check">
|
|
<input type="checkbox" id="backend-health" ${backend.health_check !== false ? 'checked' : ''}>
|
|
<label>Enable Health Checks</label>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Health Check URI</label>
|
|
<input type="text" id="backend-health-uri" value="${backend.health_uri || '/health'}">
|
|
</div>
|
|
`;
|
|
const footer = `
|
|
<button class="btn" onclick="closeModal()">Cancel</button>
|
|
<button class="btn primary" onclick="saveBackend(true)">Save Changes</button>
|
|
`;
|
|
openModal('Edit Backend', body, footer);
|
|
}
|
|
|
|
async function saveBackend(isEdit) {
|
|
const valid = validateForm([
|
|
{ id: 'backend-name', rules: { required: true, requiredMsg: 'Name is required', pattern: /^[a-zA-Z][a-zA-Z0-9_-]*$/, patternMsg: 'Name must start with letter, only letters/numbers/hyphens/underscores' }}
|
|
]);
|
|
if (!valid) return;
|
|
|
|
const data = {
|
|
name: document.getElementById('backend-name').value.trim(),
|
|
mode: document.getElementById('backend-mode').value,
|
|
balance: document.getElementById('backend-balance').value,
|
|
health_check: document.getElementById('backend-health').checked,
|
|
health_uri: document.getElementById('backend-health-uri').value.trim() || '/health'
|
|
};
|
|
|
|
let res;
|
|
if (isEdit) {
|
|
const origName = document.getElementById('backend-original-name').value;
|
|
res = await apiCall(`/backend/${encodeURIComponent(origName)}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data)
|
|
});
|
|
} else {
|
|
res = await apiCall('/backend', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data)
|
|
});
|
|
}
|
|
|
|
if (!res.error) {
|
|
closeModal();
|
|
showToast(isEdit ? 'Backend updated' : 'Backend created', 'success');
|
|
loadBackends();
|
|
loadStatus();
|
|
}
|
|
}
|
|
|
|
async function deleteBackend(name) {
|
|
const backend = cachedBackends.find(b => b.name === name);
|
|
const serverCount = backend?.servers?.length || 0;
|
|
|
|
const msg = serverCount > 0
|
|
? `Delete backend "${name}" and its ${serverCount} server(s)?`
|
|
: `Delete backend "${name}"?`;
|
|
|
|
if (!confirm(msg)) return;
|
|
|
|
const res = await apiCall(`/backend/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
|
if (!res.error) {
|
|
showToast(`Backend "${name}" deleted`, 'success');
|
|
loadBackends();
|
|
loadStatus();
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// Server CRUD (nested under Backend)
|
|
// ══════════════════════════════════════════════════════════════
|
|
async function showBackendServers(backendName) {
|
|
const d = await api(`/backend/${encodeURIComponent(backendName)}`);
|
|
const backend = d.backend || d;
|
|
const servers = backend.servers || [];
|
|
|
|
const serverRows = servers.length === 0
|
|
? '<tr><td colspan="5" style="color:var(--p31-dim)">No servers configured</td></tr>'
|
|
: servers.map(s => `
|
|
<tr>
|
|
<td>${s.name}</td>
|
|
<td>${s.address}</td>
|
|
<td>${s.port}</td>
|
|
<td>${s.weight || 100}</td>
|
|
<td>
|
|
<button class="btn" style="padding:0.3rem 0.6rem;font-size:0.75rem" onclick="showEditServerModal('${backendName}', ${JSON.stringify(s).replace(/"/g, '"')})">Edit</button>
|
|
<button class="btn danger" style="padding:0.3rem 0.6rem;font-size:0.75rem" onclick="deleteServer('${backendName}', '${s.name}')">Delete</button>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
|
|
const body = `
|
|
<p style="color:var(--p31-dim);margin-bottom:1rem">Backend: <strong>${backendName}</strong></p>
|
|
<table style="margin-bottom:1rem">
|
|
<thead><tr><th>Name</th><th>Address</th><th>Port</th><th>Weight</th><th>Actions</th></tr></thead>
|
|
<tbody>${serverRows}</tbody>
|
|
</table>
|
|
`;
|
|
const footer = `
|
|
<button class="btn" onclick="closeModal()">Close</button>
|
|
<button class="btn primary" onclick="showAddServerModal('${backendName}')">+ Add Server</button>
|
|
`;
|
|
openModal('Manage Servers', body, footer);
|
|
}
|
|
|
|
function showAddServerModal(backendName) {
|
|
const body = `
|
|
<input type="hidden" id="server-backend" value="${backendName}">
|
|
<div class="form-group">
|
|
<label>Server Name</label>
|
|
<input type="text" id="server-name" placeholder="server1">
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Address</label>
|
|
<input type="text" id="server-address" placeholder="192.168.1.10">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Port</label>
|
|
<input type="number" id="server-port" value="80" min="1" max="65535">
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Weight</label>
|
|
<input type="number" id="server-weight" value="100" min="1" max="256">
|
|
</div>
|
|
<div class="form-check" style="align-self:end;padding-bottom:0.6rem">
|
|
<input type="checkbox" id="server-check" checked>
|
|
<label>Health Check</label>
|
|
</div>
|
|
</div>
|
|
`;
|
|
const footer = `
|
|
<button class="btn" onclick="showBackendServers('${backendName}')">Back</button>
|
|
<button class="btn primary" onclick="saveServer('${backendName}', false)">Add Server</button>
|
|
`;
|
|
openModal('Add Server', body, footer);
|
|
}
|
|
|
|
function showEditServerModal(backendName, server) {
|
|
const body = `
|
|
<input type="hidden" id="server-backend" value="${backendName}">
|
|
<input type="hidden" id="server-original-name" value="${server.name}">
|
|
<div class="form-group">
|
|
<label>Server Name</label>
|
|
<input type="text" id="server-name" value="${server.name}">
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Address</label>
|
|
<input type="text" id="server-address" value="${server.address}">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Port</label>
|
|
<input type="number" id="server-port" value="${server.port}" min="1" max="65535">
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Weight</label>
|
|
<input type="number" id="server-weight" value="${server.weight || 100}" min="1" max="256">
|
|
</div>
|
|
<div class="form-check" style="align-self:end;padding-bottom:0.6rem">
|
|
<input type="checkbox" id="server-check" ${server.check !== false ? 'checked' : ''}>
|
|
<label>Health Check</label>
|
|
</div>
|
|
</div>
|
|
`;
|
|
const footer = `
|
|
<button class="btn" onclick="showBackendServers('${backendName}')">Back</button>
|
|
<button class="btn primary" onclick="saveServer('${backendName}', true)">Save Changes</button>
|
|
`;
|
|
openModal('Edit Server', body, footer);
|
|
}
|
|
|
|
async function saveServer(backendName, isEdit) {
|
|
const valid = validateForm([
|
|
{ id: 'server-name', rules: { required: true, requiredMsg: 'Server name is required' }},
|
|
{ id: 'server-address', rules: { required: true, requiredMsg: 'Address is required' }},
|
|
{ id: 'server-port', rules: { required: true, min: 1, max: 65535, minMsg: 'Port must be 1-65535', maxMsg: 'Port must be 1-65535' }}
|
|
]);
|
|
if (!valid) return;
|
|
|
|
const data = {
|
|
backend: backendName,
|
|
name: document.getElementById('server-name').value.trim(),
|
|
address: document.getElementById('server-address').value.trim(),
|
|
port: parseInt(document.getElementById('server-port').value),
|
|
weight: parseInt(document.getElementById('server-weight').value) || 100,
|
|
check: document.getElementById('server-check').checked
|
|
};
|
|
|
|
let res;
|
|
if (isEdit) {
|
|
const origName = document.getElementById('server-original-name').value;
|
|
res = await apiCall(`/server/${encodeURIComponent(origName)}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data)
|
|
});
|
|
} else {
|
|
res = await apiCall('/server', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data)
|
|
});
|
|
}
|
|
|
|
if (!res.error) {
|
|
showToast(isEdit ? 'Server updated' : 'Server added', 'success');
|
|
showBackendServers(backendName);
|
|
}
|
|
}
|
|
|
|
async function deleteServer(backendName, serverName) {
|
|
if (!confirm(`Delete server "${serverName}" from backend "${backendName}"?`)) return;
|
|
|
|
const res = await apiCall(`/server/${encodeURIComponent(serverName)}?backend=${encodeURIComponent(backendName)}`, { method: 'DELETE' });
|
|
if (!res.error) {
|
|
showToast(`Server "${serverName}" deleted`, 'success');
|
|
showBackendServers(backendName);
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// Certificate CRUD
|
|
// ══════════════════════════════════════════════════════════════
|
|
function showRequestCertModal() {
|
|
const body = `
|
|
<div class="form-group">
|
|
<label>Domain</label>
|
|
<input type="text" id="cert-domain" placeholder="example.com">
|
|
</div>
|
|
<div class="form-check">
|
|
<input type="checkbox" id="cert-staging">
|
|
<label>Use Let's Encrypt Staging (for testing)</label>
|
|
</div>
|
|
<div id="cert-progress" style="display:none;margin-top:1rem">
|
|
<p style="color:var(--p31-mid)">Requesting certificate...</p>
|
|
<div style="background:var(--tube-soft);height:4px;border-radius:2px;overflow:hidden">
|
|
<div id="cert-progress-bar" style="background:var(--p31-peak);height:100%;width:0%;transition:width 0.5s"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
const footer = `
|
|
<button class="btn" onclick="closeModal()">Cancel</button>
|
|
<button class="btn primary" id="cert-submit-btn" onclick="requestCertificate()">Request Certificate</button>
|
|
`;
|
|
openModal('Request SSL Certificate', body, footer);
|
|
}
|
|
|
|
async function requestCertificate() {
|
|
const valid = validateForm([
|
|
{ id: 'cert-domain', rules: { required: true, requiredMsg: 'Domain is required', pattern: /^[a-zA-Z0-9][-a-zA-Z0-9.]*\.[a-zA-Z]{2,}$/, patternMsg: 'Invalid domain format' }}
|
|
]);
|
|
if (!valid) return;
|
|
|
|
const domain = document.getElementById('cert-domain').value.trim();
|
|
const staging = document.getElementById('cert-staging').checked;
|
|
|
|
// Show progress
|
|
document.getElementById('cert-progress').style.display = 'block';
|
|
document.getElementById('cert-submit-btn').disabled = true;
|
|
const progressBar = document.getElementById('cert-progress-bar');
|
|
progressBar.style.width = '30%';
|
|
|
|
const res = await apiCall('/certificate/request', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ domain, staging })
|
|
});
|
|
|
|
progressBar.style.width = '100%';
|
|
|
|
if (!res.error) {
|
|
setTimeout(() => {
|
|
closeModal();
|
|
showToast(`Certificate requested for ${domain}`, 'success');
|
|
loadCerts();
|
|
}, 500);
|
|
} else {
|
|
document.getElementById('cert-progress').style.display = 'none';
|
|
document.getElementById('cert-submit-btn').disabled = false;
|
|
}
|
|
}
|
|
|
|
async function deleteCertificate(name) {
|
|
if (!confirm(`Delete certificate "${name}"?`)) return;
|
|
|
|
const res = await apiCall(`/certificate/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
|
if (!res.error) {
|
|
showToast(`Certificate "${name}" deleted`, 'success');
|
|
loadCerts();
|
|
}
|
|
}
|
|
|
|
function switchTab(tabName) {
|
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
|
document.querySelector(`[data-tab="${tabName}"]`).classList.add('active');
|
|
document.getElementById(tabName).classList.add('active');
|
|
}
|
|
|
|
async function loadStatus() {
|
|
const d = await api('/status');
|
|
|
|
// HAProxy status
|
|
const statusEl = document.getElementById('status');
|
|
const statusCard = document.getElementById('statusCard');
|
|
statusEl.textContent = d.running ? 'Running' : 'Stopped';
|
|
statusCard.className = d.running ? 'stat-card running' : 'stat-card stopped';
|
|
|
|
// WAF status
|
|
const wafEl = document.getElementById('wafStatus');
|
|
const wafIndicator = document.getElementById('wafIndicator');
|
|
const wafToggle = document.getElementById('wafToggle');
|
|
|
|
if (d.waf_enabled && d.waf_available) {
|
|
wafEl.textContent = 'Active';
|
|
wafIndicator.className = 'indicator active';
|
|
wafToggle.checked = true;
|
|
} else if (d.waf_enabled) {
|
|
wafEl.textContent = 'Unavailable';
|
|
wafIndicator.className = 'indicator inactive';
|
|
wafToggle.checked = true;
|
|
} else {
|
|
wafEl.textContent = 'Disabled';
|
|
wafIndicator.className = 'indicator inactive';
|
|
wafToggle.checked = false;
|
|
}
|
|
|
|
document.getElementById('vhosts-count').textContent = d.vhost_count || 0;
|
|
document.getElementById('backends-count').textContent = d.backend_count || 0;
|
|
|
|
// Get CrowdSec bans
|
|
const cs = await api('/crowdsec/status');
|
|
document.getElementById('bans').textContent = cs.active_bans || 0;
|
|
}
|
|
|
|
async function loadVhosts() {
|
|
const d = await api('/vhosts');
|
|
const list = document.getElementById('vhost-list');
|
|
const vhosts = d.vhosts || [];
|
|
if (!vhosts.length) {
|
|
list.innerHTML = '<tr><td colspan="5" style="color:var(--text-dim)">No vhosts configured</td></tr>';
|
|
return;
|
|
}
|
|
list.innerHTML = vhosts.map(v => `
|
|
<tr>
|
|
<td><strong>${v.domain}</strong></td>
|
|
<td>${v.backend || '-'}</td>
|
|
<td>
|
|
${v.ssl ? '<span class="badge up">SSL</span>' : ''}
|
|
${v.acme ? '<span class="badge up" style="margin-left:4px">ACME</span>' : ''}
|
|
${!v.ssl ? '<span class="badge bypass">No SSL</span>' : ''}
|
|
</td>
|
|
<td>${v.waf_bypass ? '<span class="badge bypass">Bypass</span>' : '<span class="badge waf">Protected</span>'}</td>
|
|
<td>
|
|
<button class="btn" style="padding:0.3rem 0.6rem;font-size:0.75rem" onclick='showEditVhostModal(${JSON.stringify(v)})'>Edit</button>
|
|
<button class="btn danger" style="padding:0.3rem 0.6rem;font-size:0.75rem" onclick="deleteVhost('${v.name || v.domain}')">Delete</button>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
async function loadBackends() {
|
|
const d = await api('/backends');
|
|
const list = document.getElementById('backend-list');
|
|
const backends = d.backends || [];
|
|
cachedBackends = backends;
|
|
if (!backends.length) {
|
|
list.innerHTML = '<tr><td colspan="5" style="color:var(--text-dim)">No backends</td></tr>';
|
|
return;
|
|
}
|
|
list.innerHTML = backends.map(b => {
|
|
const isWaf = b.type === 'waf' || b.name === 'waf_inspector';
|
|
const serverCount = b.servers?.length || 0;
|
|
return `
|
|
<tr>
|
|
<td><strong>${b.name}</strong></td>
|
|
<td>${b.mode || 'http'}</td>
|
|
<td>${b.balance || '-'}</td>
|
|
<td>
|
|
${isWaf ? '<span class="badge waf">WAF Inspector</span>' : `${serverCount} server${serverCount !== 1 ? 's' : ''}`}
|
|
${!isWaf ? `<button class="btn" style="padding:0.2rem 0.5rem;font-size:0.7rem;margin-left:0.5rem" onclick="showBackendServers('${b.name}')">Manage</button>` : ''}
|
|
</td>
|
|
<td>
|
|
${!isWaf ? `
|
|
<button class="btn" style="padding:0.3rem 0.6rem;font-size:0.75rem" onclick='showEditBackendModal(${JSON.stringify(b)})'>Edit</button>
|
|
<button class="btn danger" style="padding:0.3rem 0.6rem;font-size:0.75rem" onclick="deleteBackend('${b.name}')">Delete</button>
|
|
` : ''}
|
|
</td>
|
|
</tr>
|
|
`}).join('');
|
|
}
|
|
|
|
async function loadStats() {
|
|
const d = await api('/stats');
|
|
const list = document.getElementById('stats-list');
|
|
const stats = (d.stats || []).filter(s => s.svname).slice(0, 30);
|
|
let totalReqs = 0;
|
|
(d.stats || []).forEach(s => totalReqs += parseInt(s.stot || 0));
|
|
document.getElementById('requests').textContent = totalReqs.toLocaleString();
|
|
|
|
if (!stats.length) {
|
|
list.innerHTML = '<tr><td colspan="6" style="color:var(--text-dim)">No stats available</td></tr>';
|
|
return;
|
|
}
|
|
list.innerHTML = stats.map(s => `
|
|
<tr>
|
|
<td>${s.pxname}/${s.svname}</td>
|
|
<td>${s.type === '0' ? 'Frontend' : s.type === '1' ? 'Backend' : 'Server'}</td>
|
|
<td><span class="badge ${s.status === 'UP' || s.status === 'OPEN' ? 'up' : 'down'}">${s.status || '-'}</span></td>
|
|
<td>${s.scur || 0}</td>
|
|
<td>${formatBytes(s.bin || 0)}</td>
|
|
<td>${formatBytes(s.bout || 0)}</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
async function loadCerts() {
|
|
const d = await api('/certificates');
|
|
const list = document.getElementById('cert-list');
|
|
const certs = d.certificates || [];
|
|
if (!certs.length) {
|
|
list.innerHTML = '<tr><td colspan="3" style="color:var(--text-dim)">No certificates</td></tr>';
|
|
return;
|
|
}
|
|
list.innerHTML = certs.map(c => `
|
|
<tr>
|
|
<td><strong>${c.name}</strong></td>
|
|
<td style="font-family:monospace;font-size:0.8rem;color:var(--p31-dim)">${c.path}</td>
|
|
<td>
|
|
<button class="btn danger" style="padding:0.3rem 0.6rem;font-size:0.75rem" onclick="deleteCertificate('${c.name}')">Delete</button>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
bytes = parseInt(bytes) || 0;
|
|
if (bytes < 1024) return bytes + ' B';
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB';
|
|
return (bytes / 1024 / 1024 / 1024).toFixed(2) + ' GB';
|
|
}
|
|
|
|
async function toggleWAF(enabled) {
|
|
await api('/waf/toggle', { method: 'POST', body: JSON.stringify({ enabled }) });
|
|
refresh();
|
|
}
|
|
|
|
|
|
async function syncRoutes() {
|
|
const d = await api('/waf/sync-routes', { method: 'POST' });
|
|
alert(`Synced ${d.routes} routes to WAF`);
|
|
}
|
|
|
|
async function generateConfig() {
|
|
const d = await api('/generate', { method: 'POST' });
|
|
if (d.success) {
|
|
alert(`Config generated: ${d.vhosts} vhosts, ${d.backends} backends, WAF: ${d.waf_enabled ? 'enabled' : 'disabled'}`);
|
|
} else {
|
|
alert('Config validation failed: ' + (d.validation || 'Unknown error'));
|
|
}
|
|
refresh();
|
|
}
|
|
|
|
async function reload() {
|
|
await api('/reload', { method: 'POST' });
|
|
refresh();
|
|
}
|
|
|
|
async function loadComponents() {
|
|
const d = await api('/components');
|
|
const list = document.getElementById('components-list');
|
|
const components = d.components || [];
|
|
if (!components.length) {
|
|
list.innerHTML = '<tr><td colspan="3" style="color:var(--text-dim)">No components</td></tr>';
|
|
return;
|
|
}
|
|
list.innerHTML = components.map(c => {
|
|
let status = 'stopped';
|
|
let statusText = 'Unknown';
|
|
if (c.running || c.available || c.enabled || c.installed || c.exists) {
|
|
status = 'up';
|
|
statusText = c.running ? 'Running' : (c.available ? 'Available' : (c.enabled ? 'Enabled' : (c.installed ? 'Installed' : 'OK')));
|
|
} else {
|
|
statusText = c.running === false ? 'Stopped' : (c.available === false ? 'Unavailable' : 'Missing');
|
|
}
|
|
return `<tr>
|
|
<td>${c.name}</td>
|
|
<td style="color:var(--text-dim)">${c.description}</td>
|
|
<td><span class="badge ${status}">${statusText}</span></td>
|
|
</tr>`;
|
|
}).join('');
|
|
}
|
|
|
|
async function loadAccess() {
|
|
const d = await api('/access');
|
|
const list = document.getElementById('access-list');
|
|
const access = d.access || [];
|
|
if (!access.length) {
|
|
list.innerHTML = '<p style="color:var(--text-dim)">No access points</p>';
|
|
return;
|
|
}
|
|
list.innerHTML = access.map(a => {
|
|
const url = a.url.replace('{{hostname}}', location.hostname);
|
|
return `<div style="background:var(--bg-dark);padding:0.75rem 1rem;border-radius:6px;display:flex;justify-content:space-between;align-items:center;">
|
|
<div>
|
|
<strong>${a.name}</strong>
|
|
<span style="color:var(--text-dim);margin-left:1rem;font-size:0.85rem">${a.type}${a.waf_protected !== undefined ? (a.waf_protected ? ' - WAF Protected' : ' - WAF Bypass') : ''}</span>
|
|
</div>
|
|
<a href="${url}" target="_blank" style="color:var(--primary)">${url}</a>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function showMigrate() { document.getElementById('migrate-modal').style.display = 'flex'; }
|
|
function hideMigrate() { document.getElementById('migrate-modal').style.display = 'none'; }
|
|
|
|
async function doMigrate() {
|
|
const source = document.getElementById('migrate-source').value;
|
|
const d = await api('/migrate', { method: 'POST', body: JSON.stringify({ source }) });
|
|
hideMigrate();
|
|
if (d.success) {
|
|
alert(`Migration complete: ${d.vhosts || 0} vhosts, ${d.backends || 0} backends, ${d.certs || 0} certificates`);
|
|
refresh();
|
|
} else {
|
|
alert('Migration failed: ' + (d.error || 'Unknown error'));
|
|
}
|
|
}
|
|
|
|
async function restart() {
|
|
await api('/restart', { method: 'POST' });
|
|
setTimeout(refresh, 2000);
|
|
}
|
|
|
|
function refresh() {
|
|
loadComponents();
|
|
loadAccess();
|
|
loadStatus();
|
|
loadVhosts();
|
|
loadBackends();
|
|
loadStats();
|
|
loadCerts();
|
|
}
|
|
|
|
refresh();
|
|
setInterval(refresh, 15000);
|
|
</script>
|
|
<script src="/shared/crt-engine.js"></script>
|
|
</body>
|
|
</html>
|