feat: pre-computed state store and async ACME issuance (fixes timeout mismatch)
- Add lib/state.py: in-memory state store with subsystem collectors (firewall, dnsmasq, nginx, acme, wireguard) - Refactor all handlers: read from state on GET, call refresh_state() after mutations instead of invoking subprocesses per request - daemon/server.py: add refresh_state(), /status/all, /status/refresh; populate state at startup - webui/api/certs.py: async step-by-step ACME issuance (validate, issue with request_id, poll status) replacing blocking endpoint - webui/server.py: render pages from state instead of direct lib calls - Update templates, JS for async cert issuance with polling UI - Update tests for state-based mocking; add test_state.py - Fix SIM105 lint issue (contextlib.suppress) - Add TODO.md with certificate issuance issue tracking Resolves: WebUI 30s timeout freeze during cert issuance (Problem 1)
This commit is contained in:
+43
-7
@@ -35,24 +35,60 @@ def cert_details(domain: str):
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/issue", methods=["POST"])
|
||||
def issue_bp():
|
||||
@bp.route("/validate", methods=["POST"])
|
||||
def validate():
|
||||
body = request.get_json(silent=True) or {}
|
||||
domain = body.get("domain", "").strip()
|
||||
if not domain:
|
||||
return _error("'domain' is required", 400)
|
||||
try:
|
||||
result = post("/acme/validate", {"domain": domain})
|
||||
return _ok(result)
|
||||
except BadRequest as exc:
|
||||
logger.info("Validation rejected: %s", exc)
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to validate cert for '%s': %s", domain, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/issue/start", methods=["POST"])
|
||||
def issue_start():
|
||||
body = request.get_json(silent=True) or {}
|
||||
domain = body.get("domain", "").strip()
|
||||
if not domain:
|
||||
return _error("'domain' is required", 400)
|
||||
webroot = body.get("webroot")
|
||||
email = body.get("email", "").strip() or None
|
||||
webroot = body.get("webroot")
|
||||
try:
|
||||
logger.info("Certificate issuance requested for '%s' via API", domain)
|
||||
post("/acme/issue", {"domain": domain, "webroot": webroot, "email": email})
|
||||
logger.info("Certificate issued for '%s'", domain)
|
||||
return _ok(None)
|
||||
result = post(
|
||||
"/acme/issue", {"domain": domain, "webroot": webroot, "email": email}
|
||||
)
|
||||
logger.info(
|
||||
"Certificate issuance started for '%s' (id=%s)",
|
||||
domain,
|
||||
result.get("request_id"),
|
||||
)
|
||||
return _ok(result)
|
||||
except BadRequest as exc:
|
||||
logger.info("Cert issue for '%s' rejected: %s", domain, exc)
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to issue cert for '%s': %s", domain, exc)
|
||||
logger.error("Failed to start cert issue for '%s': %s", domain, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/issue/<request_id>", methods=["GET"])
|
||||
def issue_status(request_id: str):
|
||||
try:
|
||||
result = get("/acme/issue/status", {"id": request_id})
|
||||
return _ok(result)
|
||||
except NotFound as exc:
|
||||
logger.info("Issuance request '%s' not found: %s", request_id, exc)
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to get issuance status for '%s': %s", request_id, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
|
||||
+88
-37
@@ -5,12 +5,16 @@ Serves the Flask application on 127.0.0.1:9090. Nginx terminates SSL
|
||||
and enforces basic authentication before proxying to this port.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, render_template, request
|
||||
|
||||
@@ -41,6 +45,26 @@ logger.info(
|
||||
logger.info("Project directory: %s", PROJECT_DIR)
|
||||
logger.info("Process ID: %d", os.getpid())
|
||||
|
||||
_reloading = False
|
||||
|
||||
|
||||
def _sighup_handler(signum, frame):
|
||||
global _reloading
|
||||
if _reloading:
|
||||
return
|
||||
_reloading = True
|
||||
logger.info("Received SIGHUP, reloading modules...")
|
||||
for mod_name, mod in sys.modules.items():
|
||||
if mod_name.startswith("webui.") or mod_name.startswith("lib."):
|
||||
with contextlib.suppress(Exception):
|
||||
importlib.reload(mod)
|
||||
logger.info("Modules reloaded, sending SIGTERM to restart under systemd...")
|
||||
signal.signal(signal.SIGTERM, signal.SIG_DFL)
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
|
||||
|
||||
signal.signal(signal.SIGHUP, _sighup_handler)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App factory
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -184,21 +208,38 @@ def _get_service_status(dnsmasq_info, wg_info):
|
||||
return services
|
||||
|
||||
|
||||
def _fw_config_get():
|
||||
def _fw_config_get() -> dict[str, Any]:
|
||||
"""Read firewall config via daemon."""
|
||||
return get("/firewall/config")
|
||||
|
||||
|
||||
def _load_status_all() -> dict[str, Any]:
|
||||
"""Load all system state in one call."""
|
||||
return get("/status/all")
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def root_redirect():
|
||||
from flask import redirect, url_for
|
||||
|
||||
return redirect(url_for("dashboard"))
|
||||
|
||||
|
||||
@app.route("/dashboard")
|
||||
def dashboard():
|
||||
active_zones = _safely(
|
||||
lambda: {k: v for k, v in get("/firewall/zones").get("active", {}).items()}, {}
|
||||
)
|
||||
interfaces = _safely(lambda: get("/firewall/interfaces"), [])
|
||||
dnsmasq = _safely(lambda: get("/dnsmasq/status"), {})
|
||||
domains = _safely(lambda: get("/nginx/domains"), [])
|
||||
certs = _safely(lambda: get("/acme/list"), [])
|
||||
wg = _safely(lambda: get("/wireguard/status"), {})
|
||||
all_status = _safely(_load_status_all, {})
|
||||
fw_state = all_status.get("firewall", {}) or {}
|
||||
dm_state = all_status.get("dnsmasq", {}) or {}
|
||||
ng_state = all_status.get("nginx", {}) or {}
|
||||
ac_state = all_status.get("acme", {}) or {}
|
||||
wg_state = all_status.get("wireguard", {}) or {}
|
||||
|
||||
active_zones = {k: v for k, v in fw_state.get("active_zones", {}).items()}
|
||||
interfaces = fw_state.get("interfaces", [])
|
||||
dnsmasq = dm_state.get("status", {})
|
||||
domains = ng_state.get("domains", [])
|
||||
certs = ac_state.get("certs", [])
|
||||
wg = wg_state.get("status", {})
|
||||
|
||||
return render_template(
|
||||
"dashboard.html",
|
||||
@@ -210,41 +251,44 @@ def dashboard():
|
||||
wg_status=wg,
|
||||
services=_get_service_status(dnsmasq, wg),
|
||||
firewall_config=_safely(_fw_config_get, {}),
|
||||
firewall_pending=_safely(lambda: get("/firewall/config/pending"), {}),
|
||||
firewall_pending=fw_state.get("pending", {}),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/interfaces")
|
||||
def interfaces_page():
|
||||
all_status = _safely(_load_status_all, {})
|
||||
fw_state = all_status.get("firewall", {}) or {}
|
||||
return render_template(
|
||||
"interfaces.html",
|
||||
interfaces=_safely(lambda: get("/firewall/interfaces"), []),
|
||||
zones=_safely(lambda: get("/firewall/zones").get("available", []), []),
|
||||
interfaces=fw_state.get("interfaces", []),
|
||||
zones=fw_state.get("active_zones", {}).keys() or [],
|
||||
firewall_config=_safely(_fw_config_get, {}),
|
||||
firewall_pending=_safely(lambda: get("/firewall/config/pending"), {}),
|
||||
firewall_pending=fw_state.get("pending", {}),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/zones")
|
||||
def zones_page():
|
||||
firewall_config = _safely(_fw_config_get, {})
|
||||
firewall_pending = _safely(lambda: get("/firewall/config/pending"), {})
|
||||
all_status = _safely(_load_status_all, {})
|
||||
fw_state = all_status.get("firewall", {}) or {}
|
||||
return render_template(
|
||||
"zones.html",
|
||||
zones=_safely(lambda: get("/firewall/zones/all"), []),
|
||||
services=_safely(lambda: get("/firewall/services"), []),
|
||||
firewall_config=firewall_config,
|
||||
firewall_pending=firewall_pending,
|
||||
zones=list(fw_state.get("zones", {}).values()),
|
||||
services=fw_state.get("available_services", []),
|
||||
firewall_config=_safely(_fw_config_get, {}),
|
||||
firewall_pending=fw_state.get("pending", {}),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/rules")
|
||||
def rules_page():
|
||||
zones = list(_safely(lambda: get("/firewall/zones").get("active", {}).keys(), []))
|
||||
raw = _safely(_fw_config_get, {})
|
||||
rules = {}
|
||||
for zname, zcfg in raw.get("zones", {}).items():
|
||||
rr = zcfg.get("rich_rules", [])
|
||||
all_status = _safely(_load_status_all, {})
|
||||
fw_state = all_status.get("firewall", {}) or {}
|
||||
zones = list(fw_state.get("zones", {}).keys())
|
||||
rules: dict[str, list[str]] = {}
|
||||
for zname, zcfg in fw_state.get("zones", {}).items():
|
||||
rr = zcfg.get("rich-rules", [])
|
||||
if rr:
|
||||
rules[zname] = rr
|
||||
return render_template("rules.html", zones=zones, rules=rules or None)
|
||||
@@ -252,46 +296,53 @@ def rules_page():
|
||||
|
||||
@app.route("/nat")
|
||||
def nat_page():
|
||||
return render_template(
|
||||
"nat.html", zones=_safely(lambda: get("/firewall/zones/all"), [])
|
||||
)
|
||||
all_status = _safely(_load_status_all, {})
|
||||
fw_state = all_status.get("firewall", {}) or {}
|
||||
return render_template("nat.html", zones=list(fw_state.get("zones", {}).values()))
|
||||
|
||||
|
||||
@app.route("/dhcp")
|
||||
def dhcp_page():
|
||||
all_status = _safely(_load_status_all, {})
|
||||
dm_state = all_status.get("dnsmasq", {}) or {}
|
||||
return render_template(
|
||||
"dhcp.html",
|
||||
config=_safely(lambda: get("/dnsmasq/config"), {}),
|
||||
status=_safely(lambda: get("/dnsmasq/status"), {}),
|
||||
leases=_safely(lambda: get("/dnsmasq/leases"), []),
|
||||
config=dm_state.get("config", {}),
|
||||
status=dm_state.get("status", {}),
|
||||
leases=dm_state.get("leases", []),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/proxy")
|
||||
def proxy_page():
|
||||
all_status = _safely(_load_status_all, {})
|
||||
ng_state = all_status.get("nginx", {}) or {}
|
||||
return render_template(
|
||||
"proxy.html",
|
||||
domains=_safely(lambda: get("/nginx/domains"), []),
|
||||
config=_safely(lambda: get("/nginx/config"), {}),
|
||||
domains=ng_state.get("domains", []),
|
||||
config=ng_state.get("config", {}),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/certs")
|
||||
def certs_page():
|
||||
email_data = _safely(lambda: get("/acme/email"), {"email": ""})
|
||||
all_status = _safely(_load_status_all, {})
|
||||
ac_state = all_status.get("acme", {}) or {}
|
||||
return render_template(
|
||||
"certs.html",
|
||||
certs=_safely(lambda: get("/acme/list"), []),
|
||||
email=email_data.get("email", ""),
|
||||
certs=ac_state.get("certs", []),
|
||||
email=ac_state.get("email", ""),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/wireguard")
|
||||
def wireguard_page():
|
||||
all_status = _safely(_load_status_all, {})
|
||||
wg_state = all_status.get("wireguard", {}) or {}
|
||||
return render_template(
|
||||
"wireguard.html",
|
||||
config=_safely(lambda: get("/wireguard/config"), {}),
|
||||
status=_safely(lambda: get("/wireguard/status"), {}),
|
||||
config=wg_state.get("config", {}),
|
||||
status=wg_state.get("status", {}),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+175
-1
@@ -29,7 +29,7 @@ const closeModal = (id) => {
|
||||
};
|
||||
|
||||
// Tab switching
|
||||
const switchTab = (tabName) => {
|
||||
let switchTab = (tabName) => {
|
||||
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
|
||||
document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));
|
||||
document.getElementById('tab-' + tabName).classList.add('active');
|
||||
@@ -321,3 +321,177 @@ const escHtml = (s) => {
|
||||
const escAttr = (s) => {
|
||||
return String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>');
|
||||
};
|
||||
|
||||
// ─── Certificate Issue Wizard ────────────────────────────────────────
|
||||
|
||||
let _issuePollHandle = null;
|
||||
let _issueRequestId = null;
|
||||
|
||||
function closeIssueWizard() {
|
||||
if (_issuePollHandle) {
|
||||
clearInterval(_issuePollHandle);
|
||||
_issuePollHandle = null;
|
||||
}
|
||||
_issueRequestId = null;
|
||||
resetIssueWizard();
|
||||
closeModal('issue-cert-modal');
|
||||
}
|
||||
|
||||
function resetIssueWizard() {
|
||||
document.getElementById('cert-wizard-input').style.display = '';
|
||||
document.getElementById('cert-wizard-progress').style.display = 'none';
|
||||
document.getElementById('cert-check-results').style.display = 'none';
|
||||
document.getElementById('cert-check-btn').style.display = '';
|
||||
document.getElementById('cert-issue-btn').style.display = 'none';
|
||||
document.getElementById('cert-close-progress').style.display = 'none';
|
||||
}
|
||||
|
||||
function validateCertIssue() {
|
||||
const domain = document.getElementById('cert-domain').value.trim();
|
||||
if (!domain) {
|
||||
showErrorToast('Domain is required');
|
||||
return;
|
||||
}
|
||||
const email = document.getElementById('cert-email').value.trim() || undefined;
|
||||
|
||||
const checkBtn = document.getElementById('cert-check-btn');
|
||||
checkBtn.disabled = true;
|
||||
checkBtn.textContent = 'Checking...';
|
||||
|
||||
fetch('/api/certs/validate', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ domain, email })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
checkBtn.disabled = false;
|
||||
checkBtn.textContent = 'Check';
|
||||
|
||||
const result = data.ok ? data.data : data;
|
||||
renderChecks(result.checks);
|
||||
|
||||
if (result.ready) {
|
||||
document.getElementById('cert-check-btn').style.display = 'none';
|
||||
document.getElementById('cert-issue-btn').style.display = '';
|
||||
} else {
|
||||
document.getElementById('cert-issue-btn').style.display = 'none';
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
checkBtn.disabled = false;
|
||||
checkBtn.textContent = 'Check';
|
||||
showErrorToast('Validation failed: ' + e.message);
|
||||
});
|
||||
}
|
||||
|
||||
function renderChecks(checks) {
|
||||
const container = document.getElementById('cert-checks-list');
|
||||
const resultsDiv = document.getElementById('cert-check-results');
|
||||
resultsDiv.style.display = '';
|
||||
|
||||
container.innerHTML = checks.map(c => {
|
||||
let icon, badge;
|
||||
if (c.passed) {
|
||||
icon = '✓';
|
||||
badge = c.blocking ? 'badge-success' : 'badge-info';
|
||||
} else {
|
||||
icon = '✗';
|
||||
badge = 'badge-danger';
|
||||
}
|
||||
return '<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;font-size:12px;">' +
|
||||
'<span class="badge ' + badge + '">' + icon + '</span>' +
|
||||
'<span>' + escHtml(c.name).replace(/_/g, ' ') + '</span>' +
|
||||
'<span class="text-muted" style="flex:1;text-align:right;">' + escHtml(c.message || '') + '</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function startCertIssue() {
|
||||
const domain = document.getElementById('cert-domain').value.trim();
|
||||
const email = document.getElementById('cert-email').value.trim() || undefined;
|
||||
|
||||
document.getElementById('cert-wizard-input').style.display = 'none';
|
||||
document.getElementById('cert-wizard-progress').style.display = '';
|
||||
document.getElementById('cert-steps-list').innerHTML = '<div class="text-muted text-sm" style="margin:16px 0;">Starting certificate issuance…</div>';
|
||||
|
||||
fetch('/api/certs/issue/start', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ domain, email })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const result = data.ok ? data.data : data;
|
||||
_issueRequestId = result.request_id;
|
||||
if (!result.request_id) throw new Error('No request_id returned');
|
||||
|
||||
// If issuance already exists for this domain, follow the existing request
|
||||
startIssuePoll(result.request_id);
|
||||
})
|
||||
.catch(e => {
|
||||
showErrorToast('Failed to start issuance: ' + e.message);
|
||||
// Fall back to input phase
|
||||
document.getElementById('cert-wizard-input').style.display = '';
|
||||
document.getElementById('cert-wizard-progress').style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function startIssuePoll(requestId) {
|
||||
_issueRequestId = requestId;
|
||||
_issuePollHandle = setInterval(() => pollIssueStatus(requestId), 2000);
|
||||
// Also poll immediately
|
||||
pollIssueStatus(requestId);
|
||||
}
|
||||
|
||||
function pollIssueStatus(requestId) {
|
||||
fetch('/api/certs/issue/' + encodeURIComponent(requestId))
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const result = data.ok ? data.data : data;
|
||||
renderIssueSteps(result.steps, result.status);
|
||||
|
||||
if (result.status === 'completed') {
|
||||
clearInterval(_issuePollHandle);
|
||||
_issuePollHandle = null;
|
||||
document.getElementById('cert-close-progress').style.display = '';
|
||||
showSuccessToast('Certificate issued for ' + result.domain);
|
||||
} else if (result.status === 'failed') {
|
||||
clearInterval(_issuePollHandle);
|
||||
_issuePollHandle = null;
|
||||
// Show failed — user can see which step failed
|
||||
document.getElementById('cert-close-progress').style.display = '';
|
||||
showErrorToast('Certificate issuance failed for ' + result.domain);
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
// Don't poll on error — but keep trying since request might still be running
|
||||
});
|
||||
}
|
||||
|
||||
function renderIssueSteps(steps, status) {
|
||||
const container = document.getElementById('cert-steps-list');
|
||||
if (!steps || !steps.length) {
|
||||
container.innerHTML = '<div class="text-muted text-sm">Pending…</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = steps.map(s => {
|
||||
let icon;
|
||||
if (s.status === 'done') icon = '<span class="status-dot status-up"></span>';
|
||||
else if (s.status === 'running') icon = '<span class="status-dot status-pending"></span>';
|
||||
else if (s.status === 'error') icon = '<span class="status-dot status-down"></span>';
|
||||
else icon = '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--border);margin-right:6px;"></span>';
|
||||
|
||||
return '<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;font-size:13px;">' +
|
||||
icon +
|
||||
'<span>' + escHtml(s.label) + '</span>' +
|
||||
(s.status === 'running' ? '<span class="text-muted text-sm">(in progress…)</span>' :
|
||||
s.status === 'error' ? '<span class="badge badge-danger" style="margin-left:auto;">' + escHtml(s.message || 'failed') + '</span>' :
|
||||
'<span class="badge badge-success" style="margin-left:auto;">done</span>') +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
if (status === 'completed') {
|
||||
container.innerHTML += '<div style="margin-top:12px;text-align:center;"><span class="badge badge-success" style="font-size:13px;padding:4px 12px;">✓ Certificate issued</span></div>';
|
||||
}
|
||||
}
|
||||
|
||||
+28
-10
@@ -7,7 +7,7 @@
|
||||
<h1>Certificates</h1>
|
||||
<div class="subtitle">SSL/TLS certificate management</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="openModal('issue-cert-modal')">+ Issue New Certificate</button>
|
||||
<button class="btn btn-primary" onclick="resetIssueWizard(); openModal('issue-cert-modal')">+ Issue New Certificate</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@@ -53,24 +53,42 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Issue Certificate Modal -->
|
||||
<div class="modal-overlay" id="issue-cert-modal" onclick="if(event.target===this) closeModal('issue-cert-modal')">
|
||||
<div class="modal">
|
||||
<!-- Issue Certificate Modal — Phase 1: Validate -->
|
||||
<div class="modal-overlay" id="issue-cert-modal" onclick="if(event.target===this) closeIssueWizard()">
|
||||
<div class="modal" style="min-width:480px;">
|
||||
<h2>Issue New Certificate</h2>
|
||||
<form hx-post="/api/certs/issue" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ closeModal('issue-cert-modal'); refreshTable('/api/certs/list', document.getElementById('cert-rows'), renderCerts); showSuccessToast('Certificate issuance started'); }">
|
||||
|
||||
<!-- Phase 1: Input + Pre-flight Checks -->
|
||||
<div id="cert-wizard-input">
|
||||
<div class="form-group">
|
||||
<label for="cert-domain">Domain</label>
|
||||
<input type="text" id="cert-domain" name="domain" placeholder="example.com" required>
|
||||
<input type="text" id="cert-domain" placeholder="example.com" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cert-email">Contact Email</label>
|
||||
<input type="email" id="cert-email" name="email" placeholder="admin@example.com" value="{{ (email or '') | e }}">
|
||||
<input type="email" id="cert-email" placeholder="admin@example.com" value="{{ (email or '') | e }}">
|
||||
</div>
|
||||
|
||||
<!-- Pre-flight validation results (shown after Check) -->
|
||||
<div id="cert-check-results" style="display:none;">
|
||||
<div class="section-title" style="margin-top:16px;">Pre-flight Checks</div>
|
||||
<div id="cert-checks-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" onclick="closeModal('issue-cert-modal')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Issue</button>
|
||||
<button type="button" class="btn btn-outline" onclick="closeIssueWizard()">Cancel</button>
|
||||
<button type="button" id="cert-check-btn" class="btn btn-primary" onclick="validateCertIssue()">Check</button>
|
||||
<button type="button" id="cert-issue-btn" class="btn btn-primary" style="display:none;" onclick="startCertIssue()">Issue</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Phase 2: Step progress -->
|
||||
<div id="cert-wizard-progress" style="display:none;">
|
||||
<div id="cert-steps-list"></div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" id="cert-close-progress" style="display:none;" onclick="closeIssueWizard(); refreshTable('/api/certs/list', document.getElementById('cert-rows'), renderCerts);">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
+27
-15
@@ -89,36 +89,34 @@
|
||||
var refreshInterval = {{ (refresh_interval | default(15)) }};
|
||||
var currentTab = 'journal';
|
||||
|
||||
function loadTabEl(el) {
|
||||
var url = el.getAttribute('hx-get');
|
||||
if (!url) return;
|
||||
el.textContent = 'Loading...';
|
||||
fetch(url).then(function(r) { return r.text(); })
|
||||
.then(function(html) { el.innerHTML = html; })
|
||||
.catch(function() { el.innerHTML = '<div class="log-line">(failed to load log)</div>'; });
|
||||
}
|
||||
|
||||
function setActivePolling() {
|
||||
document.querySelectorAll('.log-viewer').forEach(function(el) {
|
||||
htmx.abort(el);
|
||||
if (typeof htmx !== 'undefined') htmx.abort(el);
|
||||
el.setAttribute('hx-trigger', 'none');
|
||||
});
|
||||
var activeEl = document.getElementById('log-' + currentTab);
|
||||
if (activeEl) {
|
||||
activeEl.setAttribute('hx-trigger', 'every ' + refreshInterval + 's');
|
||||
}
|
||||
if (typeof htmx !== 'undefined') htmx.process(document.body);
|
||||
}
|
||||
|
||||
function loadActiveTab() {
|
||||
var activeEl = document.getElementById('log-' + currentTab);
|
||||
if (activeEl) {
|
||||
htmx.ajax('GET', activeEl);
|
||||
loadTabEl(activeEl);
|
||||
}
|
||||
}
|
||||
|
||||
var origSwitchTab = switchTab;
|
||||
switchTab = function(tabName) {
|
||||
currentTab = tabName;
|
||||
if (typeof origSwitchTab === 'function') {
|
||||
origSwitchTab(tabName);
|
||||
}
|
||||
if (document.getElementById('auto-refresh-toggle').checked) {
|
||||
setActivePolling();
|
||||
}
|
||||
loadActiveTab();
|
||||
};
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
var toggle = document.getElementById('auto-refresh-toggle');
|
||||
if (toggle.checked) {
|
||||
@@ -126,7 +124,7 @@ function toggleAutoRefresh() {
|
||||
loadActiveTab();
|
||||
} else {
|
||||
document.querySelectorAll('.log-viewer').forEach(function(el) {
|
||||
htmx.abort(el);
|
||||
if (typeof htmx !== 'undefined') htmx.abort(el);
|
||||
el.setAttribute('hx-trigger', 'none');
|
||||
});
|
||||
}
|
||||
@@ -135,5 +133,19 @@ function toggleAutoRefresh() {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadActiveTab();
|
||||
});
|
||||
|
||||
window.addEventListener('load', function() {
|
||||
var origSwitchTab = typeof switchTab === 'function' ? switchTab : null;
|
||||
switchTab = function(tabName) {
|
||||
currentTab = tabName;
|
||||
if (origSwitchTab) {
|
||||
origSwitchTab(tabName);
|
||||
}
|
||||
if (document.getElementById('auto-refresh-toggle').checked) {
|
||||
setActivePolling();
|
||||
}
|
||||
loadActiveTab();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user