feat: auto-generate self-signed certs, hoover modal processing guard, refactor backends/proxy pages
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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';
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,16 +164,39 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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 }),
|
||||||
|
|||||||
@@ -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,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() {
|
||||||
|
|||||||
@@ -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,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,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' },
|
||||||
|
|||||||
@@ -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,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
@@ -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.' }),
|
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user