feat: auto-generate self-signed certs, hoover modal processing guard, refactor backends/proxy pages

This commit is contained in:
2026-07-13 14:30:35 +00:00
parent 05524f3756
commit 2e49dec633
34 changed files with 790 additions and 411 deletions
+4 -4
View File
@@ -981,10 +981,10 @@ def generate_self_signed(_request: Any, body: dict[str, Any] | None) -> dict[str
raise ValueError("'domain' is required")
days = body.get("days", 365)
cert_dir = _ACME_HOME / domain
cert_dir.mkdir(parents=True, exist_ok=True)
cert_file = cert_dir / "fullchain.cer"
key_file = cert_dir / f"{domain}.key"
certs_dir = PROJECT_DIR / "data" / "certs"
certs_dir.mkdir(parents=True, exist_ok=True)
cert_file = certs_dir / f"{domain}.crt"
key_file = certs_dir / f"{domain}.key"
if cert_file.is_file() and key_file.is_file():
logger.info("Self-signed cert for %s already exists, skipping", domain)
+4
View File
@@ -691,6 +691,10 @@ def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
enable = body.get("enable")
if not zone or enable is None:
raise ValueError("'zone' and 'enable' (bool) are required")
if zone == "public" and enable:
raise ValueError(
"Masquerade (NAT) is not supported on the public zone — enable it on 'internal' or 'vpn' instead"
)
action = "--add-masquerade" if enable else "--remove-masquerade"
run(["firewall-cmd", f"--zone={zone}", action, "--permanent"], sudo=True)
_reload()
+89 -9
View File
@@ -2,6 +2,7 @@
import logging
import os
import subprocess
from copy import deepcopy
from pathlib import Path
from typing import Any
@@ -354,11 +355,54 @@ def _reload_nginx() -> None:
logger.info("nginx configuration applied and reloaded")
def _ensure_self_signed_cert(domain: str) -> None:
"""Auto-generate a self-signed cert for *domain* if not yet present."""
certs_dir = PROJECT_DIR / "data" / "certs"
certs_dir.mkdir(parents=True, exist_ok=True)
cert_file = certs_dir / f"{domain}.crt"
key_file = certs_dir / f"{domain}.key"
if cert_file.is_file() and key_file.is_file():
return
logger.info("Auto-generating self-signed cert for %s", domain)
subprocess.run(
[
"openssl",
"req",
"-x509",
"-newkey",
"rsa:2048",
"-keyout",
str(key_file),
"-out",
str(cert_file),
"-days",
"365",
"-nodes",
"-subj",
f"/CN={domain}",
],
capture_output=True,
text=True,
check=True,
timeout=30,
)
cert_file.chmod(0o644)
key_file.chmod(0o600)
def _write_all_sites() -> None:
"""Regenerate all site configs and ACME challenge site."""
ensure_dirs(SITES_DIR)
cfg = _get_config()
backends = cfg.get("backends", {})
# Auto-generate self-signed certs for management domains that need them
for name, dom in cfg.get("domains", {}).items():
cert = dom.get("cert")
paths = _ngx_resolve_paths(dom, backends)
has_management = any(p.get("is_management") for p in paths.values())
if has_management and (cert == "selfsigned" or cert is None):
_ensure_self_signed_cert(name)
existing = set(SITES_DIR.iterdir()) if SITES_DIR.exists() else set()
written: set[str] = set()
for name, dom in cfg.get("domains", {}).items():
@@ -396,13 +440,19 @@ def _hash_password(password: str) -> str:
return sha256_crypt.hash(password)
def _write_htpasswd(user: str, password: str) -> None:
"""Add or update a user entry in the .htpasswd file using SHA-256 hashing."""
ensure_dirs(DATA_DIR)
def _write_htpasswd(
user: str, password: str, htpasswd_path: Path | None = None
) -> None:
"""Add or update a user entry in the .htpasswd file using SHA-256 hashing.
If *htpasswd_path* is not given, defaults to :data:`HTPASSWD_FILE`.
"""
target = htpasswd_path or HTPASSWD_FILE
ensure_dirs(target.parent)
hashed = _hash_password(password)
existing: dict[str, str] = {}
if HTPASSWD_FILE.exists():
with open(HTPASSWD_FILE) as f:
if target.exists():
with open(target) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
@@ -411,12 +461,12 @@ def _write_htpasswd(user: str, password: str) -> None:
if len(parts) == 2:
existing[parts[0]] = line
existing[user] = f"{user}:{hashed}"
tmp = HTPASSWD_FILE.with_suffix(".tmp")
tmp = target.with_suffix(".tmp")
with open(tmp, "w") as f:
for _uname, entry in existing.items():
f.write(entry + "\n")
os.chmod(tmp, 0o640)
os.replace(tmp, HTPASSWD_FILE)
os.replace(tmp, target)
def _get_nginx_state() -> dict[str, Any]:
@@ -502,7 +552,18 @@ def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
auth = body.get("auth")
if auth is not None:
entry["auth"] = auth
# Write htpasswd file when a password is provided, then store
# only {user, htpasswd path} — never persist the raw password.
if auth.get("user") and auth.get("pass"):
htpasswd_path = auth.get(
"htpasswd", str(PROJECT_DIR / "data" / "nginx" / ".htpasswd")
)
if isinstance(htpasswd_path, str) and not Path(htpasswd_path).is_absolute():
htpasswd_path = PROJECT_DIR / htpasswd_path
_write_htpasswd(auth["user"], auth["pass"], Path(htpasswd_path))
entry["auth"] = {"user": auth["user"], "htpasswd": str(htpasswd_path)}
else:
entry["auth"] = auth
cfg["domains"][domain] = entry
_save_config(cfg)
@@ -561,7 +622,26 @@ def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if body["auth"] is None:
entry.pop("auth", None)
else:
entry["auth"] = body["auth"]
# Normalize: if auth has `pass`, write htpasswd and store only
# `{user, htpasswd path}` — never persist the raw password.
if body["auth"].get("user") and body["auth"].get("pass"):
htpasswd_path = body["auth"].get(
"htpasswd", str(PROJECT_DIR / "data" / "nginx" / ".htpasswd")
)
if (
isinstance(htpasswd_path, str)
and not Path(htpasswd_path).is_absolute()
):
htpasswd_path = PROJECT_DIR / htpasswd_path
_write_htpasswd(
body["auth"]["user"], body["auth"]["pass"], Path(htpasswd_path)
)
entry["auth"] = {
"user": body["auth"]["user"],
"htpasswd": str(htpasswd_path),
}
else:
entry["auth"] = body["auth"]
_save_config(cfg)
refresh_state(["nginx"])
+5 -3
View File
@@ -803,7 +803,7 @@ Card container with optional header.
#### `ConfirmDelete(props)`
Delete button with native `confirm()` dialog, then API `DELETE` call, toast, and model refresh.
Delete button with native `confirm()` dialog, then API `DELETE` call, toast, and model refresh. Shows a spinner animation during the API call, auto-disables the button, and optionally marks the parent row/card as pending-deletion until the model refresh removes it from the DOM.
```javascript
ConfirmDelete({
@@ -825,10 +825,11 @@ ConfirmDelete({
| `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` |
| `label` | Button text (default: `'Remove'`) |
| `body` | Optional JSON body to send with DELETE |
| `deleteKey` | Unique identifier for the item. When provided, marks the row/card as pending-deletion (opacity + red border) after API success until model refresh removes it from the DOM. Requires `_deleting.has(key)` class binding on the parent element. |
#### `ActionButton(props)`
Inline button that POSTs to an API endpoint, toasts on result, and optionally refreshes models. Supports toggle labels for on/off buttons.
Inline button that POSTs to an API endpoint, toasts on result, and optionally refreshes models. Supports toggle labels for on/off buttons. Shows a spinner animation during API calls and auto-disables the button to prevent double-submit.
```javascript
ActionButton({
@@ -872,7 +873,7 @@ ActionButton({
#### `ActionCell(props)`
Standardizes "action button + ConfirmDelete" in a table cell. Use for rows that need an edit action alongside a delete action.
Standardizes "action button + ConfirmDelete" in a table cell. The delete button shows a spinner during API calls and supports pending-deletion row styling. Use for rows that need an edit action alongside a delete action.
```javascript
ActionCell({
@@ -899,6 +900,7 @@ ActionCell({
| `removeLabel` | Delete button label (default: `'Remove'`) |
| `removeBody` | Optional JSON body to send with DELETE |
| `editCls` | Override classes for edit button (default: `'btn btn-sm btn-outline'`) |
| `deleteKey` | Unique identifier forwarded to `ConfirmDelete`. Enables pending-delete row styling. |
#### `certStatusBadge(props)`
+11 -16
View File
@@ -376,29 +376,24 @@ else
fi
}
# 1A. Self-signed certificate
# 1A. Self-signed certificate for management domain
_daemon_post "/acme/self-signed" "{\"domain\":\"$DOMAIN\"}" "Self-signed certificate"
# 1C. Management proxy domain
_daemon_post "/nginx/domains/add" "$(jq -n \
# 1C. Management proxy domain — try idempotent update first, fall back to add
local mgmt_json
mgmt_json="$(jq -n \
--arg domain "$DOMAIN" \
--arg user "$MGMT_USER" \
--arg pass "$MGMT_PASS" \
'{
domain: $domain,
paths: {
"/": {
backend: {host: "127.0.0.1", port: 9090, proto: "http"},
is_management: true
},
"/ws": {
backend: {host: "127.0.0.1", port: 9091, proto: "http"},
is_websocket: true
}
},
auth_user: $user,
auth_pass: $pass
}')" "Management domain configured"
backend: "webui",
cert: "selfsigned",
force_ssl: true,
auth: {user: $user, pass: $pass}
}')"
_daemon_post "/nginx/domains/update" "$mgmt_json" "Management domain configured" || \
_daemon_post "/nginx/domains/add" "$mgmt_json" "Management domain configured"
_daemon_post "/nginx/apply" "{}" "Nginx config applied"
+2 -2
View File
@@ -33,8 +33,8 @@ server {
ssl_certificate_key {{ certs_dir }}/{{ domain }}.key;
{% endif %}
{% elif has_management %}
ssl_certificate {{ acme_cert_dir }}/fullchain.cer;
ssl_certificate_key {{ acme_cert_dir }}/{{ domain }}.key;
ssl_certificate {{ certs_dir }}/{{ domain }}.crt;
ssl_certificate_key {{ certs_dir }}/{{ domain }}.key;
{% endif %}
include snippets/vacuum-wall-ssl.conf;
+25 -25
View File
@@ -34,51 +34,51 @@ from daemon.server import ConflictError
class TestGenerateSelfSigned:
def test_generate_creates_files(self, tmp_path):
with (
patch("daemon.handlers.acme._ACME_HOME", tmp_path / "acme"),
patch("daemon.handlers.acme.PROJECT_DIR", tmp_path),
):
result = generate_self_signed(None, {"domain": "test.local"})
assert result["domain"] == "test.local"
assert result["generated"] is True
cert_dir = tmp_path / "acme" / "test.local"
assert result["cert"] == str(cert_dir / "fullchain.cer")
assert result["key"] == str(cert_dir / "test.local.key")
assert (cert_dir / "fullchain.cer").is_file()
assert (cert_dir / "test.local.key").is_file()
certs_dir = tmp_path / "data" / "certs"
assert result["cert"] == str(certs_dir / "test.local.crt")
assert result["key"] == str(certs_dir / "test.local.key")
assert (certs_dir / "test.local.crt").is_file()
assert (certs_dir / "test.local.key").is_file()
def test_generate_idempotent_skips_existing(self, tmp_path):
cert_dir = tmp_path / "acme" / "test.local"
cert_dir.mkdir(parents=True)
(cert_dir / "fullchain.cer").write_text("dummy-cert")
(cert_dir / "test.local.key").write_text("dummy-key")
certs_dir = tmp_path / "data" / "certs"
certs_dir.mkdir(parents=True)
(certs_dir / "test.local.crt").write_text("dummy-cert")
(certs_dir / "test.local.key").write_text("dummy-key")
with patch("daemon.handlers.acme._ACME_HOME", tmp_path / "acme"):
with patch("daemon.handlers.acme.PROJECT_DIR", tmp_path):
result = generate_self_signed(None, {"domain": "test.local"})
assert result["generated"] is False
def test_generate_partial_existing(self, tmp_path):
cert_dir = tmp_path / "acme" / "test.local"
cert_dir.mkdir(parents=True)
(cert_dir / "fullchain.cer").write_text("dummy-cert")
certs_dir = tmp_path / "data" / "certs"
certs_dir.mkdir(parents=True)
(certs_dir / "test.local.crt").write_text("dummy-cert")
# key missing -> should regenerate
with patch("daemon.handlers.acme._ACME_HOME", tmp_path / "acme"):
with patch("daemon.handlers.acme.PROJECT_DIR", tmp_path):
result = generate_self_signed(None, {"domain": "test.local"})
assert result["generated"] is True
def test_generate_custom_days(self, tmp_path):
with (
patch("daemon.handlers.acme._ACME_HOME", tmp_path / "acme"),
patch("daemon.handlers.acme.PROJECT_DIR", tmp_path),
patch("subprocess.run") as mock_run,
):
def _create_files(*args, **kwargs):
cert_dir = tmp_path / "acme" / "test.local"
cert_dir.mkdir(parents=True, exist_ok=True)
(cert_dir / "fullchain.cer").touch()
(cert_dir / "test.local.key").touch()
certs_dir = tmp_path / "data" / "certs"
certs_dir.mkdir(parents=True, exist_ok=True)
(certs_dir / "test.local.crt").touch()
(certs_dir / "test.local.key").touch()
return Path("")
mock_run.side_effect = _create_files
@@ -88,15 +88,15 @@ class TestGenerateSelfSigned:
idx = args.index("-days")
assert args[idx + 1] == "730"
cert_dir = tmp_path / "acme" / "test.local"
if (cert_dir / "fullchain.cer").is_file():
assert cert_dir.is_dir()
certs_dir = tmp_path / "data" / "certs"
if (certs_dir / "test.local.crt").is_file():
assert certs_dir.is_dir()
def test_generate_creates_directory(self, tmp_path):
with patch("daemon.handlers.acme._ACME_HOME", tmp_path / "acme"):
with patch("daemon.handlers.acme.PROJECT_DIR", tmp_path):
generate_self_signed(None, {"domain": "test.local"})
assert (tmp_path / "acme" / "test.local").is_dir()
assert (tmp_path / "data" / "certs").is_dir()
def test_generate_requires_domain(self):
with pytest.raises(ValueError, match="domain"):
+2 -2
View File
@@ -6,8 +6,8 @@ import ZonesPage from '/static/pages/zones.js?v=9';
import RulesPage from '/static/pages/rules.js?v=9';
import NatPage from '/static/pages/nat.js?v=9';
import DhcpPage from '/static/pages/dhcp.js?v=9';
import ProxyPage from '/static/pages/proxy.js?v=9';
import BackendsPage from '/static/pages/backends.js?v=9';
import ProxyPage from '/static/pages/proxy.js?v=10';
import BackendsPage from '/static/pages/backends.js?v=10';
import CertsPage from '/static/pages/certs.js?v=9';
import WireguardPage from '/static/pages/wireguard.js?v=9';
import LogsPage from '/static/pages/logs.js?v=9';
+59 -21
View File
@@ -3,10 +3,12 @@
*
* JSON-friendly fetch wrapper with automatic header management.
* Toast notification system with auto-dismiss.
* Modal processing guard for async form submissions.
*/
import { modelFetch } from './model.js?v=8';
import { requestUpdate } from './reactivity.js?v=8';
import { modelFetch } from './model.js?v=9';
import { requestUpdate } from './reactivity.js?v=9';
import { isModalProcessing, setModalProcessing, refreshModals } from './components/modal.js?v=9';
/**
* JSON-friendly fetch wrapper.
@@ -178,6 +180,33 @@ export async function poll(opts) {
}, interval);
}
/**
* Centralized handler wrapper that encapsulates processing guard,
* processing state, error handling, and modal re-render.
*
* Used by any handler not using `apiSubmit`. The async function receives
* no arguments and should perform validation (via `throw`), API calls,
* success/error toasting, modal closing, and data refreshing.
*
* @param {function} fn Async handler function
* @returns {function} Wrapped handler
*/
export function formAction(fn) {
return async () => {
if (isModalProcessing()) return;
setModalProcessing(true);
refreshModals();
try {
await fn();
} catch (e) {
toast(e.message || 'Failed', 'error');
} finally {
setModalProcessing(false);
refreshModals();
}
};
}
/**
* Generate action button descriptors for modal form submission.
*
@@ -211,28 +240,37 @@ export function apiSubmit(opts) {
label: submitText,
cls: 'btn-primary',
action: 's',
processing: true,
handler: async () => {
const b = body ? body() : {};
if (validate) {
const err = validate(b);
if (err) { toast(err, 'error'); return; }
}
const res = await apiFetch(url, { method, body: b });
if (res.ok) {
const synced = res.data?.synced;
let msg = successMsg;
if (synced && synced.length) {
msg += ' (auto-synced: ' + synced.join(', ') + ')';
synced.forEach(s => modelFetch(s));
if (isModalProcessing()) return;
setModalProcessing(true);
refreshModals();
try {
const b = body ? body() : {};
if (validate) {
const err = validate(b);
if (err) { toast(err, 'error'); return; }
}
toast(msg, 'success');
if (closeModal) closeModal();
if (refresh) {
const models = Array.isArray(refresh) ? refresh : [refresh];
await Promise.all(models.map(m => modelFetch(m)));
const res = await apiFetch(url, { method, body: b });
if (res.ok) {
const synced = res.data?.synced;
let msg = successMsg;
if (synced && synced.length) {
msg += ' (auto-synced: ' + synced.join(', ') + ')';
synced.forEach(s => modelFetch(s));
}
toast(msg, 'success');
if (closeModal) closeModal();
if (refresh) {
const models = Array.isArray(refresh) ? refresh : [refresh];
await Promise.all(models.map(m => modelFetch(m)));
}
} else {
toast(res.error || 'Failed', 'error');
}
} else {
toast(res.error || 'Failed', 'error');
} finally {
setModalProcessing(false);
refreshModals();
}
},
},
+3 -3
View File
@@ -15,9 +15,9 @@
* });
*/
import { reactive } from './reactivity.js?v=8';
import { h } from './vdom.js?v=8';
import { _compExpandedCache } from './render.js?v=8';
import { reactive } from './reactivity.js?v=9';
import { h } from './vdom.js?v=9';
import { _compExpandedCache } from './render.js?v=9';
/** Registry of mounted components: key → { state } */
const _mounted = new Map();
+22 -15
View File
@@ -6,12 +6,12 @@
* expandable modal, then applies all via /api/status/apply-all.
*/
import { h } from '../vdom.js?v=8';
import { html } from '../html.js?v=8';
import { reactive } from '../reactivity.js?v=8';
import { apiFetch, toast } from '../api.js?v=8';
import { modelFetch } from '../model.js?v=8';
import { openModal, closeModal, modalVNodes } from './modal.js?v=8';
import { h } from '../vdom.js?v=9';
import { html } from '../html.js?v=9';
import { reactive } from '../reactivity.js?v=9';
import { apiFetch, toast } from '../api.js?v=9';
import { modelFetch } from '../model.js?v=9';
import { openModal, closeModal, modalVNodes, isModalProcessing, setModalProcessing, refreshModals } from './modal.js?v=9';
export const SUBSYSTEM_LIST = [
{ key: 'firewall', label: 'Firewall' },
@@ -59,16 +59,23 @@ ${hasPending ? html`<span class="apply-expand-icon${isExpanded ? ' expanded' : '
* POST apply-all, toast result, close modal, refresh models.
*/
async function doApply(successMsg, refreshTargets) {
const resp = await apiFetch('/api/status/apply-all', { method: 'POST' });
if (resp.ok) {
toast(successMsg, 'success');
closeModal();
if (refreshTargets) {
const names = Array.isArray(refreshTargets) ? refreshTargets : [refreshTargets];
names.forEach(n => modelFetch(n));
if (isModalProcessing()) return;
setModalProcessing(true);
try {
const resp = await apiFetch('/api/status/apply-all', { method: 'POST' });
if (resp.ok) {
toast(successMsg, 'success');
closeModal();
if (refreshTargets) {
const names = Array.isArray(refreshTargets) ? refreshTargets : [refreshTargets];
names.forEach(n => modelFetch(n));
}
} else {
toast(resp.error || 'Apply failed', 'error');
}
} else {
toast(resp.error || 'Apply failed', 'error');
} finally {
setModalProcessing(false);
refreshModals();
}
}
+87 -40
View File
@@ -4,10 +4,16 @@
* Data display components: Badge, StatusDot, Empty, Card.
*/
import { h } from '../vdom.js?v=8';
import { esc } from '../helpers.js?v=8';
import { apiFetch, toast } from '../api.js?v=8';
import { modelFetch } from '../model.js?v=8';
import { h } from '../vdom.js?v=9';
import { esc } from '../helpers.js?v=9';
import { apiFetch, toast } from '../api.js?v=9';
import { modelFetch } from '../model.js?v=9';
import { requestUpdate } from '../reactivity.js?v=9';
const _actionPending = new Map();
const _confirmPending = new Map();
export const _deleting = new Set();
/**
* Colored badge/span.
@@ -75,30 +81,59 @@ export function Card(props = {}) {
* @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
* @param {string} [props.label] - Button text (default: 'Remove')
* @param {object} [props.body] - Optional JSON body to send with DELETE
* @param {string} [props.deleteKey] - Unique ID for pending-delete row styling
*/
export function ConfirmDelete(props = {}) {
const opts = { method: 'DELETE' };
if (props.body) opts.body = props.body;
return h('button', { class: 'btn btn-sm btn-danger',
const deleteKey = props.url + (props.body ? '::' + JSON.stringify(props.body) : '');
const pending = _confirmPending.get(deleteKey) || false;
return h('button', {
class: 'btn btn-sm btn-danger',
disabled: pending,
'on:click': async () => {
if (!confirm(props.message)) return;
const r = await apiFetch(props.url, opts);
if (r.ok) {
const synced = r.data?.synced;
let msg = props.success || 'Removed';
if (synced && synced.length) {
msg += ' (auto-synced: ' + synced.join(', ') + ')';
synced.forEach(s => modelFetch(s));
_confirmPending.set(deleteKey, true);
requestUpdate();
try {
const r = await apiFetch(props.url, opts);
if (r.ok) {
const synced = r.data?.synced;
let msg = props.success || 'Removed';
if (synced && synced.length) {
msg += ' (auto-synced: ' + synced.join(', ') + ')';
synced.forEach(s => modelFetch(s));
}
toast(msg, 'success');
if (props.deleteKey) {
_deleting.add(props.deleteKey);
}
if (props.refresh) {
const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh];
const promises = names.map(n => modelFetch(n));
if (props.deleteKey && promises.length) {
Promise.all(promises).finally(() => {
_deleting.delete(props.deleteKey);
});
}
} else if (props.deleteKey) {
// Cleanup dimming synchronously on DELETE completion rather than
// relying on a heuristic timeout that breaks when tabs are throttled.
_deleting.delete(props.deleteKey);
}
} else {
toast(r.error || 'Failed', 'error');
}
toast(msg, 'success');
if (props.refresh) {
const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh];
names.forEach(n => modelFetch(n));
}
} else {
toast(r.error || 'Failed', 'error');
} finally {
_confirmPending.delete(deleteKey);
requestUpdate();
}
}}, props.label || 'Remove');
}
}, pending ? h('span', { class: 'btn-spinner' }) : (props.label || 'Remove'));
}
/**
@@ -128,32 +163,42 @@ export function ActionButton(props = {}) {
? (props.condition ? props.labelOn : props.labelOff)
: 'Action');
const cls = props.cls || 'btn btn-outline';
const pending = _actionPending.get(props.url) || false;
return h('button', {
class: cls,
disabled: props.disabled,
disabled: !!props.disabled || pending,
'on:click': async () => {
const body = props.body ? props.body() : undefined;
const opts = { method: props.method || 'POST' };
if (body !== undefined) opts.body = body;
const resp = await apiFetch(props.url, opts);
if (resp.ok) {
const synced = resp.data?.synced;
let msg = props.successMsg || '';
if (synced && synced.length) {
if (msg) msg += ' ';
msg += '(auto-synced: ' + synced.join(', ') + ')';
synced.forEach(s => modelFetch(s));
if (pending) return;
_actionPending.set(props.url, true);
requestUpdate();
try {
const body = props.body ? props.body() : undefined;
const opts = { method: props.method || 'POST' };
if (body !== undefined) opts.body = body;
const resp = await apiFetch(props.url, opts);
if (resp.ok) {
const synced = resp.data?.synced;
let msg = props.successMsg || '';
if (synced && synced.length) {
if (msg) msg += ' ';
msg += '(auto-synced: ' + synced.join(', ') + ')';
synced.forEach(s => modelFetch(s));
}
if (msg) toast(msg, 'success');
if (props.refresh) {
const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh];
names.forEach(n => modelFetch(n));
}
} else {
toast(resp.error || 'Failed', props.errorType || 'error');
}
if (msg) toast(msg, 'success');
if (props.refresh) {
const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh];
names.forEach(n => modelFetch(n));
}
} else {
toast(resp.error || 'Failed', props.errorType || 'error');
} finally {
_actionPending.delete(props.url);
requestUpdate();
}
}
}, label);
}, pending ? h('span', { class: 'btn-spinner' }) : label);
}
/**
@@ -279,6 +324,7 @@ export function ServiceStatus(props = {}) {
* @param {string} [props.removeLabel] - Delete button label (default: 'Remove')
* @param {object} [props.removeBody] - Optional JSON body to send with DELETE
* @param {string} [props.editCls] - Override classes for edit button (default: 'btn btn-sm btn-outline')
* @param {string} [props.deleteKey] - Unique ID forwarded to ConfirmDelete for pending-delete styling
*/
export function ActionCell(props = {}) {
return h('td', null,
@@ -294,6 +340,7 @@ export function ActionCell(props = {}) {
refresh: props.removeRefresh,
label: props.removeLabel || 'Remove',
body: props.removeBody,
deleteKey: props.deleteKey,
}),
);
}
+3 -3
View File
@@ -4,9 +4,9 @@
* Layout components: PageHeader, Tabs, SectionTitle, DataTableSection, ActionGroup.
*/
import { h } from '../vdom.js?v=8';
import { Table } from './data.js?v=8';
import { collectLoadingModels } from '../model.js?v=8';
import { h } from '../vdom.js?v=9';
import { Table } from './data.js?v=9';
import { collectLoadingModels } from '../model.js?v=9';
/**
* Page header with title, optional subtitle, and action buttons.
+67 -15
View File
@@ -6,10 +6,9 @@
* avoid fighting with the main render cycle.
*/
import { esc } from '../helpers.js?v=8';
import { att_esc } from '../helpers.js?v=8';
import { apiSubmit } from '../api.js?v=8';
import { createDom } from '../vdom.js?v=8';
import { esc, att_esc } from '../helpers.js?v=9';
import { apiSubmit } from '../api.js?v=9';
import { createDom } from '../vdom.js?v=9';
/**
* Render Hoover VNodes into a modal content element.
@@ -33,7 +32,13 @@ function _renderModals() {
_modalQueue.forEach((m, idx) => {
const wrap = document.createElement('div');
wrap.className = 'modal-overlay active';
wrap.onclick = (e) => { if (e.target === wrap) closeModal(idx); };
wrap.onclick = (e) => {
if (e.target === wrap) {
if (m._processing) return;
if (m._hasInputs && !confirm('Discard changes?')) return;
closeModal(idx);
}
};
const content = document.createElement('div');
content.className = 'modal';
content.onclick = (e) => e.stopPropagation();
@@ -55,12 +60,36 @@ function _renderModals() {
*/
export function openModal(content) {
const entry = typeof content === 'function'
? { renderFn: content, id: _modalQueue.length }
: { id: _modalQueue.length, renderFn: (inner) => modalVNodes(inner, content) };
? { renderFn: content, id: _modalQueue.length, _processing: false, _hasInputs: false }
: { id: _modalQueue.length, renderFn: (inner) => modalVNodes(inner, content), _processing: false, _hasInputs: false };
_modalQueue.push(entry);
_renderModals();
}
/**
* Check if the topmost modal (or specified index) has an active async operation.
*
* @param {number} [idx] Modal index (defaults to topmost)
* @returns {boolean}
*/
export function isModalProcessing(idx) {
if (idx === undefined) idx = _modalQueue.length - 1;
if (idx < 0 || idx >= _modalQueue.length) return false;
return _modalQueue[idx]._processing;
}
/**
* Set the processing flag on the topmost modal (or specified index).
*
* @param {boolean} flag Whether the modal is currently processing
* @param {number} [idx] Modal index (defaults to topmost)
*/
export function setModalProcessing(flag, idx) {
if (idx === undefined) idx = _modalQueue.length - 1;
if (idx < 0 || idx >= _modalQueue.length) return;
_modalQueue[idx]._processing = flag;
}
/**
* Close a modal by index. Closes the topmost modal if index is omitted.
*
@@ -135,16 +164,39 @@ export function formModal(inner, title, fields, actions) {
+ (f.type !== 'text' ? ' type="' + att_esc(f.type) + '"' : '')
+ (f.value !== undefined ? ' value="' + att_esc(f.value) + '"' : '')
+ (f.placeholder ? ' placeholder="' + att_esc(f.placeholder) + '"' : '')
+ (f.checked ? ' checked' : '')
+ '></' + tag + '></div>';
}).join('') + '</div><div class="modal-actions">'
+ actions.map(a =>
'<button class="btn ' + att_esc(a.cls) + '" data-action="' + att_esc(a.action) + '">' + esc(a.label) + '</button>',
).join('') + '</div>';
}).join('') + '</div><div class="modal-actions"></div>';
actions.forEach(a => {
const btn = inner.querySelector('[data-action="' + att_esc(a.action) + '"]');
if (btn) btn.addEventListener('click', a.handler);
});
// Mark modal as having editable inputs
const topEntry = _modalQueue[_modalQueue.length - 1];
if (topEntry) topEntry._hasInputs = true;
// Build action buttons with processing-aware rendering
const actionsBar = inner.querySelector('.modal-actions');
const actionBtns = [];
for (const a of actions) {
const actionId = 'am-' + a.action + '-' + (_modalQueue.length - 1);
const btn = document.createElement('button');
btn.className = 'btn ' + a.cls;
btn.id = actionId;
if (a.processing && isModalProcessing(_modalQueue.length - 1)) {
btn.disabled = true;
btn.innerHTML = '<span class="btn-spinner"></span>';
} else {
btn.appendChild(document.createTextNode(a.label));
}
// Store reference for later re-binding
actionBtns.push({ btn, action: a });
if (a.handler) {
const origHandler = a.handler;
btn.addEventListener('click', () => {
refreshModals();
origHandler();
});
}
actionsBar.appendChild(btn);
}
}
/**
+2 -2
View File
@@ -5,8 +5,8 @@
* Uses the toast/dismissToast state from api.js.
*/
import { h } from '../vdom.js?v=8';
import { _toasts, dismissToast } from '../api.js?v=8';
import { h } from '../vdom.js?v=9';
import { _toasts, dismissToast } from '../api.js?v=9';
/**
* Render all pending toast notifications.
+1 -1
View File
@@ -1,4 +1,4 @@
import htm from '../../vendor/htm.js';
import { htmAdapter } from './vdom.js?v=8';
import { htmAdapter } from './vdom.js?v=9';
export const html = htm.bind(htmAdapter);
+15 -15
View File
@@ -5,46 +5,46 @@
*/
/* ── Reactivity ──────────────────────────────────────────────── */
export { reactive, requestUpdate } from './reactivity.js?v=8';
export { reactive, requestUpdate } from './reactivity.js?v=9';
/* ── VDOM ────────────────────────────────────────────────────── */
export { h } from './vdom.js?v=8';
export { h } from './vdom.js?v=9';
/* ── HTM ──────────────────────────────────────────────────────── */
export { html } from './html.js?v=8';
export { html } from './html.js?v=9';
/* ── Render ──────────────────────────────────────────────────── */
export { render } from './render.js?v=8';
export { render } from './render.js?v=9';
/* ── Component ───────────────────────────────────────────────── */
export { definePage, hComp } from './component.js?v=8';
export { definePage, hComp } from './component.js?v=9';
/* ── Router ──────────────────────────────────────────────────── */
export { createRouter, Link } from './router.js?v=8';
export { createRouter, Link } from './router.js?v=9';
/* ── WebSocket ───────────────────────────────────────────────── */
export { connect, onMessage } from './websocket.js?v=8';
export { connect, onMessage } from './websocket.js?v=9';
/* ── API & Toast ─────────────────────────────────────────────── */
export { apiFetch, toast, dismissToast, apiSubmit, checkAbort, poll, refactorLoad } from './api.js?v=8';
export { apiFetch, toast, dismissToast, apiSubmit, checkAbort, poll, refactorLoad, formAction } from './api.js?v=9';
/* ── Model ───────────────────────────────────────────────────── */
export { modelRegister, getModel, modelFetch, collectLoadingModels } from './model.js?v=8';
export { modelRegister, getModel, modelFetch, collectLoadingModels } from './model.js?v=9';
/* ── Helpers ─────────────────────────────────────────────────── */
export { esc, att_esc, enc, $val, parseZones, downloadBlob } from './helpers.js?v=8';
export { esc, att_esc, enc, $val, parseZones, downloadBlob } from './helpers.js?v=9';
/* ── UI Components: Layout ───────────────────────────────────── */
export { PageHeader, renderGuard, renderGuardMulti, Tabs, SectionTitle, ActionGroup, DataTableSection } from './components/layout.js?v=8';
export { PageHeader, renderGuard, renderGuardMulti, Tabs, SectionTitle, ActionGroup, DataTableSection } from './components/layout.js?v=9';
/* ── UI Components: Data ─────────────────────────────────────── */
export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, certStatusBadge, serviceStatusBadge, StatCard, StatusText, ServiceStatus, ActionCell, MonoText, ZoneSelect } from './components/data.js?v=8';
export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, certStatusBadge, serviceStatusBadge, StatCard, StatusText, ServiceStatus, ActionCell, MonoText, ZoneSelect } from './components/data.js?v=9';
/* ── UI Components: Modal ────────────────────────────────────── */
export { openModal, closeModal, closeAllModals, modalVNodes, refreshModals, formModal, MultiSelectModal, QuickModal } from './components/modal.js?v=8';
export { openModal, closeModal, closeAllModals, modalVNodes, refreshModals, formModal, MultiSelectModal, QuickModal, isModalProcessing, setModalProcessing } from './components/modal.js?v=9';
/* ── UI Components: Apply ────────────────────────────────────── */
export { ApplyConfirm } from './components/applyconfirm.js?v=8';
export { ApplyConfirm } from './components/applyconfirm.js?v=9';
/* ── UI Components: Toast ────────────────────────────────────── */
export { ToastContainer } from './components/toast.js?v=8';
export { ToastContainer } from './components/toast.js?v=9';
+1 -1
View File
@@ -13,7 +13,7 @@
* collectLoadingModels(...models) — combine loading/refreshing/error
*/
import { reactive } from './reactivity.js?v=8';
import { reactive } from './reactivity.js?v=9';
/** Registered models: name → { model, subsystem, fetch } */
const _models = new Map();
+3 -3
View File
@@ -5,12 +5,12 @@
* batched re-render loop integration with reactivity.js.
*/
import { requestUpdate, setCommitFn } from './reactivity.js?v=8';
import { requestUpdate, setCommitFn } from './reactivity.js?v=9';
import {
_vnodeDom, createDom, getDom, patchNode, sweepDom,
setMountFn, setUnmountFn,
} from './vdom.js?v=8';
import { mountComponent, unmountComponent } from './component.js?v=8';
} from './vdom.js?v=9';
import { mountComponent, unmountComponent } from './component.js?v=9';
/** Container → previous root vnodes */
export const _renderSlots = new Map();
+2 -2
View File
@@ -5,8 +5,8 @@
* navigation). Link component for client-side navigation.
*/
import { reactive } from './reactivity.js?v=8';
import { h } from './vdom.js?v=8';
import { reactive } from './reactivity.js?v=9';
import { h } from './vdom.js?v=9';
/**
* Hash-based router.
+1 -1
View File
@@ -6,7 +6,7 @@
* Page-level subscribe/unsubscribe is replaced by the model layer.
*/
import { refreshByTopic } from './model.js?v=8';
import { refreshByTopic } from './model.js?v=9';
let _wsConn = null;
let _wsReconnectMs = 0;
+147 -44
View File
@@ -1,15 +1,77 @@
import { html, PageHeader, Badge, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch } from '/static/hoover/index.js?v=8';
import { openModal, closeModal } from '/static/hoover/components/modal.js?v=8';
import { h, html, PageHeader, Badge, Empty, Table, renderGuard, renderGuardMulti, ConfirmDelete, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup } from '/static/hoover/index.js?v=9';
import { openModal, closeModal, isModalProcessing, setModalProcessing, refreshModals } from '/static/hoover/components/modal.js?v=9';
import { _deleting } from '/static/hoover/components/data.js?v=9';
// ---------------------------------------------------------------------------
// Build a datalist element from DHCP leases
// ---------------------------------------------------------------------------
function _buildHostDatalist(leases, uniqueId) {
const datalist = document.createElement('datalist');
datalist.id = 'hosts-' + uniqueId;
for (const lease of leases) {
const option = document.createElement('option');
option.value = lease.ip;
if (lease.hostname) {
option.setAttribute('label', lease.hostname);
const displayIp = esc(lease.ip);
const displayHost = esc(lease.hostname);
option.appendChild(document.createTextNode(displayIp + ' (' + displayHost + ')'));
} else {
option.appendChild(document.createTextNode(esc(lease.ip)));
}
datalist.appendChild(option);
}
return datalist;
}
// ---------------------------------------------------------------------------
// Inject discovered hosts hint section into modal
// ---------------------------------------------------------------------------
function _injectDiscoveredHosts(modalContent, leases, datalistId) {
const hostsHint = document.createElement('div');
hostsHint.className = 'form-group';
const details = document.createElement('details');
const summary = document.createElement('summary');
summary.textContent = leases.length + ' host' + (leases.length !== 1 ? 's' : '') + ' discovered';
details.appendChild(summary);
for (const lease of leases) {
const item = document.createElement('div');
item.style.cssText = 'cursor:pointer;padding:2px 4px;margin:2px 0;border-radius:4px;font-size:0.875rem;';
const hostname = lease.hostname ? lease.hostname + ' → ' : '';
item.textContent = hostname + lease.ip;
item.style.color = '#0d6efd';
item.addEventListener('click', () => {
const hostInput = document.querySelector('#' + datalistId + '-host-input');
if (hostInput) {
hostInput.value = lease.ip;
}
details.removeAttribute('open');
});
item.addEventListener('mouseenter', () => { item.style.background = '#e9ecef'; });
item.addEventListener('mouseleave', () => { item.style.background = ''; });
details.appendChild(item);
}
hostsHint.appendChild(details);
const pathsGroup = modalContent.querySelector('#paths-' + datalistId.split('-')[1])?.parentElement;
if (pathsGroup) {
pathsGroup.parentElement.insertBefore(hostsHint, pathsGroup);
}
}
// ---------------------------------------------------------------------------
// Add a path row element to the paths container
// ---------------------------------------------------------------------------
function _addPathRow(container, data) {
function _addPathRow(container, data, datalistId, isFirst) {
const row = document.createElement('div');
row.className = 'path-row-row';
const hostListAttr = datalistId ? ' list="' + datalistId + '"' : '';
const hostInputId = isFirst ? ' id="' + datalistId + '-host-input"' : '';
row.innerHTML = `
<input class="form-input path-field" type="text" placeholder="Path" value="${data ? esc(data.path) : '/'}" />
<input class="form-input path-field" type="text" placeholder="Host" value="${data ? esc((data.backend || {}).host || '') : ''}" />
<input class="form-input path-field"${hostInputId + hostListAttr} type="text" placeholder="Host" value="${data ? esc((data.backend || {}).host || '') : ''}" />
<button type="button" class="btn btn-sm btn-outline host-picker" title="Pick host from list">?</button>
<input class="form-input path-field" type="number" placeholder="Port" value="${data ? (data.backend || {}).port || '' : ''}" />
<select class="form-select path-field">
<option value="http"${(data && (data.backend || {}).proto === 'http') || !data ? ' selected' : ''}>http</option>
@@ -26,6 +88,17 @@ function _addPathRow(container, data) {
toast('At least one path required', 'warning');
}
});
if (datalistId) {
const picker = row.querySelector('.host-picker');
const hostInput = row.querySelector('input[placeholder="Host"]');
picker.addEventListener('click', () => {
if (hostInput.showPicker) {
hostInput.showPicker();
} else {
hostInput.focus();
}
});
}
container.appendChild(row);
return row;
}
@@ -37,11 +110,14 @@ function _collectPaths(container) {
const result = {};
const seen = new Set();
container.querySelectorAll('.path-row-row').forEach(row => {
const inputs = row.querySelectorAll('.path-field');
const path = (inputs[0].value || '').trim() || '/';
const host = (inputs[1].value || '').trim();
const port = parseInt(inputs[2].value);
const proto = inputs[3].value;
const pathField = row.querySelector('input[placeholder="Path"]');
const hostField = row.querySelector('input[placeholder="Host"]');
const portField = row.querySelector('input[placeholder="Port"]');
const protoSelect = row.querySelector('select');
const path = (pathField?.value || '').trim() || '/';
const host = (hostField?.value || '').trim();
const port = parseInt(portField?.value || '');
const proto = protoSelect?.value || 'http';
if (!host || !port) return;
if (seen.has(path)) { toast('Duplicate path ' + path, 'warning'); return; }
seen.add(path);
@@ -56,12 +132,16 @@ function _collectPaths(container) {
// ---------------------------------------------------------------------------
// Open backend form modal
// ---------------------------------------------------------------------------
function openBackendModal(state, backend) {
export function openBackendModal(state, backend) {
const isEdit = !!backend;
const title = isEdit ? ('Edit Backend: ' + esc(backend.name)) : 'Add Backend';
// Extract DHCP leases
const leases = state.dnsmasq?.data?.leases || [];
openModal((modalContent) => {
const uniqueId = Date.now();
const datalistId = 'hosts-' + uniqueId;
const pathsId = 'paths-' + uniqueId;
// Build form HTML
@@ -96,16 +176,24 @@ function openBackendModal(state, backend) {
</div>
`;
// Inject datalist
if (leases.length > 0) {
const datalist = _buildHostDatalist(leases, uniqueId);
modalContent.appendChild(datalist);
_injectDiscoveredHosts(modalContent, leases, datalistId);
}
// Add paths container refs
const pathsContainer = modalContent.querySelector('#' + pathsId);
// Add initial path rows
if (isEdit && backend.data.paths) {
Object.entries(backend.data.paths).forEach(([path, cfg]) => {
_addPathRow(pathsContainer, { path, ...cfg });
const entries = Object.entries(backend.data.paths);
entries.forEach(([path, cfg], i) => {
_addPathRow(pathsContainer, { path, ...cfg }, datalistId, i === 0);
});
} else {
_addPathRow(pathsContainer, null);
_addPathRow(pathsContainer, null, datalistId, true);
}
// "Add path" button
@@ -114,7 +202,7 @@ function openBackendModal(state, backend) {
addPathBtn.className = 'btn btn-sm btn-outline';
addPathBtn.style.marginTop = '8px';
addPathBtn.textContent = '+ Add Path';
addPathBtn.addEventListener('click', () => _addPathRow(pathsContainer, null));
addPathBtn.addEventListener('click', () => _addPathRow(pathsContainer, null, datalistId, false));
pathsContainer.parentNode.querySelector('.form-label')
.parentElement.insertBefore(addPathBtn, pathsContainer.nextSibling);
@@ -123,35 +211,42 @@ function openBackendModal(state, backend) {
// Submit button
modalContent.querySelector('#submit-' + uniqueId).addEventListener('click', async () => {
const nameInput = modalContent.querySelector('#name-' + uniqueId);
const labelInput = modalContent.querySelector('#label-' + uniqueId);
const authSelect = modalContent.querySelector('#auth-' + uniqueId);
if (isModalProcessing()) return;
setModalProcessing(true);
try {
const nameInput = modalContent.querySelector('#name-' + uniqueId);
const labelInput = modalContent.querySelector('#label-' + uniqueId);
const authSelect = modalContent.querySelector('#auth-' + uniqueId);
const name = (nameInput.value || '').trim();
const label = (labelInput.value || '').trim();
const authType = authSelect.value;
const paths = _collectPaths(pathsContainer);
const name = (nameInput.value || '').trim();
const label = (labelInput.value || '').trim();
const authType = authSelect.value;
const paths = _collectPaths(pathsContainer);
if (!name) { toast('Name is required', 'error'); return; }
if (!label) { toast('Label is required', 'error'); return; }
if (!Object.keys(paths).length) { toast('At least one valid path is required', 'error'); return; }
if (!name) { toast('Name is required', 'error'); return; }
if (!label) { toast('Label is required', 'error'); return; }
if (!Object.keys(paths).length) { toast('Please fill in Host and Port for at least one path', 'error'); return; }
const body = { name, label, paths };
if (authType === 'htpasswd') {
body.auth = { user: 'admin', htpasswd: 'data/nginx/.htpasswd' };
} else {
body.auth = null;
}
const body = { name, label, paths };
if (authType === 'htpasswd') {
body.auth = { user: 'admin', htpasswd: 'data/nginx/.htpasswd' };
} else {
body.auth = null;
}
const method = isEdit ? 'PATCH' : 'POST';
const res = await apiFetch('/api/proxy/backends', { method, body });
if (res.ok) {
toast(isEdit ? 'Backend updated' : 'Backend added', 'success');
closeModal();
await modelFetch('backends');
modelFetch('nginx');
} else {
toast(res.error || 'Failed', 'error');
const method = isEdit ? 'PATCH' : 'POST';
const res = await apiFetch('/api/proxy/backends', { method, body });
if (res.ok) {
toast(isEdit ? 'Backend updated' : 'Backend added', 'success');
closeModal();
await modelFetch('backends');
modelFetch('nginx');
} else {
toast(res.error || 'Failed', 'error');
}
} finally {
setModalProcessing(false);
refreshModals();
}
});
});
@@ -164,17 +259,18 @@ export default definePage({
init() {
return {
backends: getModel('backends'),
dnsmasq: getModel('dnsmasq'),
};
},
render(state) {
const guard = renderGuard(state.backends, 'Backends', 'Reusable proxy backend templates', state.backends.data);
const guard = renderGuardMulti('Backends', 'Reusable proxy backend templates', state.backends);
if (guard) return guard;
const backends = state.backends.data || {};
const entries = Object.entries(backends);
const rows = entries.map(([name, b]) =>
html`<tr key=${name}>
html`<tr key=${name} class=${_deleting.has(name) ? 'pending-delete' : ''}>
<td><strong>${esc(name)}</strong></td>
<td>${esc(b.label || name)}</td>
<td>${Object.keys(b.paths || {}).length}</td>
@@ -191,6 +287,7 @@ export default definePage({
? ''
: html`<${ConfirmDelete}
url=${'/api/proxy/backends/' + enc(name)}
deleteKey=${name}
message=${'Remove backend ' + enc(name) + '?'}
success="Backend removed"
refresh=["backends", "nginx"]
@@ -200,9 +297,15 @@ export default definePage({
</tr>`
);
const actions = html`
<button class="btn btn-primary" onClick=${() => openBackendModal(state)}>Add Backend</button>
`;
const actions = ActionGroup(
h('button', { class: 'btn btn-primary', 'on:click': () => openBackendModal(state) }, 'Add Backend'),
ActionButton({
url: '/api/proxy/apply',
successMsg: 'Nginx applied & reloaded',
label: 'Apply',
refresh: ['backends', 'nginx'],
}),
);
return [
PageHeader({ title: 'Backends', subtitle: 'Reusable proxy backend templates', actions }),
+67 -43
View File
@@ -1,4 +1,5 @@
import { html, PageHeader, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, modalVNodes, refreshModals, definePage, getModel, modelFetch, ActionCell, certStatusBadge, poll } from '/static/hoover/index.js?v=8';
import { html, PageHeader, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, modalVNodes, refreshModals, definePage, getModel, modelFetch, ActionCell, certStatusBadge, poll, formAction } from '/static/hoover/index.js?v=9';
import { isModalProcessing, setModalProcessing } from '/static/hoover/components/modal.js?v=9';
function _accountCard(account) {
if (!account || !account.registered) {
@@ -44,22 +45,19 @@ function registerAccountModal() {
label: 'Register',
cls: 'btn-primary',
action: 'r',
handler: async () => {
handler: formAction(async () => {
const email = ($val('reg-email') || '').trim();
if (!email) { toast('Email is required', 'error'); return; }
if (!email) throw 'Email is required';
const server = document.getElementById('reg-server')?.value || 'letsencrypt';
const resp = await apiFetch('/api/certs/account/register', {
method: 'POST',
body: { email, server },
});
if (resp.ok) {
toast('ACME account registered', 'success');
closeModal();
modelFetch('acme');
} else {
toast(resp.error || 'Registration failed', 'error');
}
},
if (!resp.ok) throw resp.error || 'Registration failed';
toast('ACME account registered', 'success');
closeModal();
modelFetch('acme');
}),
},
],
);
@@ -95,30 +93,44 @@ function settingsModal(account) {
});
inner.querySelector('[data-action="set-save"]')?.addEventListener('click', async () => {
const email = ($val('set-email') || '').trim();
if (!email) { toast('Email is required', 'error'); return; }
const resp = await apiFetch('/api/certs/email', {
method: 'POST',
body: { email },
});
if (resp.ok) {
toast('Email updated', 'success');
closeModal(idx);
modelFetch('acme');
} else {
toast(resp.error || 'Failed', 'error');
if (isModalProcessing()) return;
setModalProcessing(true);
try {
const email = ($val('set-email') || '').trim();
if (!email) { toast('Email is required', 'error'); return; }
const resp = await apiFetch('/api/certs/email', {
method: 'POST',
body: { email },
});
if (resp.ok) {
toast('Email updated', 'success');
closeModal(idx);
modelFetch('acme');
} else {
toast(resp.error || 'Failed', 'error');
}
} finally {
setModalProcessing(false);
refreshModals();
}
});
inner.querySelector('[data-action="set-deactivate"]')?.addEventListener('click', async () => {
if (!confirm('Deactivate ACME account? You will need to register again to issue certificates.')) return;
const resp = await apiFetch('/api/certs/account', { method: 'DELETE' });
if (resp.ok) {
toast('Account deactivated', 'success');
closeModal(idx);
modelFetch('acme');
} else {
toast(resp.error || 'Failed', 'error');
if (isModalProcessing()) return;
setModalProcessing(true);
try {
const resp = await apiFetch('/api/certs/account', { method: 'DELETE' });
if (resp.ok) {
toast('Account deactivated', 'success');
closeModal(idx);
modelFetch('acme');
} else {
toast(resp.error || 'Failed', 'error');
}
} finally {
setModalProcessing(false);
refreshModals();
}
});
});
@@ -215,9 +227,11 @@ function _bindIssueButtons(inner, modalIdx) {
inner.querySelector('[data-action="ic-validate"]')?.addEventListener('click', async () => {
if (_currentIssueState.validating) return;
if (isModalProcessing()) return;
const s = _currentIssueState;
const domain = ($val('ic-domain') || '').trim();
if (!domain) { toast('Domain is required', 'error'); return; }
setModalProcessing(true);
s.validating = true;
s.domain = domain;
try {
@@ -229,24 +243,33 @@ function _bindIssueButtons(inner, modalIdx) {
refreshModals();
} finally {
s.validating = false;
setModalProcessing(false);
refreshModals();
}
});
inner.querySelector('[data-action="ic-issue"]')?.addEventListener('click', async () => {
const body = { domain: _currentIssueState.domain };
const issueResp = await apiFetch('/api/certs/issue/start', { method: 'POST', body });
if (issueResp.ok) {
const status = issueResp.data?.status;
if (status === 'existing') {
toast('Issuance already in progress for ' + _currentIssueState.domain, 'warning');
if (isModalProcessing()) return;
setModalProcessing(true);
try {
const body = { domain: _currentIssueState.domain };
const issueResp = await apiFetch('/api/certs/issue/start', { method: 'POST', body });
if (issueResp.ok) {
const status = issueResp.data?.status;
if (status === 'existing') {
toast('Issuance already in progress for ' + _currentIssueState.domain, 'warning');
} else {
toast('Issuance started for ' + _currentIssueState.domain, 'success');
}
closeModal(modalIdx);
const rid = issueResp.data?.request_id;
if (rid) pollCertIssue(rid);
} else {
toast('Issuance started for ' + _currentIssueState.domain, 'success');
toast(issueResp.error || 'Failed', 'error');
}
closeModal(modalIdx);
const rid = issueResp.data?.request_id;
if (rid) pollCertIssue(rid);
} else {
toast(issueResp.error || 'Failed', 'error');
} finally {
setModalProcessing(false);
refreshModals();
}
});
}
@@ -295,7 +318,8 @@ export default definePage({
removeUrl=${'/api/certs/' + enc(c.domain)}
removeMessage=${'Remove certificate for ' + c.domain + '?'}
removeSuccess="Certificate removed"
removeRefresh="acme" />
removeRefresh="acme"
deleteKey=${c.domain} />
</tr>`;
});
+1 -1
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, definePage, getModel, renderGuard, ServiceStatus, StatCard } from '/static/hoover/index.js?v=8';
import { html, PageHeader, definePage, getModel, renderGuard, ServiceStatus, StatCard } from '/static/hoover/index.js?v=9';
export default definePage({
init() {
+4 -1
View File
@@ -1,4 +1,4 @@
import { html, h, PageHeader, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup, QuickModal } from '/static/hoover/index.js?v=8';
import { html, h, PageHeader, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup, QuickModal } from '/static/hoover/index.js?v=9';
function makeAddRange(activeZones, interfaces) {
const opts = [
@@ -137,6 +137,7 @@ export default definePage({
<td>
<${ConfirmDelete}
url="/api/dhcp/ranges"
deleteKey=${(r.interface || '_g') + '-' + r.start + '-' + r.end}
message=${'Remove range ' + r.start + ' - ' + r.end + '?'}
body=${{ interface: r.interface || '', start: r.start, end: r.end }}
success="Range removed"
@@ -151,6 +152,7 @@ export default definePage({
<td>
<${ConfirmDelete}
url=${'/api/dhcp/static-lease/' + enc(l.mac)}
deleteKey=${l.mac}
message=${'Remove lease ' + l.mac + '?'}
success="Lease removed"
refresh="dnsmasq" />
@@ -163,6 +165,7 @@ export default definePage({
<td>
<${ConfirmDelete}
url=${'/api/dhcp/dns-record/' + enc(rec.name || '')}
deleteKey=${rec.name || 'unnamed'}
message=${'Remove DNS record ' + (rec.name || 'unnamed') + '?'}
success="Record removed"
refresh="dnsmasq" />
+1 -1
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js?v=8';
import { html, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js?v=9';
async function changeZone(name, zone, state) {
const r = await apiFetch('/api/firewall/zones/' + enc(zone) + '/interfaces', {
+1 -1
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, Tabs, esc, definePage, renderGuard, modelFetch, getModel } from '/static/hoover/index.js?v=8';
import { html, PageHeader, Tabs, esc, definePage, renderGuard, modelFetch, getModel } from '/static/hoover/index.js?v=9';
const logTabs = [
{ key: 'journal', label: 'Journal' },
+5 -2
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete } from '/static/hoover/index.js?v=8';
import { html, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete } from '/static/hoover/index.js?v=9';
const addFwd = QuickModal({
title: 'Add Port Forward',
@@ -60,7 +60,9 @@ export default definePage({
<td><${Badge} text=${iface.zone || '—'} variant="secondary" /></td>
</tr>`);
const masqRows = Object.entries(zoneData).map(([zone, zcfg]) => {
const masqRows = Object.entries(zoneData)
.filter(([zone]) => zone !== "public")
.map(([zone, zcfg]) => {
const masq = !!zcfg.masquerade;
return html`<tr key=${'m-' + zone}>
<td><strong>${zone}</strong></td>
@@ -92,6 +94,7 @@ export default definePage({
<td>
<${ConfirmDelete}
url=${'/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto)}
deleteKey=${zone + '/' + port + '/' + proto}
message=${'Remove forward ' + zone + ':' + port + '/' + proto + '?'}
success="Rule removed"
refresh="firewall" />
+1 -1
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, definePage } from '/static/hoover/index.js?v=8';
import { html, PageHeader, definePage } from '/static/hoover/index.js?v=9';
export default definePage({
init() {
+102 -120
View File
@@ -1,9 +1,7 @@
import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, certStatusBadge, ActionGroup } from '/static/hoover/index.js?v=8';
import { openModal, formModal, closeModal } from '/static/hoover/components/modal.js?v=8';
import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, certStatusBadge, ActionGroup, formAction } from '/static/hoover/index.js?v=9';
import { openModal, formModal, closeModal } from '/static/hoover/components/modal.js?v=9';
import { openBackendModal } from '/static/pages/backends.js?v=11';
// ---------------------------------------------------------------------------
// Cert lookup map from ACME state keyed by domain name
// ---------------------------------------------------------------------------
function certLookup(acmeData) {
const m = {};
if (acmeData && acmeData.certs) {
@@ -14,9 +12,6 @@ function certLookup(acmeData) {
return m;
}
// ---------------------------------------------------------------------------
// Build cert select options from ACME certs array
// ---------------------------------------------------------------------------
function buildCertOptions(certs) {
const opts = [
['', '(none)'],
@@ -25,26 +20,18 @@ function buildCertOptions(certs) {
['file', 'file — custom path'],
];
for (const c of (certs || [])) {
const days = c.expired
? 'Expired'
: `${c.days_remaining}d`;
const days = c.expired ? 'Expired' : `${c.days_remaining}d`;
opts.push([`acme|${c.domain}`, `acme: ${c.domain} (${days})`]);
}
return opts;
}
// ---------------------------------------------------------------------------
// Map cert select value to payload cert string
// ---------------------------------------------------------------------------
function certValueFromSelect(raw) {
if (raw === 'acme' || (raw && raw.startsWith('acme|'))) return 'acme';
if (raw === 'selfsigned' || raw === 'file') return raw;
return undefined;
}
// ---------------------------------------------------------------------------
// Build backend select options from backends model
// ---------------------------------------------------------------------------
function buildBackendOptions(backends) {
const opts = [['', '(select backend)']];
const entries = Object.entries(backends || {});
@@ -57,14 +44,44 @@ function buildBackendOptions(backends) {
return opts;
}
// ---------------------------------------------------------------------------
// Add Domain modal — backend selector
// ---------------------------------------------------------------------------
function addDomain(state) {
function _groupByBackend(domains, backends) {
const backendMap = {};
for (const d of domains) {
const bn = d.backend_name;
if (!backendMap[bn]) backendMap[bn] = {};
if (!backendMap[bn][d.domain]) backendMap[bn][d.domain] = [];
backendMap[bn][d.domain].push(d);
}
const backendNames = new Set(Object.keys(backends || {}));
for (const bn of Object.keys(backendMap)) {
backendNames.add(bn);
}
const sections = [];
for (const bn of backendNames) {
const b = (backends || {})[bn] || {};
const domainGroups = backendMap[bn] || {};
const sortedDomains = Object.entries(domainGroups)
.map(([domainName, paths]) => ({ domain: domainName, paths }))
.sort((a, b) => a.domain.localeCompare(b.domain));
sections.push({
backendName: bn,
backend: { name: bn, label: b.label || bn, builtin: !!b.builtin, paths_count: Object.keys(b.paths || {}).length, ...b },
domains: sortedDomains,
hasBuiltin: !!b.builtin,
});
}
sections.sort((a, b) => {
if (a.hasBuiltin && !b.hasBuiltin) return -1;
if (!a.hasBuiltin && b.hasBuiltin) return 1;
return a.backend.label.localeCompare(b.backend.label);
});
return sections;
}
function addDomain(state, preselectedBackend) {
const backends = state.backends ? (state.backends.data || {}) : {};
const certOptions = buildCertOptions(state.acme ? (state.acme.data.certs || []) : []);
const backendOptions = buildBackendOptions(backends);
openModal((inner) => {
formModal(inner, 'Add Proxy Domain', [
{ label: 'Domain', id: 'p-domain', placeholder: 'example.com' },
@@ -76,34 +93,26 @@ function addDomain(state) {
label: 'Add',
cls: 'btn-primary',
action: 's',
handler: async () => {
handler: formAction(async () => {
const domain = ($val('p-domain') || '').trim();
if (!domain) throw 'Domain is required';
const backend = ($val('p-backend') || '').trim();
const rawCert = $val('p-cert');
if (!domain) { toast('Domain is required', 'error'); return; }
if (!backend) { toast('Backend is required', 'error'); return; }
const body = {
domain,
backend,
force_ssl: true,
};
const certVal = certValueFromSelect(rawCert);
if (!backend) throw 'Backend is required';
const body = { domain, backend, force_ssl: true };
const certVal = certValueFromSelect($val('p-cert'));
if (certVal) body.cert = certVal;
const res = await apiFetch('/api/proxy/domains', { method: 'POST', body });
if (res.ok) {
toast('Domain added', 'success');
closeModal();
await Promise.all(['nginx', 'acme'].map(m => modelFetch(m)));
} else {
toast(res.error || 'Failed', 'error');
}
},
if (!res.ok) throw res.error || 'Failed';
toast('Domain added', 'success');
closeModal();
await Promise.all(['nginx', 'acme'].map(m => modelFetch(m)));
}),
},
]);
if (preselectedBackend) {
const backendSelect = inner.querySelector('#p-backend');
if (backendSelect) backendSelect.value = preselectedBackend;
}
const certSelect = inner.querySelector('#p-cert');
const domainInput = inner.querySelector('#p-domain');
if (certSelect && domainInput) {
@@ -117,14 +126,10 @@ function addDomain(state) {
});
}
// ---------------------------------------------------------------------------
// Edit Domain modal — cert and force_ssl only (backend is read-only)
// ---------------------------------------------------------------------------
function editDomain(d, state) {
const backends = state.backends ? (state.backends.data || {}) : {};
const backend = backends[d.backend_name] || {};
const certOptions = buildCertOptions(state.acme ? (state.acme.data.certs || []) : []);
const certMap = certLookup(state.acme ? state.acme.data : null);
const domainCert = certMap[d.domain];
let selectedCert = '';
@@ -133,8 +138,6 @@ function editDomain(d, state) {
} else if (d.cert) {
selectedCert = d.cert;
}
// Build path summary rows (read-only)
const paths = backend.paths || {};
const pathKeys = Object.keys(paths);
const pathSummary = pathKeys.map(p => {
@@ -142,7 +145,6 @@ function editDomain(d, state) {
const be = pcfg.backend || {};
return `${esc(p)}${esc(be.host || '-')}:${be.port || '-'}`;
}).join('\n') || '—';
openModal((inner) => {
formModal(inner, 'Edit: ' + esc(d.domain), [
{ label: 'Domain', id: 'pe-domain', value: d.domain },
@@ -156,59 +158,35 @@ function editDomain(d, state) {
label: 'Save',
cls: 'btn-primary',
action: 's',
handler: async () => {
handler: formAction(async () => {
const rawCert = $val('pe-cert');
if (!rawCert) throw 'Cert is required';
const forceSsl = document.getElementById('pe-force-ssl')?.checked ?? true;
const body = {};
body.cert = certValueFromSelect(rawCert);
body.force_ssl = forceSsl;
const body = { cert: certValueFromSelect(rawCert), force_ssl: forceSsl };
const res = await apiFetch('/api/proxy/domains/' + enc(d.domain), { method: 'PUT', body });
if (res.ok) {
toast('Domain updated', 'success');
closeModal();
await Promise.all(['nginx', 'acme'].map(m => modelFetch(m)));
} else {
toast(res.error || 'Failed', 'error');
}
},
if (!res.ok) throw res.error || 'Failed';
toast('Domain updated', 'success');
closeModal();
await Promise.all(['nginx', 'acme'].map(m => modelFetch(m)));
}),
},
]);
// Set cert select value
const certSelect = inner.querySelector('#pe-cert');
if (certSelect) certSelect.value = selectedCert;
// Make read-only fields actually read-only
const domainInput = inner.querySelector('#pe-domain');
if (domainInput) {
domainInput.readOnly = true;
domainInput.style.background = '#f5f5f5';
}
if (domainInput) { domainInput.readOnly = true; domainInput.style.background = '#f5f5f5'; }
const backendInput = inner.querySelector('#pe-backend');
if (backendInput) {
backendInput.readOnly = true;
backendInput.style.background = '#f5f5f5';
}
if (backendInput) { backendInput.readOnly = true; backendInput.style.background = '#f5f5f5'; }
});
}
// ---------------------------------------------------------------------------
// Row for a domain (grouped by domain name)
// ---------------------------------------------------------------------------
function domainRow(domainName, domainPaths, state) {
const d = domainPaths[0];
const certMap = certLookup(state.acme ? state.acme.data : null);
const backend = state.backends ? (state.backends.data || {})[d.backend_name] : {};
const cert = certMap[d.domain];
let certBadge, certTitle;
if (cert) {
certBadge = certStatusBadge({
daysRemaining: cert.days_remaining,
expired: cert.expired,
});
certBadge = certStatusBadge({ daysRemaining: cert.days_remaining, expired: cert.expired });
certTitle = 'ACME: ' + cert.domain;
} else if (d.cert === 'selfsigned') {
certBadge = Badge({ text: 'Self-signed', variant: 'warning' });
@@ -220,8 +198,6 @@ function domainRow(domainName, domainPaths, state) {
certBadge = Badge({ text: '—', variant: 'info' });
certTitle = 'No certificate';
}
// Build paths summary
const pathSummaries = domainPaths.map(p => {
const be = p.backend || {};
let parts = [esc(p.path), `${esc(be.host || '-')}:${be.port || '-'}`];
@@ -231,13 +207,8 @@ function domainRow(domainName, domainPaths, state) {
if (flags.length) parts.push(flags.join(', '));
return parts.join(' → ');
});
return html`<tr key=${domainName} class="domain-row">
<td><strong>${esc(domainName)}</strong></td>
<td>
<${Badge} text=${esc(d.backend_name || '-')} variant="primary" />
<span class="text-muted" style="margin-left:4px">${esc(backend.label || '')}</span>
</td>
<td>${pathSummaries}</td>
<td title=${certTitle}>${certBadge}</td>
<td>${d.force_ssl ? html`<${Badge} text="on" variant="success" />` : html`<${Badge} text="off" variant="secondary" />`}</td>
@@ -254,9 +225,37 @@ function domainRow(domainName, domainPaths, state) {
</tr>`;
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
function backendSection(section, state) {
const { backendName, backend, domains } = section;
const rows = domains.map(d => domainRow(d.domain, d.paths, state));
const sectionActions = [];
if (!backend.builtin) {
sectionActions.push(html`<button class="btn btn-sm btn-outline" onClick=${() => openBackendModal(state, { name: backendName, data: backend })}>Edit Backend</button>`);
if (domains.length === 0) {
sectionActions.push(html`<${ConfirmDelete}
url=${'/api/proxy/backends/' + enc(backendName)}
deleteKey=${backendName}
message=${'Remove backend ' + enc(backendName) + '?'}
success="Backend removed"
refresh=["backends", "nginx"]
label="Delete" />`);
}
}
sectionActions.push(html`<button class="btn btn-sm btn-primary" onClick=${() => addDomain(state, backendName)}>+ Add Domain → ${esc(backendName)}</button>`);
return html`<div class="backend-section" key=${backendName} style="margin-bottom:24px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid #dee2e6;">
<h3 style="margin:0;display:flex;align-items:center;gap:8px;">
<${Badge} text=${esc(backendName)} variant="primary" />
${esc(backend.label || backendName)}
</h3>
<div style="display:flex;gap:8px;">${sectionActions}</div>
</div>
${domains.length
? Table({ columns: ['Domain', 'Paths', 'Cert', 'Force SSL', 'Actions'], rows })
: Empty({ text: 'No domains using this backend.' })}
</div>`;
}
export default definePage({
init() {
return {
@@ -268,36 +267,19 @@ export default definePage({
render(state) {
const guard = renderGuardMulti('Proxy', 'Nginx reverse proxy', state.nginx, state.backends, state.acme);
if (guard) return guard;
const domains = state.nginx.data.domains || [];
// Group by domain name
const groups = {};
for (const d of domains) {
if (!groups[d.domain]) groups[d.domain] = [];
groups[d.domain].push(d);
}
const rows = Object.values(groups).map(paths => domainRow(paths[0].domain, paths, state));
const backends = state.backends.data || {};
const sections = _groupByBackend(domains, backends);
const sectionVNodes = sections.map(s => backendSection(s, state));
const actions = ActionGroup(
html`<button class="btn btn-primary" onClick=${() => addDomain(state)}>Add Domain</button>`,
ActionButton({
url: '/api/proxy/apply',
successMsg: 'Nginx applied & reloaded',
label: 'Apply',
refresh: ['nginx', 'acme'],
}),
ActionButton({ url: '/api/proxy/apply', successMsg: 'Nginx applied & reloaded', label: 'Apply', refresh: ['nginx', 'acme'] }),
);
return [
PageHeader({ title: 'Proxy', subtitle: 'Nginx reverse proxy', actions }),
domains.length
? Table({
columns: ['Domain', 'Backend', 'Paths', 'Cert', 'Force SSL', 'Actions'],
rows,
})
: Empty({ text: 'No proxy domains configured. Add a domain to start terminating SSL.' }),
Object.keys(backends).length || domains.length
? sectionVNodes
: Empty({ text: 'No backends configured. Create a backend first, then add domains.' }),
];
},
});
+2 -1
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, MonoText, QuickModal } from '/static/hoover/index.js?v=8';
import { html, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, MonoText, QuickModal } from '/static/hoover/index.js?v=9';
const addRule = QuickModal({
title: 'Add Rich Rule',
@@ -44,6 +44,7 @@ export default definePage({
<td>
<${ConfirmDelete}
url=${'/api/firewall/rich-rules/' + enc(zone) + '/' + enc(ruleId || '')}
deleteKey=${zone + '-' + (ruleId || i)}
message=${'Remove rule: ' + ruleText.substring(0, 40) + '...?'}
success="Rule removed"
refresh="firewall" />
+13 -12
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionButton, ActionCell, MonoText, ActionGroup, QuickModal, downloadBlob } from '/static/hoover/index.js?v=8';
import { html, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionButton, ActionCell, MonoText, ActionGroup, QuickModal, downloadBlob, formAction } from '/static/hoover/index.js?v=9';
const addPeer = QuickModal({
title: 'Add WireGuard Peer',
@@ -29,21 +29,21 @@ function downloadConfigModal(peerName, config, state) {
[
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
{
label: 'Generate', cls: 'btn-primary', action: 's', handler: async () => {
label: 'Generate', cls: 'btn-primary', action: 's',
handler: formAction(async () => {
const endpoint = ($val('wg-srv-endpoint') || '').trim();
if (!endpoint) { toast('Server endpoint is required', 'error'); return; }
if (!endpoint) throw 'Server endpoint is required';
const resp = await apiFetch('/api/wireguard/generate-client', {
method: 'POST',
body: { name: peerName, server_endpoint: endpoint },
});
if (resp.ok && resp.data?.config) {
downloadBlob(new Blob([resp.data.config], { type: 'text/plain' }), peerName + '.conf');
toast('Config downloaded', 'success');
closeModal(idx);
} else {
toast(resp.error || 'Failed', 'error');
}
},
if (!resp.ok) throw resp.error || 'Failed';
const configContent = resp.data?.config;
if (!configContent) throw 'No config returned';
downloadBlob(new Blob([configContent], { type: 'text/plain' }), peerName + '.conf');
toast('Config downloaded', 'success');
closeModal(idx);
}),
},
],
);
@@ -84,7 +84,8 @@ export default definePage({
removeUrl=${'/api/wireguard/peers/' + enc(p.name)}
removeMessage=${'Remove peer ' + p.name + '?'}
removeSuccess="Peer removed"
removeRefresh="wireguard" />
removeRefresh="wireguard"
deleteKey=${p.name} />
</tr>`;
});
+2 -1
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, definePage, getModel, MultiSelectModal, QuickModal } from '/static/hoover/index.js?v=8';
import { html, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, definePage, getModel, MultiSelectModal, QuickModal } from '/static/hoover/index.js?v=9';
const addZone = QuickModal({
title: 'Add Zone',
@@ -81,6 +81,7 @@ export default definePage({
})()}>Services</button>
<${ConfirmDelete}
url=${'/api/firewall/zones/' + enc(name)}
deleteKey=${name}
message=${'Delete zone ' + name + '?'}
success=${'Zone ' + name + ' deleted'}
refresh="firewall"
+36
View File
@@ -699,6 +699,42 @@ body {
text-align: center;
}
/* Spinner animation for buttons during pending operations */
@keyframes btn-spin {
to { transform: rotate(360deg); }
}
.btn-spinner {
display: inline-block;
width: 1em;
height: 1em;
margin: 0 0.5em;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: btn-spin 0.5s linear infinite;
vertical-align: middle;
}
/* Pending deletion row indicator */
.pending-delete td {
opacity: 0.5;
}
.pending-delete td:last-child {
border-left: 3px solid var(--danger);
}
.pending-delete td:first-child {
border-left: 0;
}
/* Disabled button styling */
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* ApplyConfirm modal */
.apply-subsystem-row {
display: flex;