Add update-vendor.sh symlink support, unify install.sh vendor flow

- update-vendor.sh now creates webui/vendor symlinks (htm.js)
- install.sh calls update-vendor.sh after package install
- Add vendor/.empty and webui/vendor/.empty as directory placeholders in git
This commit is contained in:
2026-07-01 00:44:08 +00:00
parent 575cf06a4b
commit 8c13ad55ce
32 changed files with 1371 additions and 445 deletions
+51
View File
@@ -0,0 +1,51 @@
"""Aggregate status API blueprint.
Exposed at /api/status/* and delegates all operations to vacuum-walld.
"""
from __future__ import annotations
import logging
from flask import Blueprint
from daemon.client import get, post
from daemon.iface import GET_STATUS_PENDING, POST_STATUS_APPLY_ALL
from webui.api.common import _error, _ok
logger = logging.getLogger(__name__)
bp = Blueprint("status", __name__)
@bp.route("/pending", methods=["GET"])
def pending():
"""Retrieve aggregate pending changes across all subsystems.
Endpoint:
GET /api/status/pending
Returns:
JSON response with per-subsystem pending status and total change count.
"""
try:
return _ok(get(GET_STATUS_PENDING))
except RuntimeError as exc:
logger.error("Failed to get pending status: %s", exc)
return _error(str(exc), 500)
@bp.route("/apply-all", methods=["POST"])
def apply_all():
"""Apply pending changes for all subsystems in dependency order.
Endpoint:
POST /api/status/apply-all
Returns:
JSON response with applied subsystems list and any errors encountered.
"""
try:
return _ok(post(POST_STATUS_APPLY_ALL))
except RuntimeError as exc:
logger.error("Failed to apply all pending changes: %s", exc)
return _error(str(exc), 500)
+3
View File
@@ -26,6 +26,7 @@ from webui.api.firewall import bp as firewall_bp
from webui.api.logs import bp as logs_bp
from webui.api.network import bp as network_bp
from webui.api.proxy import bp as proxy_bp
from webui.api.status import bp as status_bp
from webui.api.wireguard import bp as wireguard_bp
# ---------------------------------------------------------------------------
@@ -94,6 +95,7 @@ app.register_blueprint(proxy_bp, url_prefix="/api/proxy")
app.register_blueprint(certs_bp, url_prefix="/api/certs")
app.register_blueprint(wireguard_bp, url_prefix="/api/wireguard")
app.register_blueprint(logs_bp, url_prefix="/api/logs")
app.register_blueprint(status_bp, url_prefix="/api/status")
BLUEPRINTS = [
("firewall", firewall_bp),
@@ -103,6 +105,7 @@ BLUEPRINTS = [
("certs", certs_bp),
("wireguard", wireguard_bp),
("logs", logs_bp),
("status", status_bp),
]
for name, _ in BLUEPRINTS:
@@ -0,0 +1,138 @@
/**
* Hoover — components/applyconfirm.js
*
* Apply button with cross-subsystem confirmation modal.
* Fetches pending changes from /api/status/pending, shows them in an
* expandable modal, then applies all via /api/status/apply-all.
*/
import { h } from '../vdom.js?v=8';
import { html } from '../html.js?v=8';
import { reactive } from '../reactivity.js?v=8';
import { apiFetch, toast } from '../api.js?v=8';
import { modelFetch } from '../model.js?v=8';
import { openModal, closeModal, modalVNodes } from './modal.js?v=8';
export const SUBSYSTEM_LIST = [
{ key: 'firewall', label: 'Firewall' },
{ key: 'dnsmasq', label: 'DHCP/DNS' },
{ key: 'nginx', label: 'Nginx' },
{ key: 'wireguard', label: 'WireGuard' },
{ key: 'networkd', label: 'Network' },
];
/**
* Extract pending state from a subsystem result.
* Handles firewall's `needs_apply` vs hash subsystems' `pending_changes`.
*/
export function isPending(ss) {
return (ss.needs_apply || ss.pending_changes || false);
}
/**
* Build the VNode array for modal rows given pending data and expanded state.
*/
export function buildRows(pendingData, expanded) {
const vnodeList = [];
for (const sub of SUBSYSTEM_LIST) {
const ss = pendingData[sub.key] || {};
const changes = ss.changes || [];
const hasPending = isPending(ss) && changes.length > 0;
const isExpanded = !!expanded[sub.key];
vnodeList.push(html`<div class="apply-subsystem-row${hasPending ? ' pending' : ''}">
<span class="apply-subsystem-name">${sub.label}</span>
<span class="apply-subsystem-status${hasPending ? ' pending' : ''}">${hasPending ? changes.length + ' pending changes' : 'Up to date'}</span>
${hasPending ? html`<span class="apply-expand-icon${isExpanded ? ' expanded' : ''}">\u25B6</span>` : ''}
</div>`);
if (hasPending && isExpanded) {
vnodeList.push(html`<div class="apply-detail-section">${changes.map(c => html`<div class="apply-detail-item">${c.summary || c.detail || c}</div>`)}</div>`);
}
}
return vnodeList;
}
/**
* POST apply-all, toast result, close modal, refresh models.
*/
async function doApply(successMsg, refreshTargets) {
const resp = await apiFetch('/api/status/apply-all', { method: 'POST' });
if (resp.ok) {
toast(successMsg, 'success');
closeModal();
if (refreshTargets) {
const names = Array.isArray(refreshTargets) ? refreshTargets : [refreshTargets];
names.forEach(n => modelFetch(n));
}
} else {
toast(resp.error || 'Apply failed', 'error');
}
}
/**
* Fetch pending state, then open the confirmation modal.
*/
async function openApplyModal(successMsg, refreshTargets) {
const pendingResp = await apiFetch('/api/status/pending');
if (!pendingResp.ok) {
toast(pendingResp.error || 'Could not fetch pending changes', 'error');
return;
}
const pendingData = pendingResp.data || {};
const totalChanges = pendingData.total_changes || 0;
const expanded = reactive({});
openModal((inner) => {
const rows = buildRows(pendingData, expanded);
if (totalChanges === 0) {
modalVNodes(inner, html`<div>
<h2 class="modal-title">Confirm: Apply All Changes</h2>
<div class="apply-no-changes">No pending changes to apply.</div>
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Cancel</button></div>
</div>`);
return;
}
modalVNodes(inner, html`<div>
<h2 class="modal-title">Confirm: Apply All Changes</h2>
<div class="modal-body">${rows}</div>
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Cancel</button><button class="btn btn-primary" onClick="${() => doApply(successMsg, refreshTargets)}">Apply All</button></div>
</div>`);
});
}
/**
* Apply button with cross-subsystem confirmation modal.
*
* @param {object} props
* @param {boolean} props.pending - Whether any subsystem has pending changes
* @param {string} [props.label] - Apply button text (default: 'Apply')
* @param {string} [props.syncedLabel] - Synced button text (default: 'Synced')
* @param {string} [props.cls] - Button CSS classes (default: 'btn btn-primary' when pending, 'btn btn-outline' when synced)
* @param {string} [props.successMsg] - Success toast message (default: 'All changes applied')
* @param {string|string[]} [props.refresh] - Model name(s) to refresh after apply
*/
export function ApplyConfirm(props = {}) {
const label = props.label || 'Apply';
const syncedLabel = props.syncedLabel || 'Synced';
const successMsg = props.successMsg || 'All changes applied';
return h('button', {
class: props.cls !== undefined
? props.cls
: (props.pending ? 'btn btn-primary' : 'btn btn-outline'),
'on:click': () => {
if (!props.pending) {
toast(successMsg || 'All synced', 'info');
return;
}
openApplyModal(successMsg, props.refresh);
},
}, props.pending ? label : syncedLabel);
}
+3
View File
@@ -43,5 +43,8 @@ export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, cert
/* ── UI Components: Modal ────────────────────────────────────── */
export { openModal, closeModal, closeAllModals, modalVNodes, refreshModals, formModal, MultiSelectModal, QuickModal } from './components/modal.js?v=8';
/* ── UI Components: Apply ────────────────────────────────────── */
export { ApplyConfirm } from './components/applyconfirm.js?v=8';
/* ── UI Components: Toast ────────────────────────────────────── */
export { ToastContainer } from './components/toast.js?v=8';
+76
View File
@@ -699,6 +699,82 @@ body {
text-align: center;
}
/* ApplyConfirm modal */
.apply-subsystem-row {
display: flex;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid var(--border);
cursor: default;
}
.apply-subsystem-row.pending {
cursor: pointer;
}
.apply-subsystem-row.pending:hover {
background: rgba(0, 180, 216, 0.08);
}
.apply-subsystem-name {
flex: 1;
font-weight: 600;
font-size: 14px;
}
.apply-subsystem-status {
margin-left: 12px;
color: var(--text-muted);
font-size: 13px;
}
.apply-subsystem-status.pending {
color: var(--warning);
font-weight: 500;
}
.apply-expand-icon {
margin-left: 8px;
transition: transform 0.2s;
font-size: 12px;
}
.apply-expand-icon.expanded {
transform: rotate(90deg);
}
.apply-detail-section {
padding: 8px 12px;
background: rgba(0, 0, 0, 0.15);
margin: 4px 0 4px 12px;
border-radius: 4px;
font-size: 13px;
}
.apply-detail-item {
padding: 4px 0;
border-bottom: 1px solid var(--border);
}
.apply-detail-item:last-child {
border-bottom: none;
}
.apply-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 12px;
margin-top: 12px;
border-top: 1px solid var(--border);
}
.apply-no-changes {
padding: 16px;
text-align: center;
color: var(--text-muted);
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {
View File