Add state management, WebSocket polling, html.js templating, and refactor pages
- lib/state.py: per-subsystem collectors with versioned state store - daemon/server.py: state refresh on request, batch routing updates - webui/static/hoover/html.js: new html tag template helper via htm.js - webui/static/hoover/websocket.js: real-time state change notifications - webui/static/hoover/vdom.js: VDOM improvements for keyed diff - All frontend pages refactored to use html templates - Add tests for state management and polling - Update docs and AGENTS.md
This commit is contained in:
@@ -9,6 +9,20 @@
|
||||
import { esc } from '../helpers.js?v=7';
|
||||
import { att_esc } from '../helpers.js?v=7';
|
||||
import { apiSubmit } from '../api.js?v=7';
|
||||
import { createDom } from '../vdom.js?v=7';
|
||||
|
||||
/**
|
||||
* Render Hoover VNodes into a modal content element.
|
||||
* VDOM is not diffed across modal re-render — modals are transient and
|
||||
* innerHTML is cleared/repainted each time (avoids lifecycle baggage).
|
||||
*/
|
||||
export function modalVNodes(inner, vnodes) {
|
||||
inner.innerHTML = '';
|
||||
const nodes = Array.isArray(vnodes) ? vnodes : [vnodes];
|
||||
for (const vnode of nodes) {
|
||||
if (vnode) inner.appendChild(createDom(vnode));
|
||||
}
|
||||
}
|
||||
|
||||
const _modalQueue = [];
|
||||
|
||||
@@ -35,10 +49,15 @@ function _renderModals() {
|
||||
/**
|
||||
* Open a modal dialog.
|
||||
*
|
||||
* @param {function} renderFn – (contentEl, idx) => void, renders into contentEl
|
||||
* @param {function|object} content – Either:
|
||||
* - renderFn(contentEl, idx) => void (legacy innerHTML path)
|
||||
* - VNode / VNode[] (new VDOM path — uses modalVNodes)
|
||||
*/
|
||||
export function openModal(renderFn) {
|
||||
_modalQueue.push({ renderFn, id: _modalQueue.length });
|
||||
export function openModal(content) {
|
||||
const entry = typeof content === 'function'
|
||||
? { renderFn: content, id: _modalQueue.length }
|
||||
: { id: _modalQueue.length, renderFn: (inner) => modalVNodes(inner, content) };
|
||||
_modalQueue.push(entry);
|
||||
_renderModals();
|
||||
}
|
||||
|
||||
@@ -61,6 +80,11 @@ export function closeAllModals() {
|
||||
_renderModals();
|
||||
}
|
||||
|
||||
/** Re-render all open modals. Used by long-lived modals that update in place. */
|
||||
export function refreshModals() {
|
||||
_renderModals();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a standard modal layout: title, form fields, action buttons.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import htm from '../../vendor/htm.js';
|
||||
import { htmAdapter } from './vdom.js?v=7';
|
||||
|
||||
export const html = htm.bind(htmAdapter);
|
||||
@@ -10,6 +10,9 @@ export { reactive, requestUpdate } from './reactivity.js?v=7';
|
||||
/* ── VDOM ────────────────────────────────────────────────────── */
|
||||
export { h } from './vdom.js?v=7';
|
||||
|
||||
/* ── HTM ──────────────────────────────────────────────────────── */
|
||||
export { html } from './html.js?v=7';
|
||||
|
||||
/* ── Render ──────────────────────────────────────────────────── */
|
||||
export { render } from './render.js?v=7';
|
||||
|
||||
@@ -38,7 +41,7 @@ export { PageHeader, renderGuard, renderGuardMulti, Tabs, SectionTitle, ActionGr
|
||||
export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, certStatusBadge, serviceStatusBadge, StatCard, StatusText, ServiceStatus, ActionCell, MonoText, ZoneSelect } from './components/data.js?v=7';
|
||||
|
||||
/* ── UI Components: Modal ────────────────────────────────────── */
|
||||
export { openModal, closeModal, closeAllModals, formModal, MultiSelectModal, QuickModal } from './components/modal.js?v=7';
|
||||
export { openModal, closeModal, closeAllModals, modalVNodes, refreshModals, formModal, MultiSelectModal, QuickModal } from './components/modal.js?v=7';
|
||||
|
||||
/* ── UI Components: Toast ────────────────────────────────────── */
|
||||
export { ToastContainer } from './components/toast.js?v=7';
|
||||
|
||||
@@ -21,6 +21,29 @@ export const _unmountFn = { fn: null };
|
||||
export function setMountFn(fn) { _mountFn.fn = fn; }
|
||||
export function setUnmountFn(fn) { _unmountFn.fn = fn; }
|
||||
|
||||
/**
|
||||
* htm event adapter: translates camelCase events (onClick) to Hoover's on:click.
|
||||
*/
|
||||
export function htmAdapter(tag, props, ...children) {
|
||||
if (tag === '#text' || tag === '#comp' || typeof tag === 'function') {
|
||||
return h(tag, props, ...children);
|
||||
}
|
||||
|
||||
if (props) {
|
||||
const normalized = {};
|
||||
for (const [key, val] of Object.entries(props)) {
|
||||
if (key.startsWith('on') && key.length > 2 && key[2] >= 'A' && key[2] <= 'Z') {
|
||||
normalized['on:' + key.slice(2).toLowerCase()] = val;
|
||||
} else {
|
||||
normalized[key] = val;
|
||||
}
|
||||
}
|
||||
props = normalized;
|
||||
}
|
||||
|
||||
return h(tag, props, ...children);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a VNode. Three forms:
|
||||
* h('div', { class: 'x' }, h('span', null, 'hi')) — element
|
||||
|
||||
@@ -57,14 +57,15 @@ function _wsConnect() {
|
||||
*
|
||||
* Expected message shapes:
|
||||
* { type: 'versions', updated: ['firewall', 'dnsmasq', …] }
|
||||
* { type: 'tick', subsystems: ['firewall', 'wireguard', …] }
|
||||
* { type: 'notify', topic: 'firewall' }
|
||||
* { type: 'status', topic: 'firewall', … }
|
||||
*/
|
||||
function handleMessage(msg) {
|
||||
const topics = [];
|
||||
|
||||
if (msg.type === 'versions' || msg.type === 'refresh') {
|
||||
topics.push(...(msg.updated || msg.topics || []));
|
||||
if (msg.type === 'versions' || msg.type === 'refresh' || msg.type === 'tick') {
|
||||
topics.push(...(msg.updated || msg.subsystems || msg.topics || []));
|
||||
} else if (msg.type === 'notify') {
|
||||
topics.push(msg.topic);
|
||||
} else if (msg.type === 'status') {
|
||||
|
||||
Reference in New Issue
Block a user