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") raise ValueError("'domain' is required")
days = body.get("days", 365) days = body.get("days", 365)
cert_dir = _ACME_HOME / domain certs_dir = PROJECT_DIR / "data" / "certs"
cert_dir.mkdir(parents=True, exist_ok=True) certs_dir.mkdir(parents=True, exist_ok=True)
cert_file = cert_dir / "fullchain.cer" cert_file = certs_dir / f"{domain}.crt"
key_file = cert_dir / f"{domain}.key" key_file = certs_dir / f"{domain}.key"
if cert_file.is_file() and key_file.is_file(): if cert_file.is_file() and key_file.is_file():
logger.info("Self-signed cert for %s already exists, skipping", domain) 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") enable = body.get("enable")
if not zone or enable is None: if not zone or enable is None:
raise ValueError("'zone' and 'enable' (bool) are required") 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" action = "--add-masquerade" if enable else "--remove-masquerade"
run(["firewall-cmd", f"--zone={zone}", action, "--permanent"], sudo=True) run(["firewall-cmd", f"--zone={zone}", action, "--permanent"], sudo=True)
_reload() _reload()
+87 -7
View File
@@ -2,6 +2,7 @@
import logging import logging
import os import os
import subprocess
from copy import deepcopy from copy import deepcopy
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -354,11 +355,54 @@ def _reload_nginx() -> None:
logger.info("nginx configuration applied and reloaded") 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: def _write_all_sites() -> None:
"""Regenerate all site configs and ACME challenge site.""" """Regenerate all site configs and ACME challenge site."""
ensure_dirs(SITES_DIR) ensure_dirs(SITES_DIR)
cfg = _get_config() cfg = _get_config()
backends = cfg.get("backends", {}) 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() existing = set(SITES_DIR.iterdir()) if SITES_DIR.exists() else set()
written: set[str] = set() written: set[str] = set()
for name, dom in cfg.get("domains", {}).items(): for name, dom in cfg.get("domains", {}).items():
@@ -396,13 +440,19 @@ def _hash_password(password: str) -> str:
return sha256_crypt.hash(password) return sha256_crypt.hash(password)
def _write_htpasswd(user: str, password: str) -> None: def _write_htpasswd(
"""Add or update a user entry in the .htpasswd file using SHA-256 hashing.""" user: str, password: str, htpasswd_path: Path | None = None
ensure_dirs(DATA_DIR) ) -> 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) hashed = _hash_password(password)
existing: dict[str, str] = {} existing: dict[str, str] = {}
if HTPASSWD_FILE.exists(): if target.exists():
with open(HTPASSWD_FILE) as f: with open(target) as f:
for line in f: for line in f:
line = line.strip() line = line.strip()
if not line or line.startswith("#"): if not line or line.startswith("#"):
@@ -411,12 +461,12 @@ def _write_htpasswd(user: str, password: str) -> None:
if len(parts) == 2: if len(parts) == 2:
existing[parts[0]] = line existing[parts[0]] = line
existing[user] = f"{user}:{hashed}" existing[user] = f"{user}:{hashed}"
tmp = HTPASSWD_FILE.with_suffix(".tmp") tmp = target.with_suffix(".tmp")
with open(tmp, "w") as f: with open(tmp, "w") as f:
for _uname, entry in existing.items(): for _uname, entry in existing.items():
f.write(entry + "\n") f.write(entry + "\n")
os.chmod(tmp, 0o640) os.chmod(tmp, 0o640)
os.replace(tmp, HTPASSWD_FILE) os.replace(tmp, target)
def _get_nginx_state() -> dict[str, Any]: def _get_nginx_state() -> dict[str, Any]:
@@ -502,6 +552,17 @@ def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
auth = body.get("auth") auth = body.get("auth")
if auth is not None: if auth is not None:
# 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 entry["auth"] = auth
cfg["domains"][domain] = entry cfg["domains"][domain] = entry
@@ -560,6 +621,25 @@ def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if "auth" in body: if "auth" in body:
if body["auth"] is None: if body["auth"] is None:
entry.pop("auth", None) entry.pop("auth", None)
else:
# 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: else:
entry["auth"] = body["auth"] entry["auth"] = body["auth"]
+5 -3
View File
@@ -803,7 +803,7 @@ Card container with optional header.
#### `ConfirmDelete(props)` #### `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 ```javascript
ConfirmDelete({ ConfirmDelete({
@@ -825,10 +825,11 @@ ConfirmDelete({
| `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` | | `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` |
| `label` | Button text (default: `'Remove'`) | | `label` | Button text (default: `'Remove'`) |
| `body` | Optional JSON body to send with DELETE | | `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)` #### `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 ```javascript
ActionButton({ ActionButton({
@@ -872,7 +873,7 @@ ActionButton({
#### `ActionCell(props)` #### `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 ```javascript
ActionCell({ ActionCell({
@@ -899,6 +900,7 @@ ActionCell({
| `removeLabel` | Delete button label (default: `'Remove'`) | | `removeLabel` | Delete button label (default: `'Remove'`) |
| `removeBody` | Optional JSON body to send with DELETE | | `removeBody` | Optional JSON body to send with DELETE |
| `editCls` | Override classes for edit button (default: `'btn btn-sm btn-outline'`) | | `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)` #### `certStatusBadge(props)`
+11 -16
View File
@@ -376,29 +376,24 @@ else
fi fi
} }
# 1A. Self-signed certificate # 1A. Self-signed certificate for management domain
_daemon_post "/acme/self-signed" "{\"domain\":\"$DOMAIN\"}" "Self-signed certificate" _daemon_post "/acme/self-signed" "{\"domain\":\"$DOMAIN\"}" "Self-signed certificate"
# 1C. Management proxy domain # 1C. Management proxy domain — try idempotent update first, fall back to add
_daemon_post "/nginx/domains/add" "$(jq -n \ local mgmt_json
mgmt_json="$(jq -n \
--arg domain "$DOMAIN" \ --arg domain "$DOMAIN" \
--arg user "$MGMT_USER" \ --arg user "$MGMT_USER" \
--arg pass "$MGMT_PASS" \ --arg pass "$MGMT_PASS" \
'{ '{
domain: $domain, domain: $domain,
paths: { backend: "webui",
"/": { cert: "selfsigned",
backend: {host: "127.0.0.1", port: 9090, proto: "http"}, force_ssl: true,
is_management: true auth: {user: $user, pass: $pass}
}, }')"
"/ws": { _daemon_post "/nginx/domains/update" "$mgmt_json" "Management domain configured" || \
backend: {host: "127.0.0.1", port: 9091, proto: "http"}, _daemon_post "/nginx/domains/add" "$mgmt_json" "Management domain configured"
is_websocket: true
}
},
auth_user: $user,
auth_pass: $pass
}')" "Management domain configured"
_daemon_post "/nginx/apply" "{}" "Nginx config applied" _daemon_post "/nginx/apply" "{}" "Nginx config applied"
+2 -2
View File
@@ -33,8 +33,8 @@ server {
ssl_certificate_key {{ certs_dir }}/{{ domain }}.key; ssl_certificate_key {{ certs_dir }}/{{ domain }}.key;
{% endif %} {% endif %}
{% elif has_management %} {% elif has_management %}
ssl_certificate {{ acme_cert_dir }}/fullchain.cer; ssl_certificate {{ certs_dir }}/{{ domain }}.crt;
ssl_certificate_key {{ acme_cert_dir }}/{{ domain }}.key; ssl_certificate_key {{ certs_dir }}/{{ domain }}.key;
{% endif %} {% endif %}
include snippets/vacuum-wall-ssl.conf; include snippets/vacuum-wall-ssl.conf;
+25 -25
View File
@@ -34,51 +34,51 @@ from daemon.server import ConflictError
class TestGenerateSelfSigned: class TestGenerateSelfSigned:
def test_generate_creates_files(self, tmp_path): def test_generate_creates_files(self, tmp_path):
with ( 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"}) result = generate_self_signed(None, {"domain": "test.local"})
assert result["domain"] == "test.local" assert result["domain"] == "test.local"
assert result["generated"] is True assert result["generated"] is True
cert_dir = tmp_path / "acme" / "test.local" certs_dir = tmp_path / "data" / "certs"
assert result["cert"] == str(cert_dir / "fullchain.cer") assert result["cert"] == str(certs_dir / "test.local.crt")
assert result["key"] == str(cert_dir / "test.local.key") assert result["key"] == str(certs_dir / "test.local.key")
assert (cert_dir / "fullchain.cer").is_file() assert (certs_dir / "test.local.crt").is_file()
assert (cert_dir / "test.local.key").is_file() assert (certs_dir / "test.local.key").is_file()
def test_generate_idempotent_skips_existing(self, tmp_path): def test_generate_idempotent_skips_existing(self, tmp_path):
cert_dir = tmp_path / "acme" / "test.local" certs_dir = tmp_path / "data" / "certs"
cert_dir.mkdir(parents=True) certs_dir.mkdir(parents=True)
(cert_dir / "fullchain.cer").write_text("dummy-cert") (certs_dir / "test.local.crt").write_text("dummy-cert")
(cert_dir / "test.local.key").write_text("dummy-key") (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"}) result = generate_self_signed(None, {"domain": "test.local"})
assert result["generated"] is False assert result["generated"] is False
def test_generate_partial_existing(self, tmp_path): def test_generate_partial_existing(self, tmp_path):
cert_dir = tmp_path / "acme" / "test.local" certs_dir = tmp_path / "data" / "certs"
cert_dir.mkdir(parents=True) certs_dir.mkdir(parents=True)
(cert_dir / "fullchain.cer").write_text("dummy-cert") (certs_dir / "test.local.crt").write_text("dummy-cert")
# key missing -> should regenerate # 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"}) result = generate_self_signed(None, {"domain": "test.local"})
assert result["generated"] is True assert result["generated"] is True
def test_generate_custom_days(self, tmp_path): def test_generate_custom_days(self, tmp_path):
with ( with (
patch("daemon.handlers.acme._ACME_HOME", tmp_path / "acme"), patch("daemon.handlers.acme.PROJECT_DIR", tmp_path),
patch("subprocess.run") as mock_run, patch("subprocess.run") as mock_run,
): ):
def _create_files(*args, **kwargs): def _create_files(*args, **kwargs):
cert_dir = tmp_path / "acme" / "test.local" certs_dir = tmp_path / "data" / "certs"
cert_dir.mkdir(parents=True, exist_ok=True) certs_dir.mkdir(parents=True, exist_ok=True)
(cert_dir / "fullchain.cer").touch() (certs_dir / "test.local.crt").touch()
(cert_dir / "test.local.key").touch() (certs_dir / "test.local.key").touch()
return Path("") return Path("")
mock_run.side_effect = _create_files mock_run.side_effect = _create_files
@@ -88,15 +88,15 @@ class TestGenerateSelfSigned:
idx = args.index("-days") idx = args.index("-days")
assert args[idx + 1] == "730" assert args[idx + 1] == "730"
cert_dir = tmp_path / "acme" / "test.local" certs_dir = tmp_path / "data" / "certs"
if (cert_dir / "fullchain.cer").is_file(): if (certs_dir / "test.local.crt").is_file():
assert cert_dir.is_dir() assert certs_dir.is_dir()
def test_generate_creates_directory(self, tmp_path): 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"}) 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): def test_generate_requires_domain(self):
with pytest.raises(ValueError, match="domain"): 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 RulesPage from '/static/pages/rules.js?v=9';
import NatPage from '/static/pages/nat.js?v=9'; import NatPage from '/static/pages/nat.js?v=9';
import DhcpPage from '/static/pages/dhcp.js?v=9'; import DhcpPage from '/static/pages/dhcp.js?v=9';
import ProxyPage from '/static/pages/proxy.js?v=9'; import ProxyPage from '/static/pages/proxy.js?v=10';
import BackendsPage from '/static/pages/backends.js?v=9'; import BackendsPage from '/static/pages/backends.js?v=10';
import CertsPage from '/static/pages/certs.js?v=9'; import CertsPage from '/static/pages/certs.js?v=9';
import WireguardPage from '/static/pages/wireguard.js?v=9'; import WireguardPage from '/static/pages/wireguard.js?v=9';
import LogsPage from '/static/pages/logs.js?v=9'; import LogsPage from '/static/pages/logs.js?v=9';
+40 -2
View File
@@ -3,10 +3,12 @@
* *
* JSON-friendly fetch wrapper with automatic header management. * JSON-friendly fetch wrapper with automatic header management.
* Toast notification system with auto-dismiss. * Toast notification system with auto-dismiss.
* Modal processing guard for async form submissions.
*/ */
import { modelFetch } from './model.js?v=8'; import { modelFetch } from './model.js?v=9';
import { requestUpdate } from './reactivity.js?v=8'; import { requestUpdate } from './reactivity.js?v=9';
import { isModalProcessing, setModalProcessing, refreshModals } from './components/modal.js?v=9';
/** /**
* JSON-friendly fetch wrapper. * JSON-friendly fetch wrapper.
@@ -178,6 +180,33 @@ export async function poll(opts) {
}, interval); }, 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. * Generate action button descriptors for modal form submission.
* *
@@ -211,7 +240,12 @@ export function apiSubmit(opts) {
label: submitText, label: submitText,
cls: 'btn-primary', cls: 'btn-primary',
action: 's', action: 's',
processing: true,
handler: async () => { handler: async () => {
if (isModalProcessing()) return;
setModalProcessing(true);
refreshModals();
try {
const b = body ? body() : {}; const b = body ? body() : {};
if (validate) { if (validate) {
const err = validate(b); const err = validate(b);
@@ -234,6 +268,10 @@ export function apiSubmit(opts) {
} else { } else {
toast(res.error || 'Failed', 'error'); 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 { reactive } from './reactivity.js?v=9';
import { h } from './vdom.js?v=8'; import { h } from './vdom.js?v=9';
import { _compExpandedCache } from './render.js?v=8'; import { _compExpandedCache } from './render.js?v=9';
/** Registry of mounted components: key → { state } */ /** Registry of mounted components: key → { state } */
const _mounted = new Map(); const _mounted = new Map();
+13 -6
View File
@@ -6,12 +6,12 @@
* expandable modal, then applies all via /api/status/apply-all. * expandable modal, then applies all via /api/status/apply-all.
*/ */
import { h } from '../vdom.js?v=8'; import { h } from '../vdom.js?v=9';
import { html } from '../html.js?v=8'; import { html } from '../html.js?v=9';
import { reactive } from '../reactivity.js?v=8'; import { reactive } from '../reactivity.js?v=9';
import { apiFetch, toast } from '../api.js?v=8'; import { apiFetch, toast } from '../api.js?v=9';
import { modelFetch } from '../model.js?v=8'; import { modelFetch } from '../model.js?v=9';
import { openModal, closeModal, modalVNodes } from './modal.js?v=8'; import { openModal, closeModal, modalVNodes, isModalProcessing, setModalProcessing, refreshModals } from './modal.js?v=9';
export const SUBSYSTEM_LIST = [ export const SUBSYSTEM_LIST = [
{ key: 'firewall', label: 'Firewall' }, { key: 'firewall', label: 'Firewall' },
@@ -59,6 +59,9 @@ ${hasPending ? html`<span class="apply-expand-icon${isExpanded ? ' expanded' : '
* POST apply-all, toast result, close modal, refresh models. * POST apply-all, toast result, close modal, refresh models.
*/ */
async function doApply(successMsg, refreshTargets) { async function doApply(successMsg, refreshTargets) {
if (isModalProcessing()) return;
setModalProcessing(true);
try {
const resp = await apiFetch('/api/status/apply-all', { method: 'POST' }); const resp = await apiFetch('/api/status/apply-all', { method: 'POST' });
if (resp.ok) { if (resp.ok) {
toast(successMsg, 'success'); toast(successMsg, 'success');
@@ -70,6 +73,10 @@ async function doApply(successMsg, refreshTargets) {
} else { } else {
toast(resp.error || 'Apply failed', 'error'); toast(resp.error || 'Apply failed', 'error');
} }
} finally {
setModalProcessing(false);
refreshModals();
}
} }
/** /**
+56 -9
View File
@@ -4,10 +4,16 @@
* Data display components: Badge, StatusDot, Empty, Card. * Data display components: Badge, StatusDot, Empty, Card.
*/ */
import { h } from '../vdom.js?v=8'; import { h } from '../vdom.js?v=9';
import { esc } from '../helpers.js?v=8'; import { esc } from '../helpers.js?v=9';
import { apiFetch, toast } from '../api.js?v=8'; import { apiFetch, toast } from '../api.js?v=9';
import { modelFetch } from '../model.js?v=8'; 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. * Colored badge/span.
@@ -75,13 +81,22 @@ export function Card(props = {}) {
* @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch * @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
* @param {string} [props.label] - Button text (default: 'Remove') * @param {string} [props.label] - Button text (default: 'Remove')
* @param {object} [props.body] - Optional JSON body to send with DELETE * @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 = {}) { export function ConfirmDelete(props = {}) {
const opts = { method: 'DELETE' }; const opts = { method: 'DELETE' };
if (props.body) opts.body = props.body; 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 () => { 'on:click': async () => {
if (!confirm(props.message)) return; if (!confirm(props.message)) return;
_confirmPending.set(deleteKey, true);
requestUpdate();
try {
const r = await apiFetch(props.url, opts); const r = await apiFetch(props.url, opts);
if (r.ok) { if (r.ok) {
const synced = r.data?.synced; const synced = r.data?.synced;
@@ -91,14 +106,34 @@ export function ConfirmDelete(props = {}) {
synced.forEach(s => modelFetch(s)); synced.forEach(s => modelFetch(s));
} }
toast(msg, 'success'); toast(msg, 'success');
if (props.deleteKey) {
_deleting.add(props.deleteKey);
}
if (props.refresh) { if (props.refresh) {
const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh]; const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh];
names.forEach(n => modelFetch(n)); 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 { } else {
toast(r.error || 'Failed', 'error'); toast(r.error || 'Failed', 'error');
} }
}}, props.label || 'Remove'); } finally {
_confirmPending.delete(deleteKey);
requestUpdate();
}
}
}, pending ? h('span', { class: 'btn-spinner' }) : (props.label || 'Remove'));
} }
/** /**
@@ -128,10 +163,16 @@ export function ActionButton(props = {}) {
? (props.condition ? props.labelOn : props.labelOff) ? (props.condition ? props.labelOn : props.labelOff)
: 'Action'); : 'Action');
const cls = props.cls || 'btn btn-outline'; const cls = props.cls || 'btn btn-outline';
const pending = _actionPending.get(props.url) || false;
return h('button', { return h('button', {
class: cls, class: cls,
disabled: props.disabled, disabled: !!props.disabled || pending,
'on:click': async () => { 'on:click': async () => {
if (pending) return;
_actionPending.set(props.url, true);
requestUpdate();
try {
const body = props.body ? props.body() : undefined; const body = props.body ? props.body() : undefined;
const opts = { method: props.method || 'POST' }; const opts = { method: props.method || 'POST' };
if (body !== undefined) opts.body = body; if (body !== undefined) opts.body = body;
@@ -152,8 +193,12 @@ export function ActionButton(props = {}) {
} else { } else {
toast(resp.error || 'Failed', props.errorType || 'error'); 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 {string} [props.removeLabel] - Delete button label (default: 'Remove')
* @param {object} [props.removeBody] - Optional JSON body to send with DELETE * @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.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 = {}) { export function ActionCell(props = {}) {
return h('td', null, return h('td', null,
@@ -294,6 +340,7 @@ export function ActionCell(props = {}) {
refresh: props.removeRefresh, refresh: props.removeRefresh,
label: props.removeLabel || 'Remove', label: props.removeLabel || 'Remove',
body: props.removeBody, body: props.removeBody,
deleteKey: props.deleteKey,
}), }),
); );
} }
+3 -3
View File
@@ -4,9 +4,9 @@
* Layout components: PageHeader, Tabs, SectionTitle, DataTableSection, ActionGroup. * Layout components: PageHeader, Tabs, SectionTitle, DataTableSection, ActionGroup.
*/ */
import { h } from '../vdom.js?v=8'; import { h } from '../vdom.js?v=9';
import { Table } from './data.js?v=8'; import { Table } from './data.js?v=9';
import { collectLoadingModels } from '../model.js?v=8'; import { collectLoadingModels } from '../model.js?v=9';
/** /**
* Page header with title, optional subtitle, and action buttons. * Page header with title, optional subtitle, and action buttons.
+66 -14
View File
@@ -6,10 +6,9 @@
* avoid fighting with the main render cycle. * avoid fighting with the main render cycle.
*/ */
import { esc } from '../helpers.js?v=8'; import { esc, att_esc } from '../helpers.js?v=9';
import { att_esc } from '../helpers.js?v=8'; import { apiSubmit } from '../api.js?v=9';
import { apiSubmit } from '../api.js?v=8'; import { createDom } from '../vdom.js?v=9';
import { createDom } from '../vdom.js?v=8';
/** /**
* Render Hoover VNodes into a modal content element. * Render Hoover VNodes into a modal content element.
@@ -33,7 +32,13 @@ function _renderModals() {
_modalQueue.forEach((m, idx) => { _modalQueue.forEach((m, idx) => {
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.className = 'modal-overlay active'; 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'); const content = document.createElement('div');
content.className = 'modal'; content.className = 'modal';
content.onclick = (e) => e.stopPropagation(); content.onclick = (e) => e.stopPropagation();
@@ -55,12 +60,36 @@ function _renderModals() {
*/ */
export function openModal(content) { export function openModal(content) {
const entry = typeof content === 'function' const entry = typeof content === 'function'
? { renderFn: content, id: _modalQueue.length } ? { renderFn: content, id: _modalQueue.length, _processing: false, _hasInputs: false }
: { id: _modalQueue.length, renderFn: (inner) => modalVNodes(inner, content) }; : { id: _modalQueue.length, renderFn: (inner) => modalVNodes(inner, content), _processing: false, _hasInputs: false };
_modalQueue.push(entry); _modalQueue.push(entry);
_renderModals(); _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. * Close a modal by index. Closes the topmost modal if index is omitted.
* *
@@ -135,17 +164,40 @@ export function formModal(inner, title, fields, actions) {
+ (f.type !== 'text' ? ' type="' + att_esc(f.type) + '"' : '') + (f.type !== 'text' ? ' type="' + att_esc(f.type) + '"' : '')
+ (f.value !== undefined ? ' value="' + att_esc(f.value) + '"' : '') + (f.value !== undefined ? ' value="' + att_esc(f.value) + '"' : '')
+ (f.placeholder ? ' placeholder="' + att_esc(f.placeholder) + '"' : '') + (f.placeholder ? ' placeholder="' + att_esc(f.placeholder) + '"' : '')
+ (f.checked ? ' checked' : '')
+ '></' + tag + '></div>'; + '></' + tag + '></div>';
}).join('') + '</div><div class="modal-actions">' }).join('') + '</div><div class="modal-actions"></div>';
+ actions.map(a =>
'<button class="btn ' + att_esc(a.cls) + '" data-action="' + att_esc(a.action) + '">' + esc(a.label) + '</button>',
).join('') + '</div>';
actions.forEach(a => { // Mark modal as having editable inputs
const btn = inner.querySelector('[data-action="' + att_esc(a.action) + '"]'); const topEntry = _modalQueue[_modalQueue.length - 1];
if (btn) btn.addEventListener('click', a.handler); 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);
}
}
/** /**
* Factory that returns a function to open a multi-select modal. * Factory that returns a function to open a multi-select modal.
+2 -2
View File
@@ -5,8 +5,8 @@
* Uses the toast/dismissToast state from api.js. * Uses the toast/dismissToast state from api.js.
*/ */
import { h } from '../vdom.js?v=8'; import { h } from '../vdom.js?v=9';
import { _toasts, dismissToast } from '../api.js?v=8'; import { _toasts, dismissToast } from '../api.js?v=9';
/** /**
* Render all pending toast notifications. * Render all pending toast notifications.
+1 -1
View File
@@ -1,4 +1,4 @@
import htm from '../../vendor/htm.js'; 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); export const html = htm.bind(htmAdapter);
+15 -15
View File
@@ -5,46 +5,46 @@
*/ */
/* ── Reactivity ──────────────────────────────────────────────── */ /* ── Reactivity ──────────────────────────────────────────────── */
export { reactive, requestUpdate } from './reactivity.js?v=8'; export { reactive, requestUpdate } from './reactivity.js?v=9';
/* ── VDOM ────────────────────────────────────────────────────── */ /* ── VDOM ────────────────────────────────────────────────────── */
export { h } from './vdom.js?v=8'; export { h } from './vdom.js?v=9';
/* ── HTM ──────────────────────────────────────────────────────── */ /* ── HTM ──────────────────────────────────────────────────────── */
export { html } from './html.js?v=8'; export { html } from './html.js?v=9';
/* ── Render ──────────────────────────────────────────────────── */ /* ── Render ──────────────────────────────────────────────────── */
export { render } from './render.js?v=8'; export { render } from './render.js?v=9';
/* ── Component ───────────────────────────────────────────────── */ /* ── Component ───────────────────────────────────────────────── */
export { definePage, hComp } from './component.js?v=8'; export { definePage, hComp } from './component.js?v=9';
/* ── Router ──────────────────────────────────────────────────── */ /* ── Router ──────────────────────────────────────────────────── */
export { createRouter, Link } from './router.js?v=8'; export { createRouter, Link } from './router.js?v=9';
/* ── WebSocket ───────────────────────────────────────────────── */ /* ── WebSocket ───────────────────────────────────────────────── */
export { connect, onMessage } from './websocket.js?v=8'; export { connect, onMessage } from './websocket.js?v=9';
/* ── API & Toast ─────────────────────────────────────────────── */ /* ── 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 ───────────────────────────────────────────────────── */ /* ── Model ───────────────────────────────────────────────────── */
export { modelRegister, getModel, modelFetch, collectLoadingModels } from './model.js?v=8'; export { modelRegister, getModel, modelFetch, collectLoadingModels } from './model.js?v=9';
/* ── Helpers ─────────────────────────────────────────────────── */ /* ── 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 ───────────────────────────────────── */ /* ── 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 ─────────────────────────────────────── */ /* ── 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 ────────────────────────────────────── */ /* ── 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 ────────────────────────────────────── */ /* ── UI Components: Apply ────────────────────────────────────── */
export { ApplyConfirm } from './components/applyconfirm.js?v=8'; export { ApplyConfirm } from './components/applyconfirm.js?v=9';
/* ── UI Components: Toast ────────────────────────────────────── */ /* ── 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 * 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 } */ /** Registered models: name → { model, subsystem, fetch } */
const _models = new Map(); const _models = new Map();
+3 -3
View File
@@ -5,12 +5,12 @@
* batched re-render loop integration with reactivity.js. * 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 { import {
_vnodeDom, createDom, getDom, patchNode, sweepDom, _vnodeDom, createDom, getDom, patchNode, sweepDom,
setMountFn, setUnmountFn, setMountFn, setUnmountFn,
} from './vdom.js?v=8'; } from './vdom.js?v=9';
import { mountComponent, unmountComponent } from './component.js?v=8'; import { mountComponent, unmountComponent } from './component.js?v=9';
/** Container → previous root vnodes */ /** Container → previous root vnodes */
export const _renderSlots = new Map(); export const _renderSlots = new Map();
+2 -2
View File
@@ -5,8 +5,8 @@
* navigation). Link component for client-side navigation. * navigation). Link component for client-side navigation.
*/ */
import { reactive } from './reactivity.js?v=8'; import { reactive } from './reactivity.js?v=9';
import { h } from './vdom.js?v=8'; import { h } from './vdom.js?v=9';
/** /**
* Hash-based router. * Hash-based router.
+1 -1
View File
@@ -6,7 +6,7 @@
* Page-level subscribe/unsubscribe is replaced by the model layer. * 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 _wsConn = null;
let _wsReconnectMs = 0; let _wsReconnectMs = 0;
+123 -20
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 { 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 } from '/static/hoover/components/modal.js?v=8'; 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 // Add a path row element to the paths container
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function _addPathRow(container, data) { function _addPathRow(container, data, datalistId, isFirst) {
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'path-row-row'; row.className = 'path-row-row';
const hostListAttr = datalistId ? ' list="' + datalistId + '"' : '';
const hostInputId = isFirst ? ' id="' + datalistId + '-host-input"' : '';
row.innerHTML = ` 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="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 || '' : ''}" /> <input class="form-input path-field" type="number" placeholder="Port" value="${data ? (data.backend || {}).port || '' : ''}" />
<select class="form-select path-field"> <select class="form-select path-field">
<option value="http"${(data && (data.backend || {}).proto === 'http') || !data ? ' selected' : ''}>http</option> <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'); 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); container.appendChild(row);
return row; return row;
} }
@@ -37,11 +110,14 @@ function _collectPaths(container) {
const result = {}; const result = {};
const seen = new Set(); const seen = new Set();
container.querySelectorAll('.path-row-row').forEach(row => { container.querySelectorAll('.path-row-row').forEach(row => {
const inputs = row.querySelectorAll('.path-field'); const pathField = row.querySelector('input[placeholder="Path"]');
const path = (inputs[0].value || '').trim() || '/'; const hostField = row.querySelector('input[placeholder="Host"]');
const host = (inputs[1].value || '').trim(); const portField = row.querySelector('input[placeholder="Port"]');
const port = parseInt(inputs[2].value); const protoSelect = row.querySelector('select');
const proto = inputs[3].value; 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 (!host || !port) return;
if (seen.has(path)) { toast('Duplicate path ' + path, 'warning'); return; } if (seen.has(path)) { toast('Duplicate path ' + path, 'warning'); return; }
seen.add(path); seen.add(path);
@@ -56,12 +132,16 @@ function _collectPaths(container) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Open backend form modal // Open backend form modal
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function openBackendModal(state, backend) { export function openBackendModal(state, backend) {
const isEdit = !!backend; const isEdit = !!backend;
const title = isEdit ? ('Edit Backend: ' + esc(backend.name)) : 'Add Backend'; const title = isEdit ? ('Edit Backend: ' + esc(backend.name)) : 'Add Backend';
// Extract DHCP leases
const leases = state.dnsmasq?.data?.leases || [];
openModal((modalContent) => { openModal((modalContent) => {
const uniqueId = Date.now(); const uniqueId = Date.now();
const datalistId = 'hosts-' + uniqueId;
const pathsId = 'paths-' + uniqueId; const pathsId = 'paths-' + uniqueId;
// Build form HTML // Build form HTML
@@ -96,16 +176,24 @@ function openBackendModal(state, backend) {
</div> </div>
`; `;
// Inject datalist
if (leases.length > 0) {
const datalist = _buildHostDatalist(leases, uniqueId);
modalContent.appendChild(datalist);
_injectDiscoveredHosts(modalContent, leases, datalistId);
}
// Add paths container refs // Add paths container refs
const pathsContainer = modalContent.querySelector('#' + pathsId); const pathsContainer = modalContent.querySelector('#' + pathsId);
// Add initial path rows // Add initial path rows
if (isEdit && backend.data.paths) { if (isEdit && backend.data.paths) {
Object.entries(backend.data.paths).forEach(([path, cfg]) => { const entries = Object.entries(backend.data.paths);
_addPathRow(pathsContainer, { path, ...cfg }); entries.forEach(([path, cfg], i) => {
_addPathRow(pathsContainer, { path, ...cfg }, datalistId, i === 0);
}); });
} else { } else {
_addPathRow(pathsContainer, null); _addPathRow(pathsContainer, null, datalistId, true);
} }
// "Add path" button // "Add path" button
@@ -114,7 +202,7 @@ function openBackendModal(state, backend) {
addPathBtn.className = 'btn btn-sm btn-outline'; addPathBtn.className = 'btn btn-sm btn-outline';
addPathBtn.style.marginTop = '8px'; addPathBtn.style.marginTop = '8px';
addPathBtn.textContent = '+ Add Path'; addPathBtn.textContent = '+ Add Path';
addPathBtn.addEventListener('click', () => _addPathRow(pathsContainer, null)); addPathBtn.addEventListener('click', () => _addPathRow(pathsContainer, null, datalistId, false));
pathsContainer.parentNode.querySelector('.form-label') pathsContainer.parentNode.querySelector('.form-label')
.parentElement.insertBefore(addPathBtn, pathsContainer.nextSibling); .parentElement.insertBefore(addPathBtn, pathsContainer.nextSibling);
@@ -123,6 +211,9 @@ function openBackendModal(state, backend) {
// Submit button // Submit button
modalContent.querySelector('#submit-' + uniqueId).addEventListener('click', async () => { modalContent.querySelector('#submit-' + uniqueId).addEventListener('click', async () => {
if (isModalProcessing()) return;
setModalProcessing(true);
try {
const nameInput = modalContent.querySelector('#name-' + uniqueId); const nameInput = modalContent.querySelector('#name-' + uniqueId);
const labelInput = modalContent.querySelector('#label-' + uniqueId); const labelInput = modalContent.querySelector('#label-' + uniqueId);
const authSelect = modalContent.querySelector('#auth-' + uniqueId); const authSelect = modalContent.querySelector('#auth-' + uniqueId);
@@ -134,7 +225,7 @@ function openBackendModal(state, backend) {
if (!name) { toast('Name is required', 'error'); return; } if (!name) { toast('Name is required', 'error'); return; }
if (!label) { toast('Label 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 (!Object.keys(paths).length) { toast('Please fill in Host and Port for at least one path', 'error'); return; }
const body = { name, label, paths }; const body = { name, label, paths };
if (authType === 'htpasswd') { if (authType === 'htpasswd') {
@@ -153,6 +244,10 @@ function openBackendModal(state, backend) {
} else { } else {
toast(res.error || 'Failed', 'error'); toast(res.error || 'Failed', 'error');
} }
} finally {
setModalProcessing(false);
refreshModals();
}
}); });
}); });
} }
@@ -164,17 +259,18 @@ export default definePage({
init() { init() {
return { return {
backends: getModel('backends'), backends: getModel('backends'),
dnsmasq: getModel('dnsmasq'),
}; };
}, },
render(state) { 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; if (guard) return guard;
const backends = state.backends.data || {}; const backends = state.backends.data || {};
const entries = Object.entries(backends); const entries = Object.entries(backends);
const rows = entries.map(([name, b]) => 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><strong>${esc(name)}</strong></td>
<td>${esc(b.label || name)}</td> <td>${esc(b.label || name)}</td>
<td>${Object.keys(b.paths || {}).length}</td> <td>${Object.keys(b.paths || {}).length}</td>
@@ -191,6 +287,7 @@ export default definePage({
? '' ? ''
: html`<${ConfirmDelete} : html`<${ConfirmDelete}
url=${'/api/proxy/backends/' + enc(name)} url=${'/api/proxy/backends/' + enc(name)}
deleteKey=${name}
message=${'Remove backend ' + enc(name) + '?'} message=${'Remove backend ' + enc(name) + '?'}
success="Backend removed" success="Backend removed"
refresh=["backends", "nginx"] refresh=["backends", "nginx"]
@@ -200,9 +297,15 @@ export default definePage({
</tr>` </tr>`
); );
const actions = html` const actions = ActionGroup(
<button class="btn btn-primary" onClick=${() => openBackendModal(state)}>Add Backend</button> 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 [ return [
PageHeader({ title: 'Backends', subtitle: 'Reusable proxy backend templates', actions }), PageHeader({ title: 'Backends', subtitle: 'Reusable proxy backend templates', actions }),
+33 -9
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) { function _accountCard(account) {
if (!account || !account.registered) { if (!account || !account.registered) {
@@ -44,22 +45,19 @@ function registerAccountModal() {
label: 'Register', label: 'Register',
cls: 'btn-primary', cls: 'btn-primary',
action: 'r', action: 'r',
handler: async () => { handler: formAction(async () => {
const email = ($val('reg-email') || '').trim(); 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 server = document.getElementById('reg-server')?.value || 'letsencrypt';
const resp = await apiFetch('/api/certs/account/register', { const resp = await apiFetch('/api/certs/account/register', {
method: 'POST', method: 'POST',
body: { email, server }, body: { email, server },
}); });
if (resp.ok) { if (!resp.ok) throw resp.error || 'Registration failed';
toast('ACME account registered', 'success'); toast('ACME account registered', 'success');
closeModal(); closeModal();
modelFetch('acme'); modelFetch('acme');
} else { }),
toast(resp.error || 'Registration failed', 'error');
}
},
}, },
], ],
); );
@@ -95,6 +93,9 @@ function settingsModal(account) {
}); });
inner.querySelector('[data-action="set-save"]')?.addEventListener('click', async () => { inner.querySelector('[data-action="set-save"]')?.addEventListener('click', async () => {
if (isModalProcessing()) return;
setModalProcessing(true);
try {
const email = ($val('set-email') || '').trim(); const email = ($val('set-email') || '').trim();
if (!email) { toast('Email is required', 'error'); return; } if (!email) { toast('Email is required', 'error'); return; }
const resp = await apiFetch('/api/certs/email', { const resp = await apiFetch('/api/certs/email', {
@@ -108,10 +109,17 @@ function settingsModal(account) {
} else { } else {
toast(resp.error || 'Failed', 'error'); toast(resp.error || 'Failed', 'error');
} }
} finally {
setModalProcessing(false);
refreshModals();
}
}); });
inner.querySelector('[data-action="set-deactivate"]')?.addEventListener('click', async () => { inner.querySelector('[data-action="set-deactivate"]')?.addEventListener('click', async () => {
if (!confirm('Deactivate ACME account? You will need to register again to issue certificates.')) return; if (!confirm('Deactivate ACME account? You will need to register again to issue certificates.')) return;
if (isModalProcessing()) return;
setModalProcessing(true);
try {
const resp = await apiFetch('/api/certs/account', { method: 'DELETE' }); const resp = await apiFetch('/api/certs/account', { method: 'DELETE' });
if (resp.ok) { if (resp.ok) {
toast('Account deactivated', 'success'); toast('Account deactivated', 'success');
@@ -120,6 +128,10 @@ function settingsModal(account) {
} else { } else {
toast(resp.error || 'Failed', 'error'); 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 () => { inner.querySelector('[data-action="ic-validate"]')?.addEventListener('click', async () => {
if (_currentIssueState.validating) return; if (_currentIssueState.validating) return;
if (isModalProcessing()) return;
const s = _currentIssueState; const s = _currentIssueState;
const domain = ($val('ic-domain') || '').trim(); const domain = ($val('ic-domain') || '').trim();
if (!domain) { toast('Domain is required', 'error'); return; } if (!domain) { toast('Domain is required', 'error'); return; }
setModalProcessing(true);
s.validating = true; s.validating = true;
s.domain = domain; s.domain = domain;
try { try {
@@ -229,10 +243,15 @@ function _bindIssueButtons(inner, modalIdx) {
refreshModals(); refreshModals();
} finally { } finally {
s.validating = false; s.validating = false;
setModalProcessing(false);
refreshModals();
} }
}); });
inner.querySelector('[data-action="ic-issue"]')?.addEventListener('click', async () => { inner.querySelector('[data-action="ic-issue"]')?.addEventListener('click', async () => {
if (isModalProcessing()) return;
setModalProcessing(true);
try {
const body = { domain: _currentIssueState.domain }; const body = { domain: _currentIssueState.domain };
const issueResp = await apiFetch('/api/certs/issue/start', { method: 'POST', body }); const issueResp = await apiFetch('/api/certs/issue/start', { method: 'POST', body });
if (issueResp.ok) { if (issueResp.ok) {
@@ -248,6 +267,10 @@ function _bindIssueButtons(inner, modalIdx) {
} else { } else {
toast(issueResp.error || 'Failed', 'error'); toast(issueResp.error || 'Failed', 'error');
} }
} finally {
setModalProcessing(false);
refreshModals();
}
}); });
} }
@@ -295,7 +318,8 @@ export default definePage({
removeUrl=${'/api/certs/' + enc(c.domain)} removeUrl=${'/api/certs/' + enc(c.domain)}
removeMessage=${'Remove certificate for ' + c.domain + '?'} removeMessage=${'Remove certificate for ' + c.domain + '?'}
removeSuccess="Certificate removed" removeSuccess="Certificate removed"
removeRefresh="acme" /> removeRefresh="acme"
deleteKey=${c.domain} />
</tr>`; </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({ export default definePage({
init() { 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) { function makeAddRange(activeZones, interfaces) {
const opts = [ const opts = [
@@ -137,6 +137,7 @@ export default definePage({
<td> <td>
<${ConfirmDelete} <${ConfirmDelete}
url="/api/dhcp/ranges" url="/api/dhcp/ranges"
deleteKey=${(r.interface || '_g') + '-' + r.start + '-' + r.end}
message=${'Remove range ' + r.start + ' - ' + r.end + '?'} message=${'Remove range ' + r.start + ' - ' + r.end + '?'}
body=${{ interface: r.interface || '', start: r.start, end: r.end }} body=${{ interface: r.interface || '', start: r.start, end: r.end }}
success="Range removed" success="Range removed"
@@ -151,6 +152,7 @@ export default definePage({
<td> <td>
<${ConfirmDelete} <${ConfirmDelete}
url=${'/api/dhcp/static-lease/' + enc(l.mac)} url=${'/api/dhcp/static-lease/' + enc(l.mac)}
deleteKey=${l.mac}
message=${'Remove lease ' + l.mac + '?'} message=${'Remove lease ' + l.mac + '?'}
success="Lease removed" success="Lease removed"
refresh="dnsmasq" /> refresh="dnsmasq" />
@@ -163,6 +165,7 @@ export default definePage({
<td> <td>
<${ConfirmDelete} <${ConfirmDelete}
url=${'/api/dhcp/dns-record/' + enc(rec.name || '')} url=${'/api/dhcp/dns-record/' + enc(rec.name || '')}
deleteKey=${rec.name || 'unnamed'}
message=${'Remove DNS record ' + (rec.name || 'unnamed') + '?'} message=${'Remove DNS record ' + (rec.name || 'unnamed') + '?'}
success="Record removed" success="Record removed"
refresh="dnsmasq" /> 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) { async function changeZone(name, zone, state) {
const r = await apiFetch('/api/firewall/zones/' + enc(zone) + '/interfaces', { 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 = [ const logTabs = [
{ key: 'journal', label: 'Journal' }, { 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({ const addFwd = QuickModal({
title: 'Add Port Forward', title: 'Add Port Forward',
@@ -60,7 +60,9 @@ export default definePage({
<td><${Badge} text=${iface.zone || '—'} variant="secondary" /></td> <td><${Badge} text=${iface.zone || '—'} variant="secondary" /></td>
</tr>`); </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; const masq = !!zcfg.masquerade;
return html`<tr key=${'m-' + zone}> return html`<tr key=${'m-' + zone}>
<td><strong>${zone}</strong></td> <td><strong>${zone}</strong></td>
@@ -92,6 +94,7 @@ export default definePage({
<td> <td>
<${ConfirmDelete} <${ConfirmDelete}
url=${'/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto)} url=${'/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto)}
deleteKey=${zone + '/' + port + '/' + proto}
message=${'Remove forward ' + zone + ':' + port + '/' + proto + '?'} message=${'Remove forward ' + zone + ':' + port + '/' + proto + '?'}
success="Rule removed" success="Rule removed"
refresh="firewall" /> 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({ export default definePage({
init() { init() {
+96 -114
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 { 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=8'; 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) { function certLookup(acmeData) {
const m = {}; const m = {};
if (acmeData && acmeData.certs) { if (acmeData && acmeData.certs) {
@@ -14,9 +12,6 @@ function certLookup(acmeData) {
return m; return m;
} }
// ---------------------------------------------------------------------------
// Build cert select options from ACME certs array
// ---------------------------------------------------------------------------
function buildCertOptions(certs) { function buildCertOptions(certs) {
const opts = [ const opts = [
['', '(none)'], ['', '(none)'],
@@ -25,26 +20,18 @@ function buildCertOptions(certs) {
['file', 'file — custom path'], ['file', 'file — custom path'],
]; ];
for (const c of (certs || [])) { for (const c of (certs || [])) {
const days = c.expired const days = c.expired ? 'Expired' : `${c.days_remaining}d`;
? 'Expired'
: `${c.days_remaining}d`;
opts.push([`acme|${c.domain}`, `acme: ${c.domain} (${days})`]); opts.push([`acme|${c.domain}`, `acme: ${c.domain} (${days})`]);
} }
return opts; return opts;
} }
// ---------------------------------------------------------------------------
// Map cert select value to payload cert string
// ---------------------------------------------------------------------------
function certValueFromSelect(raw) { function certValueFromSelect(raw) {
if (raw === 'acme' || (raw && raw.startsWith('acme|'))) return 'acme'; if (raw === 'acme' || (raw && raw.startsWith('acme|'))) return 'acme';
if (raw === 'selfsigned' || raw === 'file') return raw; if (raw === 'selfsigned' || raw === 'file') return raw;
return undefined; return undefined;
} }
// ---------------------------------------------------------------------------
// Build backend select options from backends model
// ---------------------------------------------------------------------------
function buildBackendOptions(backends) { function buildBackendOptions(backends) {
const opts = [['', '(select backend)']]; const opts = [['', '(select backend)']];
const entries = Object.entries(backends || {}); const entries = Object.entries(backends || {});
@@ -57,14 +44,44 @@ function buildBackendOptions(backends) {
return opts; return opts;
} }
// --------------------------------------------------------------------------- function _groupByBackend(domains, backends) {
// Add Domain modal — backend selector const backendMap = {};
// --------------------------------------------------------------------------- for (const d of domains) {
function addDomain(state) { 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 backends = state.backends ? (state.backends.data || {}) : {};
const certOptions = buildCertOptions(state.acme ? (state.acme.data.certs || []) : []); const certOptions = buildCertOptions(state.acme ? (state.acme.data.certs || []) : []);
const backendOptions = buildBackendOptions(backends); const backendOptions = buildBackendOptions(backends);
openModal((inner) => { openModal((inner) => {
formModal(inner, 'Add Proxy Domain', [ formModal(inner, 'Add Proxy Domain', [
{ label: 'Domain', id: 'p-domain', placeholder: 'example.com' }, { label: 'Domain', id: 'p-domain', placeholder: 'example.com' },
@@ -76,34 +93,26 @@ function addDomain(state) {
label: 'Add', label: 'Add',
cls: 'btn-primary', cls: 'btn-primary',
action: 's', action: 's',
handler: async () => { handler: formAction(async () => {
const domain = ($val('p-domain') || '').trim(); const domain = ($val('p-domain') || '').trim();
if (!domain) throw 'Domain is required';
const backend = ($val('p-backend') || '').trim(); const backend = ($val('p-backend') || '').trim();
const rawCert = $val('p-cert'); if (!backend) throw 'Backend is required';
const body = { domain, backend, force_ssl: true };
if (!domain) { toast('Domain is required', 'error'); return; } const certVal = certValueFromSelect($val('p-cert'));
if (!backend) { toast('Backend is required', 'error'); return; }
const body = {
domain,
backend,
force_ssl: true,
};
const certVal = certValueFromSelect(rawCert);
if (certVal) body.cert = certVal; if (certVal) body.cert = certVal;
const res = await apiFetch('/api/proxy/domains', { method: 'POST', body }); const res = await apiFetch('/api/proxy/domains', { method: 'POST', body });
if (res.ok) { if (!res.ok) throw res.error || 'Failed';
toast('Domain added', 'success'); toast('Domain added', 'success');
closeModal(); closeModal();
await Promise.all(['nginx', 'acme'].map(m => modelFetch(m))); await Promise.all(['nginx', 'acme'].map(m => modelFetch(m)));
} else { }),
toast(res.error || 'Failed', 'error');
}
},
}, },
]); ]);
if (preselectedBackend) {
const backendSelect = inner.querySelector('#p-backend');
if (backendSelect) backendSelect.value = preselectedBackend;
}
const certSelect = inner.querySelector('#p-cert'); const certSelect = inner.querySelector('#p-cert');
const domainInput = inner.querySelector('#p-domain'); const domainInput = inner.querySelector('#p-domain');
if (certSelect && domainInput) { 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) { function editDomain(d, state) {
const backends = state.backends ? (state.backends.data || {}) : {}; const backends = state.backends ? (state.backends.data || {}) : {};
const backend = backends[d.backend_name] || {}; const backend = backends[d.backend_name] || {};
const certOptions = buildCertOptions(state.acme ? (state.acme.data.certs || []) : []); const certOptions = buildCertOptions(state.acme ? (state.acme.data.certs || []) : []);
const certMap = certLookup(state.acme ? state.acme.data : null); const certMap = certLookup(state.acme ? state.acme.data : null);
const domainCert = certMap[d.domain]; const domainCert = certMap[d.domain];
let selectedCert = ''; let selectedCert = '';
@@ -133,8 +138,6 @@ function editDomain(d, state) {
} else if (d.cert) { } else if (d.cert) {
selectedCert = d.cert; selectedCert = d.cert;
} }
// Build path summary rows (read-only)
const paths = backend.paths || {}; const paths = backend.paths || {};
const pathKeys = Object.keys(paths); const pathKeys = Object.keys(paths);
const pathSummary = pathKeys.map(p => { const pathSummary = pathKeys.map(p => {
@@ -142,7 +145,6 @@ function editDomain(d, state) {
const be = pcfg.backend || {}; const be = pcfg.backend || {};
return `${esc(p)}${esc(be.host || '-')}:${be.port || '-'}`; return `${esc(p)}${esc(be.host || '-')}:${be.port || '-'}`;
}).join('\n') || '—'; }).join('\n') || '—';
openModal((inner) => { openModal((inner) => {
formModal(inner, 'Edit: ' + esc(d.domain), [ formModal(inner, 'Edit: ' + esc(d.domain), [
{ label: 'Domain', id: 'pe-domain', value: d.domain }, { label: 'Domain', id: 'pe-domain', value: d.domain },
@@ -156,59 +158,35 @@ function editDomain(d, state) {
label: 'Save', label: 'Save',
cls: 'btn-primary', cls: 'btn-primary',
action: 's', action: 's',
handler: async () => { handler: formAction(async () => {
const rawCert = $val('pe-cert'); const rawCert = $val('pe-cert');
if (!rawCert) throw 'Cert is required';
const forceSsl = document.getElementById('pe-force-ssl')?.checked ?? true; const forceSsl = document.getElementById('pe-force-ssl')?.checked ?? true;
const body = { cert: certValueFromSelect(rawCert), force_ssl: forceSsl };
const body = {};
body.cert = certValueFromSelect(rawCert);
body.force_ssl = forceSsl;
const res = await apiFetch('/api/proxy/domains/' + enc(d.domain), { method: 'PUT', body }); const res = await apiFetch('/api/proxy/domains/' + enc(d.domain), { method: 'PUT', body });
if (res.ok) { if (!res.ok) throw res.error || 'Failed';
toast('Domain updated', 'success'); toast('Domain updated', 'success');
closeModal(); closeModal();
await Promise.all(['nginx', 'acme'].map(m => modelFetch(m))); await Promise.all(['nginx', 'acme'].map(m => modelFetch(m)));
} else { }),
toast(res.error || 'Failed', 'error');
}
},
}, },
]); ]);
// Set cert select value
const certSelect = inner.querySelector('#pe-cert'); const certSelect = inner.querySelector('#pe-cert');
if (certSelect) certSelect.value = selectedCert; if (certSelect) certSelect.value = selectedCert;
// Make read-only fields actually read-only
const domainInput = inner.querySelector('#pe-domain'); const domainInput = inner.querySelector('#pe-domain');
if (domainInput) { if (domainInput) { domainInput.readOnly = true; domainInput.style.background = '#f5f5f5'; }
domainInput.readOnly = true;
domainInput.style.background = '#f5f5f5';
}
const backendInput = inner.querySelector('#pe-backend'); const backendInput = inner.querySelector('#pe-backend');
if (backendInput) { if (backendInput) { backendInput.readOnly = true; backendInput.style.background = '#f5f5f5'; }
backendInput.readOnly = true;
backendInput.style.background = '#f5f5f5';
}
}); });
} }
// ---------------------------------------------------------------------------
// Row for a domain (grouped by domain name)
// ---------------------------------------------------------------------------
function domainRow(domainName, domainPaths, state) { function domainRow(domainName, domainPaths, state) {
const d = domainPaths[0]; const d = domainPaths[0];
const certMap = certLookup(state.acme ? state.acme.data : null); const certMap = certLookup(state.acme ? state.acme.data : null);
const backend = state.backends ? (state.backends.data || {})[d.backend_name] : {};
const cert = certMap[d.domain]; const cert = certMap[d.domain];
let certBadge, certTitle; let certBadge, certTitle;
if (cert) { if (cert) {
certBadge = certStatusBadge({ certBadge = certStatusBadge({ daysRemaining: cert.days_remaining, expired: cert.expired });
daysRemaining: cert.days_remaining,
expired: cert.expired,
});
certTitle = 'ACME: ' + cert.domain; certTitle = 'ACME: ' + cert.domain;
} else if (d.cert === 'selfsigned') { } else if (d.cert === 'selfsigned') {
certBadge = Badge({ text: 'Self-signed', variant: 'warning' }); certBadge = Badge({ text: 'Self-signed', variant: 'warning' });
@@ -220,8 +198,6 @@ function domainRow(domainName, domainPaths, state) {
certBadge = Badge({ text: '—', variant: 'info' }); certBadge = Badge({ text: '—', variant: 'info' });
certTitle = 'No certificate'; certTitle = 'No certificate';
} }
// Build paths summary
const pathSummaries = domainPaths.map(p => { const pathSummaries = domainPaths.map(p => {
const be = p.backend || {}; const be = p.backend || {};
let parts = [esc(p.path), `${esc(be.host || '-')}:${be.port || '-'}`]; 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(', ')); if (flags.length) parts.push(flags.join(', '));
return parts.join(' → '); return parts.join(' → ');
}); });
return html`<tr key=${domainName} class="domain-row"> return html`<tr key=${domainName} class="domain-row">
<td><strong>${esc(domainName)}</strong></td> <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>${pathSummaries}</td>
<td title=${certTitle}>${certBadge}</td> <td title=${certTitle}>${certBadge}</td>
<td>${d.force_ssl ? html`<${Badge} text="on" variant="success" />` : html`<${Badge} text="off" variant="secondary" />`}</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>`; </tr>`;
} }
// --------------------------------------------------------------------------- function backendSection(section, state) {
// Page 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({ export default definePage({
init() { init() {
return { return {
@@ -268,36 +267,19 @@ export default definePage({
render(state) { render(state) {
const guard = renderGuardMulti('Proxy', 'Nginx reverse proxy', state.nginx, state.backends, state.acme); const guard = renderGuardMulti('Proxy', 'Nginx reverse proxy', state.nginx, state.backends, state.acme);
if (guard) return guard; if (guard) return guard;
const domains = state.nginx.data.domains || []; const domains = state.nginx.data.domains || [];
const backends = state.backends.data || {};
// Group by domain name const sections = _groupByBackend(domains, backends);
const groups = {}; const sectionVNodes = sections.map(s => backendSection(s, state));
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 actions = ActionGroup( const actions = ActionGroup(
html`<button class="btn btn-primary" onClick=${() => addDomain(state)}>Add Domain</button>`, html`<button class="btn btn-primary" onClick=${() => addDomain(state)}>Add Domain</button>`,
ActionButton({ ActionButton({ url: '/api/proxy/apply', successMsg: 'Nginx applied & reloaded', label: 'Apply', refresh: ['nginx', 'acme'] }),
url: '/api/proxy/apply',
successMsg: 'Nginx applied & reloaded',
label: 'Apply',
refresh: ['nginx', 'acme'],
}),
); );
return [ return [
PageHeader({ title: 'Proxy', subtitle: 'Nginx reverse proxy', actions }), PageHeader({ title: 'Proxy', subtitle: 'Nginx reverse proxy', actions }),
domains.length Object.keys(backends).length || domains.length
? Table({ ? sectionVNodes
columns: ['Domain', 'Backend', 'Paths', 'Cert', 'Force SSL', 'Actions'], : Empty({ text: 'No backends configured. Create a backend first, then add domains.' }),
rows,
})
: Empty({ text: 'No proxy domains configured. Add a domain to start terminating SSL.' }),
]; ];
}, },
}); });
+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({ const addRule = QuickModal({
title: 'Add Rich Rule', title: 'Add Rich Rule',
@@ -44,6 +44,7 @@ export default definePage({
<td> <td>
<${ConfirmDelete} <${ConfirmDelete}
url=${'/api/firewall/rich-rules/' + enc(zone) + '/' + enc(ruleId || '')} url=${'/api/firewall/rich-rules/' + enc(zone) + '/' + enc(ruleId || '')}
deleteKey=${zone + '-' + (ruleId || i)}
message=${'Remove rule: ' + ruleText.substring(0, 40) + '...?'} message=${'Remove rule: ' + ruleText.substring(0, 40) + '...?'}
success="Rule removed" success="Rule removed"
refresh="firewall" /> refresh="firewall" />
+11 -10
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({ const addPeer = QuickModal({
title: 'Add WireGuard Peer', title: 'Add WireGuard Peer',
@@ -29,21 +29,21 @@ function downloadConfigModal(peerName, config, state) {
[ [
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, { 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(); 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', { const resp = await apiFetch('/api/wireguard/generate-client', {
method: 'POST', method: 'POST',
body: { name: peerName, server_endpoint: endpoint }, body: { name: peerName, server_endpoint: endpoint },
}); });
if (resp.ok && resp.data?.config) { if (!resp.ok) throw resp.error || 'Failed';
downloadBlob(new Blob([resp.data.config], { type: 'text/plain' }), peerName + '.conf'); const configContent = resp.data?.config;
if (!configContent) throw 'No config returned';
downloadBlob(new Blob([configContent], { type: 'text/plain' }), peerName + '.conf');
toast('Config downloaded', 'success'); toast('Config downloaded', 'success');
closeModal(idx); closeModal(idx);
} else { }),
toast(resp.error || 'Failed', 'error');
}
},
}, },
], ],
); );
@@ -84,7 +84,8 @@ export default definePage({
removeUrl=${'/api/wireguard/peers/' + enc(p.name)} removeUrl=${'/api/wireguard/peers/' + enc(p.name)}
removeMessage=${'Remove peer ' + p.name + '?'} removeMessage=${'Remove peer ' + p.name + '?'}
removeSuccess="Peer removed" removeSuccess="Peer removed"
removeRefresh="wireguard" /> removeRefresh="wireguard"
deleteKey=${p.name} />
</tr>`; </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({ const addZone = QuickModal({
title: 'Add Zone', title: 'Add Zone',
@@ -81,6 +81,7 @@ export default definePage({
})()}>Services</button> })()}>Services</button>
<${ConfirmDelete} <${ConfirmDelete}
url=${'/api/firewall/zones/' + enc(name)} url=${'/api/firewall/zones/' + enc(name)}
deleteKey=${name}
message=${'Delete zone ' + name + '?'} message=${'Delete zone ' + name + '?'}
success=${'Zone ' + name + ' deleted'} success=${'Zone ' + name + ' deleted'}
refresh="firewall" refresh="firewall"
+36
View File
@@ -699,6 +699,42 @@ body {
text-align: center; 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 */ /* ApplyConfirm modal */
.apply-subsystem-row { .apply-subsystem-row {
display: flex; display: flex;