refactor: modernize frontend with hoover framework components and docs

- Add quick modal, table, service status, and confirmation dialog components
- Refactor all pages (certs, dhcp, proxy, etc.) to use new component patterns
- Introduce refactor load utility and render guard for consistent UX
- Add hoover documentation and update AGENTS.md, architecture, overview
This commit is contained in:
2026-06-21 04:29:27 +00:00
parent b8f20e99d9
commit 633505e7dc
29 changed files with 2558 additions and 1414 deletions
+11 -1
View File
@@ -41,7 +41,17 @@ Project uses `.venv`. Install deps with `pip install -e .` (from `pyproject.toml
### Frontend (hoover) ### Frontend (hoover)
Custom reactive SPA framework at `webui/static/hoover/`. Provides VDOM rendering, reactivity, router, WebSocket bindings, API helpers, and shared UI components. Exported via `hoover/index.js`. Pages in `webui/static/pages/` each define a route using `definePage()`. Bootstrap is `webui/static/app.js`. No build step — served raw. Custom reactive SPA framework at `webui/static/hoover/`. See `docs/hoover.md` for full API reference.
Conventions:
- All imports from `/static/hoover/index.js` (barrel export of reactivity, VDOM, router, API, components).
- Pages in `webui/static/pages/` export `definePage({ init, subscribe, load, render })` as default.
- Bootstrap: `webui/static/app.js` mounts two render roots (`#sidebar`, `#main`), then `connect()` for WS.
- `h()` builds VNodes; `#comp` + `hComp()` for component lifecycle; `key` for keyed diff.
- Events use `on:` prefix (`on:click`, `on:submit`). `class` prop accepts object.
- State always has `loading`, `refreshing`, `error` plus data. `load()` receives `(state, abortController, entry)`.
- `openModal` + `formModal` for dialogs; `apiSubmit()` for form submission. `ToastContainer()` in main root.
- No build step — ES modules served raw. Assets versioned via `?v=N` query string.
### Daemon Endpoints ### Daemon Endpoints
+27
View File
@@ -138,6 +138,33 @@ The following file system locations are used for integration with system service
The `/etc/nginx/conf.d/vacuum-wall.conf` include file ensures that all domain-specific configurations in `sites-enabled/` are loaded by nginx without modifying the main `nginx.conf`. The SSL snippet keeps TLS settings consistent across all managed domains and allows global updates from a single location. The `/etc/nginx/conf.d/vacuum-wall.conf` include file ensures that all domain-specific configurations in `sites-enabled/` are loaded by nginx without modifying the main `nginx.conf`. The SSL snippet keeps TLS settings consistent across all managed domains and allows global updates from a single location.
## Frontend Architecture
The web UI is a single-page application built on **Hoover**, a custom lightweight VDOM framework. See [Hoover Framework Reference](hoover.md) for the complete API.
### Request Flow (Frontend)
```
Client requests index.html ──→ nginx ──→ Flask (server-side __WS_URL_PLACEHOLDER__ substitution)
Client loads app.js ──→ Hoover initializes, mounts #sidebar and #main render roots
Hoover connects WebSocket ──→ daemon/ws (127.0.0.1:9091)
Page navigate (hash change) ──→ reactive router state updates ──→ render engine re-executes ──→ VDOM diff patches DOM
User action (form submit) ──→ apiFetch() ──→ Flask REST API ──→ daemon/client.py ──→ vacuum-walld
WebSocket message (versions) ──→ topic match ──→ page load() re-executed ──→ state updated ──→ render engine patches DOM
```
### Component Model
Each route is a `definePage()` component with reactive state, async data loading, and WebSocket auto-refresh. Pages are mounted using `hComp(page, key)` in the router, where the key determines lifecycle boundaries. The same key reuses the component instance (preserving state); a different key unmounts the old page and mounts the new one.
### No Build Step
All JavaScript is served as ES modules. The `?v=N` query string param version-pins asset imports for cache invalidation. Dev mode (`VACUUM_WALL_DEV`) disables aggressive static asset caching.
### WebSocket Broadcast
The daemon broadcasts state-change notifications via WebSocket. Hoover's `subscribe` mechanism maps page-level topic subscriptions to automatic `load()` re-executions. Messages are debounced (300ms) and in-flight loads are aborted before re-loading, ensuring the UI always displays the latest available data.
## Zone Model ## Zone Model
The firewalld zone layout in Vacuum Wall follows a defense-in-depth approach, segmenting traffic based on trust level: The firewalld zone layout in Vacuum Wall follows a defense-in-depth approach, segmenting traffic based on trust level:
+963
View File
@@ -0,0 +1,963 @@
# Hoover — SPA Framework
Hoover is the custom reactive SPA framework used by the Vacuum Wall web UI. It provides a lightweight VDOM rendering engine, reactive state, a hash-based router, WebSocket bindings, and shared UI components. No build step is required — all code runs as ES modules served raw by nginx.
## Overview
| Module | File | Purpose |
|---|---|---|
| Reactivity | `reactivity.js` | Reactive Proxy state with batched render requests |
| VDOM | `vdom.js` | Virtual DOM: `h()` factory, diffing, patching |
| Render | `render.js` | Render engine: container-level diffing, component lifecycle |
| Component | `component.js` | Page definitions, lifecycle hooks, state caching |
| Router | `router.js` | Hash-based SPA router, `Link` navigation component |
| WebSocket | `websocket.js` | Auto-reconnect WS, topic subscriptions, auto-refresh |
| API | `api.js` | JSON fetch wrapper, toast notifications, form submissions |
| Helpers | `helpers.js` | Escaping, DOM value helpers, zone parsing |
| Components | `components/*.js` | Reusable UI: layout, data tables, modals, toasts |
| Barrel | `index.js` | Single import point for all public APIs |
All public APIs are exported from `hoover/index.js`. Pages and app bootstrap import from this single entry point.
## Architecture
```
index.html — static shell with #sidebar, #main, #modal-root
└── app.js — SPA bootstrap
├── render(sidebarEl, Sidebar) — sidebar render root
├── render(mainEl, MainContent) — main content render root
└── connect() — WebSocket lifecycle
```
The HTML shell (`index.html`) provides named DOM containers (`#sidebar`, `#main`) plus a `#modal-root` anchor for modals. The `app.js` bootstrap mounts Hoover render functions onto `#sidebar` and `#main`, creating two independent render roots. The server substitutes `__WS_URL_PLACEHOLDER__` in `index.html` to set `window.__WS_URL__` for WebSocket routing.
Each render root registers a render function via `render(container, fn)`. When reactive state changes, all registered render functions re-execute in a single batched microtask, producing new VNodes that are diffed against the previous tree and patched into the DOM.
## Bootstrap
The app starts from `webui/static/app.js`:
```javascript
import { h, render, Link, hComp, ToastContainer, connect, reactive } from '/static/hoover/index.js?v=4';
// 1. Create reactive router state
const router = {
state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
component() {
const name = this.state.path.replace(/^\//, '');
const page = Pages[name] || NotFoundPage;
return hComp(page, this.state.path);
},
};
// 2. Listen for hash changes
window.addEventListener('hashchange', () => {
router.state.path = location.hash.slice(1) || '/dashboard';
});
// 3. Mount render roots
render(sidebarEl, Sidebar);
render(mainEl, MainContent);
// 4. Start WebSocket (deferred to avoid initial render conflict)
setTimeout(connect, 0);
```
## Reactivity
### `reactive(obj)`
Wraps a plain object in a reactive `Proxy`. Any property assignment that changes the value automatically schedules a batched re-render across all registered render roots.
```javascript
const state = reactive({ data: null, loading: true, error: null });
// Triggers re-render
state.loading = false;
state.data = result;
```
Multiple property mutations in the same microtask tick produce a single render cycle. Read properties normally; only writes trigger updates.
**Important:** Hoover's reactivity proxy intercepts property `set` only. It does not track property additions/deletions, array mutations (e.g., `push`, `splice`), or nested object deep changes. Always mutate top-level properties by assignment:
```javascript
// Correct — assigns a new array
state.items = [...state.items, newItem];
// Incorrect — push won't trigger re-render
state.items.push(newItem);
```
### `requestUpdate()`
Manually schedule a re-render. Only one microtask is queued regardless of how many times it's called in the same tick.
## Virtual DOM
### `h(tag, props, ...children)`
The VNode factory. Three forms:
```javascript
// Element
h('div', { class: 'card' }, h('span', null, 'Hello'))
// Text node
h('#text', 'some text')
// Component (Hoover component, not function — must use hComp or h('#comp', ...))
h('#comp', { component: MyPage, key: '/dashboard' }, [])
```
**Children flattening:** `null`, `undefined`, and `false` children are filtered out. String and number primitives are automatically converted to text VNodes.
### Props
| Prop | Behavior |
|---|---|
| `class` | String or object (`{ active: bool }` → truthy keys joined as class names) |
| `style` | String or object (`{ color: 'red' }` → applies to `el.style`) |
| `html` / `innerHTML` | Sets `innerHTML` directly |
| `textContent` | Sets `textContent` directly |
| `value` | On `<input>`, `<textarea>`, `<select>`: sets `.value`; otherwise sets attribute |
| `checked` | On `<input>`: sets `.checked`; otherwise sets attribute |
| `disabled` | Sets `.disabled` boolean property on applicable elements |
| `on:click`, `on:submit`, etc. | Event listeners (`on:` prefix + event name) |
| `key` | Used by keyed diff algorithm; not applied to DOM |
| `ref` | Reserved (no-op); not applied to DOM |
All other keys are set as HTML attributes. `null`, `undefined`, and `false` values remove the attribute.
### Diffing
The diff algorithm uses index-based unkeyed diffing by default. When any VNode in a sibling set has a `key` prop, the keyed algorithm is used for the entire set. Keyed diff preserves DOM element order and reuses elements by key.
Use `key` when rendering lists that can be reordered, inserted, or removed:
```javascript
items.map(item =>
h('li', { key: item.id }, esc(item.name))
)
```
## Rendering
### `render(container, fn)`
Mount a render function onto a DOM element. First call creates DOM from scratch; subsequent calls diff and patch in place.
```javascript
function View() {
return h('div', null, 'Hello ' + state.name);
}
render(document.getElementById('root'), View);
```
The render function executes on every reactive update. It can return a single VNode or an array of VNodes.
## Pages
### `definePage(def)`
Define a page component with reactive state, WebSocket topic subscriptions, async data loading, and rendering.
```javascript
export default definePage({
// Return initial data. `loading`, `refreshing`, and `error` are auto-injected.
init() {
return { data: null };
},
// WebSocket topics to subscribe to on mount ('*' = all)
subscribe: ['firewall'],
// Called on mount and when WS topic updates arrive
async load(state, abortController, entry) {
const myId = entry ? entry.requestId : 0;
state.loading = true;
try {
const res = await apiFetch('/api/firewall/status', { signal: abortController?.signal });
if (abortController?.signal.aborted || entry.requestId !== myId) return;
if (res.ok) state.data = res.data;
else state.error = res.error;
} catch (e) {
if (abortController?.signal.aborted) return;
state.error = String(e);
}
state.loading = false;
},
// Called on every reactive update — return VNode(s)
render(state) {
const guard = renderGuard(state, 'Zones', 'Zone management', state.data);
if (guard) return guard;
return [
PageHeader({ title: 'Zones' }),
h('div', { class: 'card' }, esc(JSON.stringify(state.data))),
];
},
});
```
**Auto-injected state:** `definePage` automatically injects `loading: true`, `refreshing: false`, and `error: null` into the state object before merging with `init()`'s return value. Your `init()` only needs to define data fields:
```javascript
// Before
init() { return { items: [], loading: true, refreshing: false, error: null }; }
// After (auto-injected)
init() { return { items: [] }; }
```
Page `init()` values override defaults if explicitly set.
### Page Definition Properties
| Property | Required | Description |
|---|---|---|
| `init()` | Yes | Returns initial state. Wrapped with `reactive()` by `definePage`. |
| `subscribe` | No | Array of topic strings (e.g., `['firewall']`). Use `['*']` for all. WS auto-refresh calls `load()` on topic update. |
| `load(state, abortController, entry)` | No | Async data loader. Called on mount and by WS auto-refresh. Receive an `AbortController` for cancellation. Check `entry.requestId` against captured ID to discard stale results. |
| `render(state)` | Yes | Returns VNode(s) for the page. |
| `onUnmount(state)` | No | Called when page is unmounted. Use for custom cleanup. |
### Page Lifecycle
1. **Mount**: `init()` creates state → `load()` fires with fresh `AbortController` → WS subscriptions registered.
2. **Update**: Reactive state change → `render()` re-executes → VDOM diff patches DOM.
3. **WS auto-refresh**: Topic message arrives → debounced (300ms) → prior `load()` aborted → `load()` re-called with new `AbortController`.
4. **Unmount**: Prior `load()` aborted → WS subscriptions removed → `onUnmount()` called → component entry destroyed.
### `hComp(renderer, key)`
Create a VNode for a page component. The `key` determines lifecycle boundaries — the same key reuses the existing component instance (preserving state and in-flight loads).
```javascript
// Router pattern — key is the path so navigation to a different page unmounts the old one
return hComp(page, this.state.path);
```
## Router
### Custom Router Pattern (Used by Vacuum Wall)
The Vacuum Wall app uses a custom router object rather than `createRouter()`. Reactive path state with `hashchange` listener handles navigation:
```javascript
const router = {
state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
component() {
const name = this.state.path.replace(/^\//, '');
const page = Pages[name] || NotFoundPage;
return hComp(page, this.state.path);
},
};
window.addEventListener('hashchange', () => {
router.state.path = location.hash.slice(1) || '/dashboard';
});
```
### `createRouter(routes)`
Alternative: built-in hash-based router with route map.
```javascript
const router = createRouter({
'/dashboard': () => h('#comp', { component: DashboardPage, key: '/dashboard' }, []),
'/zones': () => h('#comp', { component: ZonesPage, key: '/zones' }, []),
'*': () => h('#comp', { component: NotFoundPage, key: '*' }, []),
});
```
Returns `{ state, navigate(path), component() }`. The `component()` function returns the VNode for the current route and should be used inside a render function.
### `Link(props)`
Client-side navigation link. Sets `location.hash` without full page navigation. Accepts `path`, `class`, `children`.
```javascript
Link({ path: '/zones', class: 'active', children: ['Zones'] })
// Renders: <a href="#/zones" class="active">Zones</a>
```
## WebSocket
### `connect()`
Start the WebSocket connection to the daemon at `ws://<host>/ws` (auto-detects `wss:` for HTTPS). Set `window.__WS_URL__` to override. Auto-reconnects with exponential backoff (max 15s).
### WS Message Types
| Type | Fields | Effect |
|---|---|---|
| `versions` | `updated: [topic, …]` | Auto-refresh components subscribed to listed topics |
| `refresh` | `topics: [topic, …]` | Same as `versions` |
| `notify` | `topic` | Auto-refresh components subscribed to the topic |
| `status` | `topic` | Auto-refresh components subscribed to the topic |
Components subscribed to `'*'` match all topics.
### WS Auto-Refresh Flow
When a WS message arrives for a subscribed topic:
1. Debounce starts/cancels (300ms).
2. Any in-flight `load()` is aborted.
3. After debounce, `load()` is called with a new `AbortController`.
4. The `entry.requestId` pattern ensures stale async results are discarded.
### `onMessage(topics, handler)`
Direct one-off subscription for code outside `definePage`:
```javascript
const unsub = onMessage(['firewall'], (state) => {
// handle message
});
// Later: unsub();
```
## API
### `apiFetch(url, options)`
Fetch wrapper with automatic JSON handling.
```javascript
const res = await apiFetch('/api/firewall/zones', { method: 'GET' });
// res: { ok: true, data: …, error: null, status: 200 }
```
- Automatically sets `Accept: application/json`.
- If `body` is a plain object (not `FormData`), stringifies it and sets `Content-Type: application/json`.
- On HTTP 401, reloads the page (session expired).
- On non-2xx, returns `{ ok: false, error: "message", status }`.
- On network error, returns `{ ok: false, error: "Network error", status: 0 }`.
- Passes `credentials: 'same-origin'` by default.
### `toast(message, type, duration)`
Show a toast notification. Auto-dismisses after `duration` ms (default 4000). `type` is one of `'info'`, `'success'`, `'error'`, `'warning'`. Returns a toast ID.
### `dismissToast(id)`
Dismiss a specific toast by ID.
### `ToastContainer()`
Component that renders queued toasts. Include it in the main render root:
```javascript
function MainContent() {
return [router.component(), ToastContainer()];
}
```
### `apiSubmit(config)`
Build a form action for `formModal`. Collects body, validates, submits via `apiFetch`, toasts, and closes the modal on success.
```javascript
apiSubmit({
url: '/api/firewall/zones',
method: 'POST', // optional, defaults to 'POST'
body: () => ({ name: $val('zone-name') }),
validate: (b) => !b.name ? 'Name required' : null,
successMsg: 'Zone created',
reload: () => load(state), // optional, called after success toast
}),
```
Returns an object matching the `formModal` action shape. Spread it into the actions array: `...apiSubmit({ … })`.
### `checkAbort(entry, abortController)`
Check if a request has been aborted or become stale. Returns `true` if the caller should bail out early. Used between sequential fetches in multi-fetch page loads.
```javascript
if (checkAbort(entry, abortController)) return;
```
### `refactorLoad(state, checkDone, fetchFn, opts)`
Async load wrapper that encapsulates `loading`/`refreshing` flag management, abort checking, and staleness guards. Replaces the ~12-line boilerplate pattern in every page's `load()` function.
```javascript
import { refactorLoad } from '/static/hoover/index.js';
async function load(state, abortController, entry) {
await refactorLoad(state,
// checkDone: truthy means existing data, use refreshing vs loading
s => s.items?.length,
// fetchFn: receives (state, signal, isAborted)
// isAborted is a zero-arg function to re-check abort between sequential fetches
async (s, signal, isAborted) => {
const r = await apiFetch('/api/mydata', { signal });
if (r.ok) s.items = r.data || [];
else s.error = r.error;
},
{ entry, abortController },
);
}
```
**Parameters:**
| Parameter | Description |
|---|---|
| `state` | Page state object |
| `checkDone(state)` | Returns truthy if data already exists (sets `refreshing` vs `loading`) |
| `fetchFn(state, signal, isAborted)` | Page-specific async fetch logic. The third argument `isAborted()` is a zero-arg function to re-check abort/stale status between sequential fetches |
| `opts.entry` | Router entry with `requestId` for staleness checks |
| `opts.abortController` | AbortController for cancellation |
### `poll(props)`
Poll an API endpoint until a terminal state is reached. Returns an abort handle `() => void`.
```javascript
import { poll } from '/static/hoover/index.js';
poll({
url: '/api/certs/issue/' + enc(requestId),
interval: 2000,
timeout: 120000,
successKey: (d) => d.status === 'completed',
onErrorKey: (d) => d.status === 'failed',
onComplete: (d) => {
toast('Certificate issued', 'success');
load(state);
},
onError: (d) => {
toast('Issuance failed', 'error');
},
});
```
**Parameters:**
| Parameter | Description |
|---|---|
| `url` | Poll URL |
| `interval` | Poll interval in ms (default: `2000`) |
| `timeout` | Max poll time in ms (default: `120000`) |
| `successKey` | `(data) => boolean` — when true, stops polling and calls `onComplete` |
| `onErrorKey` | `(data) => boolean` — when true, stops polling and calls `onError` |
| `onComplete` | `(data) => void`, called on success |
| `onError` | `(data) => void`, called on error or timeout |
## UI Components
### Layout
#### `PageHeader(props)`
Page header with title, optional subtitle, and action buttons.
```javascript
PageHeader({
title: 'Zones',
subtitle: 'Firewall zone management',
actions: h('button', { class: 'btn btn-primary', 'on:click': () => addZoneModal(state) }, 'Add Zone'),
})
```
#### `Tabs(props)`
Tab bar component. Writes to `state[prop]` on tab click. The caller is responsible for rendering tab body content.
```javascript
Tabs({
state,
tabs: ['ranges', 'leases', 'dns'],
prop: 'activeTab', // optional, defaults to 'activeTab'
formatLabel: k => k.replace(/-/g, ' '), // optional, defaults to capitalize
onTabClick: k => { /* side effect on tab change */ }, // optional
})
```
**Parameters:**
| Parameter | Description |
|---|---|
| `state` | Reactive state object |
| `tabs` | Array of tab keys (e.g. `['ranges', 'leases']`) |
| `prop` | State property name for active tab (default: `'activeTab'`) |
| `formatLabel(key)` | Label formatter function (default: capitalize first letter) |
| `onTabClick(key)` | Optional callback after state update |
#### `SectionTitle({ title })`
Section header with `h3.section-title` styling.
```javascript
SectionTitle({ title: 'WAN / External' })
```
#### `DataTableSection({ title, columns, rows, emptyText, key })`
SectionTitle heading followed by a Table wrapper. Combines section heading and table into a single component.
```javascript
DataTableSection({
title: 'WAN / External',
columns: ['Interface', 'IPv4', 'IPv6', 'MAC', 'Zone'],
rows: ifaceRows(wanIface),
emptyText: 'No WAN interfaces',
key: 'wan-ifaces', // optional
})
```
**Parameters:**
| Parameter | Description |
|---|---|
| `title` | Section heading |
| `columns` | Column header labels |
| `rows` | Body row vnodes |
| `emptyText` | Empty-state message |
| `key` | VNode key |
#### `ActionGroup(...children)`
Flex button container with 8px gap. Accepts VNode children directly.
```javascript
ActionGroup(
h('button', { class: 'btn btn-primary', 'on:click': addFn }, 'Add'),
ActionButton({ url: '/api/apply', label: 'Apply', reload: () => load(state) }),
)
```
#### `renderGuard(state, title, subtitle, data)`
Return early with loading/error/empty-state VNodes. Returns `null` when data is ready, allowing the page to render its content.
```javascript
const guard = renderGuard(state, 'Zones', 'Zone management', state.zones);
if (guard) return guard;
```
Checks `state.loading`, `state.error`, and `data` presence in that order. Uses `state.refreshing` to show "Refreshing…" instead of "Loading…".
### Data Display
#### `Badge({ text, variant })`
Colored label. `variant`: `'info'`, `'success'`, `'warning'`, `'danger'`.
#### `StatusDot({ status })`
Status indicator dot. `status`: `'success'`/`'up'` (green), `'danger'`/`'down'` (red), or `'pending'` (yellow).
#### `StatCard({ label, value, meta })`
Dashboard stat card with label, value, and optional meta.
```javascript
StatCard({ label: 'Active Zones', value: 3, meta: 'lan, wan, dmz' })
```
#### `StatusText({ status })`
StatusDot + human-readable label. Returns `[StatusDot, ' ', label]`.
```javascript
StatusText({ status: iface.state })
// status: 'up' → [green dot, ' ', 'Up']
// status: 'down' → [red dot, ' ', 'Down']
// status: 'pending' → [yellow dot, ' ', 'Pending']
```
#### `Empty({ text })`
Empty-state placeholder card.
#### `Card({ header, children })`
Card container with optional header.
#### `ConfirmDelete(props)`
Delete button with native `confirm()` dialog, then API `DELETE` call, toast, and optional reload.
```javascript
ConfirmDelete({
url: '/api/firewall/zones/myzone',
message: 'Delete zone myzone?',
success: 'Zone deleted',
reload: () => load(state),
label: 'Delete',
})
```
#### `ActionButton(props)`
Inline button that POSTs to an API endpoint, toasts on result, and optionally reloads state. Supports toggle labels for on/off buttons.
```javascript
ActionButton({
url: '/api/dhcp/apply',
method: 'POST', // optional, defaults to 'POST'
body: () => undefined, // optional
label: 'Apply',
successMsg: 'Applied',
errorType: 'error', // optional, defaults to 'error'
reload: () => load(state),
cls: 'btn btn-outline', // optional
disabled: false,
})
// Toggle variant (e.g., enable/disable masquerade):
ActionButton({
url: '/api/firewall/masquerade',
body: () => ({ zone: z.name, enable: !z.masquerade }),
labelOn: 'Disable',
labelOff: 'Enable',
condition: z.masquerade,
reload: () => load(state),
}),
```
**Parameters:**
| Parameter | Description |
|---|---|
| `url` | API URL |
| `method` | HTTP method (default: `'POST'`) |
| `body` | `() => body` or `undefined` for no body |
| `label` | Button text |
| `labelOn` / `labelOff` | Toggle labels when `condition` is true/false |
| `condition` | Toggle condition for `labelOn`/`labelOff` |
| `successMsg` | Success toast message |
| `errorType` | Toast type for errors (default: `'error'`) |
| `reload` | `() => Promise`, called on success |
| `cls` | Button CSS classes (default: `'btn btn-outline'`) |
| `disabled` | Disabled state |
#### `ActionCell(props)`
Standardizes "action button + ConfirmDelete" in a table cell. Replaces the common pattern of an edit button followed by a delete button.
```javascript
ActionCell({
editLabel: 'Edit',
editClick: () => editDomain({ ...item, _s: state }),
removeUrl: '/api/proxy/domains/' + enc(item.domain),
removeMessage: 'Remove proxy for ' + item.domain + '?',
removeSuccess: 'Domain removed',
removeReload: () => load(state),
removeLabel: 'Delete', // optional, defaults to 'Remove'
removeBody: undefined, // optional, JSON body for DELETE
editCls: 'btn btn-sm btn-outline', // optional, defaults to 'btn btn-sm btn-outline'
}),
```
**Parameters:**
| Parameter | Description |
|---|---|
| `editLabel` | First button text |
| `editClick` | First button click handler |
| `removeUrl` | API DELETE URL |
| `removeMessage` | Confirmation prompt text |
| `removeSuccess` | Success toast message |
| `removeReload` | Reload function |
| `removeLabel` | Delete button label (default: `'Remove'`) |
| `removeBody` | Optional JSON body to send with DELETE |
| `editCls` | Override classes for edit button (default: `'btn btn-sm btn-outline'`) |
#### `certStatusBadge(props)`
Badge for certificate status based on expiry data. Evaluates `certStatus`, `expired`, and `daysRemaining` to determine badge text and color.
```javascript
certStatusBadge({ expired: c.expired, daysRemaining: c.days_remaining })
// Returns: Badge({ text: '30d left', variant: 'warning' })
```
Evaluation order:
| Condition | Result |
|---|---|
| `certStatus === 'valid'` or `'active'` | `'Valid'` (success) |
| `expired`, `certStatus === 'expired'`, or `daysRemaining <= 0` | `'Expired'` (danger) |
| `daysRemaining <= 30` | `'Xd left'` (warning) |
| `daysRemaining` (positive, > 30) | `'Xd left'` (success) |
| fallback | `certStatus` or `'N/A'` (info) |
**Parameters:**
| Parameter | Description |
|---|---|
| `daysRemaining` | Days until expiry |
| `expired` | Explicitly expired flag |
| `certStatus` | Status string (e.g. `'valid'`, `'active'`, `'expired'`) |
#### `serviceStatusBadge(props)`
Returns a `StatusDot` + `Badge` pair for a service state string.
```javascript
serviceStatusBadge({ state: statusUp.state || 'down' })
// Returns: [StatusDot({ status: 'success' }), ' ', Badge({ text: 'up', variant: 'success' })]
```
**Parameters:**
| Parameter | Description |
|---|---|
| `state` | Service state (e.g. `'up'`, `'down'`) |
#### `ServiceStatus(props)`
ServiceStatusBadge + label in a single `<span class="service-status">` vnode. Convenient for embedding in list items or standalone status lines.
```javascript
ServiceStatus({ state: st.state || 'down' })
ServiceStatus({ state: dmsk.state || 'down', label: 'Dnsmasq' })
```
**Parameters:**
| Parameter | Description |
|---|---|
| `state` | Service state string (e.g. `'up'`, `'down'`) |
| `label` | Optional label text after the badge |
#### `ActionCell(props)`
Standardizes "action button + ConfirmDelete" in a table cell. Use for rows that need an edit action alongside a delete action.
```javascript
ActionCell({
editLabel: 'Edit',
editClick: () => editDomain({ ...d, _s: state }),
removeUrl: '/api/proxy/domains/' + enc(d.domain),
removeMessage: 'Remove proxy for ' + d.domain + '?',
removeSuccess: 'Domain removed',
removeReload: () => load(state),
removeLabel: 'Delete',
})
```
See `ActionButton(props)` and `ConfirmDelete(props)` for parameter details.
**Parameters:**
| Parameter | Description |
|---|---|
| `editLabel` | First button text |
| `editClick` | First button click handler |
| `removeUrl` | API DELETE URL |
| `removeMessage` | Confirmation prompt text |
| `removeSuccess` | Success toast message |
| `removeReload` | Reload function |
| `removeLabel` | Delete button label (default: `'Remove'`) |
| `removeBody` | Optional JSON body to send with DELETE |
| `editCls` | Override classes for edit button (default: `'btn btn-sm btn-outline'`) |
#### `MonoText(props)`
Monospace text with optional truncation. Renders as `<span class="mono-text">`.
```javascript
MonoText({ text: p.publicKey })
MonoText({ text: p.publicKey, maxLength: 20 })
// Truncates with "..." if text exceeds maxLength
```
**Parameters:**
| Parameter | Description |
|---|---|
| `text` | Text to display |
| `maxLength` | Truncate with "..." if longer (optional) |
#### `ZoneSelect(props)`
Dropdown to select a firewall zone. Renders as `<select class="form-select">`.
```javascript
ZoneSelect({
zones: state.zones,
value: iface.zone,
onChange: (z) => changeZone(iface.name, z, state),
})
```
**Parameters:**
| Parameter | Description |
|---|---|
| `zones` | Available zone names (`string[]`) |
| `value` | Currently selected zone |
| `onChange` | `(zone) => void` callback |
| `placeholder` | Placeholder option text (optional) |
#### `Table({ columns, rows, emptyText, wrapCard, key })`
Table wrapper with header, body, and empty-state row. `rows` expects pre-built `<tr>` VNodes.
```javascript
Table({
columns: ['Name', 'Status', 'Action'],
rows: items.map(i => h('tr', null,
h('td', null, esc(i.name)),
h('td', null, StatusDot({ status: i.state })),
h('td', null, ConfirmDelete({ url: `/api/item/${i.id}`, message: `Delete ${i.name}?`, reload: () => load(state) })),
)),
emptyText: 'No items',
})
```
### Modal
#### `openModal(renderFn)`
Open a modal dialog. `renderFn` receives the modal content element:
```javascript
openModal((inner) => {
inner.innerHTML = '<h2 class="modal-title">Details</h2>…';
});
```
#### `closeModal([idx])`
Close a modal. Without argument, closes the topmost modal.
#### `closeAllModals()`
Close all open modals.
#### `formModal(inner, title, fields, actions)`
Render a standard modal form inside the modal content element.
**Field shape:**
```javascript
{ label: 'Name', id: 'name', placeholder: 'Enter name' }
{ label: 'Type', id: 'type', tag: 'select', options: [['a', true], 'b', 'c'] }
{ label: 'Notes', id: 'notes', tag: 'textarea', value: '' }
```
- `tag`: `'input'` (default), `'select'`, `'textarea'`
- For `select`: `options` is an array of strings or `[value, selected]` tuples
- `value` is pre-populated value
**Action shape:**
```javascript
{ label: 'Save', cls: 'btn-primary', action: 's', handler: () => { } }
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() }
```
The `action` field becomes a `data-action` attribute used for button lookup.
#### `QuickModal(props)`
Factory that returns a function to open a modal with form fields and API submission. The returned function accepts a `data` argument forwarded to `title`, `fields`, `submit.url`, and `submit.body` resolvers. Use as an `on:click` handler.
```javascript
const addZone = QuickModal({
title: 'Add Zone', // string or (data) => string
fields: (data) => [ // or static array
{ label: 'Name', id: 'name', placeholder: 'Enter name' },
],
submit: {
url: '/api/zones', // or (data) => string
method: 'POST', // optional, default 'POST'
body: (data) => ({ name: $val('name') }), // or static object
validate: (b) => !b.name ? 'Name required' : null,
successMsg: 'Zone created', // or (data) => string
},
reload: (data) => load(data), // called on success with data argument
});
// Usage in render — pass state as data so reload can call load(state):
h('button', { 'on:click': () => addZone(state) }, 'Add Zone')
```
**Parameters:**
| Parameter | Description |
|---|---|
| `title` | Modal title or `(data) => string` |
| `fields` | Form field descriptors or `(data) => object[]` |
| `submit.url` | API URL or `(data) => string` |
| `submit.method` | HTTP method (default: `'POST'`) |
| `submit.body` | `(data) => object`, body to send (note: the function is called with the data argument from the outer call) |
| `submit.validate` | `(body) => string\|null`, validation function |
| `submit.successMsg` | Success toast message or `(data) => string` |
| `reload` | `(data) => Promise`, called after success; receives the same data argument passed to the modal |
| `handler` | Optional custom handler `(data, closeModal) => void` that bypasses apiSubmit |
| `submitLabel` | Submit button label (default: `'Submit'`) |
#### `MultiSelectModal(props)`
Factory that returns a function to open a multi-select modal. Use as an `on:click` handler in VNode props.
```javascript
const editIface = MultiSelectModal({
title: 'Interfaces: ' + zoneName,
url: '/api/firewall/zones/' + enc(zoneName) + '/interfaces',
options: state.interfaces,
selected: zone.interfaces,
fieldKey: 'interfaces',
successMsg: 'Interfaces updated',
reload: () => load(state),
});
// Usage:
h('button', { 'on:click': editIface }, 'Edit')
```
**Parameters:**
| Parameter | Description |
|---|---|
| `title` | Modal title |
| `url` | API POST URL |
| `options` | All selectable options (`string[]`) |
| `selected` | Currently selected values (`string[]`) |
| `fieldKey` | JSON key for the submitted field |
| `successMsg` | Success toast message (default: `'Updated'`) |
| `reload` | `() => Promise`, called on success |
### Toast
#### `ToastContainer()`
Render the toast notification container. Include in the main render root. See API section above.
## Helpers
| Function | Description |
|---|---|
| `esc(s)` | HTML-escape a string for safe text content |
| `att_esc(s)` | Escape for safe use in HTML attributes |
| `enc(s)` | URL-encode a string (`encodeURIComponent`) |
| `$val(id)` | Get `value` of `document.getElementById(id)` |
| `parseZones(data)` | Parse zone data from API responses into a flat string array |
| `downloadBlob(blob, filename)` | Trigger a browser file download from a Blob |
## Versioned Imports
Static assets in `app.js` are imported with querystring version pins (e.g., `?v=4`) to invalidate browser cache when the framework changes. Page imports omit the version string since they reference the barrel export, which the server handles with appropriate caching headers.
Dev mode (`VACUUM_WALL_DEV` set) disables aggressive static asset caching.
## Conventions
- **Pages** export `definePage({ … })` as default. Each page in `webui/static/pages/`.
- **State**: `definePage` auto-injects `loading: true`, `refreshing: false`, `error: null`. `init()` only returns data fields.
- **Load function** pattern: set `loading`/`refreshing``apiFetch` with abort signal → check staleness → assign data or error → clear loading flags.
- **Render** pattern: `renderGuard` early return → data rendering. Always return VNode array or single VNode.
- **Keys** on list items use unique identifiers (`item.id`), not array indices.
- **Escaping:** Use `esc()` for any user-controlled text rendered in `h()` children. Use `enc()` for URL segments.
- **Modals:** Use `formModal` + `apiSubmit` for standard CRUD operations. Use `openModal` + custom render function for non-form content.
+16 -2
View File
@@ -110,14 +110,27 @@ After installation, access the management interface at `https://<hostname>.local
│ │ ├── wireguard.py # WireGuard API │ │ ├── wireguard.py # WireGuard API
│ │ ├── network.py # Networkd API │ │ ├── network.py # Networkd API
│ │ └── logs.py # Logs API │ │ └── logs.py # Logs API
│ └── static/ # SPA (index.html, app.js, reactive-dom.js, style.css) │ └── static/ # SPA (index.html, app.js, style.css)
│ ├── hoover/ # Hoover SPA framework (VDOM, reactivity, router, components)
│ │ ├── index.js # Barrel export of all public APIs
│ │ ├── reactivity.js
│ │ ├── vdom.js
│ │ ├── render.js
│ │ ├── component.js
│ │ ├── router.js
│ │ ├── websocket.js
│ │ ├── api.js
│ │ ├── helpers.js
│ │ └── components/ # Layout, data display, modal, toast
│ └── pages/ # Page modules (each defines a route via definePage)
├── docs/ # Documentation ├── docs/ # Documentation
│ ├── overview.md # This file │ ├── overview.md # This file
│ ├── deployment.md │ ├── deployment.md
│ ├── api.md │ ├── api.md
│ ├── security.md │ ├── security.md
│ ├── architecture.md │ ├── architecture.md
── config.md ── config.md
│ └── hoover.md # Hoover SPA framework
└── scripts/ # Utility scripts └── scripts/ # Utility scripts
└── update-vendor.sh # Vendor frontend library updates └── update-vendor.sh # Vendor frontend library updates
``` ```
@@ -129,3 +142,4 @@ After installation, access the management interface at `https://<hostname>.local
- [Security Model](security.md) - Privilege model and sudo whitelist - [Security Model](security.md) - Privilege model and sudo whitelist
- [Architecture](architecture.md) - Detailed subsystem design - [Architecture](architecture.md) - Detailed subsystem design
- [Configuration](config.md) - Config file formats and locations - [Configuration](config.md) - Config file formats and locations
- [Hoover Framework](hoover.md) - Frontend SPA framework reference
+12 -12
View File
@@ -1,16 +1,16 @@
import { h, render, Link, hComp, ToastContainer, connect, requestUpdate, reactive } from '/static/hoover/index.js?v=4'; import { h, render, Link, hComp, ToastContainer, connect, requestUpdate, reactive } from '/static/hoover/index.js?v=6';
import DashboardPage from '/static/pages/dashboard.js?v=4'; import DashboardPage from '/static/pages/dashboard.js?v=6';
import InterfacesPage from '/static/pages/interfaces.js?v=4'; import InterfacesPage from '/static/pages/interfaces.js?v=6';
import ZonesPage from '/static/pages/zones.js?v=4'; import ZonesPage from '/static/pages/zones.js?v=6';
import RulesPage from '/static/pages/rules.js?v=4'; import RulesPage from '/static/pages/rules.js?v=6';
import NatPage from '/static/pages/nat.js?v=4'; import NatPage from '/static/pages/nat.js?v=6';
import DhcpPage from '/static/pages/dhcp.js?v=4'; import DhcpPage from '/static/pages/dhcp.js?v=6';
import ProxyPage from '/static/pages/proxy.js?v=4'; import ProxyPage from '/static/pages/proxy.js?v=6';
import CertsPage from '/static/pages/certs.js?v=4'; import CertsPage from '/static/pages/certs.js?v=6';
import WireguardPage from '/static/pages/wireguard.js?v=4'; import WireguardPage from '/static/pages/wireguard.js?v=6';
import LogsPage from '/static/pages/logs.js?v=4'; import LogsPage from '/static/pages/logs.js?v=6';
import NotFoundPage from '/static/pages/notfound.js?v=4'; import NotFoundPage from '/static/pages/notfound.js?v=6';
/* ── Navigation items ──────────────────────────────────────── */ /* ── Navigation items ──────────────────────────────────────── */
const Nav = [ const Nav = [
+148 -1
View File
@@ -6,7 +6,7 @@
* ToastContainer component for rendering queued toasts. * ToastContainer component for rendering queued toasts.
*/ */
import { h } from './vdom.js'; import { h } from './vdom.js?v=6';
/** /**
* JSON-friendly fetch wrapper. * JSON-friendly fetch wrapper.
@@ -96,3 +96,150 @@ export function ToastContainer() {
), ),
); );
} }
/**
* Create an abort-checking function from an AbortController.
*
* @param {AbortController} ac
* @returns {function} () => boolean
*/
export function checkAbort(ac) {
return () => ac?.signal?.aborted || false;
}
/**
* Standard data loading wrapper with state management and abort handling.
*
* Sets loading=true before, loading=false after, tracks errors.
*
* @param {object} state - Reactive state object
* @param {function} dataKey - (s) => any, current data to compare for refresh detection
* @param {function} fetchFn - (state, signal, isAborted) => Promise
* @param {object} [opts] - Additional options
* @param {object} [opts.entry] - Component entry for requestId tracking
* @param {AbortController} [opts.abortController] - Fresh abort controller
*/
export async function refactorLoad(state, dataKey, fetchFn, opts = {}) {
const entry = opts.entry;
const myId = entry ? entry.requestId : 0;
const ab = opts.abortController;
const isAborted = ab ? checkAbort(ab) : () => false;
const signal = ab ? ab.signal : null;
if (entry) {
if (dataKey(state) !== undefined) state.refreshing = true;
else state.loading = true;
}
state.error = null;
try {
await fetchFn(state, signal, isAborted);
} catch (e) {
if (!isAborted()) state.error = e.message || 'Request failed';
} finally {
if (!isAborted()) {
if (entry) {
state.loading = false;
state.refreshing = false;
}
}
}
}
/**
* Poll a URL until success or error condition is met.
*
* @param {object} opts
* @param {string} opts.url - URL to poll
* @param {function} opts.successKey - (data) => boolean, when true poll succeeds
* @param {function} opts.onErrorKey - (data) => boolean, when true poll fails
* @param {function} [opts.onComplete] - (data) => void, called on success
* @param {function} [opts.onError] - (data) => void, called on failure
* @param {number} [opts.interval] - Poll interval in ms (default: 3000)
* @param {number} [opts.timeout] - Overall timeout in ms (default: 60000)
*/
export async function poll(opts) {
const {
url,
successKey,
onErrorKey,
onComplete,
onError,
interval = 3000,
timeout = 60000,
} = opts;
const start = Date.now();
const timer = setInterval(async () => {
if (Date.now() - start > timeout) {
clearInterval(timer);
if (onError) onError(null);
return;
}
const res = await apiFetch(url);
if (!res.ok) {
clearInterval(timer);
if (onError) onError(res);
return;
}
if (successKey(res.data)) {
clearInterval(timer);
if (onComplete) onComplete(res.data);
} else if (onErrorKey(res.data)) {
clearInterval(timer);
if (onError) onError(res.data);
}
}, interval);
}
/**
* Generate action button descriptors for modal form submission.
*
* Returns an array of action descriptors that can be spread into the
* actions array passed to formModal. First item is the submit button.
*
* @param {object} opts
* @param {string} opts.url - API URL to POST/PUT to
* @param {string} [opts.method] - HTTP method (default: 'POST')
* @param {function} [opts.body] - () => object, body builder
* @param {function} [opts.validate] - (body) => string|null, validation function
* @param {string} [opts.successMsg] - Success toast message
* @param {function} [opts.reload] - () => Promise, data reload function
* @param {string} [opts.submitText] - Submit button text (default: 'Submit')
* @returns {object[]} Array of action descriptors
*/
export function apiSubmit(opts) {
const {
url,
method = 'POST',
body,
validate,
successMsg = 'Saved',
reload,
submitText = 'Submit',
closeModal,
} = opts;
return [
{
label: submitText,
cls: 'btn-primary',
action: 's',
handler: async () => {
const b = body ? body() : {};
if (validate) {
const err = validate(b);
if (err) { toast(err, 'error'); return; }
}
const res = await apiFetch(url, { method, body: b });
if (res.ok) {
toast(successMsg, 'success');
if (closeModal) closeModal();
if (reload) await reload();
} else {
toast(res.error || 'Failed', 'error');
}
},
},
];
}
+18 -9
View File
@@ -16,9 +16,9 @@
* }); * });
*/ */
import { reactive } from './reactivity.js'; import { reactive } from './reactivity.js?v=6';
import { h } from './vdom.js'; import { h } from './vdom.js?v=6';
import { _compExpandedCache } from './render.js'; import { _compExpandedCache } from './render.js?v=6';
/** Registry of mounted components: key → { state, subscriptions, loadAbort, entry, isLoading } */ /** Registry of mounted components: key → { state, subscriptions, loadAbort, entry, isLoading } */
const _mounted = new Map(); const _mounted = new Map();
@@ -32,6 +32,15 @@ export function isComponentStateMounted(state) {
return false; return false;
} }
/** Get the full mounted entry for a state object.
* Used by websocket.js to abort in-flight loads before triggering a refresh. */
export function getComponentEntry(state) {
for (const entry of _mounted.values()) {
if (entry.state === state) return entry;
}
return null;
}
/** External subscribe function from websocket.js. /** External subscribe function from websocket.js.
* Set via setSubscribeFn() when the websocket module initializes. * Set via setSubscribeFn() when the websocket module initializes.
*/ */
@@ -79,12 +88,11 @@ export function mountComponent(key, renderer) {
let entry = _mounted.get(key); let entry = _mounted.get(key);
if (entry) { if (entry) {
// Re-mount of an already-mounted page: restart load with fresh AbortController // Re-mount: component already exists with its data and subscriptions.
if (entry.loadAbort) { // Don't abort or restart loads — that re-render was triggered by a
entry.loadAbort.abort(); // state change (load completion, reactive update, etc). Let existing
} // in-flight loads complete naturally. WS handles auto-refresh.
entry.requestId++; return;
entry.loadAbort = null;
} else { } else {
// Fresh mount // Fresh mount
entry = { entry = {
@@ -101,6 +109,7 @@ export function mountComponent(key, renderer) {
// Fire load with fresh AbortController // Fire load with fresh AbortController
if (pd.load) { if (pd.load) {
if (entry.isLoading) return;
const abortController = new AbortController(); const abortController = new AbortController();
entry.loadAbort = abortController; entry.loadAbort = abortController;
entry.requestId++; entry.requestId++;
+253 -3
View File
@@ -4,7 +4,9 @@
* Data display components: Badge, StatusDot, Empty, Card. * Data display components: Badge, StatusDot, Empty, Card.
*/ */
import { h } from '../vdom.js'; import { h } from '../vdom.js?v=6';
import { esc } from '../helpers.js?v=6';
import { apiFetch, toast } from '../api.js?v=6';
/** /**
* Colored badge/span. * Colored badge/span.
@@ -49,11 +51,259 @@ export function Empty(props = {}) {
* @param {VNode[]} [props.children] * @param {VNode[]} [props.children]
*/ */
export function Card(props = {}) { export function Card(props = {}) {
const key = props.key !== undefined ? { key: props.key } : {};
if (props.header) { if (props.header) {
return h('div', { class: 'card' }, return h('div', { class: 'card', ...key },
h('div', { class: 'card-header' }, props.header), h('div', { class: 'card-header' }, props.header),
h('div', { class: 'card-body' }, props.children || []), h('div', { class: 'card-body' }, props.children || []),
); );
} }
return h('div', { class: 'card' }, props.children || []); return h('div', { class: 'card', ...key }, props.children || []);
}
/**
* A Remove button that confirms, deletes via API, toasts, and reloads.
*
* @param {object} props
* @param {string} props.url - API DELETE URL
* @param {string} props.message - Confirmation prompt text
* @param {string} [props.success] - Success toast message (default: 'Removed')
* @param {function} [props.reload] - Function to call on success (e.g., load)
* @param {string} [props.label] - Button text (default: 'Remove')
* @param {object} [props.body] - Optional JSON body to send with DELETE
*/
export function ConfirmDelete(props = {}) {
const opts = { method: 'DELETE' };
if (props.body) opts.body = props.body;
return h('button', { class: 'btn btn-sm btn-danger',
'on:click': async () => {
if (!confirm(props.message)) return;
const r = await apiFetch(props.url, opts);
if (r.ok) {
toast(props.success || 'Removed', 'success');
if (props.reload) await props.reload();
} else {
toast(r.error || 'Failed', 'error');
}
}}, props.label || 'Remove');
}
/**
* An action button that POSTs to an API endpoint, toasts on result,
* and optionally reloads state. Supports toggle labels for on/off buttons.
*
* @param {object} props
* @param {string} props.url - API URL
* @param {string} [props.method] - HTTP method (default: 'POST')
* @param {function} [props.body] - () => body, or undefined for no body
* @param {string} [props.label] - Button text
* @param {string} [props.labelOn] - Label when condition is true (toggle)
* @param {string} [props.labelOff] - Label when condition is false (toggle)
* @param {boolean} [props.condition] - Toggle condition for labelOn/labelOff
* @param {string} [props.successMsg] - Success toast message
* @param {string} [props.errorType] - Toast type for errors (default: 'error')
* @param {function} [props.reload] - () => Promise, called on success
* @param {string} [props.cls] - Button CSS classes (default: 'btn btn-outline')
* @param {boolean} [props.disabled] - Disabled state
*/
export function ActionButton(props = {}) {
const label = props.label !== undefined ? props.label :
(props.labelOn !== undefined && props.labelOff !== undefined
? (props.condition ? props.labelOn : props.labelOff)
: 'Action');
const cls = props.cls || 'btn btn-outline';
return h('button', {
class: cls,
disabled: props.disabled,
'on:click': async () => {
const body = props.body ? props.body() : undefined;
const opts = { method: props.method || 'POST' };
if (body !== undefined) opts.body = body;
const resp = await apiFetch(props.url, opts);
if (resp.ok) {
if (props.successMsg) toast(props.successMsg, 'success');
if (props.reload) await props.reload();
} else {
toast(resp.error || 'Failed', props.errorType || 'error');
}
}
}, label);
}
/**
* Table wrapper with header, body, and empty-state row.
*
* @param {object} props
* @param {string[]} props.columns - Column header labels
* @param {VNode[]} props.rows - Body row vnodes
* @param {string} [props.emptyText] - Empty-state message
* @param {boolean} [props.wrapCard] - Wrap in div.card (default: true)
* @param {string} [props.key] - VNode key
*/
export function Table(props = {}) {
const cols = props.columns || [];
const ths = cols.map(c => h('th', null, c));
const table = h('table', { class: 'table' },
h('thead', null, h('tr', null, ...ths)),
h('tbody', null,
props.rows.length ? props.rows : [
h('tr', null,
h('td', { colspan: cols.length, class: 'text-muted text-sm' },
props.emptyText || 'No data'),
),
],
),
);
const key = props.key !== undefined ? { key: props.key } : {};
if (props.wrapCard !== false) {
return h('div', { class: 'card', ...key }, table);
}
return h('div', key, table);
}
/**
* Dashboard stat card.
*
* @param {object} props
* @param {string} props.label
* @param {*} props.value
* @param {*} [props.meta]
*/
export function StatCard(props = {}) {
return h('div', { class: 'stat-card' },
h('div', { class: 'label' }, props.label),
h('div', { class: 'value' }, props.value),
props.meta ? h('div', { class: 'meta' }, props.meta) : null,
);
}
/**
* StatusDot + human-readable label.
*
* @param {object} props
* @param {string} props.status
*/
export function StatusText(props = {}) {
const status = props.status || 'down';
const label = status === 'up' ? 'Up' : status === 'pending' ? 'Pending' : 'Down';
return [StatusDot({ status }), ' ', label];
}
/**
* Badge for certificate status based on expiry data.
*
* @param {object} props
* @param {number} [props.daysRemaining] - Days until expiry
* @param {boolean} [props.expired] - Explicitly expired flag
* @param {string} [props.certStatus] - Status string (e.g. 'valid', 'active', 'expired')
*/
export function certStatusBadge(props = {}) {
const { daysRemaining, expired, certStatus } = props;
if (certStatus === 'valid' || certStatus === 'active')
return Badge({ text: 'Valid', variant: 'success' });
if (expired || certStatus === 'expired' || (daysRemaining !== undefined && daysRemaining <= 0))
return Badge({ text: 'Expired', variant: 'danger' });
if (daysRemaining !== undefined && daysRemaining <= 30)
return Badge({ text: daysRemaining + 'd left', variant: 'warning' });
if (daysRemaining !== undefined)
return Badge({ text: daysRemaining + 'd left', variant: 'success' });
return Badge({ text: certStatus || 'N/A', variant: 'info' });
}
/**
* StatusDot + Badge pair for a service state string.
*
* @param {object} props
* @param {string} props.state - Service state (e.g. 'up', 'down')
*/
export function serviceStatusBadge(props = {}) {
const state = props.state || 'down';
const isUp = state === 'up';
return [
StatusDot({ status: isUp ? 'success' : 'danger' }),
' ',
Badge({ text: state, variant: isUp ? 'success' : 'danger' }),
];
}
/**
* ServiceStatusBadge + label in a single vnode.
*
* @param {object} props
* @param {string} props.state - Service state string
* @param {string} [props.label] - Optional label text after the badge
*/
export function ServiceStatus(props = {}) {
return h('span', { class: 'service-status' },
...serviceStatusBadge({ state: props.state }),
props.label ? ' ' + props.label : null,
);
}
/**
* ActionCell — standardizes "action button + ConfirmDelete" in a table cell.
*
* @param {object} props
* @param {string} props.editLabel - First button text
* @param {function} props.editClick - First button click handler
* @param {string} props.removeUrl - API DELETE URL
* @param {string} props.removeMessage - Confirmation prompt text
* @param {string} [props.removeSuccess] - Success toast message
* @param {function} [props.removeReload] - Reload function
* @param {string} [props.removeLabel] - Delete button label (default: 'Remove')
* @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')
*/
export function ActionCell(props = {}) {
return h('td', null,
h('button', {
class: props.editCls || 'btn btn-sm btn-outline',
style: 'margin-right:4px;',
'on:click': props.editClick,
}, props.editLabel),
ConfirmDelete({
url: props.removeUrl,
message: props.removeMessage,
success: props.removeSuccess,
reload: props.removeReload,
label: props.removeLabel || 'Remove',
body: props.removeBody,
}),
);
}
/**
* Monospace text with optional truncation.
*
* @param {object} props
* @param {string} props.text
* @param {number} [props.maxLength] - Truncate with "..." if longer
*/
export function MonoText(props = {}) {
const text = String(props.text || '');
const display = props.maxLength && text.length > props.maxLength
? text.substring(0, props.maxLength) + '...'
: text;
return h('span', { class: 'mono-text' }, esc(display));
}
/**
* Dropdown to select a firewall zone.
*
* @param {object} props
* @param {string[]} props.zones - Available zone names
* @param {string} [props.value] - Currently selected zone
* @param {function} [props.onChange] - (zone) => void
* @param {string} [props.placeholder]
*/
export function ZoneSelect(props = {}) {
return h('select', {
class: 'form-select',
'on:change': (e) => props.onChange?.(e.target.value),
},
props.placeholder ? h('option', { value: '' }, props.placeholder) : null,
props.zones.map(z =>
h('option', { value: z, selected: z === props.value }, z),
),
);
} }
+111 -3
View File
@@ -1,11 +1,11 @@
/** /**
* Hoover — components/layout.js * Hoover — components/layout.js
* *
* Layout components: PageHeader for page titles with optional subtitles * Layout components: PageHeader, Tabs, SectionTitle, DataTableSection, ActionGroup.
* and action buttons.
*/ */
import { h } from '../vdom.js'; import { h } from '../vdom.js?v=6';
import { Table } from './data.js?v=6';
/** /**
* Page header with title, optional subtitle, and action buttons. * Page header with title, optional subtitle, and action buttons.
@@ -24,3 +24,111 @@ export function PageHeader(props = {}) {
props.actions ? h('div', { class: 'page-actions' }, props.actions) : null, props.actions ? h('div', { class: 'page-actions' }, props.actions) : null,
); );
} }
/**
* Handle loading/error/no-data states and return early if applicable.
* Returns null when data is ready for the page to render its content.
*
* @param {object} state - Page state with loading/error flags
* @param {string} title - Page header title
* @param {string} [subtitle] - Page header subtitle
* @param {*} [data] - Data presence check for "no data" state
* @returns {VNode[]|null}
*/
export function renderGuard(state, title, subtitle, data) {
if (state.loading && !state.refreshing) {
return [
PageHeader({ title, subtitle }),
h('div', { class: 'card', key: 'loading' },
h('div', { class: 'card-body loading' },
state.refreshing ? 'Refreshing...' : 'Loading...',
),
),
];
}
if (state.error) {
return [
PageHeader({ title, subtitle }),
h('div', { class: 'card', key: 'error' },
h('div', { class: 'card-body error-msg' }, state.error),
),
];
}
if ((data === undefined || data === null) && !state.loading) {
return [
PageHeader({ title, subtitle }),
h('div', { class: 'card', key: 'no-data' },
h('div', { class: 'card-body loading' }, 'No data available'),
),
];
}
return null;
}
/**
* Tab bar component. Writes to state[prop] on tab click.
* The caller is responsible for rendering tab body content.
*
* @param {object} props
* @param {object} props.state - Reactive state object
* @param {string[]} props.tabs - Array of tab keys (e.g. ['ranges', 'leases'])
* @param {string} [props.prop] - State property name for active tab (default: 'activeTab')
* @param {function} [props.formatLabel] - (key) => label string (default: capitalize)
* @param {function} [props.onTabClick] - (key) => void, called after state update (for async side effects)
*/
export function Tabs(props = {}) {
const tabKeys = props.tabs || [];
const prop = props.prop || 'activeTab';
const formatLabel = props.formatLabel || ((k) => k.charAt(0).toUpperCase() + k.slice(1));
return h('div', { class: 'tabs' },
tabKeys.map(t => h('span', {
class: 'tab ' + (props.state[prop] === t ? 'active' : ''),
'on:click': () => {
props.state[prop] = t;
if (props.onTabClick) props.onTabClick(t);
},
style: 'cursor:pointer;',
}, formatLabel(t))),
);
}
/**
* Section header.
*
* @param {object} props
* @param {string} props.title
*/
export function SectionTitle(props = {}) {
return h('h3', { class: 'section-title' }, props.title);
}
/**
* Flex button container with 8px gap.
*
* @param {VNode[]} children
*/
export function ActionGroup(...children) {
return h('div', { style: 'display:flex;gap:8px;' }, ...children);
}
/**
* DataTableSection — SectionTitle heading followed by a Table.
*
* @param {object} props
* @param {string} props.title - Section heading
* @param {string[]} props.columns
* @param {VNode[]} props.rows
* @param {string} [props.emptyText]
* @param {string} [props.key]
*/
export function DataTableSection(props = {}) {
const key = props.key !== undefined ? { key: props.key } : {};
return h('div', { class: 'data-table-section', ...key },
SectionTitle({ title: props.title }),
Table({
columns: props.columns,
rows: props.rows,
emptyText: props.emptyText,
}),
);
}
+105 -3
View File
@@ -6,8 +6,9 @@
* avoid fighting with the main render cycle. * avoid fighting with the main render cycle.
*/ */
import { esc } from '../helpers.js'; import { esc } from '../helpers.js?v=6';
import { att_esc } from '../helpers.js'; import { att_esc } from '../helpers.js?v=6';
import { apiSubmit } from '../api.js?v=6';
const _modalQueue = []; const _modalQueue = [];
@@ -78,7 +79,8 @@ export function formModal(inner, title, fields, actions) {
inner.innerHTML = '<h2 class="modal-title">' + esc(title) + '</h2><div class="modal-body">' inner.innerHTML = '<h2 class="modal-title">' + esc(title) + '</h2><div class="modal-body">'
+ fields.map(f => { + fields.map(f => {
if (f.tag === 'select') if (f.tag === 'select')
return '<div class="form-group"><label>' + esc(f.label) + '</label><select id="' + att_esc(f.id) + '">' return '<div class="form-group"><label>' + esc(f.label) + '</label><select id="' + att_esc(f.id) + '"'
+ (f.multiple ? ' multiple' : '') + '>'
+ (f.options || []).map(o => + (f.options || []).map(o =>
typeof o === 'string' typeof o === 'string'
? '<option value="' + att_esc(o) + '">' + esc(o) + '</option>' ? '<option value="' + att_esc(o) + '">' + esc(o) + '</option>'
@@ -101,3 +103,103 @@ export function formModal(inner, title, fields, actions) {
if (btn) btn.addEventListener('click', a.handler); if (btn) btn.addEventListener('click', a.handler);
}); });
} }
/**
* Factory that returns a function to open a multi-select modal.
*
* @param {object} props
* @param {string} props.title - Modal title
* @param {string} props.url - API POST URL
* @param {string[]} props.options - All selectable options
* @param {string[]} props.selected - Currently selected values
* @param {string} props.fieldKey - JSON key for the field
* @param {string} [props.successMsg] - Success toast message
* @param {function} [props.reload] - () => Promise, called on success
* @returns {function} () => void, calls openModal
*/
export function MultiSelectModal(props = {}) {
return () => {
const selectId = 'ms-' + props.fieldKey;
openModal((inner) => {
formModal(inner, props.title,
[{
label: props.fieldKey,
id: selectId,
tag: 'select',
multiple: true,
options: (props.options || []).map(o => [o, (props.selected || []).includes(o)]),
}],
[
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
...apiSubmit({
url: props.url,
body: () => ({
[props.fieldKey]: Array.from(document.getElementById(selectId).selectedOptions)
.map(o => o.value),
}),
successMsg: props.successMsg || 'Updated',
reload: props.reload,
closeModal: () => closeModal(),
}),
],
);
});
};
}
/**
* Factory that returns a function to open a modal with form fields and apiSubmit.
* Accepts an optional `data` argument forwarded to title, fields, submit.url, submit.body resolvers.
*
* @param {object} props
* @param {string|function} props.title - Modal title or (data) => string
* @param {object[]|function} props.fields - Form field descriptors or (data) => object[]
* @param {object} props.submit - Submit configuration
* @param {string|function} props.submit.url - API URL or (data) => string
* @param {string} [props.submit.method] - HTTP method (default: 'POST')
* @param {function} [props.submit.body] - (data) => object
* @param {function} [props.submit.validate] - (body) => string|null
* @param {string|function} [props.submit.successMsg] - Toast message or (data) => string
* @param {function} [props.reload] - (data) => Promise, called on success with the data argument
* @param {function} [props.handler] - Custom submit handler override (bypasses apiSubmit)
* @param {string} [props.submitLabel] - Submit button label (default: 'Submit')
* @returns {function} (data) => void, calls openModal
*/
export function QuickModal(props = {}) {
return (data) => {
const title = typeof props.title === 'function' ? props.title(data) : props.title;
const fields = typeof props.fields === 'function' ? props.fields(data) : props.fields;
const url = typeof props.submit.url === 'function' ? props.submit.url(data) : props.submit.url;
openModal((inner) => {
let actions;
if (props.handler) {
actions = [
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
{
label: props.submitLabel || 'Submit',
cls: 'btn-primary',
action: 's',
handler: () => props.handler(data, () => closeModal()),
},
];
} else {
actions = [
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
...apiSubmit({
url,
method: props.submit.method || 'POST',
body: props.submit.body ? () => props.submit.body(data) : undefined,
validate: props.submit.validate,
successMsg: typeof props.submit.successMsg === 'function'
? props.submit.successMsg(data)
: (props.submit.successMsg || 'Done'),
reload: props.reload ? () => props.reload(data) : undefined,
closeModal: () => closeModal(),
}),
];
}
formModal(inner, title, fields, actions);
});
};
}
+2 -2
View File
@@ -5,8 +5,8 @@
* Uses the toast/dismissToast state from api.js. * Uses the toast/dismissToast state from api.js.
*/ */
import { h } from '../vdom.js'; import { h } from '../vdom.js?v=6';
import { _toasts, dismissToast } from '../api.js'; import { _toasts, dismissToast } from '../api.js?v=6';
/** /**
* Render all pending toast notifications. * Render all pending toast notifications.
+17
View File
@@ -49,3 +49,20 @@ export function parseZones(data) {
z = Object.values(z).map(i => i?.name || i); z = Object.values(z).map(i => i?.name || i);
return Array.isArray(z) ? z : []; return Array.isArray(z) ? z : [];
} }
/**
* Trigger a browser file download from a Blob.
*
* @param {Blob} blob
* @param {string} filename
*/
export function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
+12 -12
View File
@@ -5,37 +5,37 @@
*/ */
/* ── Reactivity ──────────────────────────────────────────────── */ /* ── Reactivity ──────────────────────────────────────────────── */
export { reactive, requestUpdate } from './reactivity.js'; export { reactive, requestUpdate } from './reactivity.js?v=6';
/* ── VDOM ────────────────────────────────────────────────────── */ /* ── VDOM ────────────────────────────────────────────────────── */
export { h } from './vdom.js'; export { h } from './vdom.js?v=6';
/* ── Render ──────────────────────────────────────────────────── */ /* ── Render ──────────────────────────────────────────────────── */
export { render } from './render.js'; export { render } from './render.js?v=6';
/* ── Component ───────────────────────────────────────────────── */ /* ── Component ───────────────────────────────────────────────── */
export { definePage, hComp } from './component.js'; export { definePage, hComp } from './component.js?v=6';
/* ── Router ──────────────────────────────────────────────────── */ /* ── Router ──────────────────────────────────────────────────── */
export { createRouter, Link } from './router.js'; export { createRouter, Link } from './router.js?v=6';
/* ── WebSocket ───────────────────────────────────────────────── */ /* ── WebSocket ───────────────────────────────────────────────── */
export { connect, onMessage } from './websocket.js'; export { connect, onMessage } from './websocket.js?v=6';
/* ── API & Toast ─────────────────────────────────────────────── */ /* ── API & Toast ─────────────────────────────────────────────── */
export { apiFetch, toast, dismissToast } from './api.js'; export { apiFetch, toast, dismissToast, apiSubmit, refactorLoad, checkAbort, poll } from './api.js?v=6';
/* ── Helpers ─────────────────────────────────────────────────── */ /* ── Helpers ─────────────────────────────────────────────────── */
export { esc, att_esc, enc, $val, parseZones } from './helpers.js'; export { esc, att_esc, enc, $val, parseZones, downloadBlob } from './helpers.js?v=6';
/* ── UI Components: Layout ───────────────────────────────────── */ /* ── UI Components: Layout ───────────────────────────────────── */
export { PageHeader } from './components/layout.js'; export { PageHeader, renderGuard, Tabs, SectionTitle, ActionGroup, DataTableSection } from './components/layout.js?v=6';
/* ── UI Components: Data ─────────────────────────────────────── */ /* ── UI Components: Data ─────────────────────────────────────── */
export { Badge, StatusDot, Empty, Card } from './components/data.js'; export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, certStatusBadge, serviceStatusBadge, StatCard, StatusText, ServiceStatus, ActionCell, MonoText, ZoneSelect } from './components/data.js?v=6';
/* ── UI Components: Modal ────────────────────────────────────── */ /* ── UI Components: Modal ────────────────────────────────────── */
export { openModal, closeModal, closeAllModals, formModal } from './components/modal.js'; export { openModal, closeModal, closeAllModals, formModal, MultiSelectModal, QuickModal } from './components/modal.js?v=6';
/* ── UI Components: Toast ────────────────────────────────────── */ /* ── UI Components: Toast ────────────────────────────────────── */
export { ToastContainer } from './components/toast.js'; export { ToastContainer } from './components/toast.js?v=6';
+35 -11
View File
@@ -5,12 +5,12 @@
* batched re-render loop integration with reactivity.js. * batched re-render loop integration with reactivity.js.
*/ */
import { requestUpdate, setCommitFn } from './reactivity.js'; import { requestUpdate, setCommitFn } from './reactivity.js?v=6';
import { import {
_vnodeDom, createDom, getDom, patchNode, _vnodeDom, createDom, getDom, patchNode, sweepDom,
setMountFn, setUnmountFn, setMountFn, setUnmountFn,
} from './vdom.js'; } from './vdom.js?v=6';
import { mountComponent, unmountComponent } from './component.js'; import { mountComponent, unmountComponent } from './component.js?v=6';
/** Container → previous root vnodes */ /** Container → previous root vnodes */
export const _renderSlots = new Map(); export const _renderSlots = new Map();
@@ -21,6 +21,9 @@ export const _renderFns = new Map();
/** Component key → last normalized #comp output (for _vnodeDom preservation) */ /** Component key → last normalized #comp output (for _vnodeDom preservation) */
export const _compExpandedCache = new Map(); export const _compExpandedCache = new Map();
/** Component key → renderer function (survives normalization that expands #comp) */
const _compRegistry = new Map();
/** /**
* Set up lifecycle callback hooks from vdom.js. * Set up lifecycle callback hooks from vdom.js.
* Called once during render initialization. * Called once during render initialization.
@@ -87,7 +90,7 @@ function commit(container) {
* and manage component lifecycle based on key changes. * and manage component lifecycle based on key changes.
*/ */
function normalizeVNodesWithLifecycle(result, prevVnodes) { function normalizeVNodesWithLifecycle(result, prevVnodes) {
const oldEntries = prevVnodes ? collectCompEntries(prevVnodes, []) : []; const oldEntries = [..._compRegistry.entries()].map(([key, renderer]) => ({ key, renderer }));
const oldKeyMap = new Map(oldEntries.map(e => [e.key, e])); const oldKeyMap = new Map(oldEntries.map(e => [e.key, e]));
const newEntries = []; const newEntries = [];
@@ -104,6 +107,16 @@ function normalizeVNodesWithLifecycle(result, prevVnodes) {
} }
} }
// Sync registry with current render (prevVnodes are normalized and lack #comp tags,
// so collectCompEntries always returns [] after the first render)
const newKeySet = new Set(newEntries.map(e => e.key));
for (const [key] of _compRegistry) {
if (!newKeySet.has(key)) _compRegistry.delete(key);
}
for (const entry of newEntries) {
_compRegistry.set(entry.key, entry.renderer);
}
return normalized; return normalized;
} }
@@ -199,6 +212,7 @@ function diffContainer(container, prev, vnodes) {
if (d?.parentNode) { if (d?.parentNode) {
if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d); if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d);
d.parentNode.removeChild(d); d.parentNode.removeChild(d);
lastDom = i > 0 ? getDom(prev[i - 1]) : null;
} }
continue; continue;
} }
@@ -214,15 +228,25 @@ function diffContainer(container, prev, vnodes) {
if (oldDom && oldV.tag === newV.tag) { if (oldDom && oldV.tag === newV.tag) {
patchNode(oldDom.nodeType === 1 ? oldDom.parentNode : container, oldV, newV, null); patchNode(oldDom.nodeType === 1 ? oldDom.parentNode : container, oldV, newV, null);
lastDom = getDom(newV); lastDom = getDom(newV);
} else { } else if (oldDom && !oldDom.parentNode) {
if (oldDom?.nodeType === Node.ELEMENT_NODE) sweepDom(oldDom); // oldDom exists in _vnodeDom but detached from the tree
if (oldDom.nodeType === Node.ELEMENT_NODE) sweepDom(oldDom);
const nd = createDom(newV);
_vnodeDom.set(newV, nd);
container.insertBefore(nd, lastDom ? lastDom.nextSibling : null);
lastDom = nd;
} else if (oldDom && oldV.tag !== newV.tag) {
// tag mismatch — replace old DOM with new
if (oldDom.nodeType === Node.ELEMENT_NODE) sweepDom(oldDom);
const nd = createDom(newV);
_vnodeDom.set(newV, nd);
oldDom.parentNode.replaceChild(nd, oldDom);
lastDom = nd;
} else {
// oldDom is null — create and insert new DOM
const nd = createDom(newV); const nd = createDom(newV);
_vnodeDom.set(newV, nd); _vnodeDom.set(newV, nd);
if (oldDom?.parentNode) {
oldDom.parentNode.replaceChild(nd, oldDom);
} else if (nd.parentNode !== container) {
container.insertBefore(nd, lastDom ? lastDom.nextSibling : null); container.insertBefore(nd, lastDom ? lastDom.nextSibling : null);
}
lastDom = nd; lastDom = nd;
} }
} }
+2 -2
View File
@@ -5,8 +5,8 @@
* navigation). Link component for client-side navigation. * navigation). Link component for client-side navigation.
*/ */
import { reactive } from './reactivity.js'; import { reactive } from './reactivity.js?v=6';
import { h } from './vdom.js'; import { h } from './vdom.js?v=6';
/** /**
* Hash-based router. * Hash-based router.
+1
View File
@@ -198,6 +198,7 @@ export function patchUnkeyed(parent, oldCh, newCh) {
if (d?.parentNode) { if (d?.parentNode) {
if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d); if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d);
d.parentNode.removeChild(d); d.parentNode.removeChild(d);
lastDom = i > 0 ? getDom(oldCh[i - 1]) : null;
} }
continue; continue;
} }
+77 -9
View File
@@ -8,7 +8,7 @@
* auto-refresh messages from the backend can trigger page reloads. * auto-refresh messages from the backend can trigger page reloads.
*/ */
import { setSubscribeFn, isComponentStateMounted } from './component.js'; import { setSubscribeFn, isComponentStateMounted, getComponentEntry } from './component.js?v=6';
const _wsSubs = new Map(); const _wsSubs = new Map();
let _wsConn = null; let _wsConn = null;
@@ -52,6 +52,43 @@ function _wsConnect() {
}; };
} }
/** Per-state debounce timer (shared across all subscriptions for that state). */
const _wsDebounceTimers = new Map();
/**
* Fire the debounced load for a component state.
*
* Only one load fires per state regardless of how many subscriptions
* matched. Passes the mount entry so refactorLoad can toggle
* loading / refreshing flags correctly.
*/
function debouncedLoad(state, entry) {
if (!isComponentStateMounted(state)) return;
// Abort any in-flight load for this component
if (entry && entry.loadAbort) entry.loadAbort.abort();
const ac = new AbortController();
const firstSub = [..._wsSubs.values()]
.find(s => !s.unsubscribed && s.state === state);
if (firstSub) {
firstSub.loadFn(state, ac, entry);
}
}
/**
* Debounce helper: coalesces all matching subscriptions for the same
* component state into a single reload, keyed by state object.
*/
function scheduleReload(state) {
if (_wsDebounceTimers.has(state)) {
clearTimeout(_wsDebounceTimers.get(state));
}
_wsDebounceTimers.set(state, setTimeout(() => {
_wsDebounceTimers.delete(state);
const entry = getComponentEntry(state);
debouncedLoad(state, entry);
}, 300));
}
/** /**
* Route an incoming WS message to subscribed components. * Route an incoming WS message to subscribed components.
* *
@@ -61,6 +98,11 @@ function _wsConnect() {
* { type: 'status', topic: 'firewall', … } * { type: 'status', topic: 'firewall', … }
* *
* Components subscribed to wildcard ('*') match every topic. * Components subscribed to wildcard ('*') match every topic.
*
* Uses per-component-state debouncing (300ms) to prevent a burst of WS
* messages or multiple matching topics from triggering overlapping
* loads. All subscriptions that share the same state object are
* coalesced into a single debounced reload.
*/ */
function handleMessage(msg) { function handleMessage(msg) {
const topics = []; const topics = [];
@@ -73,13 +115,20 @@ function handleMessage(msg) {
topics.push(msg.topic || '*'); topics.push(msg.topic || '*');
} }
// Track which states have already been scheduled to avoid
// double-scheduling when multiple subscriptions of the same
// component match the same message.
const scheduled = new Set();
for (const s of _wsSubs.values()) { for (const s of _wsSubs.values()) {
if (s.unsubscribed || !isComponentStateMounted(s.state)) continue; if (s.unsubscribed || !isComponentStateMounted(s.state)) continue;
if (s.topic === '*') {
s.loadFn(s.state); const matched = s.topic === '*' || topics.some(t => t === s.topic || t === '*');
} else if (topics.some(t => t === s.topic || t === '*')) { if (!matched) continue;
s.loadFn(s.state);
} if (scheduled.has(s.state)) continue;
scheduled.add(s.state);
scheduleReload(s.state);
} }
} }
@@ -89,6 +138,9 @@ function handleMessage(msg) {
* Called by component.js on mount. Returns an unsubscribe function * Called by component.js on mount. Returns an unsubscribe function
* called by component.js on unmount. * called by component.js on unmount.
* *
* Key is `componentFn + ':' + topic` so a component can subscribe to
* multiple topics without overwriting previous subscriptions.
*
* @param {function} componentFn The page renderer function (used as map key) * @param {function} componentFn The page renderer function (used as map key)
* @param {string} topic Topic to listen for ('*' = all) * @param {string} topic Topic to listen for ('*' = all)
* @param {function} loadFn Function to call when topic updates * @param {function} loadFn Function to call when topic updates
@@ -96,12 +148,21 @@ function handleMessage(msg) {
* @returns {function} unsubscribe * @returns {function} unsubscribe
*/ */
function subscribe(componentFn, topic, loadFn, state) { function subscribe(componentFn, topic, loadFn, state) {
const key = componentFn + ':' + topic;
const entry = { componentFn, topic, loadFn, state, unsubscribed: false }; const entry = { componentFn, topic, loadFn, state, unsubscribed: false };
_wsSubs.set(componentFn, entry); _wsSubs.set(key, entry);
return () => { return () => {
entry.unsubscribed = true; entry.unsubscribed = true;
_wsSubs.delete(componentFn); // Clear per-state debounce timer if this was the last active
// subscription for that state
const remaining = [..._wsSubs.values()]
.some(s => !s.unsubscribed && s.state === entry.state);
if (!remaining && _wsDebounceTimers.has(entry.state)) {
clearTimeout(_wsDebounceTimers.get(entry.state));
_wsDebounceTimers.delete(entry.state);
}
_wsSubs.delete(key);
}; };
} }
@@ -128,7 +189,14 @@ export function onMessage(topics, handler) {
unsubscribed: false unsubscribed: false
}; };
_wsSubs.set(handler + ':' + t, entry); _wsSubs.set(handler + ':' + t, entry);
fns.push(() => { entry.unsubscribed = true; _wsSubs.delete(handler + ':' + t); }); fns.push(() => {
entry.unsubscribed = true;
if (_wsDebounceTimers.has(entry.state)) {
clearTimeout(_wsDebounceTimers.get(entry.state));
_wsDebounceTimers.delete(entry.state);
}
_wsSubs.delete(handler + ':' + t);
});
} }
return () => fns.forEach(f => f()); return () => fns.forEach(f => f());
} }
+1 -1
View File
@@ -14,6 +14,6 @@
</div> </div>
</div> </div>
<script>window.__WS_URL__ = "__WS_URL_PLACEHOLDER__"</script> <script>window.__WS_URL__ = "__WS_URL_PLACEHOLDER__"</script>
<script type="module" src="/static/app.js?v=4"></script> <script type="module" src="/static/app.js?v=6"></script>
</body> </body>
</html> </html>
+41 -93
View File
@@ -1,4 +1,4 @@
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js'; import { h, PageHeader, Badge, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, refactorLoad, ActionCell, certStatusBadge, poll } from '/static/hoover/index.js?v=6';
function issueCertModal(state) { function issueCertModal(state) {
openModal((inner, idx) => { openModal((inner, idx) => {
@@ -13,14 +13,10 @@ function issueCertModal(state) {
label: 'Issue', cls: 'btn-primary', action: 's', handler: async () => { label: 'Issue', cls: 'btn-primary', action: 's', handler: async () => {
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; }
const body = { const body = { domain, email: ($val('ic-email') || '').trim() || undefined };
domain,
email: ($val('ic-email') || '').trim() || undefined,
};
const resp = await apiFetch('/api/certs/issue/start', { const resp = await apiFetch('/api/certs/issue/start', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, body,
body: JSON.stringify(body),
}); });
if (resp.ok) { if (resp.ok) {
toast('Issuance started for ' + domain, 'success'); toast('Issuance started for ' + domain, 'success');
@@ -38,103 +34,63 @@ function issueCertModal(state) {
} }
async function pollCertIssue(rid, state) { async function pollCertIssue(rid, state) {
let done = false; poll({
const timer = setInterval(async () => { url: '/api/certs/issue/' + enc(rid),
if (done) return clearInterval(timer); successKey: (d) => d.status === 'completed',
const r = await apiFetch('/api/certs/issue/' + enc(rid)); onErrorKey: (d) => d.status === 'failed',
if (r.ok && r.data) { onComplete: (d) => {
if (r.data.status === 'completed') { toast('Certificate issued for ' + (d.domain || rid), 'success');
done = true; load(state);
clearInterval(timer); },
toast('Certificate issued for ' + (r.data.domain || rid), 'success'); onError: (d) => {
await load(state); toast('Issuance failed: ' + (d?.error || 'unknown'), 'error');
} else if (r.data.status === 'failed') { },
done = true; });
clearInterval(timer);
toast('Issuance failed: ' + (r.data.error || 'unknown'), 'error');
}
}
}, 2000);
} }
async function load(state, abortController, entry) { async function load(state, abortController, entry) {
if (state.certs?.length) state.refreshing = true; await refactorLoad(state,
else state.loading = true; s => s.certs?.length,
try { async (s, sig, isAborted) => {
const myId = entry ? entry.requestId : 0;
const sig = abortController?.signal;
const r = await apiFetch('/api/certs/list', { signal: sig }); const r = await apiFetch('/api/certs/list', { signal: sig });
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; if (isAborted()) return;
if (r.ok) state.certs = r.data || []; if (r.ok) s.certs = r.data || [];
else state.error = r.error; else s.error = r.error;
} catch (e) { },
if (abortController?.signal.aborted) return; { entry, abortController },
state.error = String(e); );
}
state.loading = false;
state.refreshing = false;
} }
export default definePage({ export default definePage({
init() { init() {
return { certs: [], loading: true, refreshing: false, error: null }; return { certs: [] };
}, },
subscribe: ['acme'], subscribe: ['acme'],
load, load,
render(state) { render(state) {
if (state.loading && !state.refreshing) { const guard = renderGuard(state, 'Certificates', 'ACME certificate management', state.certs);
return [ if (guard) return guard;
PageHeader({ title: 'Certificates' }),
h('div', { class: 'card', key: 'loading' },
h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
),
];
}
if (state.error) {
return [
PageHeader({ title: 'Certificates' }),
h('div', { class: 'card', key: 'error' },
h('div', { class: 'card-body error-msg' }, state.error),
),
];
}
const rows = state.certs.map(c => { const rows = state.certs.map(c => {
const days = c.days_remaining; const badge = certStatusBadge({ expired: c.expired, daysRemaining: c.days_remaining });
let badge;
if (c.expired || (days !== undefined && days <= 0)) {
badge = Badge({ text: 'Expired', variant: 'danger' });
} else if (days !== undefined && days <= 30) {
badge = Badge({ text: days + 'd left', variant: 'warning' });
} else {
badge = Badge({ text: days !== undefined ? days + 'd left' : 'N/A', variant: 'success' });
}
return h('tr', { key: c.domain }, return h('tr', { key: c.domain },
h('td', null, h('strong', null, esc(c.domain || 'unknown'))), h('td', null, h('strong', null, esc(c.domain || 'unknown'))),
h('td', { class: 'text-sm' }, esc(c.issuer || '-')), h('td', { class: 'text-sm' }, esc(c.issuer || '-')),
h('td', null, esc(c.expiry || 'N/A')), h('td', null, esc(c.expiry || 'N/A')),
h('td', null, badge), h('td', null, badge),
h('td', null, ActionCell({
h('button', { class: 'btn btn-sm btn-outline', style: 'margin-right:4px;', editLabel: 'Renew',
'on:click': async () => { editClick: async () => {
const resp = await apiFetch('/api/certs/' + enc(c.domain) + '/renew', { method: 'POST' }); const resp = await apiFetch('/api/certs/' + enc(c.domain) + '/renew', { method: 'POST' });
if (resp.ok) toast('Renewal started for ' + c.domain, 'success'); if (resp.ok) toast('Renewal started for ' + c.domain, 'success');
else toast(resp.error || 'Failed', 'error'); else toast(resp.error || 'Failed', 'error');
}}, 'Renew'), },
h('button', { class: 'btn btn-sm btn-danger', removeUrl: '/api/certs/' + enc(c.domain),
'on:click': async () => { removeMessage: 'Remove certificate for ' + c.domain + '?',
if (!confirm('Remove certificate for ' + c.domain + '?')) return; removeSuccess: 'Certificate removed',
const resp = await apiFetch('/api/certs/' + enc(c.domain), { method: 'DELETE' }); removeReload: () => load(state),
if (resp.ok) { }),
toast('Certificate removed', 'success');
await load(state);
} else {
toast(resp.error || 'Failed', 'error');
}
}}, 'Remove'),
),
); );
}); });
@@ -146,18 +102,10 @@ export default definePage({
'on:click': () => issueCertModal(state) }, 'Issue Certificate'), 'on:click': () => issueCertModal(state) }, 'Issue Certificate'),
}), }),
rows.length rows.length
? h('div', { class: 'card' }, h('table', { class: 'table' }, ? Table({
h('thead', null, columns: ['Domain', 'Issuer', 'Expiry', 'Status', 'Actions'],
h('tr', null, rows,
h('th', null, 'Domain'), })
h('th', null, 'Issuer'),
h('th', null, 'Expiry'),
h('th', null, 'Status'),
h('th', { style: 'width:120px;' }, 'Actions'),
),
),
h('tbody', null, ...rows),
))
: Empty({ text: 'No certificates found. Issue a certificate to get started.' }), : Empty({ text: 'No certificates found. Issue a certificate to get started.' }),
]; ];
}, },
+37 -75
View File
@@ -1,55 +1,27 @@
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js'; import { h, PageHeader, apiFetch, definePage, renderGuard, refactorLoad, ServiceStatus, StatCard } from '/static/hoover/index.js?v=6';
export default definePage({ export default definePage({
init() { init() {
return { data: null, loading: true, refreshing: false, error: null }; return { data: null };
}, },
subscribe: ['*'], subscribe: ['firewall', 'dnsmasq', 'wireguard', 'acme', 'networkd'],
async load(state, abortController, entry) { async load(state, abortController, entry) {
const myId = entry ? entry.requestId : 0; await refactorLoad(state,
if (state.data) state.refreshing = true; s => s.data,
else state.loading = true; async (s, sig, isAborted) => {
try { const res = await apiFetch('/api/status/all', { signal: sig });
const res = await apiFetch('/api/status/all', { signal: abortController?.signal }); if (isAborted()) return;
if (abortController?.signal.aborted || entry.requestId !== myId) return; if (res.ok) s.data = res.data;
if (res.ok) state.data = res.data; else s.error = res.error;
else state.error = res.error; },
} catch (e) { { entry, abortController },
if (abortController?.signal.aborted) return; );
state.error = String(e);
}
state.loading = false;
state.refreshing = false;
}, },
render(state) { render(state) {
if (state.loading && !state.refreshing) { const guard = renderGuard(state, 'Dashboard', 'System overview', state.data);
return [ if (guard) return guard;
PageHeader({ title: 'Dashboard', subtitle: 'System overview' }),
h('div', { class: 'card', key: 'loading' },
h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
),
];
}
if (state.error) {
return [
PageHeader({ title: 'Dashboard', subtitle: 'System overview' }),
h('div', { class: 'card', key: 'error' },
h('div', { class: 'card-body error-msg' }, state.error),
),
];
}
const d = state.data; const d = state.data;
if (!d) {
return [
PageHeader({ title: 'Dashboard', subtitle: 'System overview' }),
h('div', { class: 'card', key: 'no-data' },
h('div', { class: 'card-body loading' }, 'No data available'),
),
];
}
const fwZones = (d.firewall?.zones) || {}; const fwZones = (d.firewall?.zones) || {};
const net = d.net || {}; const net = d.net || {};
const nCount = Object.keys(net).length; const nCount = Object.keys(net).length;
@@ -58,49 +30,39 @@ export default definePage({
const certs = d.certs || []; const certs = d.certs || [];
const certW = certs.filter(c => c.expired || c.days_remaining <= 30); const certW = certs.filter(c => c.expired || c.days_remaining <= 30);
const dmsk = d.dnsmasq?.status || {}; const dmsk = d.dnsmasq?.status || {};
const dmskUp = dmsk.state === 'up';
const wUp = (d.wg?.state || 'down') === 'up';
const wP = (d.wg || {}).peers || []; const wP = (d.wg || {}).peers || [];
return [ return [
PageHeader({ title: 'Dashboard', subtitle: 'System overview' }), PageHeader({ title: 'Dashboard', subtitle: 'System overview' }),
h('div', { class: 'grid grid-4' }, h('div', { class: 'grid grid-4' },
h('div', { class: 'stat-card' }, StatCard({
h('div', { class: 'label' }, 'Active Zones'), label: 'Active Zones',
h('div', { class: 'value' }, Object.keys(fwZones).length), value: Object.keys(fwZones).length,
h('div', { class: 'meta' }, Object.keys(fwZones).join(', ') || 'None'), meta: Object.keys(fwZones).join(', ') || 'None',
), }),
h('div', { class: 'stat-card' }, StatCard({
h('div', { class: 'label' }, 'Interfaces Up'), label: 'Interfaces Up',
h('div', { class: 'value' }, upC + '/' + nCount), value: upC + '/' + nCount,
h('div', { class: 'meta' }, upI.map(i => i.name).join(', ') || 'None up'), meta: upI.map(i => i.name).join(', ') || 'None up',
), }),
h('div', { class: 'stat-card' }, StatCard({
h('div', { class: 'label' }, 'WireGuard'), label: 'WireGuard',
h('div', { class: 'value' }, String(d.wg?.state || 'unknown')), value: String(d.wg?.state || 'unknown'),
h('div', { class: 'meta' }, wP.length + ' peers'), meta: wP.length + ' peers',
), }),
h('div', { class: 'stat-card' }, StatCard({
h('div', { class: 'label' }, 'Certificates'), label: 'Certificates',
h('div', { class: 'value' }, certs.length), value: certs.length,
h('div', { class: 'meta' }, certW.length + ' expiring/expired'), meta: certW.length + ' expiring/expired',
), }),
), ),
h('div', { class: 'grid grid-2' }, h('div', { class: 'grid grid-2' },
h('div', { class: 'card' }, h('div', { class: 'card' },
h('div', { class: 'card-header' }, 'Services'), h('div', { class: 'card-header' }, 'Services'),
h('div', { class: 'card-body' }, h('div', { class: 'card-body' },
h('ul', { class: 'service-list' }, h('ul', { class: 'service-list' },
h('li', null, h('li', null, ServiceStatus({ state: dmsk.state || 'down', label: 'Dnsmasq' })),
StatusDot({ status: dmskUp ? 'success' : 'danger' }), h('li', null, ServiceStatus({ state: d.wg?.state || 'down', label: 'WireGuard' })),
' Dnsmasq ',
Badge({ text: dmsk.state || 'down', variant: dmskUp ? 'success' : 'danger' }),
),
h('li', null,
StatusDot({ status: wUp ? 'success' : 'danger' }),
' WireGuard ',
Badge({ text: String(d.wg?.state || 'down'), variant: wUp ? 'success' : 'danger' }),
),
), ),
), ),
), ),
+97 -252
View File
@@ -1,335 +1,180 @@
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js'; import { h, PageHeader, Badge, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, definePage, refactorLoad, ActionButton, ActionGroup, QuickModal } from '/static/hoover/index.js?v=6';
function addRangeModal(state) { const addRange = QuickModal({
openModal((inner, idx) => { title: 'Add DHCP Range',
formModal(inner, 'Add DHCP Range', fields: [
[
{ label: 'Interface (optional)', id: 'r-iface', placeholder: 'Leave empty for global' }, { label: 'Interface (optional)', id: 'r-iface', placeholder: 'Leave empty for global' },
{ label: 'Start IP', id: 'r-start', placeholder: '192.168.1.100' }, { label: 'Start IP', id: 'r-start', placeholder: '192.168.1.100' },
{ label: 'End IP', id: 'r-end', placeholder: '192.168.1.200' }, { label: 'End IP', id: 'r-end', placeholder: '192.168.1.200' },
{ label: 'Lease Time', id: 'r-lease', placeholder: '12h' }, { label: 'Lease Time', id: 'r-lease', placeholder: '12h' },
], ],
[ submit: {
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, url: '/api/dhcp/ranges',
{ body: () => ({
label: 'Add', cls: 'btn-primary', action: 's', handler: async () => {
const body = {
interface: ($val('r-iface') || '').trim() || undefined, interface: ($val('r-iface') || '').trim() || undefined,
start: ($val('r-start') || '').trim(), start: ($val('r-start') || '').trim(),
end: ($val('r-end') || '').trim(), end: ($val('r-end') || '').trim(),
lease_time: ($val('r-lease') || '').trim() || '12h', lease_time: ($val('r-lease') || '').trim() || '12h',
}; }),
if (!body.start || !body.end) { validate: (b) => !b.start || !b.end ? 'Start and end are required' : null,
toast('Start and end are required', 'error'); successMsg: 'Range added',
return;
}
const resp = await apiFetch('/api/dhcp/ranges', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (resp.ok) {
toast('Range added', 'success');
closeModal(idx);
await load(state);
} else {
toast(resp.error || 'Failed', 'error');
}
}, },
}, reload: (s) => load(s),
],
);
}); });
}
function addLeaseModal(state) { const addLease = QuickModal({
openModal((inner, idx) => { title: 'Add Static Lease',
formModal(inner, 'Add Static Lease', fields: [
[
{ label: 'MAC', id: 'l-mac', placeholder: 'aa:bb:cc:dd:ee:ff' }, { label: 'MAC', id: 'l-mac', placeholder: 'aa:bb:cc:dd:ee:ff' },
{ label: 'IP', id: 'l-ip', placeholder: '192.168.1.50' }, { label: 'IP', id: 'l-ip', placeholder: '192.168.1.50' },
{ label: 'Hostname (optional)', id: 'l-host', placeholder: 'device-name' }, { label: 'Hostname (optional)', id: 'l-host', placeholder: 'device-name' },
], ],
[ submit: {
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, url: '/api/dhcp/static-lease',
{ body: () => ({
label: 'Add', cls: 'btn-primary', action: 's', handler: async () => {
const body = {
mac: ($val('l-mac') || '').trim(), mac: ($val('l-mac') || '').trim(),
ip: ($val('l-ip') || '').trim(), ip: ($val('l-ip') || '').trim(),
hostname: ($val('l-host') || '').trim() || undefined, hostname: ($val('l-host') || '').trim() || undefined,
}; }),
if (!body.mac || !body.ip) { validate: (b) => !b.mac || !b.ip ? 'MAC and IP are required' : null,
toast('MAC and IP are required', 'error'); successMsg: 'Lease added',
return;
}
const resp = await apiFetch('/api/dhcp/static-lease', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (resp.ok) {
toast('Lease added', 'success');
closeModal(idx);
await load(state);
} else {
toast(resp.error || 'Failed', 'error');
}
}, },
}, reload: (s) => load(s),
],
);
}); });
}
function addDnsModal(state) { const addDns = QuickModal({
openModal((inner, idx) => { title: 'Add DNS Record',
formModal(inner, 'Add DNS Record', fields: [
[
{ label: 'Name', id: 'd-name', placeholder: 'host.local' }, { label: 'Name', id: 'd-name', placeholder: 'host.local' },
{ label: 'Address', id: 'd-addr', placeholder: '192.168.1.10' }, { label: 'Address', id: 'd-addr', placeholder: '192.168.1.10' },
], ],
[ submit: {
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, url: '/api/dhcp/dns-record',
{ body: () => ({ name: ($val('d-name') || '').trim(), address: ($val('d-addr') || '').trim() }),
label: 'Add', cls: 'btn-primary', action: 's', handler: async () => { validate: (b) => !b.name || !b.address ? 'Name and address are required' : null,
const body = { successMsg: 'DNS record added',
name: ($val('d-name') || '').trim(),
address: ($val('d-addr') || '').trim(),
};
if (!body.name || !body.address) {
toast('Name and address are required', 'error');
return;
}
const resp = await apiFetch('/api/dhcp/dns-record', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (resp.ok) {
toast('DNS record added', 'success');
closeModal(idx);
await load(state);
} else {
toast(resp.error || 'Failed', 'error');
}
}, },
}, reload: (s) => load(s),
],
);
}); });
}
async function load(state, abortController, entry) { async function load(state, abortController, entry) {
if (Object.keys(state.config || {}).length) state.refreshing = true; await refactorLoad(state,
else state.loading = true; s => Object.keys(s.config || {}).length,
try { async (s, sig, isAborted) => {
const myId = entry ? entry.requestId : 0; const [cfgR, stR, lsR] = await Promise.allSettled([
const sig = abortController?.signal; apiFetch('/api/dhcp/config', { signal: sig }),
const cfgR = await apiFetch('/api/dhcp/config', { signal: sig }); apiFetch('/api/dhcp/status', { signal: sig }),
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; apiFetch('/api/dhcp/leases', { signal: sig }),
if (cfgR.ok) state.config = cfgR.data || {}; ]);
const stR = await apiFetch('/api/dhcp/status', { signal: sig }); if (isAborted()) return;
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; const errors = [];
if (stR.ok) state.status = stR.data || {}; if (cfgR.status === 'rejected') errors.push(cfgR.reason?.message || 'Failed');
const lsR = await apiFetch('/api/dhcp/leases', { signal: sig }); else if (!cfgR.value.ok) errors.push(cfgR.value.error || 'Failed');
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; if (stR.status === 'rejected') errors.push(stR.reason?.message || 'Failed');
if (lsR.ok) state.leases = lsR.data || []; else if (!stR.value.ok) errors.push(stR.value.error || 'Failed');
} catch (e) { if (lsR.status === 'rejected') errors.push(lsR.reason?.message || 'Failed');
if (abortController?.signal.aborted) return; else if (!lsR.value.ok) errors.push(lsR.value.error || 'Failed');
state.error = String(e); if (errors.length) {
s.error = errors[0];
return;
} }
state.loading = false; s.config = cfgR.value.data || {};
state.refreshing = false; s.status = stR.value.data || {};
s.leases = lsR.value.data || [];
},
{ entry, abortController },
);
} }
export default definePage({ export default definePage({
init() { init() {
return { config: {}, status: {}, leases: [], loading: true, refreshing: false, error: null, activeTab: 'ranges' }; return { config: {}, status: {}, leases: [], activeTab: 'ranges' };
}, },
subscribe: ['dnsmasq'], subscribe: ['dnsmasq'],
load, load,
render(state) { render(state) {
if (state.loading && !state.refreshing) { const guard = renderGuard(state, 'DHCP & DNS', null, state.leases);
return [ if (guard) return guard;
PageHeader({ title: 'DHCP & DNS' }),
h('div', { class: 'card', key: 'loading' },
h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
),
];
}
if (state.error) {
return [
PageHeader({ title: 'DHCP & DNS' }),
h('div', { class: 'card', key: 'error' },
h('div', { class: 'card-body error-msg' }, state.error),
),
];
}
const cfg = state.config || {}; const cfg = state.config || {};
const ranges = cfg.ranges || []; const ranges = cfg.ranges || [];
const staticLeases = cfg.static_leases || []; const staticLeases = cfg.static_leases || [];
const dnsRecords = cfg.dns_records || []; const dnsRecords = cfg.dns_records || [];
const statusUp = state.status || {}; const statusUp = state.status || {};
const isUp = statusUp.state === 'up';
const rangesRows = ranges.map((r, i) => h('tr', { key: i }, const rangesRows = ranges.map((r, i) => h('tr', { key: (r.interface || '_g') + '-' + r.start + '-' + r.end },
h('td', null, r.interface || '(global)'), h('td', null, r.interface || '(global)'),
h('td', null, esc(r.start)), h('td', null, esc(r.start)),
h('td', null, esc(r.end)), h('td', null, esc(r.end)),
h('td', null, esc(r.lease_time || '12h')), h('td', null, esc(r.lease_time || '12h')),
h('td', null, h('td', null,
h('button', { class: 'btn btn-sm btn-danger', ConfirmDelete({
'on:click': async () => { url: '/api/dhcp/ranges',
if (!confirm('Remove range ' + r.start + ' - ' + r.end + '?')) return; message: 'Remove range ' + r.start + ' - ' + r.end + '?',
const resp = await apiFetch('/api/dhcp/ranges', { body: { interface: r.interface || '', start: r.start, end: r.end },
method: 'DELETE', success: 'Range removed',
headers: { 'Content-Type': 'application/json' }, reload: () => load(state),
body: JSON.stringify({ interface: r.interface || '', start: r.start, end: r.end }), }),
});
if (resp.ok) {
toast('Range removed', 'success');
await load(state);
} else {
toast(resp.error || 'Failed', 'error');
}
}}, 'Remove'),
), ),
)); ));
const leaseRows = staticLeases.map((l, i) => h('tr', { key: i }, const leaseRows = staticLeases.map((l, i) => h('tr', { key: l.mac },
h('td', null, esc(l.mac)), h('td', null, esc(l.mac)),
h('td', null, esc(l.ip)), h('td', null, esc(l.ip)),
h('td', null, l.hostname || '-'), h('td', null, l.hostname || '-'),
h('td', null, h('td', null,
h('button', { class: 'btn btn-sm btn-danger', ConfirmDelete({
'on:click': async () => { url: '/api/dhcp/static-lease/' + enc(l.mac),
if (!confirm('Remove lease ' + l.mac + '?')) return; message: 'Remove lease ' + l.mac + '?',
const resp = await apiFetch('/api/dhcp/static-lease/' + enc(l.mac), { method: 'DELETE' }); success: 'Lease removed',
if (resp.ok) { reload: () => load(state),
toast('Lease removed', 'success'); }),
await load(state);
} else {
toast(resp.error || 'Failed', 'error');
}
}}, 'Remove'),
), ),
)); ));
const dnsRows = dnsRecords.map((rec, i) => h('tr', { key: i }, const dnsRows = dnsRecords.map((rec, i) => h('tr', { key: rec.name },
h('td', null, h('strong', null, esc(rec.name || 'unnamed'))), h('td', null, h('strong', null, esc(rec.name || 'unnamed'))),
h('td', { class: 'text-sm' }, esc(rec.address || '-')), h('td', { class: 'text-sm' }, esc(rec.address || '-')),
h('td', null, h('td', null,
h('button', { class: 'btn btn-sm btn-danger', ConfirmDelete({
'on:click': async () => { url: '/api/dhcp/dns-record/' + enc(rec.name || ''),
if (!confirm('Remove DNS record ' + (rec.name || 'unnamed') + '?')) return; message: 'Remove DNS record ' + (rec.name || 'unnamed') + '?',
const resp = await apiFetch('/api/dhcp/dns-record/' + enc(rec.name || ''), { method: 'DELETE' }); success: 'Record removed',
if (resp.ok) { reload: () => load(state),
toast('Record removed', 'success'); }),
await load(state);
} else {
toast(resp.error || 'Failed', 'error');
}
}}, 'Remove'),
), ),
)); ));
const tabNames = ['ranges', 'leases', 'dns', 'active']; const tabNames = ['ranges', 'leases', 'dns', 'active'];
const actions = h('div', { style: 'display:flex;gap:8px;' }, const actions = ActionGroup(
h('button', { class: 'btn btn-primary', 'on:click': () => addRangeModal(state) }, 'Add Range'), h('button', { class: 'btn btn-primary', 'on:click': () => addRange(state) }, 'Add Range'),
h('button', { class: 'btn btn-outline', 'on:click': () => addLeaseModal(state) }, 'Static Lease'), h('button', { class: 'btn btn-outline', 'on:click': () => addLease(state) }, 'Static Lease'),
h('button', { class: 'btn btn-outline', 'on:click': () => addDnsModal(state) }, 'DNS Record'), h('button', { class: 'btn btn-outline', 'on:click': () => addDns(state) }, 'DNS Record'),
h('button', { class: 'btn btn-outline', ActionButton({
'on:click': async () => { url: '/api/dhcp/apply',
const resp = await apiFetch('/api/dhcp/apply', { method: 'POST' }); successMsg: 'dnsmasq applied',
if (resp.ok) toast('dnsmasq applied', 'success'); label: 'Apply',
else toast(resp.error || 'Failed', 'error'); reload: () => load(state),
}}, 'Apply'), }),
); );
return [ return [
PageHeader({ title: 'DHCP & DNS', subtitle: 'Dnsmasq management', actions }), PageHeader({ title: 'DHCP & DNS', subtitle: 'Dnsmasq management', actions }),
h('div', null, ServiceStatus({ state: statusUp.state || 'down', label: 'Dnsmasq' }),
StatusDot({ status: isUp ? 'success' : 'danger' }), Tabs({ state, tabs: tabNames }),
' Dnsmasq ',
Badge({ text: statusUp.state || 'unknown', variant: isUp ? 'success' : 'danger' }),
),
h('div', { class: 'tabs' },
tabNames.map(t => h('span', {
class: 'tab ' + (state.activeTab === t ? 'active' : ''),
'on:click': () => { state.activeTab = t; },
style: 'cursor:pointer;',
}, t.charAt(0).toUpperCase() + t.slice(1))),
),
state.activeTab === 'ranges' state.activeTab === 'ranges'
? h('div', { class: 'card' }, h('table', { class: 'table' }, ? Table({ columns: ['Interface', 'Start', 'End', 'Lease', 'Action'], rows: rangesRows, emptyText: 'No DHCP ranges' }) : null,
h('thead', null,
h('tr', null,
h('th', null, 'Interface'),
h('th', null, 'Start'),
h('th', null, 'End'),
h('th', null, 'Lease'),
h('th', { style: 'width:80px;' }, 'Action'),
),
),
h('tbody', null,
...(rangesRows.length ? rangesRows : [
h('tr', null, h('td', { colspan: 5, class: 'text-muted text-sm' }, 'No DHCP ranges')),
]),
),
)) : null,
state.activeTab === 'leases' state.activeTab === 'leases'
? h('div', { class: 'card' }, h('table', { class: 'table' }, ? Table({ columns: ['MAC', 'IP', 'Hostname', 'Action'], rows: leaseRows, emptyText: 'No static leases' }) : null,
h('thead', null,
h('tr', null,
h('th', null, 'MAC'),
h('th', null, 'IP'),
h('th', null, 'Hostname'),
h('th', { style: 'width:80px;' }, 'Action'),
),
),
h('tbody', null,
...(leaseRows.length ? leaseRows : [
h('tr', null, h('td', { colspan: 4, class: 'text-muted text-sm' }, 'No static leases')),
]),
),
)) : null,
state.activeTab === 'dns' state.activeTab === 'dns'
? h('div', { class: 'card' }, h('table', { class: 'table' }, ? Table({ columns: ['Name', 'Address', 'Action'], rows: dnsRows, emptyText: 'No custom DNS records' }) : null,
h('thead', null,
h('tr', null,
h('th', null, 'Name'),
h('th', null, 'Address'),
h('th', { style: 'width:80px;' }, 'Action'),
),
),
h('tbody', null,
...(dnsRows.length ? dnsRows : [
h('tr', null, h('td', { colspan: 3, class: 'text-muted text-sm' }, 'No custom DNS records')),
]),
),
)) : null,
state.activeTab === 'active' state.activeTab === 'active'
? h('div', { class: 'card' }, h('table', { class: 'table' }, ? Table({ columns: ['MAC', 'IP', 'Hostname', 'Expires'], rows: (state.leases || []).map((l, i) => h('tr', { key: l.mac || i },
h('thead', null,
h('tr', null,
h('th', null, 'MAC'),
h('th', null, 'IP'),
h('th', null, 'Hostname'),
h('th', null, 'Expires'),
),
),
h('tbody', null,
(state.leases || []).map((l, i) => h('tr', { key: i },
h('td', null, esc(l.mac || '-')), h('td', null, esc(l.mac || '-')),
h('td', null, esc(l.ip || '-')), h('td', null, esc(l.ip || '-')),
h('td', null, esc(l.hostname || '-')), h('td', null, esc(l.hostname || '-')),
h('td', null, esc(l.expires || '-')), h('td', null, esc(l.expires || '-')),
)), )), emptyText: 'No active leases' }) : null,
),
)) : null,
]; ];
}, },
}); });
+46 -101
View File
@@ -1,10 +1,9 @@
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js'; import { h, PageHeader, Table, renderGuard, enc, $val, apiFetch, toast, definePage, refactorLoad, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js?v=6';
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', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, body: { interfaces: [name] },
body: JSON.stringify({ interfaces: [name] }),
}); });
if (r.ok) { if (r.ok) {
toast(name + ' \u2192 ' + zone, 'success'); toast(name + ' \u2192 ' + zone, 'success');
@@ -14,126 +13,87 @@ async function changeZone(name, zone, state) {
} }
} }
function cfgModal(name, state) { const cfgModalFn = QuickModal({
openModal((inner, idx) => { title: (d) => 'Config: ' + d.name,
formModal(inner, 'Config: ' + name, fields: (d) => {
[ const cfg = d.config || {};
{ label: 'Addresses (comma-separated)', id: 'cfg-addrs', placeholder: '192.168.1.1/24' }, return [
{ label: 'Gateway', id: 'cfg-gw' }, { label: 'Addresses (comma-separated)', id: 'cfg-addrs', value: (cfg.addresses || []).join(', '), placeholder: '192.168.1.1/24' },
{ label: 'DNS (comma-separated)', id: 'cfg-dns', placeholder: '1.1.1.1, 8.8.8.8' }, { label: 'Gateway', id: 'cfg-gw', value: cfg.gateway || '' },
], { label: 'DNS (comma-separated)', id: 'cfg-dns', value: (cfg.dns || []).join(', '), placeholder: '1.1.1.1, 8.8.8.8' },
[ ];
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, },
{ submit: {
label: 'Save', cls: 'btn-primary', action: 's', handler: async () => { url: (d) => '/api/network/interfaces/' + enc(d.name),
const body = { body: () => ({
addresses: ($val('cfg-addrs') || '').split(',').map(s => s.trim()).filter(Boolean), addresses: ($val('cfg-addrs') || '').split(',').map(s => s.trim()).filter(Boolean),
gateway: ($val('cfg-gw') || '').trim() || undefined, gateway: ($val('cfg-gw') || '').trim() || undefined,
dns: ($val('cfg-dns') || '').split(',').map(s => s.trim()).filter(Boolean), dns: ($val('cfg-dns') || '').split(',').map(s => s.trim()).filter(Boolean),
}; }),
const r = await apiFetch('/api/network/interfaces/' + enc(name), { successMsg: 'Config saved',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (r.ok) {
toast('Config saved', 'success');
closeModal(idx);
await load(state);
} else {
toast(r.error || 'Failed', 'error');
}
}, },
}, reload: (s) => load(s),
],
);
}); });
}
async function load(state, abortController, entry) { async function load(state, abortController, entry) {
if (state.ifaces?.length) state.refreshing = true; await refactorLoad(state,
else state.loading = true; s => s.ifaces?.length,
try { async (s, sig, isAborted) => {
const myId = entry ? entry.requestId : 0;
const sig = abortController?.signal;
const [fw, net] = await Promise.all([ const [fw, net] = await Promise.all([
apiFetch('/api/firewall/zones', { signal: sig }), apiFetch('/api/firewall/zones', { signal: sig }),
apiFetch('/api/network/interfaces', { signal: sig }), apiFetch('/api/network/interfaces', { signal: sig }),
]); ]);
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; if (isAborted()) return;
// Extract zone names from available zones (for the dropdown) if (fw.ok) s.zones = fw.data?.available || [];
state.zones = fw.ok ? (fw.data?.available || []) : []; else s.error = fw.error;
if (net.ok) { if (net.ok) {
// Build reverse zone map: interface name → zone name, from active zones
const ifaceZone = {}; const ifaceZone = {};
for (const [zoneName, ifaces] of Object.entries(fw.data?.active || {})) { for (const [zoneName, ifaces] of Object.entries(fw.data?.active || {})) {
for (const name of (ifaces || [])) ifaceZone[name] = zoneName; for (const name of (ifaces || [])) ifaceZone[name] = zoneName;
} }
// Transform { interfaces: { name: { config, runtime } }, timestamp }
// → array of { name, mac, ips, state, zone }
const ifacesObj = net.data?.interfaces || {}; const ifacesObj = net.data?.interfaces || {};
state.ifaces = Object.entries(ifacesObj).map(([name, entry]) => ({ s.ifaces = Object.entries(ifacesObj).map(([name, entry]) => ({
name, name,
mac: entry?.runtime?.mac || null, mac: entry?.runtime?.mac || null,
ips: [...(entry?.config?.addresses || []), ...(entry?.runtime?.addresses || [])], ips: [...(entry?.config?.addresses || []), ...(entry?.runtime?.addresses || [])],
state: (entry?.runtime?.state || '').startsWith('routable') || (entry?.runtime?.state || '').startsWith('carrier') ? 'up' : 'down', state: (entry?.runtime?.state || '').startsWith('routable') || (entry?.runtime?.state || '').startsWith('carrier') ? 'up' : 'down',
zone: ifaceZone[name] || null, zone: ifaceZone[name] || null,
config: entry?.config || {},
})); }));
} else { } else if (!s.error) {
state.error = net.error; s.error = net.error;
} }
} catch (e) { },
if (abortController?.signal.aborted) return; { entry, abortController },
state.error = String(e); );
}
state.loading = false;
state.refreshing = false;
} }
export default definePage({ export default definePage({
init() { init() {
return { ifaces: [], zones: [], loading: true, refreshing: false, error: null }; return { ifaces: [], zones: [] };
}, },
subscribe: ['firewall', 'networkd'], subscribe: ['firewall', 'networkd'],
load, load,
render(state) { render(state) {
if (state.loading && !state.refreshing) { const guard = renderGuard(state, 'Interfaces', 'Network interface management', state.ifaces.length);
return [ if (guard) return guard;
PageHeader({ title: 'Interfaces' }),
h('div', { class: 'card', key: 'loading' },
h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
),
];
}
if (state.error) {
return [
PageHeader({ title: 'Interfaces' }),
h('div', { class: 'card', key: 'error' },
h('div', { class: 'card-body error-msg' }, state.error),
),
];
}
const rows = state.ifaces.map(iface => { const rows = state.ifaces.map(iface => {
return h('tr', { key: iface.name }, return h('tr', { key: iface.name },
h('td', null, h('strong', null, iface.name)), h('td', null, h('strong', null, iface.name)),
h('td', { class: 'text-muted' }, String(iface.mac || 'N/A')), h('td', { class: 'text-muted' }, String(iface.mac || 'N/A')),
h('td', null, (iface.ips || []).join(', ') || 'N/A'), h('td', null, (iface.ips || []).join(', ') || 'N/A'),
h('td', null, StatusText({ status: iface.state })),
h('td', null, h('td', null,
StatusDot({ status: iface.state }), ZoneSelect({
' ' + (iface.state === 'up' ? 'Up' : 'Down'), zones: state.zones,
), value: iface.zone,
h('td', null, onChange: (z) => changeZone(iface.name, z, state),
h('select', { }),
'on:change': (e) => changeZone(iface.name, e.target.value, state),
}, state.zones.map(z =>
h('option', { value: z, selected: z === iface.zone }, z),
)),
h('button', { h('button', {
class: 'btn btn-sm btn-outline', class: 'btn btn-sm btn-outline',
style: 'margin-left:8px', style: 'margin-left:8px',
'on:click': () => cfgModal(iface.name, state), 'on:click': () => cfgModalFn(iface),
}, 'Config'), }, 'Config'),
), ),
); );
@@ -141,26 +101,11 @@ export default definePage({
return [ return [
PageHeader({ title: 'Interfaces', subtitle: 'Network interface management' }), PageHeader({ title: 'Interfaces', subtitle: 'Network interface management' }),
h('div', { class: 'card' }, Table({
h('table', { class: 'table' }, columns: ['Name', 'MAC', 'IPs', 'State', 'Zone / Actions'],
h('thead', null, rows,
h('tr', null, emptyText: 'No interfaces found',
h('th', null, 'Name'), }),
h('th', null, 'MAC'),
h('th', null, 'IPs'),
h('th', null, 'State'),
h('th', null, 'Zone / Actions'),
),
),
h('tbody', null,
...(rows.length ? rows : [
h('tr', null,
h('td', { colspan: 5, class: 'text-muted text-sm' }, 'No interfaces found'),
),
]),
),
),
),
]; ];
}, },
}); });
+50 -28
View File
@@ -1,4 +1,4 @@
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js'; import { h, PageHeader, Tabs, esc, definePage, refactorLoad } from '/static/hoover/index.js?v=6';
const logTabs = [ const logTabs = [
{ key: 'journal', label: 'Journal', url: '/api/logs/journal' }, { key: 'journal', label: 'Journal', url: '/api/logs/journal' },
@@ -8,38 +8,33 @@ const logTabs = [
{ key: 'app', label: 'App', url: '/api/logs/app' }, { key: 'app', label: 'App', url: '/api/logs/app' },
]; ];
async function fetchLog(state, url, signal) { async function fetchLog(state, url, signal) {
state.loading = true; if (signal?.aborted) return;
state.error = null;
try {
const res = await fetch(url, { signal }); const res = await fetch(url, { signal });
if (signal?.aborted) return; if (signal?.aborted) return;
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text(); const text = await res.text();
if (signal?.aborted) return; if (signal?.aborted) return;
state.lines = text.split('\n').filter(l => l.length > 0); state.lines = text.split('\n').filter(l => l.length > 0);
} catch (e) {
if (signal?.aborted) return;
state.error = String(e);
}
state.loading = false;
state.refreshing = false;
} }
export default definePage({ export default definePage({
init() { init() {
return { activeTab: 'journal', lines: [], loading: false, refreshing: false, error: null }; return { activeTab: 'journal', lines: [], loading: false, _abortCtrl: null };
}, },
subscribe: [], subscribe: [],
async load(state, abortController, entry) { async load(state, abortController, entry) {
if (state.lines?.length) state.refreshing = true; await refactorLoad(state,
else state.loading = true; s => s.lines?.length,
const myId = entry ? entry.requestId : 0; async (s, sig, isAborted) => {
const tab = logTabs.find(t => t.key === state.activeTab) || logTabs[0]; const tab = logTabs.find(t => t.key === s.activeTab) || logTabs[0];
await fetchLog(state, tab.url, abortController?.signal); await fetchLog(s, tab.url, sig);
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; },
{ entry, abortController },
);
}, },
onUnmount(state) { onUnmount(state) {
state._abortCtrl?.abort();
state.lines = []; state.lines = [];
}, },
render(state) { render(state) {
@@ -51,16 +46,32 @@ export default definePage({
return [ return [
PageHeader({ title: 'Logs', subtitle: 'System & service logs' }), PageHeader({ title: 'Logs', subtitle: 'System & service logs' }),
h('div', { class: 'tabs', key: 'log-tabs' }, Tabs({
logTabs.map(t => h('span', { state,
class: 'tab ' + (state.activeTab === t.key ? 'active' : ''), tabs: logTabs.map(t => t.key),
'on:click': async () => { formatLabel: (k) => {
state.activeTab = t.key; const tab = logTabs.find(t => t.key === k);
await fetchLog(state, t.url); return tab ? tab.label : k.charAt(0).toUpperCase() + k.slice(1);
}, },
style: 'cursor:pointer;', onTabClick: async (key) => {
}, t.label)) const tab = logTabs.find(t => t.key === key);
), if (!tab) return;
state._abortCtrl?.abort();
const ctrl = new AbortController();
state._abortCtrl = ctrl;
const tabs = state.lines?.length ? state : null;
state.refreshing = !!tabs;
if (!tabs) state.loading = true;
state.error = null;
try {
await fetchLog(state, tab.url, ctrl.signal);
} catch (e) {
if (!ctrl.signal.aborted) state.error = String(e);
}
state.loading = false;
state.refreshing = false;
},
}),
h('div', { class: 'card', key: 'log-card' }, h('div', { class: 'card', key: 'log-card' },
h('div', { class: 'card-header' }, h('div', { class: 'card-header' },
h('span', null, tab.label), h('span', null, tab.label),
@@ -68,7 +79,18 @@ export default definePage({
class: 'btn btn-sm btn-outline', class: 'btn btn-sm btn-outline',
style: 'float:right;', style: 'float:right;',
'on:click': async () => { 'on:click': async () => {
await fetchLog(state, tab.url); state._abortCtrl?.abort();
const ctrl = new AbortController();
state._abortCtrl = ctrl;
state.refreshing = true;
state.error = null;
try {
await fetchLog(state, tab.url, ctrl.signal);
} catch (e) {
if (!ctrl.signal.aborted) state.error = String(e);
}
state.loading = false;
state.refreshing = false;
}, },
}, '\u21BB') }, '\u21BB')
), ),
+102 -134
View File
@@ -1,117 +1,101 @@
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js'; import { h, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, refactorLoad, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete, ZoneSelect } from '/static/hoover/index.js?v=6';
function addFwdModal(zones, state) { const addFwd = QuickModal({
openModal((inner, idx) => { title: 'Add Port Forward',
formModal(inner, 'Add Port Forward', fields: (d) => [
[ { label: 'Zone', id: 'fwd-zone', tag: 'select', options: d.zones },
{ label: 'Zone', id: 'fwd-zone', tag: 'select', options: zones.map(z => [z, z === zones[0]]) },
{ label: 'Port', id: 'fwd-port', type: 'number' }, { label: 'Port', id: 'fwd-port', type: 'number' },
{ label: 'Protocol', id: 'fwd-proto', placeholder: 'tcp or udp' }, { label: 'Protocol', id: 'fwd-proto', placeholder: 'tcp or udp' },
{ label: 'To Address', id: 'fwd-toaddr', placeholder: '192.168.1.100' }, { label: 'To Address', id: 'fwd-toaddr', placeholder: '192.168.1.100' },
{ label: 'To Port (optional)', id: 'fwd-toport', type: 'number' }, { label: 'To Port (optional)', id: 'fwd-toport', type: 'number' },
], ],
[ submit: {
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, url: '/api/firewall/forward-port',
{ body: (s) => ({
label: 'Add', cls: 'btn-primary', action: 's', handler: async () => {
const body = {
zone: $val('fwd-zone'), zone: $val('fwd-zone'),
port: parseInt($val('fwd-port')), port: parseInt($val('fwd-port')),
proto: ($val('fwd-proto') || 'tcp').trim(), proto: ($val('fwd-proto') || 'tcp').trim(),
toaddr: ($val('fwd-toaddr') || '').trim() || undefined, toaddr: ($val('fwd-toaddr') || '').trim() || undefined,
toport: $val('fwd-toport') ? parseInt($val('fwd-toport')) : undefined, toport: $val('fwd-toport') ? parseInt($val('fwd-toport')) : undefined,
}; }),
if (!body.zone || !body.port || !body.proto) { validate: (b) => !b.zone || !b.port || !b.proto ? 'Zone, port, and proto are required' : null,
toast('Zone, port, and proto are required', 'error'); successMsg: 'Forward rule added',
return;
}
const r = await apiFetch('/api/firewall/forward-port', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (r.ok) {
toast('Forward rule added', 'success');
closeModal(idx);
await load(state);
} else {
toast(r.error || 'Failed', 'error');
}
}, },
}, reload: (s) => load(s._s),
],
);
}); });
}
async function load(state, abortController, entry) { async function load(state, abortController, entry) {
if (Object.keys(state.config || {}).length) state.refreshing = true; await refactorLoad(state,
else state.loading = true; s => Object.keys(s.config || {}).length,
try { async (s, sig, isAborted) => {
const myId = entry ? entry.requestId : 0;
const sig = abortController?.signal;
const r = await apiFetch('/api/firewall/config', { signal: sig }); const r = await apiFetch('/api/firewall/config', { signal: sig });
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; if (isAborted()) return;
if (r.ok) state.config = r.data || {}; if (r.ok) s.config = r.data || {};
else s.error = r.error;
const zr = await apiFetch('/api/firewall/zones', { signal: sig }); const zr = await apiFetch('/api/firewall/zones', { signal: sig });
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; if (isAborted()) return;
if (zr.ok) state.activeZones = Object.keys(zr.data?.active || {}); if (zr.ok) s.activeZones = Object.keys(zr.data?.active || {});
} catch (e) { else if (!s.error) s.error = zr.error;
if (abortController?.signal.aborted) return; const sr = await apiFetch('/api/firewall/state', { signal: sig });
state.error = String(e); if (isAborted()) return;
} if (sr.ok) s.stateData = sr.data;
state.loading = false; },
state.refreshing = false; { entry, abortController },
);
} }
export default definePage({ export default definePage({
init() { init() {
return { config: {}, activeZones: [], loading: true, refreshing: false, error: null }; return { config: {}, activeZones: [], stateData: null };
}, },
subscribe: ['firewall'], subscribe: ['firewall'],
load, load,
render(state) { render(state) {
if (state.loading && !state.refreshing) { const guard = renderGuard(state, 'NAT', 'Masquerade & port forwarding', state.config);
return [ if (guard) return guard;
PageHeader({ title: 'NAT', subtitle: 'Masquerade & port forwarding' }),
h('div', { class: 'card', key: 'loading' },
h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
),
];
}
if (state.error) {
return [
PageHeader({ title: 'NAT' }),
h('div', { class: 'card', key: 'error' },
h('div', { class: 'card-body error-msg' }, state.error),
),
];
}
const cfg = state.config || {}; const cfg = state.config || {};
const zoneData = cfg.zones || {}; const zoneData = cfg.zones || {};
const sIface = (state.stateData || {}).interfaces || [];
const masqZones = new Set(
Object.entries(zoneData)
.filter(([, zcfg]) => !!zcfg.masquerade)
.map(([z]) => z)
);
const wanIface = sIface.filter((i) => i.zone && masqZones.has(i.zone));
const lanIface = sIface.filter((i) => i.zone && !masqZones.has(i.zone));
const ifaceRows = (ifaces) =>
ifaces.map((iface) =>
h('tr', { key: 'ii-' + iface.name },
h('td', null,
h('div', { class: 'd-flex align-items-center gap-2' },
StatusDot({ status: iface.state === 'UP' ? 'up' : 'down' }),
h('strong', null, iface.name),
),
),
h('td', null, (iface.ips || []).join(', ') || h('span', { class: 'text-muted' }, '—')),
h('td', null, (iface.ipv6 || []).join(', ') || h('span', { class: 'text-muted' }, '—')),
h('td', null, iface.mac || h('span', { class: 'text-muted' }, '—')),
h('td', null, Badge({ text: iface.zone || '—', variant: 'secondary' })),
)
);
const masqRows = Object.entries(zoneData).map(([zone, zcfg]) => { const masqRows = Object.entries(zoneData).map(([zone, zcfg]) => {
const masq = !!zcfg.masquerade; const masq = !!zcfg.masquerade;
return h('tr', { key: 'm-' + zone }, return h('tr', { key: 'm-' + zone },
h('td', null, h('strong', null, zone)), h('td', null, h('strong', null, zone)),
h('td', null, Badge({ text: masq ? 'Enabled' : 'Disabled', variant: masq ? 'success' : 'info' })), h('td', null, Badge({ text: masq ? 'Enabled' : 'Disabled', variant: masq ? 'success' : 'info' })),
h('td', null, h('td', null,
h('button', { class: 'btn btn-sm btn-outline', ActionButton({
'on:click': async () => { url: '/api/firewall/masquerade',
const r = await apiFetch('/api/firewall/masquerade', { cls: 'btn btn-sm btn-outline',
method: 'POST', labelOn: 'Disable', labelOff: 'Enable', condition: masq,
headers: { 'Content-Type': 'application/json' }, body: () => ({ zone, enable: !masq }),
body: JSON.stringify({ zone, enable: !masq }), successMsg: 'Masquerade ' + (masq ? 'disabled' : 'enabled') + ' on ' + zone,
}); reload: () => load(state),
if (r.ok) { }),
toast('Masquerade ' + (masq ? 'disabled' : 'enabled') + ' on ' + zone, 'success');
await load(state);
} else {
toast(r.error || 'Failed', 'error');
}
}}, masq ? 'Disable' : 'Enable'),
), ),
); );
}); });
@@ -120,25 +104,21 @@ export default definePage({
Object.entries(zoneData).forEach(([zone, zcfg]) => { Object.entries(zoneData).forEach(([zone, zcfg]) => {
const forwards = zcfg.forward_ports || []; const forwards = zcfg.forward_ports || [];
forwards.forEach((fwd, i) => { forwards.forEach((fwd, i) => {
const port = fwd.port;
const proto = fwd['proxy-protocol'] || fwd.proto;
fwRows.push(h('tr', { key: 'f-' + zone + '-' + i }, fwRows.push(h('tr', { key: 'f-' + zone + '-' + i },
h('td', null, h('strong', null, zone)), h('td', null, h('strong', null, zone)),
h('td', null, Badge({ text: fwd['proxy-protocol'] || fwd.proto || 'tcp', variant: 'info' })), h('td', null, Badge({ text: proto || 'tcp', variant: 'info' })),
h('td', null, fwd.port), h('td', null, port),
h('td', null, fwd['to-addr'] || fwd.toaddr || '-'), h('td', null, fwd['to-addr'] || fwd.toaddr || '-'),
h('td', null, fwd['to-port'] || fwd.toport || '-'), h('td', null, fwd['to-port'] || fwd.toport || '-'),
h('td', null, h('td', null,
h('button', { class: 'btn btn-sm btn-danger', ConfirmDelete({
'on:click': async () => { url: '/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto),
const port = fwd.port, proto = fwd['proxy-protocol'] || fwd.proto; message: 'Remove forward ' + zone + ':' + port + '/' + proto + '?',
if (!confirm('Remove forward ' + zone + ':' + port + '/' + proto + '?')) return; success: 'Rule removed',
const r = await apiFetch('/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto), { method: 'DELETE' }); reload: () => load(state),
if (r.ok) { }),
toast('Rule removed', 'success');
await load(state);
} else {
toast(r.error || 'Failed', 'error');
}
}}, 'Remove'),
), ),
)); ));
}); });
@@ -146,49 +126,37 @@ export default definePage({
return [ return [
PageHeader({ title: 'NAT', subtitle: 'Masquerade & port forwarding' }), PageHeader({ title: 'NAT', subtitle: 'Masquerade & port forwarding' }),
h('h3', { class: 'section-title' }, 'Masquerade'), DataTableSection({
h('div', { class: 'card' }, title: 'WAN / External',
h('table', { class: 'table' }, columns: ['Interface', 'IPv4', 'IPv6', 'MAC', 'Zone'],
h('thead', null, rows: ifaceRows(wanIface),
h('tr', null, emptyText: 'No WAN interfaces with masquerade enabled',
h('th', null, 'Zone'), }),
h('th', null, 'Status'), DataTableSection({
h('th', { style: 'width:100px;' }, 'Action'), title: 'Internal / LAN',
), columns: ['Interface', 'IPv4', 'IPv6', 'MAC', 'Zone'],
), rows: ifaceRows(lanIface),
h('tbody', null, emptyText: 'No internal interfaces',
...(masqRows.length ? masqRows : [ }),
h('tr', null, h('td', { colspan: 3, class: 'text-muted' }, 'No zones')), DataTableSection({
]), title: 'Masquerade',
), columns: ['Zone', 'Status', 'Action'],
), rows: masqRows,
), emptyText: 'No zones',
h('h3', { class: 'section-title' }, 'Port Forwarding'), }),
h('div', { class: 'card' }, SectionTitle({ title: 'Port Forwarding' }),
h('div', { style: 'padding:0.75rem;', class: 'flex' }, Card({ children: [
ActionGroup(
h('button', { class: 'btn btn-sm btn-primary', h('button', { class: 'btn btn-sm btn-primary',
'on:click': () => addFwdModal(state.activeZones, state) }, 'Add Forward'), 'on:click': () => addFwd({ zones: state.activeZones, _s: state }) }, 'Add Forward'),
),
h('table', { class: 'table' },
h('thead', null,
h('tr', null,
h('th', null, 'Zone'),
h('th', null, 'Proto'),
h('th', null, 'Port'),
h('th', null, 'To Addr'),
h('th', null, 'To Port'),
h('th', { style: 'width:80px;' }, 'Action'),
),
),
h('tbody', null,
...(fwRows.length ? fwRows : [
h('tr', null,
h('td', { colspan: 6, class: 'text-muted text-sm' }, 'No port forwarding rules'),
),
]),
),
),
), ),
Table({
columns: ['Zone', 'Proto', 'Port', 'To Addr', 'To Port', 'Action'],
rows: fwRows,
emptyText: 'No port forwarding rules',
wrapCard: false,
}),
]}),
]; ];
}, },
}); });
+1 -1
View File
@@ -1,4 +1,4 @@
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js'; import { h, PageHeader, definePage } from '/static/hoover/index.js?v=6';
export default definePage({ export default definePage({
init() { init() {
+67 -140
View File
@@ -1,143 +1,85 @@
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js'; import { h, PageHeader, Badge, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, refactorLoad, ActionButton, ActionCell, certStatusBadge, ActionGroup, QuickModal } from '/static/hoover/index.js?v=6';
function addDomainModal(state) { const addDomain = QuickModal({
openModal((inner, idx) => { title: 'Add Proxy Domain',
formModal(inner, 'Add Proxy Domain', fields: [
[
{ label: 'Domain', id: 'p-domain', placeholder: 'example.com' }, { label: 'Domain', id: 'p-domain', placeholder: 'example.com' },
{ label: 'Backend Host', id: 'p-host', placeholder: '192.168.1.10' }, { label: 'Backend Host', id: 'p-host', placeholder: '192.168.1.10' },
{ label: 'Backend Port', id: 'p-port', type: 'number', placeholder: '8080' }, { label: 'Backend Port', id: 'p-port', type: 'number', placeholder: '8080' },
{ label: 'Protocol', id: 'p-proto', placeholder: 'http or https' }, { label: 'Protocol', id: 'p-proto', placeholder: 'http or https' },
{ label: 'Cert (optional)', id: 'p-cert', placeholder: 'acme' }, { label: 'Cert (optional)', id: 'p-cert', placeholder: 'acme' },
], ],
[ submit: {
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, url: '/api/proxy/domains',
{ body: () => ({
label: 'Add', cls: 'btn-primary', action: 's', handler: async () => {
const body = {
domain: ($val('p-domain') || '').trim(), domain: ($val('p-domain') || '').trim(),
backend_host: ($val('p-host') || '').trim(), backend_host: ($val('p-host') || '').trim(),
backend_port: parseInt($val('p-port')), backend_port: parseInt($val('p-port')),
backend_proto: ($val('p-proto') || 'http').trim() || 'http', backend_proto: ($val('p-proto') || 'http').trim() || 'http',
cert: ($val('p-cert') || '').trim() || undefined, cert: ($val('p-cert') || '').trim() || undefined,
}; }),
if (!body.domain || !body.backend_host || !body.backend_port) { validate: (b) => !b.domain || !b.backend_host || !b.backend_port ? 'Domain, host, and port are required' : null,
toast('Domain, host, and port are required', 'error'); successMsg: 'Domain added',
return;
}
const resp = await apiFetch('/api/proxy/domains', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (resp.ok) {
toast('Domain added', 'success');
closeModal(idx);
await load(state);
} else {
toast(resp.error || 'Failed', 'error');
}
}, },
}, reload: (s) => load(s),
],
);
}); });
}
function editDomainModal(domain, state) { const editDomain = QuickModal({
openModal((inner, idx) => { title: (d) => 'Edit: ' + d.domain,
formModal(inner, 'Edit: ' + domain.domain, fields: (d) => [
[ { label: 'Backend Host', id: 'pe-host', value: d.backend_host || '' },
{ label: 'Backend Host', id: 'pe-host', value: domain.backend_host || '' }, { label: 'Backend Port', id: 'pe-port', type: 'number', value: d.backend_port || '' },
{ label: 'Backend Port', id: 'pe-port', type: 'number', value: domain.backend_port || '' }, { label: 'Protocol', id: 'pe-proto', value: d.backend_proto || d.protocol || 'http' },
{ label: 'Protocol', id: 'pe-proto', value: domain.backend_proto || domain.protocol || 'http' }, { label: 'Cert (optional)', id: 'pe-cert', value: d.cert || '' },
{ label: 'Cert (optional)', id: 'pe-cert', value: domain.cert || '' },
], ],
[ submit: {
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, url: (d) => '/api/proxy/domains/' + enc(d.domain),
{ method: 'PUT',
label: 'Save', cls: 'btn-primary', action: 's', handler: async () => { body: (d) => ({
const body = {
backend_host: ($val('pe-host') || '').trim(), backend_host: ($val('pe-host') || '').trim(),
backend_port: parseInt($val('pe-port')), backend_port: parseInt($val('pe-port')),
backend_proto: ($val('pe-proto') || 'http').trim(), backend_proto: ($val('pe-proto') || 'http').trim(),
cert: ($val('pe-cert') || '').trim() || undefined, cert: ($val('pe-cert') || '').trim() || undefined,
}; }),
const resp = await apiFetch('/api/proxy/domains/' + enc(domain.domain), { validate: (b) => !b.backend_host || !b.backend_port ? 'Host and port are required' : null,
method: 'PUT', successMsg: 'Domain updated',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (resp.ok) {
toast('Domain updated', 'success');
closeModal(idx);
await load(state);
} else {
toast(resp.error || 'Failed', 'error');
}
}, },
}, reload: (s) => load(s._s),
],
);
}); });
}
async function load(state, abortController, entry) { async function load(state, abortController, entry) {
if (state.domains?.length) state.refreshing = true; await refactorLoad(state,
else state.loading = true; s => s.domains?.length,
try { async (s, sig, isAborted) => {
const myId = entry ? entry.requestId : 0;
const sig = abortController?.signal;
const domainsR = await apiFetch('/api/proxy/domains', { signal: sig }); const domainsR = await apiFetch('/api/proxy/domains', { signal: sig });
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; if (isAborted()) return;
if (domainsR.ok) state.domains = domainsR.data || []; if (domainsR.ok) s.domains = domainsR.data || [];
else s.error = domainsR.error;
const certsR = await apiFetch('/api/certs/list', { signal: sig }); const certsR = await apiFetch('/api/certs/list', { signal: sig });
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; if (isAborted()) return;
if (certsR.ok) state.certs = certsR.data || []; if (certsR.ok) s.certs = certsR.data || [];
} catch (e) { else if (!s.error) s.error = certsR.error;
if (abortController?.signal.aborted) return; },
state.error = String(e); { entry, abortController },
} );
state.loading = false;
state.refreshing = false;
} }
export default definePage({ export default definePage({
init() { init() {
return { domains: [], certs: [], loading: true, refreshing: false, error: null }; return { domains: [], certs: [] };
}, },
subscribe: ['nginx', 'acme'], subscribe: ['nginx', 'acme'],
load, load,
render(state) { render(state) {
if (state.loading && !state.refreshing) { const guard = renderGuard(state, 'Proxy', 'Nginx reverse proxy', state.domains);
return [ if (guard) return guard;
PageHeader({ title: 'Proxy' }),
h('div', { class: 'card', key: 'loading' },
h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
),
];
}
if (state.error) {
return [
PageHeader({ title: 'Proxy' }),
h('div', { class: 'card', key: 'error' },
h('div', { class: 'card-body error-msg' }, state.error),
),
];
}
const rows = state.domains.map(d => { const rows = state.domains.map(d => {
let certBadge = Badge({ text: 'No cert', variant: 'info' }); const certBadge = certStatusBadge({
if (d.cert_status === 'valid' || d.cert_status === 'active') { certStatus: d.cert_status,
certBadge = Badge({ text: 'Valid', variant: 'success' }); daysRemaining: d.days_remaining,
} else if (d.cert_status === 'expired' || (d.days_remaining !== undefined && d.days_remaining <= 0)) { expired: d.cert_status === 'expired',
certBadge = Badge({ text: 'Expired', variant: 'danger' }); });
} else if (d.days_remaining !== undefined && d.days_remaining <= 30) {
certBadge = Badge({ text: d.days_remaining + 'd', variant: 'warning' });
} else if (d.days_remaining !== undefined) {
certBadge = Badge({ text: d.days_remaining + 'd', variant: 'success' });
}
return h('tr', { key: d.domain }, return h('tr', { key: d.domain },
h('td', null, h('strong', null, esc(d.domain))), h('td', null, h('strong', null, esc(d.domain))),
@@ -145,50 +87,35 @@ export default definePage({
h('td', null, d.backend_port || '-'), h('td', null, d.backend_port || '-'),
h('td', null, Badge({ text: d.backend_proto || d.protocol || 'http', variant: 'info' })), h('td', null, Badge({ text: d.backend_proto || d.protocol || 'http', variant: 'info' })),
h('td', null, certBadge), h('td', null, certBadge),
h('td', null, ActionCell({
h('button', { class: 'btn btn-sm btn-outline', style: 'margin-right:4px;', editLabel: 'Edit',
'on:click': () => editDomainModal(d, state) }, 'Edit'), editClick: () => editDomain({ ...d, _s: state }),
h('button', { class: 'btn btn-sm btn-danger', removeUrl: '/api/proxy/domains/' + enc(d.domain),
'on:click': async () => { removeMessage: 'Remove proxy for ' + d.domain + '?',
if (!confirm('Remove proxy for ' + d.domain + '?')) return; removeSuccess: 'Domain removed',
const r = await apiFetch('/api/proxy/domains/' + enc(d.domain), { method: 'DELETE' }); removeReload: () => load(state),
if (r.ok) { removeLabel: 'Delete',
toast('Domain removed', 'success'); }),
await load(state);
} else {
toast(r.error || 'Failed', 'error');
}
}}, 'Delete'),
),
); );
}); });
const actions = h('div', { style: 'display:flex;gap:8px;' }, const actions = ActionGroup(
h('button', { class: 'btn btn-primary', 'on:click': () => addDomainModal(state) }, 'Add Domain'), h('button', { class: 'btn btn-primary', 'on:click': () => addDomain(state) }, 'Add Domain'),
h('button', { class: 'btn btn-outline', ActionButton({
'on:click': async () => { url: '/api/proxy/apply',
const resp = await apiFetch('/api/proxy/apply', { method: 'POST' }); successMsg: 'Nginx applied & reloaded',
if (resp.ok) toast('Nginx applied & reloaded', 'success'); label: 'Apply',
else toast(resp.error || 'Failed', 'error'); reload: () => load(state),
}}, 'Apply'), }),
); );
return [ return [
PageHeader({ title: 'Proxy', subtitle: 'Nginx reverse proxy', actions }), PageHeader({ title: 'Proxy', subtitle: 'Nginx reverse proxy', actions }),
rows.length rows.length
? h('div', { class: 'card' }, h('table', { class: 'table' }, ? Table({
h('thead', null, columns: ['Domain', 'Backend Host', 'Port', 'Proto', 'Cert', 'Actions'],
h('tr', null, rows,
h('th', null, 'Domain'), })
h('th', null, 'Backend Host'),
h('th', null, 'Port'),
h('th', null, 'Proto'),
h('th', null, 'Cert'),
h('th', { style: 'width:140px;' }, 'Actions'),
),
),
h('tbody', null, ...rows),
))
: Empty({ text: 'No proxy domains configured. Add a domain to start terminating SSL.' }), : Empty({ text: 'No proxy domains configured. Add a domain to start terminating SSL.' }),
]; ];
}, },
+44 -93
View File
@@ -1,83 +1,46 @@
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js'; import { h, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, refactorLoad, MonoText, QuickModal } from '/static/hoover/index.js?v=6';
function addRuleModal(zones, state) { const addRule = QuickModal({
openModal((inner, idx) => { title: 'Add Rich Rule',
formModal(inner, 'Add Rich Rule', fields: (d) => [
[ { label: 'Zone', id: 'rule-zone', tag: 'select', options: d.zones },
{ label: 'Zone', id: 'rule-zone', tag: 'select', options: zones.map(z => [z, z === zones[0]]) },
{ label: 'Rule (XML)', id: 'rule-text', tag: 'textarea', placeholder: 'rule family="ipv4" source address="192.168.1.0/24" accept' }, { label: 'Rule (XML)', id: 'rule-text', tag: 'textarea', placeholder: 'rule family="ipv4" source address="192.168.1.0/24" accept' },
], ],
[ submit: {
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, url: '/api/firewall/rich-rules',
{ body: (s) => ({ zone: $val('rule-zone'), rule: ($val('rule-text') || '').trim() }),
label: 'Add', cls: 'btn-primary', action: 's', handler: async () => { validate: (b) => !b.zone || !b.rule ? 'Zone and rule are required' : null,
const zone = $val('rule-zone'); successMsg: 'Rule added',
const rule = ($val('rule-text') || '').trim();
if (!zone || !rule) { toast('Zone and rule are required', 'error'); return; }
const r = await apiFetch('/api/firewall/rich-rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ zone, rule }),
});
if (r.ok) {
toast('Rule added', 'success');
closeModal(idx);
await load(state);
} else {
toast(r.error || 'Failed', 'error');
}
}, },
}, reload: (s) => load(s._s),
],
);
}); });
}
async function load(state, abortController, entry) { async function load(state, abortController, entry) {
if (Object.keys(state.config || {}).length) state.refreshing = true; await refactorLoad(state,
else state.loading = true; s => Object.keys(s.config || {}).length,
try { async (s, sig, isAborted) => {
const myId = entry ? entry.requestId : 0;
const sig = abortController?.signal;
const r = await apiFetch('/api/firewall/config', { signal: sig }); const r = await apiFetch('/api/firewall/config', { signal: sig });
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; if (isAborted()) return;
if (r.ok) state.config = r.data || {}; if (r.ok) s.config = r.data || {};
else state.error = r.error; else s.error = r.error;
const zr = await apiFetch('/api/firewall/zones', { signal: sig }); const zr = await apiFetch('/api/firewall/zones', { signal: sig });
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; if (isAborted()) return;
if (zr.ok) state.zones = Object.keys(zr.data?.active || {}); if (zr.ok) s.zones = Object.keys(zr.data?.active || {});
} catch (e) { else if (!s.error) s.error = zr.error;
if (abortController?.signal.aborted) return; },
state.error = String(e); { entry, abortController },
} );
state.loading = false;
state.refreshing = false;
} }
export default definePage({ export default definePage({
init() { init() {
return { config: {}, loading: true, refreshing: false, error: null, zones: [] }; return { config: {}, zones: [] };
}, },
subscribe: ['firewall'], subscribe: ['firewall'],
load, load,
render(state) { render(state) {
if (state.loading && !state.refreshing) { const guard = renderGuard(state, 'Rules', 'Firewall rich rules', state.config);
return [ if (guard) return guard;
PageHeader({ title: 'Rules', subtitle: 'Firewall rich rules' }),
h('div', { class: 'card', key: 'loading' },
h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
),
];
}
if (state.error) {
return [
PageHeader({ title: 'Rules' }),
h('div', { class: 'card', key: 'error' },
h('div', { class: 'card-body error-msg' }, state.error),
),
];
}
const cfg = state.config || {}; const cfg = state.config || {};
const zoneData = cfg.zones || {}; const zoneData = cfg.zones || {};
@@ -88,43 +51,31 @@ export default definePage({
}); });
const cards = Object.entries(zoneRules).map(([zone, rules]) => { const cards = Object.entries(zoneRules).map(([zone, rules]) => {
return h('div', { class: 'card', key: zone }, return Card({
h('div', { class: 'card-header' }, 'Zone: ' + esc(zone)), header: 'Zone: ' + esc(zone),
h('div', { class: 'card-body' }, key: zone,
h('table', { class: 'table' }, children: [Table({
h('thead', null, columns: ['#', 'Rule', 'Action'],
h('tr', null, rows: (Array.isArray(rules) ? rules : []).map((entry, i) => {
h('th', null, '#'),
h('th', null, 'Rule'),
h('th', { style: 'width:80px;' }, 'Action'),
),
),
h('tbody', null,
(Array.isArray(rules) ? rules : []).map((entry, i) => {
const ruleId = typeof entry === 'object' ? entry.id : null; const ruleId = typeof entry === 'object' ? entry.id : null;
const ruleText = typeof entry === 'object' ? (entry.rule || '') : String(entry); const ruleText = typeof entry === 'object' ? (entry.rule || '') : String(entry);
return h('tr', { key: i }, return h('tr', { key: i },
h('td', { class: 'text-muted' }, i + 1), h('td', { class: 'text-muted' }, i + 1),
h('td', { style: 'font-family:monospace;font-size:12px;word-break:break-all;' }, esc(ruleText)), h('td', { class: 'mono-text td-fullwidth' }, MonoText({ text: ruleText })),
h('td', null, h('td', null,
h('button', { class: 'btn btn-sm btn-danger', ConfirmDelete({
'on:click': async () => { url: '/api/firewall/rich-rules/' + enc(zone) + '/' + enc(ruleId || ''),
if (!confirm('Remove rule: ' + ruleText.substring(0, 40) + '...?')) return; message: 'Remove rule: ' + ruleText.substring(0, 40) + '...?',
const r = await apiFetch('/api/firewall/rich-rules/' + enc(zone) + '/' + enc(ruleId || ''), { method: 'DELETE' }); success: 'Rule removed',
if (r.ok) { reload: () => load(state),
toast('Rule removed', 'success'); }),
await load(state);
} else {
toast(r.error || 'Failed', 'error');
}
}}, 'Remove'),
), ),
); );
}), }),
), emptyText: 'No rules',
), wrapCard: false,
), })],
); });
}); });
return [ return [
@@ -132,7 +83,7 @@ export default definePage({
title: 'Rules', title: 'Rules',
subtitle: 'Firewall rich rules', subtitle: 'Firewall rich rules',
actions: h('button', { class: 'btn btn-primary', actions: h('button', { class: 'btn btn-primary',
'on:click': () => addRuleModal(state.zones, state) }, 'Add Rule'), 'on:click': () => addRule({ zones: state.zones, _s: state }), }, 'Add Rule'),
}), }),
...(cards.length ? cards : [Empty({ text: 'No rich rules configured.' })]), ...(cards.length ? cards : [Empty({ text: 'No rich rules configured.' })]),
]; ];
+59 -134
View File
@@ -1,43 +1,26 @@
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js'; import { h, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, apiSubmit, refactorLoad, ActionButton, ActionCell, MonoText, ActionGroup, QuickModal, downloadBlob } from '/static/hoover/index.js?v=6';
function addPeerModal(state) { const addPeer = QuickModal({
openModal((inner, idx) => { title: 'Add WireGuard Peer',
formModal(inner, 'Add WireGuard Peer', fields: [
[
{ label: 'Name', id: 'wg-name', placeholder: 'client-name' }, { label: 'Name', id: 'wg-name', placeholder: 'client-name' },
{ label: 'Endpoint (optional)', id: 'wg-endpoint', placeholder: '1.2.3.4:51820' }, { label: 'Endpoint (optional)', id: 'wg-endpoint', placeholder: '1.2.3.4:51820' },
{ label: 'Allowed IPs (optional)', id: 'wg-allowed', placeholder: '10.0.0.2/32' }, { label: 'Allowed IPs (optional)', id: 'wg-allowed', placeholder: '10.0.0.2/32' },
{ label: 'Persistent Keepalive (optional)', id: 'wg-keepalive', type: 'number', placeholder: '25' }, { label: 'Persistent Keepalive (optional)', id: 'wg-keepalive', type: 'number', placeholder: '25' },
], ],
[ submit: {
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, url: '/api/wireguard/peers',
{ body: () => ({
label: 'Add', cls: 'btn-primary', action: 's', handler: async () => {
const body = {
name: ($val('wg-name') || '').trim(), name: ($val('wg-name') || '').trim(),
endpoint: ($val('wg-endpoint') || '').trim() || undefined, endpoint: ($val('wg-endpoint') || '').trim() || undefined,
allowed_ips: ($val('wg-allowed') || '').trim() ? [($val('wg-allowed') || '').trim()] : [], allowed_ips: ($val('wg-allowed') || '').trim() ? [($val('wg-allowed') || '').trim()] : [],
persistent_keepalive: $val('wg-keepalive') ? parseInt($val('wg-keepalive')) : undefined, persistent_keepalive: $val('wg-keepalive') ? parseInt($val('wg-keepalive')) : undefined,
}; }),
if (!body.name) { toast('Name is required', 'error'); return; } validate: (b) => !b.name ? 'Name is required' : null,
const resp = await apiFetch('/api/wireguard/peers', { successMsg: 'Peer added',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (resp.ok) {
toast('Peer added', 'success');
closeModal(idx);
await load(state);
} else {
toast(resp.error || 'Failed', 'error');
}
}, },
}, reload: (s) => load(s),
],
);
}); });
}
function downloadConfigModal(peerName, config, state) { function downloadConfigModal(peerName, config, state) {
openModal((inner, idx) => { openModal((inner, idx) => {
@@ -51,19 +34,10 @@ function downloadConfigModal(peerName, config, state) {
if (!endpoint) { toast('Server endpoint is required', 'error'); return; } if (!endpoint) { toast('Server endpoint is required', 'error'); return; }
const resp = await apiFetch('/api/wireguard/generate-client', { const resp = await apiFetch('/api/wireguard/generate-client', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, body: { name: peerName, server_endpoint: endpoint },
body: JSON.stringify({ name: peerName, server_endpoint: endpoint }),
}); });
if (resp.ok && resp.data?.config) { if (resp.ok && resp.data?.config) {
const blob = new Blob([resp.data.config], { type: 'text/plain' }); downloadBlob(new Blob([resp.data.config], { type: 'text/plain' }), peerName + '.conf');
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = peerName + '.conf';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast('Config downloaded', 'success'); toast('Config downloaded', 'success');
closeModal(idx); closeModal(idx);
} else { } else {
@@ -77,52 +51,35 @@ function downloadConfigModal(peerName, config, state) {
} }
async function load(state, abortController, entry) { async function load(state, abortController, entry) {
if (state.peers?.length) state.refreshing = true; await refactorLoad(state,
else state.loading = true; s => s.peers?.length,
try { async (s, sig, isAborted) => {
const myId = entry ? entry.requestId : 0;
const sig = abortController?.signal;
const stR = await apiFetch('/api/wireguard/status', { signal: sig }); const stR = await apiFetch('/api/wireguard/status', { signal: sig });
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; if (isAborted()) return;
if (stR.ok) state.status = stR.data || {}; if (stR.ok) s.status = stR.data || {};
else s.error = stR.error;
const pR = await apiFetch('/api/wireguard/peers', { signal: sig }); const pR = await apiFetch('/api/wireguard/peers', { signal: sig });
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; if (isAborted()) return;
if (pR.ok) state.peers = pR.data || []; if (pR.ok) s.peers = pR.data || [];
else if (!s.error) s.error = pR.error;
const cfgR = await apiFetch('/api/wireguard/config', { signal: sig }); const cfgR = await apiFetch('/api/wireguard/config', { signal: sig });
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; if (isAborted()) return;
if (cfgR.ok) state.config = cfgR.data || {}; if (cfgR.ok) s.config = cfgR.data || {};
} catch (e) { else if (!s.error) s.error = cfgR.error;
if (abortController?.signal.aborted) return; },
state.error = String(e); { entry, abortController },
} );
state.loading = false;
state.refreshing = false;
} }
export default definePage({ export default definePage({
init() { init() {
return { status: {}, peers: [], config: {}, loading: true, refreshing: false, error: null }; return { status: {}, peers: [], config: {} };
}, },
subscribe: ['wireguard'], subscribe: ['wireguard'],
load, load,
render(state) { render(state) {
if (state.loading && !state.refreshing) { const guard = renderGuard(state, 'WireGuard', 'Tunnel: ' + ((state.status || {}).state || 'unknown') + ', Listen: ' + ((state.config?.interface || {}).listen_port || '-'), state.peers);
return [ if (guard) return guard;
PageHeader({ title: 'WireGuard' }),
h('div', { class: 'card', key: 'loading' },
h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
),
];
}
if (state.error) {
return [
PageHeader({ title: 'WireGuard' }),
h('div', { class: 'card', key: 'error' },
h('div', { class: 'card-body error-msg' }, state.error),
),
];
}
const st = state.status || {}; const st = state.status || {};
const isUp = st.state === 'up'; const isUp = st.state === 'up';
@@ -135,10 +92,7 @@ export default definePage({
StatusDot({ status: hasHandshake ? 'success' : 'danger' }), StatusDot({ status: hasHandshake ? 'success' : 'danger' }),
h('strong', null, esc(p.name || 'unnamed')), h('strong', null, esc(p.name || 'unnamed')),
), ),
h('td', { style: 'font-family:monospace;font-size:11px;' }, h('td', null, MonoText({ text: p.public_key || 'N/A', maxLength: 20 })),
esc((p.public_key || 'N/A').substring(0, 20)) +
(p.public_key && p.public_key.length > 20 ? '...' : ''),
),
h('td', { class: 'text-sm' }, esc(p.allowed_ips || '-')), h('td', { class: 'text-sm' }, esc(p.allowed_ips || '-')),
h('td', { class: 'text-sm' }, esc(p.endpoint || '-')), h('td', { class: 'text-sm' }, esc(p.endpoint || '-')),
h('td', { class: 'text-sm' }, esc(p.latest_handshake || 'Never')), h('td', { class: 'text-sm' }, esc(p.latest_handshake || 'Never')),
@@ -147,46 +101,31 @@ export default definePage({
h('br'), h('br'),
'Sent: ' + esc(p.transfer_sent || '0'), 'Sent: ' + esc(p.transfer_sent || '0'),
), ),
h('td', null, ActionCell({
h('button', { class: 'btn btn-sm btn-outline', style: 'margin-right:4px;', editLabel: 'Config',
'on:click': () => downloadConfigModal(p.name, state.config, state) }, 'Config'), editClick: () => downloadConfigModal(p.name, state.config, state),
h('button', { class: 'btn btn-sm btn-danger', removeUrl: '/api/wireguard/peers/' + enc(p.name),
'on:click': async () => { removeMessage: 'Remove peer ' + p.name + '?',
if (!confirm('Remove peer ' + p.name + '?')) return; removeSuccess: 'Peer removed',
const resp = await apiFetch('/api/wireguard/peers/' + enc(p.name), { method: 'DELETE' }); removeReload: () => load(state),
if (resp.ok) { }),
toast('Peer removed', 'success');
await load(state);
} else {
toast(resp.error || 'Failed', 'error');
}
}}, 'Remove'),
),
); );
}); });
const actions = h('div', { style: 'display:flex;gap:8px;' }, const actions = ActionGroup(
h('button', { class: 'btn btn-primary', 'on:click': () => addPeerModal(state) }, 'Add Peer'), h('button', { class: 'btn btn-primary', 'on:click': () => addPeer(state) }, 'Add Peer'),
h('button', { class: 'btn btn-outline', ActionButton({
'on:click': async () => { url: '/api/wireguard/' + (isUp ? 'down' : 'up'),
const resp = await apiFetch('/api/wireguard/' + (isUp ? 'down' : 'up'), { method: 'POST' }); labelOn: 'Stop', labelOff: 'Start', condition: isUp,
if (resp.ok) { successMsg: 'Tunnel ' + (isUp ? 'stopped' : 'started'),
toast('Tunnel ' + (isUp ? 'stopped' : 'started'), 'success'); reload: () => load(state),
await load(state); }),
} else { ActionButton({
toast(resp.error || 'Failed', 'error'); url: '/api/wireguard/apply',
} successMsg: 'Config applied',
}}, isUp ? 'Stop' : 'Start'), label: 'Apply',
h('button', { class: 'btn btn-outline', reload: () => load(state),
'on:click': async () => { }),
const resp = await apiFetch('/api/wireguard/apply', { method: 'POST' });
if (resp.ok) {
toast('Config applied', 'success');
await load(state);
} else {
toast(resp.error || 'Failed', 'error');
}
}}, 'Apply'),
); );
return [ return [
@@ -195,26 +134,12 @@ export default definePage({
subtitle: 'Tunnel: ' + (st.state || 'unknown') + ', Listen: ' + listenPort, subtitle: 'Tunnel: ' + (st.state || 'unknown') + ', Listen: ' + listenPort,
actions, actions,
}), }),
h('div', null, ServiceStatus({ state: st.state || 'down' }),
StatusDot({ status: isUp ? 'success' : 'danger' }),
' ',
Badge({ text: st.state || 'down', variant: isUp ? 'success' : 'danger' }),
),
peerRows.length peerRows.length
? h('div', { class: 'card' }, h('table', { class: 'table' }, ? Table({
h('thead', null, columns: ['Peer', 'Public Key', 'Allowed IPs', 'Endpoint', 'Handshake', 'Transfer', 'Actions'],
h('tr', null, rows: peerRows,
h('th', null, 'Peer'), })
h('th', null, 'Public Key'),
h('th', null, 'Allowed IPs'),
h('th', null, 'Endpoint'),
h('th', null, 'Handshake'),
h('th', null, 'Transfer'),
h('th', { style: 'width:120px;' }, 'Actions'),
),
),
h('tbody', null, ...peerRows),
))
: Empty({ text: 'No peers configured. Add a peer above.' }), : Empty({ text: 'No peers configured. Add a peer above.' }),
]; ];
}, },
+70 -159
View File
@@ -1,142 +1,59 @@
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js'; import { h, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, apiFetch, toast, definePage, refactorLoad, MultiSelectModal, QuickModal } from '/static/hoover/index.js?v=6';
function addZoneModal(state) { const addZone = QuickModal({
openModal((inner, idx) => { title: 'Add Zone',
formModal(inner, 'Add Zone', fields: [
[
{ label: 'Zone name', id: 'zone-name', placeholder: 'e.g. internal' }, { label: 'Zone name', id: 'zone-name', placeholder: 'e.g. internal' },
{ label: 'Target', id: 'zone-target', placeholder: 'default (usually leave as default)' }, { label: 'Target', id: 'zone-target', placeholder: 'default (usually leave as default)' },
], ],
[ submit: {
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, url: '/api/firewall/zones',
{ body: () => ({ name: ($val('zone-name') || '').trim(), target: ($val('zone-target') || '').trim() || 'default' }),
label: 'Create', cls: 'btn-primary', action: 's', handler: async () => { validate: (b) => !b.name ? 'Zone name required' : null,
const name = ($val('zone-name') || '').trim(); successMsg: 'Zone created',
if (!name) { toast('Zone name required', 'error'); return; } },
const target = ($val('zone-target') || '').trim() || 'default'; reload: (s) => load(s),
const r = await apiFetch('/api/firewall/zones', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, target }),
}); });
if (r.ok) {
toast('Zone ' + name + ' created', 'success');
closeModal(idx);
await load(state);
} else {
toast(r.error || 'Failed', 'error');
}
},
},
],
);
});
}
function zoneIfaceModal(zoneName, state) {
const zdata = state.zones?.[zoneName] || {};
const current = Array.isArray(zdata.interfaces) ? zdata.interfaces : [];
const allIfaces = Array.isArray(state.interfaces) ? state.interfaces : [];
openModal((inner, idx) => {
formModal(inner, 'Interfaces: ' + zoneName,
[
{
label: 'Interfaces', id: 'z-iface-select', tag: 'select',
options: allIfaces.map(i => [i, current.includes(i)]),
},
],
[
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
{
label: 'Save', cls: 'btn-primary', action: 's', handler: async () => {
const sel = document.getElementById('z-iface-select');
const selected = Array.from(sel.selectedOptions).map(o => o.value);
const r = await apiFetch('/api/firewall/zones/' + enc(zoneName) + '/interfaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ interfaces: selected }),
});
if (r.ok) {
toast('Interfaces updated', 'success');
closeModal(idx);
await load(state);
} else {
toast(r.error || 'Failed', 'error');
}
},
},
],
);
});
}
function zoneSvcModal(zoneName, state) {
const zdata = state.zones?.[zoneName] || {};
const current = Array.isArray(zdata.services) ? zdata.services : [];
const all = Array.isArray(state.services) ? state.services : [];
openModal((inner, idx) => {
formModal(inner, 'Services: ' + zoneName,
[
{
label: 'Services', id: 'z-svc-select', tag: 'select',
options: all.map(s => [s, current.includes(s)]),
},
],
[
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
{
label: 'Save', cls: 'btn-primary', action: 's', handler: async () => {
const sel = document.getElementById('z-svc-select');
const selected = Array.from(sel.selectedOptions).map(o => o.value);
const r = await apiFetch('/api/firewall/zones/' + enc(zoneName) + '/services', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ services: selected }),
});
if (r.ok) {
toast('Services updated', 'success');
closeModal(idx);
await load(state);
} else {
toast(r.error || 'Failed', 'error');
}
},
},
],
);
});
}
async function load(state, abortController, entry) { async function load(state, abortController, entry) {
if (Object.keys(state.zones || {}).length) state.refreshing = true; await refactorLoad(state,
else state.loading = true; s => Object.keys(s.zones || {}).length,
try { async (s, sig, isAborted) => {
const myId = entry ? entry.requestId : 0; const [zRes, svcRes, ifRes] = await Promise.allSettled([
const sig = abortController?.signal;
const [zRes, svcRes, ifRes] = await Promise.all([
apiFetch('/api/firewall/zones', { signal: sig }), apiFetch('/api/firewall/zones', { signal: sig }),
apiFetch('/api/firewall/services', { signal: sig }), apiFetch('/api/firewall/services', { signal: sig }),
apiFetch('/api/firewall/interfaces', { signal: sig }), apiFetch('/api/firewall/interfaces', { signal: sig }),
]); ]);
if (isAborted()) return;
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; const errors = [];
if (zRes.status === 'rejected') errors.push(zRes.reason?.message || 'Failed');
else if (!zRes.value.ok) errors.push(zRes.value.error || 'Failed');
if (svcRes.status === 'rejected') errors.push(svcRes.reason?.message || 'Failed');
else if (!svcRes.value.ok) errors.push(svcRes.value.error || 'Failed');
if (ifRes.status === 'rejected') errors.push(ifRes.reason?.message || 'Failed');
else if (!ifRes.value.ok) errors.push(ifRes.value.error || 'Failed');
if (errors.length) {
s.error = errors[0];
return;
}
if (zRes.ok) { const data = zRes.value.data || {};
const data = zRes.data || {};
const activeZones = data.active || {}; const activeZones = data.active || {};
const availableZones = data.available || []; const availableZones = data.available || [];
const detailPromises = availableZones.map(name => const detailPromises = availableZones.map(name =>
apiFetch('/api/firewall/zones/' + enc(name), { signal: sig }).catch(() => null) apiFetch('/api/firewall/zones/' + enc(name), { signal: sig })
); );
const detailResults = await Promise.all(detailPromises); const detailResults = await Promise.allSettled(detailPromises);
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; if (isAborted()) return;
const zones = {}; const zones = {};
for (let i = 0; i < availableZones.length; i++) { for (let i = 0; i < availableZones.length; i++) {
const name = availableZones[i]; const name = availableZones[i];
const detail = detailResults[i]; const res = detailResults[i];
const detail = res.status === 'fulfilled' ? res.value : null;
if (detail && detail.ok) { if (detail && detail.ok) {
zones[name] = detail.data; zones[name] = detail.data;
const activeIfaces = activeZones[name]; const activeIfaces = activeZones[name];
@@ -145,43 +62,23 @@ async function load(state, abortController, entry) {
} }
} }
} }
state.zones = zones; s.zones = zones;
} s.services = svcRes.value.data || [];
s.interfaces = ifRes.value.data || [];
if (svcRes.ok) state.services = svcRes.data || []; },
if (ifRes.ok) state.interfaces = ifRes.data || []; { entry, abortController },
} catch (e) { );
if (abortController?.signal.aborted) return;
state.error = String(e);
}
state.loading = false;
state.refreshing = false;
} }
export default definePage({ export default definePage({
init() { init() {
return { zones: {}, services: [], interfaces: [], loading: true, refreshing: false, error: null }; return { zones: {}, services: [], interfaces: [] };
}, },
subscribe: ['firewall'], subscribe: ['firewall'],
load, load,
render(state) { render(state) {
if (state.loading && !state.refreshing) { const guard = renderGuard(state, 'Zones', 'Firewall zone management', Object.keys(state.zones || {}).length);
return [ if (guard) return guard;
PageHeader({ title: 'Zones', subtitle: 'Firewall zone management' }),
h('div', { class: 'card', key: 'loading' },
h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
),
];
}
if (state.error) {
return [
PageHeader({ title: 'Zones' }),
h('div', { class: 'card', key: 'error' },
h('div', { class: 'card-body error-msg' }, state.error),
),
];
}
const zoneCards = Object.entries(state.zones || []).map(([name, zdata]) => { const zoneCards = Object.entries(state.zones || []).map(([name, zdata]) => {
const z = typeof zdata === 'object' ? zdata : {}; const z = typeof zdata === 'object' ? zdata : {};
@@ -210,20 +107,34 @@ export default definePage({
), ),
h('div', { style: 'display:flex;gap:6px;' }, h('div', { style: 'display:flex;gap:6px;' },
h('button', { class: 'btn btn-sm btn-outline', h('button', { class: 'btn btn-sm btn-outline',
'on:click': () => zoneIfaceModal(name, state) }, 'Interfaces'), 'on:click': () => MultiSelectModal({
title: 'Interfaces: ' + name,
url: '/api/firewall/zones/' + enc(name) + '/interfaces',
options: state.interfaces,
selected: ifacesArr,
fieldKey: 'interfaces',
successMsg: 'Interfaces updated',
reload: () => load(state),
})(),
}, 'Interfaces'),
h('button', { class: 'btn btn-sm btn-outline', h('button', { class: 'btn btn-sm btn-outline',
'on:click': () => zoneSvcModal(name, state) }, 'Services'), 'on:click': () => MultiSelectModal({
h('button', { class: 'btn btn-sm btn-danger', style: 'margin-left:auto;', title: 'Services: ' + name,
'on:click': async () => { url: '/api/firewall/zones/' + enc(name) + '/services',
if (!confirm('Delete zone ' + name + '?')) return; options: state.services,
const r = await apiFetch('/api/firewall/zones/' + enc(name), { method: 'DELETE' }); selected: svcsArr,
if (r.ok) { fieldKey: 'services',
toast('Zone ' + name + ' deleted', 'success'); successMsg: 'Services updated',
await load(state); reload: () => load(state),
} else { })(),
toast(r.error || 'Failed', 'error'); }, 'Services'),
} ConfirmDelete({
}}, 'Delete'), url: '/api/firewall/zones/' + enc(name),
message: 'Delete zone ' + name + '?',
success: 'Zone ' + name + ' deleted',
reload: () => load(state),
label: 'Delete',
}),
), ),
); );
}); });
@@ -233,7 +144,7 @@ export default definePage({
title: 'Zones', title: 'Zones',
subtitle: 'Firewall zones', subtitle: 'Firewall zones',
actions: h('button', { class: 'btn btn-primary', actions: h('button', { class: 'btn btn-primary',
'on:click': () => addZoneModal(state) }, 'Add Zone'), 'on:click': () => addZone(state), }, 'Add Zone'),
}), }),
zoneCards.length zoneCards.length
? h('div', { class: 'card-grid' }, ...zoneCards) ? h('div', { class: 'card-grid' }, ...zoneCards)