mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 09:14:33 +00:00
Merge: vhost webui reskin to certs look + read certs from HAProxy PEM store off-loop (#858)
Some checks are pending
License Headers / check (push) Waiting to run
Some checks are pending
License Headers / check (push) Waiting to run
This commit is contained in:
commit
99feea1057
|
|
@ -11,6 +11,8 @@ import subprocess
|
|||
import os
|
||||
import json
|
||||
import re
|
||||
import asyncio
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any
|
||||
from fastapi import FastAPI, Depends, HTTPException, BackgroundTasks
|
||||
|
|
@ -26,6 +28,17 @@ config = get_config("vhost")
|
|||
NGINX_VHOST_DIR = Path(config.get("nginx_vhost_dir", "/etc/nginx/sites-available") if config else "/etc/nginx/sites-available")
|
||||
NGINX_ENABLED_DIR = Path(config.get("nginx_enabled_dir", "/etc/nginx/sites-enabled") if config else "/etc/nginx/sites-enabled")
|
||||
ACME_DIR = Path(config.get("acme_dir", "/etc/acme") if config else "/etc/acme")
|
||||
# The real cert store is the combined HAProxy PEMs (same source the certs module
|
||||
# reads); the legacy acme.sh /etc/acme layout is empty on this platform.
|
||||
CERTS_PEM_DIR = Path(config.get("certs_pem_dir", "/data/haproxy/certs")
|
||||
if config else "/data/haproxy/certs")
|
||||
|
||||
|
||||
def _pem_cert_count() -> int:
|
||||
try:
|
||||
return len(list(CERTS_PEM_DIR.glob("*.pem"))) if CERTS_PEM_DIR.exists() else 0
|
||||
except Exception:
|
||||
return 0
|
||||
DATA_PATH = Path(config.get("data_path", "/srv/vhost") if config else "/srv/vhost")
|
||||
|
||||
|
||||
|
|
@ -81,9 +94,7 @@ async def status():
|
|||
enabled_count = len([f for f in NGINX_ENABLED_DIR.glob("*.conf") if f.is_symlink() or f.is_file()])
|
||||
|
||||
# Count certificates
|
||||
cert_count = 0
|
||||
if ACME_DIR.exists():
|
||||
cert_count = len([d for d in ACME_DIR.iterdir() if d.is_dir() and (d / "fullchain.cer").exists()])
|
||||
cert_count = _pem_cert_count()
|
||||
|
||||
return {
|
||||
"module": "vhost",
|
||||
|
|
@ -134,9 +145,7 @@ async def get_components():
|
|||
if NGINX_VHOST_DIR.exists():
|
||||
vhost_count = len(list(NGINX_VHOST_DIR.glob("*.conf")))
|
||||
|
||||
cert_count = 0
|
||||
if ACME_DIR.exists():
|
||||
cert_count = len([d for d in ACME_DIR.iterdir() if d.is_dir() and (d / "fullchain.cer").exists()])
|
||||
cert_count = _pem_cert_count()
|
||||
|
||||
return {
|
||||
"components": [
|
||||
|
|
@ -593,39 +602,67 @@ async def delete_vhost(domain: str):
|
|||
|
||||
@app.get("/certificates")
|
||||
async def list_certificates():
|
||||
"""List all certificates (public)"""
|
||||
"""List all certificates from the combined HAProxy PEM store (the real cert
|
||||
source, same as the certs module). Parsing shells out to openssl once per
|
||||
PEM — dozens of blocking subprocesses — so it runs OFF the event loop and is
|
||||
cached; otherwise it would freeze the shared aggregator loop (board-wide)."""
|
||||
now = time.time()
|
||||
if _CERT_CACHE["data"] is not None and now - _CERT_CACHE["ts"] < 60:
|
||||
certs = _CERT_CACHE["data"]
|
||||
else:
|
||||
certs = await asyncio.to_thread(_read_pem_certs)
|
||||
_CERT_CACHE.update(ts=now, data=certs)
|
||||
return {"certificates": certs, "count": len(certs)}
|
||||
|
||||
|
||||
_CERT_CACHE: dict = {"ts": 0.0, "data": None}
|
||||
|
||||
|
||||
def _read_pem_certs() -> list:
|
||||
"""Blocking: parse every PEM in the store IN-PROCESS via cryptography (no
|
||||
per-cert subprocess — dozens of openssl spawns would exhaust the box). Run
|
||||
via to_thread + cached by the caller."""
|
||||
certs = []
|
||||
|
||||
if ACME_DIR.exists():
|
||||
for cert_dir in ACME_DIR.iterdir():
|
||||
if not cert_dir.is_dir():
|
||||
continue
|
||||
cert_file = cert_dir / "fullchain.cer"
|
||||
if not cert_file.exists():
|
||||
continue
|
||||
|
||||
domain = cert_dir.name
|
||||
if not CERTS_PEM_DIR.exists():
|
||||
return certs
|
||||
try:
|
||||
from cryptography import x509
|
||||
except Exception:
|
||||
x509 = None
|
||||
for cert_file in sorted(CERTS_PEM_DIR.glob("*.pem")):
|
||||
stem = cert_file.stem
|
||||
# _wildcard_.example.com.pem -> *.example.com
|
||||
domain = (f"*.{stem[len('_wildcard_.'):]}"
|
||||
if stem.startswith("_wildcard_.") else stem)
|
||||
expires = ""
|
||||
issuer = ""
|
||||
|
||||
success, out, _ = run_cmd([
|
||||
"openssl", "x509", "-in", str(cert_file), "-noout", "-enddate", "-issuer"
|
||||
])
|
||||
if success:
|
||||
for line in out.split("\n"):
|
||||
if "notAfter" in line:
|
||||
expires = line.split("=")[-1]
|
||||
if "issuer" in line.lower():
|
||||
issuer = line.split("=", 1)[-1]
|
||||
|
||||
try:
|
||||
data = cert_file.read_bytes()
|
||||
leaf = None
|
||||
if x509 is not None:
|
||||
# combined PEM = key + leaf + chain; take the first certificate.
|
||||
if hasattr(x509, "load_pem_x509_certificates"):
|
||||
parsed = x509.load_pem_x509_certificates(data)
|
||||
leaf = parsed[0] if parsed else None
|
||||
else:
|
||||
leaf = x509.load_pem_x509_certificate(data)
|
||||
if leaf is not None:
|
||||
dt = getattr(leaf, "not_valid_after_utc", None) or leaf.not_valid_after
|
||||
# match the OpenSSL notAfter format the webui's new Date() parses
|
||||
expires = dt.strftime("%b %e %H:%M:%S %Y GMT").replace(" ", " ")
|
||||
try:
|
||||
issuer = leaf.issuer.rfc4514_string()
|
||||
except Exception:
|
||||
issuer = ""
|
||||
except Exception:
|
||||
pass
|
||||
certs.append({
|
||||
"domain": domain,
|
||||
"expires": expires,
|
||||
"issuer": issuer,
|
||||
"cert_file": str(cert_file),
|
||||
})
|
||||
|
||||
return {"certificates": certs, "count": len(certs)}
|
||||
return certs
|
||||
|
||||
|
||||
class CertRequest(BaseModel):
|
||||
|
|
|
|||
|
|
@ -6,318 +6,148 @@
|
|||
<title>SecuBox - VHost Manager</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">
|
||||
<link rel="stylesheet" href="/shared/hybrid-dark.css">
|
||||
<link rel="stylesheet" href="/shared/hybrid-skin.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);
|
||||
--cyan: #00d4ff;
|
||||
--bg-dark: #0d1117;
|
||||
--bg-card: rgba(30, 40, 55, 0.8);
|
||||
--border: rgba(100, 150, 200, 0.2);
|
||||
--text: #e8e6d9;
|
||||
--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);
|
||||
--orange: #ff9944;
|
||||
--yellow: #ffcc00;
|
||||
--green: #00dd44;
|
||||
--blue: #4488ff;
|
||||
--dim: #6b7b8b;
|
||||
/* compat aliases used by inline styles / JS */
|
||||
--primary: var(--cyan);
|
||||
--text-dim: var(--dim);
|
||||
--p31-dim: var(--dim);
|
||||
}
|
||||
* { 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; margin-left: 0.5rem; }
|
||||
.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.warning { background: rgba(210,153,34,0.1); border-color: var(--yellow); color: var(--yellow); }
|
||||
body { font-family: 'Courier Prime', monospace; background: var(--bg-dark); color: var(--text); display: flex; min-height: 100vh; }
|
||||
.sidebar { width: 220px; position: fixed; height: 100vh; overflow-y: auto; }
|
||||
.main { flex: 1; margin-left: 220px; padding: 1.5rem; padding-top: 60px; background: linear-gradient(135deg, rgba(10,15,25,0.95), rgba(15,25,40,0.9)); min-height: 100vh; }
|
||||
@media (max-width: 768px) { .main { margin-left: 0; padding-top: 60px; } }
|
||||
|
||||
.tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: 1rem; }
|
||||
.tab { padding: 0.75rem 1.5rem; cursor: pointer; border-bottom: 2px solid transparent; color: var(--text-dim); }
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.active { color: var(--primary); border-bottom-color: var(--primary); }
|
||||
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.header h1 { font-size: 1.4rem; color: var(--cyan); text-shadow: 0 0 10px var(--cyan); }
|
||||
.btn-group { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
|
||||
|
||||
.btn { padding: 0.4rem 0.8rem; border-radius: 6px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text); cursor: pointer; font-size: 0.8rem; font-family: 'Courier Prime', monospace; text-transform: uppercase; transition: all 0.2s; }
|
||||
.btn:hover { border-color: var(--cyan); color: var(--cyan); box-shadow: 0 0 8px rgba(0,212,255,0.3); }
|
||||
.btn.primary { background: rgba(0,212,255,0.15); border-color: var(--cyan); color: var(--cyan); }
|
||||
.btn.success { background: rgba(0,221,68,0.15); border-color: var(--green); color: var(--green); }
|
||||
.btn.success:hover { border-color: var(--green); color: var(--green); box-shadow: 0 0 8px rgba(0,221,68,0.3); }
|
||||
.btn.warning { background: rgba(255,204,0,0.15); border-color: var(--yellow); color: var(--yellow); }
|
||||
.btn.warning:hover { border-color: var(--yellow); color: var(--yellow); box-shadow: 0 0 8px rgba(255,204,0,0.3); }
|
||||
.btn.danger { background: rgba(255,68,102,0.15); border-color: var(--red); color: var(--red); }
|
||||
.btn.danger:hover { border-color: var(--red); color: var(--red); box-shadow: 0 0 8px rgba(255,68,102,0.3); }
|
||||
.btn.sm { padding: 0.2rem 0.5rem; font-size: 0.7rem; }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; margin-bottom: 1rem; backdrop-filter: blur(10px); }
|
||||
.card h2 { font-size: 0.95rem; margin-bottom: 0.9rem; color: var(--cyan); display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; }
|
||||
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 0.75rem; margin-bottom: 1rem; }
|
||||
.stat-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 0.6rem; text-align: center; backdrop-filter: blur(5px); }
|
||||
.stat-card .value { font-size: 1.4rem; font-weight: bold; color: var(--cyan); text-shadow: 0 0 10px currentColor; }
|
||||
.stat-card .label { font-size: 0.6rem; color: var(--dim); letter-spacing: 0.1em; margin-top: 0.2rem; }
|
||||
.stat-card.dead .value { color: var(--red); }
|
||||
.stat-card.critical .value { color: var(--orange); }
|
||||
.stat-card.warning .value { color: var(--yellow); }
|
||||
.stat-card.healthy .value { color: var(--green); }
|
||||
.stat-card.blue .value { color: var(--blue); }
|
||||
.stat-card.cyan .value { color: var(--cyan); }
|
||||
|
||||
.tabs { display: flex; border-bottom: 1px solid var(--border); margin-bottom: 1rem; flex-wrap: wrap; gap: 0.2rem; }
|
||||
.tab { padding: 0.5rem 0.9rem; cursor: pointer; color: var(--dim); border-bottom: 2px solid transparent; font-size: 0.72rem; text-transform: uppercase; transition: all 0.2s; }
|
||||
.tab:hover { color: var(--cyan); }
|
||||
.tab.active { color: var(--cyan); border-bottom-color: var(--cyan); }
|
||||
.tab-content { display: none; }
|
||||
.tab-content.active { display: block; }
|
||||
|
||||
.stats-row { display: grid; grid-template-columns: repeat(4, 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: 2rem; font-weight: bold; color: var(--cyan); }
|
||||
.stat-card .label { font-size: 0.75rem; color: var(--text-dim); }
|
||||
.components-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; margin-bottom: 1rem; }
|
||||
.component-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; backdrop-filter: blur(5px); }
|
||||
.component-card h3 { font-size: 0.95rem; margin-bottom: 0.6rem; display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; color: var(--cyan); }
|
||||
.component-card .meta { font-size: 0.75rem; color: var(--dim); margin-bottom: 0.4rem; }
|
||||
|
||||
.components-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
|
||||
.component-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem; }
|
||||
.component-card h3 { font-size: 1rem; margin-bottom: 0.75rem; display: flex; justify-content: space-between; align-items: center; }
|
||||
.component-card .meta { font-size: 0.8rem; color: var(--text-dim); margin-bottom: 0.5rem; }
|
||||
|
||||
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem; margin-bottom: 1.5rem; }
|
||||
.card h2 { font-size: 1rem; margin-bottom: 1rem; display: flex; justify-content: space-between; align-items: center; }
|
||||
.status-badge { display: inline-flex; align-items: center; gap: 0.5rem; padding: 4px 12px; border-radius: 12px; font-size: 0.8rem; }
|
||||
.status-badge.running { background: rgba(63,185,80,0.15); color: var(--green); }
|
||||
.status-badge.stopped { background: rgba(248,81,73,0.15); color: var(--red); }
|
||||
.status-badge { display: inline-flex; align-items: center; gap: 0.4rem; padding: 3px 10px; border-radius: 12px; font-size: 0.7rem; text-transform: uppercase; border: 1px solid var(--border); color: var(--dim); }
|
||||
.status-badge.running { background: rgba(0,221,68,0.15); color: var(--green); border-color: var(--green); }
|
||||
.status-badge.stopped { background: rgba(255,68,102,0.15); color: var(--red); border-color: var(--red); }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 0.6rem; text-align: left; border-bottom: 1px solid var(--border); font-size: 0.875rem; }
|
||||
th { color: var(--text-dim); font-size: 0.75rem; text-transform: uppercase; }
|
||||
.badge { padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem; }
|
||||
.badge.enabled { background: rgba(63,185,80,0.2); color: var(--green); }
|
||||
.badge.disabled { background: rgba(139,148,158,0.2); color: var(--text-dim); }
|
||||
.badge.ssl { background: rgba(88,166,255,0.2); color: var(--primary); }
|
||||
th, td { padding: 0.55rem 0.6rem; text-align: left; border-bottom: 1px solid var(--border); font-size: 0.8rem; }
|
||||
th { color: var(--dim); font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
tr:hover td { background: rgba(0,212,255,0.04); }
|
||||
.badge { padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.7rem; border: 1px solid var(--border); }
|
||||
.badge.enabled { background: rgba(0,221,68,0.15); color: var(--green); border-color: var(--green); }
|
||||
.badge.disabled { background: rgba(107,123,139,0.15); color: var(--dim); }
|
||||
.badge.ssl { background: rgba(0,212,255,0.15); color: var(--cyan); border-color: var(--cyan); }
|
||||
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem; }
|
||||
.form-group { margin-bottom: 1rem; }
|
||||
.form-group label { display: block; margin-bottom: 0.25rem; font-size: 0.8rem; color: var(--text-dim); }
|
||||
.form-group input, .form-group select { width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; background: var(--bg-dark); color: var(--text); }
|
||||
/* Certificate list — certs-style status rows */
|
||||
.cert-list { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.cert-item { display: grid; grid-template-columns: 1.5em 1fr auto auto auto; align-items: center; gap: 0.6rem; padding: 0.45rem 0.6rem; background: rgba(20,30,45,0.6); border-radius: 4px; font-size: 0.78rem; border-left: 3px solid var(--border); transition: all 0.15s; }
|
||||
.cert-item:hover { background: rgba(0,212,255,0.08); transform: translateX(2px); }
|
||||
.cert-item.dead { border-left-color: var(--red); background: rgba(255,68,102,0.1); }
|
||||
.cert-item.critical { border-left-color: var(--orange); background: rgba(255,153,68,0.08); }
|
||||
.cert-item.warning { border-left-color: var(--yellow); background: rgba(255,204,0,0.05); }
|
||||
.cert-item.healthy { border-left-color: var(--green); }
|
||||
.cert-item .dot { text-align: center; }
|
||||
.cert-item .domain { font-weight: 500; color: var(--cyan); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cert-item .issuer { font-size: 0.68rem; color: var(--dim); text-align: right; }
|
||||
.cert-item .days { font-size: 0.72rem; text-align: right; min-width: 70px; font-weight: bold; }
|
||||
.cert-item .days.dead { color: var(--red); }
|
||||
.cert-item .days.critical { color: var(--orange); }
|
||||
.cert-item .days.warning { color: var(--yellow); }
|
||||
.cert-item .days.healthy { color: var(--green); }
|
||||
.cert-item .exp { font-size: 0.68rem; color: var(--dim); text-align: right; min-width: 95px; }
|
||||
@media (max-width: 768px) { .cert-item { grid-template-columns: 1.2em 1fr auto; } .cert-item .issuer, .cert-item .exp { display: none; } }
|
||||
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 0.6rem; }
|
||||
.form-group { margin-bottom: 0.6rem; }
|
||||
.form-group label { display: block; margin-bottom: 0.2rem; font-size: 0.7rem; color: var(--dim); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.form-group input, .form-group select { width: 100%; padding: 0.45rem; background: rgba(20,30,45,0.8); border: 1px solid var(--border); border-radius: 4px; font-family: 'Courier Prime', monospace; font-size: 0.8rem; color: var(--text); }
|
||||
.form-group input:focus, .form-group select:focus { outline: none; border-color: var(--cyan); box-shadow: 0 0 5px rgba(0,212,255,0.3); }
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem; }
|
||||
|
||||
.url-box { background: var(--bg-dark); padding: 0.75rem; border-radius: 6px; font-family: monospace; font-size: 0.85rem; margin-bottom: 0.75rem; display: flex; justify-content: space-between; align-items: center; }
|
||||
.url-label { font-size: 0.7rem; color: var(--text-dim); text-transform: uppercase; margin-bottom: 0.25rem; }
|
||||
.url-box a { color: var(--primary); }
|
||||
.url-box { background: rgba(20,30,45,0.6); padding: 0.6rem 0.75rem; border-radius: 6px; border: 1px solid var(--border); font-family: 'Courier Prime', monospace; font-size: 0.8rem; margin-bottom: 0.6rem; display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; }
|
||||
.url-label { font-size: 0.65rem; color: var(--dim); text-transform: uppercase; margin-bottom: 0.2rem; }
|
||||
.url-box a { color: var(--cyan); text-decoration: none; }
|
||||
.url-box a:hover { text-shadow: 0 0 6px var(--cyan); }
|
||||
|
||||
.modal { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 100; align-items: center; justify-content: center; }
|
||||
.modal { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.9); z-index: 1000; align-items: center; justify-content: center; }
|
||||
.modal.show { display: flex; }
|
||||
.modal-content { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1.5rem; min-width: 400px; max-width: 90vw; }
|
||||
.modal-content h3 { margin-bottom: 1rem; }
|
||||
.modal-content { background: rgba(20,30,45,0.95); border: 1px solid var(--border); border-radius: 8px; padding: 1.5rem; width: 480px; max-width: 90vw; max-height: 85vh; overflow-y: auto; backdrop-filter: blur(10px); }
|
||||
.modal-content h3 { margin-bottom: 1rem; color: var(--cyan); }
|
||||
|
||||
/* 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; }
|
||||
}
|
||||
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||
::-webkit-scrollbar-track { background: rgba(20,30,45,0.5); }
|
||||
::-webkit-scrollbar-thumb { background: var(--dim); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--cyan); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="crt-light hybrid-dark">
|
||||
<body class="hybrid-dark">
|
||||
<nav class="sidebar" id="sidebar"></nav>
|
||||
<script src="/shared/sidebar.js"></script>
|
||||
<main class="main">
|
||||
<header class="header">
|
||||
<h1>VHost Manager</h1>
|
||||
<div>
|
||||
<h1>🌐 VHost Manager</h1>
|
||||
<div class="btn-group">
|
||||
<span class="status-badge" id="status-badge">Loading...</span>
|
||||
<button class="btn" onclick="reloadNginx()">Reload Nginx</button>
|
||||
<button class="btn" onclick="refresh()">Refresh</button>
|
||||
<button class="btn" onclick="reloadNginx()">↻ Reload Nginx</button>
|
||||
<button class="btn" onclick="refresh()">🔄 Refresh</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Three-fold navigation -->
|
||||
<div class="tabs">
|
||||
<div class="tab active" onclick="showTab('components')">Components</div>
|
||||
<div class="tab" onclick="showTab('status')">Status</div>
|
||||
<div class="tab" onclick="showTab('access')">Access</div>
|
||||
<div class="tab" onclick="showTab('vhosts')">VHosts</div>
|
||||
<div class="tab" onclick="showTab('certs')">Certificates</div>
|
||||
<div class="tab" onclick="showTab('actions')">Actions</div>
|
||||
<div class="tab active" onclick="showTab('components')">📦 Components</div>
|
||||
<div class="tab" onclick="showTab('status')">📊 Status</div>
|
||||
<div class="tab" onclick="showTab('access')">🔗 Access</div>
|
||||
<div class="tab" onclick="showTab('vhosts')">🌐 VHosts</div>
|
||||
<div class="tab" onclick="showTab('certs')">🔐 Certificates</div>
|
||||
</div>
|
||||
|
||||
<!-- COMPONENTS TAB -->
|
||||
|
|
@ -337,10 +167,10 @@
|
|||
|
||||
<div class="card">
|
||||
<h2>Quick Stats</h2>
|
||||
<div class="stats-row">
|
||||
<div class="stat-card"><div class="value" id="vhost-count">-</div><div class="label">Total VHosts</div></div>
|
||||
<div class="stat-card"><div class="value" id="enabled-count">-</div><div class="label">Enabled</div></div>
|
||||
<div class="stat-card"><div class="value" id="cert-count">-</div><div class="label">Certificates</div></div>
|
||||
<div class="grid">
|
||||
<div class="stat-card cyan"><div class="value" id="vhost-count">-</div><div class="label">Total VHosts</div></div>
|
||||
<div class="stat-card healthy"><div class="value" id="enabled-count">-</div><div class="label">Enabled</div></div>
|
||||
<div class="stat-card blue"><div class="value" id="cert-count">-</div><div class="label">Certificates</div></div>
|
||||
<div class="stat-card"><div class="value" id="nginx-status">-</div><div class="label">Nginx Status</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -391,11 +221,7 @@
|
|||
<!-- VHOSTS TAB -->
|
||||
<div class="tab-content" id="tab-vhosts">
|
||||
<div class="card">
|
||||
<h2>Add VHost <button class="btn success" style="padding:0.25rem 0.5rem;font-size:0.75rem" onclick="showAddVhost()">+ New</button></h2>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Virtual Hosts</h2>
|
||||
<h2>Virtual Hosts <button class="btn success sm" onclick="showAddVhost()">+ New</button></h2>
|
||||
<table>
|
||||
<thead><tr><th>Domain</th><th>Backend</th><th>TLS</th><th>Exposition</th><th>Status</th><th>Actions</th></tr></thead>
|
||||
<tbody id="vhost-list"><tr><td colspan="6">Loading...</td></tr></tbody>
|
||||
|
|
@ -405,31 +231,19 @@
|
|||
|
||||
<!-- CERTS TAB -->
|
||||
<div class="tab-content" id="tab-certs">
|
||||
<div class="grid">
|
||||
<div class="stat-card dead"><div class="value" id="cert-expired">-</div><div class="label">💀 Expired</div></div>
|
||||
<div class="stat-card warning"><div class="value" id="cert-expiring">-</div><div class="label">⚠️ Expiring <30d</div></div>
|
||||
<div class="stat-card healthy"><div class="value" id="cert-valid">-</div><div class="label">💚 Valid</div></div>
|
||||
<div class="stat-card blue"><div class="value" id="cert-total">-</div><div class="label">📜 Total</div></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Certificates <button class="btn success" style="padding:0.25rem 0.5rem;font-size:0.75rem" onclick="showRequestCert()">+ Request</button></h2>
|
||||
<table>
|
||||
<thead><tr><th>Domain</th><th>Expires</th><th>Issuer</th><th>Actions</th></tr></thead>
|
||||
<tbody id="cert-list"><tr><td colspan="4">Loading...</td></tr></tbody>
|
||||
</table>
|
||||
<h2>Certificates <button class="btn success sm" onclick="showRequestCert()">+ Request</button></h2>
|
||||
<div class="cert-list" id="cert-list">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ACTIONS TAB -->
|
||||
<div class="tab-content" id="tab-actions">
|
||||
<div class="card">
|
||||
<h2>Service Control</h2>
|
||||
<button class="btn" onclick="reloadNginx()">Reload Nginx</button>
|
||||
<button class="btn" onclick="testConfig()">Test Config</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Migration</h2>
|
||||
<p style="margin-bottom:1rem;color:var(--text-dim);font-size:0.85rem">
|
||||
Import vhosts and certificates from existing OpenWrt SecuBox installation.
|
||||
</p>
|
||||
<button class="btn warning" onclick="showMigrate()">Migrate from OpenWrt</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Add VHost Modal -->
|
||||
|
|
@ -477,7 +291,7 @@
|
|||
<div class="modal" id="migrate-modal">
|
||||
<div class="modal-content">
|
||||
<h3>Migrate from OpenWrt</h3>
|
||||
<p style="margin-bottom:1rem;color:var(--text-dim);font-size:0.85rem">
|
||||
<p style="margin-bottom:1rem;color:var(--dim);font-size:0.8rem">
|
||||
Import vhosts and certificates from existing SecuBox.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
|
|
@ -496,6 +310,12 @@
|
|||
const token = () => localStorage.getItem('sbx_token');
|
||||
const headers = () => ({ 'Content-Type': 'application/json', ...(token() ? { 'Authorization': 'Bearer ' + token() } : {}) });
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
try {
|
||||
const res = await fetch(API + path, { ...opts, headers: headers() });
|
||||
|
|
@ -561,41 +381,98 @@
|
|||
const list = document.getElementById('vhost-list');
|
||||
const vhosts = d.vhosts || [];
|
||||
if (!vhosts.length) {
|
||||
list.innerHTML = '<tr><td colspan="6" style="color:var(--text-dim)">No vhosts configured</td></tr>';
|
||||
list.innerHTML = '<tr><td colspan="6" style="color:var(--dim)">No vhosts configured</td></tr>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = vhosts.map(v => {
|
||||
const exp = v.exposure || { reach: '?', mesh: false };
|
||||
const reachLabel = exp.reach === 'localhost' ? 'localhost' : (exp.reach || '?').toUpperCase();
|
||||
const reachLabel = exp.reach === 'localhost' ? 'localhost' : esc((exp.reach || '?').toUpperCase());
|
||||
const reachClass = exp.reach === 'wan' ? 'ssl' : (exp.reach === 'lan' ? 'enabled' : 'disabled');
|
||||
const dom = esc(v.domain);
|
||||
return `<tr>
|
||||
<td><a href="${v.ssl ? 'https' : 'http'}://${v.domain}" target="_blank" style="color:var(--primary)">${v.domain}</a></td>
|
||||
<td style="color:var(--text-dim);font-family:monospace;font-size:0.8rem">${v.backend || '-'}</td>
|
||||
<td>${v.ssl ? '<span class="badge ssl">' + (v.tls_mode || 'SSL') + '</span>' : '-'}</td>
|
||||
<td><a href="${v.ssl ? 'https' : 'http'}://${encodeURIComponent(v.domain)}" target="_blank" style="color:var(--cyan)">${dom}</a></td>
|
||||
<td style="color:var(--dim);font-family:monospace;font-size:0.75rem">${esc(v.backend || '-')}</td>
|
||||
<td>${v.ssl ? '<span class="badge ssl">' + esc(v.tls_mode || 'SSL') + '</span>' : '-'}</td>
|
||||
<td><span class="badge ${reachClass}">${reachLabel}${exp.mesh ? ' 🕸️' : ''}</span></td>
|
||||
<td><span class="badge ${v.enabled ? 'enabled' : 'disabled'}">${v.enabled ? '✅ Enabled' : '⏸️ Disabled'}</span></td>
|
||||
<td>
|
||||
<button class="btn" onclick="toggleVhost('${v.domain}', ${!v.enabled})" style="padding:2px 8px;font-size:0.7rem">${v.enabled ? 'Disable' : 'Enable'}</button>
|
||||
<button class="btn danger" onclick="deleteVhost('${v.domain}')" style="padding:2px 8px;font-size:0.7rem">Delete</button>
|
||||
<button class="btn sm" onclick="toggleVhost('${dom}', ${!v.enabled})">${v.enabled ? 'Disable' : 'Enable'}</button>
|
||||
<button class="btn danger sm" onclick="deleteVhost('${dom}')">Delete</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function certStatus(days) {
|
||||
if (days === null) return { cls: '', dot: '❔', txt: '—' };
|
||||
if (days < 0) return { cls: 'dead', dot: '💀', txt: 'EXPIRED' };
|
||||
if (days <= 13) return { cls: 'critical', dot: '🔴', txt: days + 'd' };
|
||||
if (days <= 29) return { cls: 'warning', dot: '🟡', txt: days + 'd' };
|
||||
return { cls: 'healthy', dot: '💚', txt: days + 'd' };
|
||||
}
|
||||
|
||||
function shortenIssuer(s) {
|
||||
if (!s) return '—';
|
||||
const m = s.match(/CN\s*=\s*([^,\/]+)/i) || s.match(/O\s*=\s*([^,\/]+)/i);
|
||||
return esc((m ? m[1] : s).trim());
|
||||
}
|
||||
|
||||
function certDays(expires) {
|
||||
if (!expires) return null;
|
||||
const d = new Date(expires);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
return Math.floor((d.getTime() - Date.now()) / 86400000);
|
||||
}
|
||||
|
||||
async function loadCerts() {
|
||||
const d = await api('/certificates');
|
||||
const list = document.getElementById('cert-list');
|
||||
const certs = d.certificates || [];
|
||||
|
||||
let expired = 0, expiring = 0, valid = 0;
|
||||
certs.forEach(c => {
|
||||
const days = certDays(c.expires);
|
||||
if (days === null) return;
|
||||
if (days < 0) expired++;
|
||||
else if (days < 30) expiring++;
|
||||
else valid++;
|
||||
});
|
||||
document.getElementById('cert-expired').textContent = expired;
|
||||
document.getElementById('cert-expiring').textContent = expiring;
|
||||
document.getElementById('cert-valid').textContent = valid;
|
||||
document.getElementById('cert-total').textContent = certs.length;
|
||||
document.getElementById('cert-count').textContent = certs.length;
|
||||
|
||||
if (!certs.length) {
|
||||
list.innerHTML = '<tr><td colspan="4" style="color:var(--text-dim)">No certificates</td></tr>';
|
||||
list.innerHTML = '<div style="color:var(--dim);padding:1rem;text-align:center">No certificates</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = certs.map(c => `<tr>
|
||||
<td>${c.domain}</td>
|
||||
<td>${c.expires || '-'}</td>
|
||||
<td style="color:var(--text-dim);font-size:0.8rem">${c.issuer || '-'}</td>
|
||||
<td><button class="btn" onclick="renewCert('${c.domain}')" style="padding:2px 8px;font-size:0.7rem">Renew</button></td>
|
||||
</tr>`).join('');
|
||||
|
||||
const rows = certs.slice().map(c => {
|
||||
const days = certDays(c.expires);
|
||||
return { c, days };
|
||||
}).sort((a, b) => {
|
||||
if (a.days === null) return 1;
|
||||
if (b.days === null) return -1;
|
||||
return a.days - b.days;
|
||||
});
|
||||
|
||||
list.innerHTML = rows.map(({ c, days }) => {
|
||||
const st = certStatus(days);
|
||||
const dom = esc(c.domain);
|
||||
const parsed = c.expires ? new Date(c.expires) : null;
|
||||
const expText = parsed && !isNaN(parsed.getTime())
|
||||
? parsed.toISOString().slice(0, 10)
|
||||
: (c.expires ? esc(c.expires) : '—');
|
||||
return `<div class="cert-item ${st.cls}">
|
||||
<span class="dot">${st.dot}</span>
|
||||
<span class="domain" title="${dom}">${dom}</span>
|
||||
<span class="issuer" title="${esc(c.issuer || '')}">${shortenIssuer(c.issuer)}</span>
|
||||
<span class="days ${st.cls}">${st.txt}</span>
|
||||
<span class="exp">${expText}</span>
|
||||
<button class="btn sm" onclick="renewCert('${dom}')">↻ Renew</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function loadAccess() {
|
||||
|
|
@ -603,14 +480,14 @@
|
|||
const container = document.getElementById('access-domains');
|
||||
const vhosts = d.vhosts || [];
|
||||
if (!vhosts.length) {
|
||||
container.innerHTML = '<p style="color:var(--text-dim)">No domains configured</p>';
|
||||
container.innerHTML = '<p style="color:var(--dim)">No domains configured</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = vhosts.map(v => `
|
||||
<div class="url-label">${v.domain}</div>
|
||||
<div class="url-label">${esc(v.domain)}</div>
|
||||
<div class="url-box">
|
||||
<a href="${v.url}" target="_blank">${v.url}</a>
|
||||
<span style="color:var(--text-dim);font-size:0.8rem">${v.backend}</span>
|
||||
<a href="${esc(v.url)}" target="_blank">${esc(v.url)}</a>
|
||||
<span style="color:var(--dim);font-size:0.75rem">${esc(v.backend)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
|
@ -709,6 +586,5 @@
|
|||
function refresh() { loadStatus(); loadVhosts(); loadCerts(); }
|
||||
refresh();
|
||||
</script>
|
||||
<script src="/shared/crt-engine.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user