refactor: introduce model layer for centralized data synchronization
Add hoover model.js as a central reactive store per subsystem, replacing per-component data fetching with a single source of truth. - Add hoover/model.js with modelRegister, modelFetch, and WS invalidation - Refactor websocket.js to route messages to model refresh (drop per-component subscribe/unsubscribe) - Simplify component.js by removing WS subscription management - Add refresh option to apiSubmit, deprecate refactorLoad and checkAbort - Rewrite all pages to use getModel() instead of inline data fetching - Bootstrap model registrations in app.js - Add GET /api/firewall/state endpoint - Fix restart-services.sh restart order and add service health verification - Update hoover.md docs with model layer architecture
This commit is contained in:
+301
-149
@@ -1,6 +1,6 @@
|
|||||||
# Hoover — SPA Framework
|
# 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.
|
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, a central model layer for data synchronization, and shared UI components. No build step is required — all code runs as ES modules served raw by nginx.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -11,7 +11,8 @@ Hoover is the custom reactive SPA framework used by the Vacuum Wall web UI. It p
|
|||||||
| Render | `render.js` | Render engine: container-level diffing, component lifecycle |
|
| Render | `render.js` | Render engine: container-level diffing, component lifecycle |
|
||||||
| Component | `component.js` | Page definitions, lifecycle hooks, state caching |
|
| Component | `component.js` | Page definitions, lifecycle hooks, state caching |
|
||||||
| Router | `router.js` | Hash-based SPA router, `Link` navigation component |
|
| Router | `router.js` | Hash-based SPA router, `Link` navigation component |
|
||||||
| WebSocket | `websocket.js` | Auto-reconnect WS, topic subscriptions, auto-refresh |
|
| Model | `model.js` | **Central** reactive store per subsystem, fetch, WS invalidation, loading states |
|
||||||
|
| WebSocket | `websocket.js` | Auto-reconnect WS, topic routing to model refresh |
|
||||||
| API | `api.js` | JSON fetch wrapper, toast notifications, form submissions |
|
| API | `api.js` | JSON fetch wrapper, toast notifications, form submissions |
|
||||||
| Helpers | `helpers.js` | Escaping, DOM value helpers, zone parsing |
|
| Helpers | `helpers.js` | Escaping, DOM value helpers, zone parsing |
|
||||||
| Components | `components/*.js` | Reusable UI: layout, data tables, modals, toasts |
|
| Components | `components/*.js` | Reusable UI: layout, data tables, modals, toasts |
|
||||||
@@ -24,6 +25,9 @@ All public APIs are exported from `hoover/index.js`. Pages and app bootstrap imp
|
|||||||
```
|
```
|
||||||
index.html — static shell with #sidebar, #main, #modal-root
|
index.html — static shell with #sidebar, #main, #modal-root
|
||||||
└── app.js — SPA bootstrap
|
└── app.js — SPA bootstrap
|
||||||
|
├── modelRegister('firewall', { subsystem: 'firewall', fetch: ... })
|
||||||
|
├── modelRegister('dnsmasq', { subsystem: 'dnsmasq', fetch: ... })
|
||||||
|
├── modelFetch('firewall') / modelFetch('dnsmasq') / ...
|
||||||
├── render(sidebarEl, Sidebar) — sidebar render root
|
├── render(sidebarEl, Sidebar) — sidebar render root
|
||||||
├── render(mainEl, MainContent) — main content render root
|
├── render(mainEl, MainContent) — main content render root
|
||||||
└── connect() — WebSocket lifecycle
|
└── connect() — WebSocket lifecycle
|
||||||
@@ -33,14 +37,44 @@ The HTML shell (`index.html`) provides named DOM containers (`#sidebar`, `#main`
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
WS message → refreshByTopic(topic) → modelFetch(name) → model.data = apiFetch()
|
||||||
|
→ reactivity proxy triggers render
|
||||||
|
→ page.render(state) reads model data
|
||||||
|
```
|
||||||
|
|
||||||
|
The **model layer** is the single source of truth for subsystem data. Pages never call `apiFetch` for data loading — they call `getModel(name)` in `init()` to get a reactive model, then read `model.data`, `model.loading`, and `model.error` in `render()`.
|
||||||
|
|
||||||
|
Mutations (`ConfirmDelete`, `ActionButton`, `QuickModal`, `apiSubmit`) refresh models by name (`refresh: 'firewall'`), not by calling load functions. The model layer ensures in-flight dedup, loading flag management, and WS-driven auto-refresh.
|
||||||
|
|
||||||
## Bootstrap
|
## Bootstrap
|
||||||
|
|
||||||
The app starts from `webui/static/app.js`:
|
The app starts from `webui/static/app.js`:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import { h, render, Link, hComp, ToastContainer, connect, reactive } from '/static/hoover/index.js?v=4';
|
import { h, render, Link, hComp, ToastContainer, connect, apiFetch,
|
||||||
|
modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=7';
|
||||||
|
|
||||||
// 1. Create reactive router state
|
// 1. Register subsystem models
|
||||||
|
modelRegister('firewall', {
|
||||||
|
subsystem: 'firewall',
|
||||||
|
fetch: async () => {
|
||||||
|
const r = await apiFetch('/api/firewall/config');
|
||||||
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
return r.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ... more modelRegister calls ...
|
||||||
|
|
||||||
|
// 2. Initial fetch for all models
|
||||||
|
for (const name of ['status', 'firewall', 'network', 'dnsmasq', 'nginx', 'acme', 'wireguard']) {
|
||||||
|
modelFetch(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Create reactive router state
|
||||||
const router = {
|
const router = {
|
||||||
state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
|
state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
|
||||||
component() {
|
component() {
|
||||||
@@ -50,16 +84,16 @@ const router = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 2. Listen for hash changes
|
// 4. Listen for hash changes
|
||||||
window.addEventListener('hashchange', () => {
|
window.addEventListener('hashchange', () => {
|
||||||
router.state.path = location.hash.slice(1) || '/dashboard';
|
router.state.path = location.hash.slice(1) || '/dashboard';
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Mount render roots
|
// 5. Mount render roots
|
||||||
render(sidebarEl, Sidebar);
|
render(sidebarEl, Sidebar);
|
||||||
render(mainEl, MainContent);
|
render(mainEl, MainContent);
|
||||||
|
|
||||||
// 4. Start WebSocket (deferred to avoid initial render conflict)
|
// 6. Start WebSocket (deferred to avoid initial render conflict)
|
||||||
setTimeout(connect, 0);
|
setTimeout(connect, 0);
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -93,6 +127,121 @@ state.items.push(newItem);
|
|||||||
|
|
||||||
Manually schedule a re-render. Only one microtask is queued regardless of how many times it's called in the same tick.
|
Manually schedule a re-render. Only one microtask is queued regardless of how many times it's called in the same tick.
|
||||||
|
|
||||||
|
## Model
|
||||||
|
|
||||||
|
The model layer (`model.js`) is the **central** data synchronization mechanism. Each subsystem gets one reactive model with `{ data, loading, refreshing, error }`. Hoover handles fetching, WS invalidation, loading states, and in-flight dedup.
|
||||||
|
|
||||||
|
### `modelRegister(name, definition)`
|
||||||
|
|
||||||
|
Register a subsystem model at app bootstrap.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
modelRegister('firewall', {
|
||||||
|
subsystem: 'firewall', // WS topic to listen for ('*' = all)
|
||||||
|
fetch: async (signal) => { // async fetch function
|
||||||
|
const r = await apiFetch('/api/firewall/config', { signal });
|
||||||
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
return r.data;
|
||||||
|
},
|
||||||
|
defaultData: null, // optional, initial data value
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parameterized example — tab-aware fetch:
|
||||||
|
modelRegister('logs', {
|
||||||
|
subsystem: '*',
|
||||||
|
fetch: async (signal, tab) => {
|
||||||
|
const url = LOG_TABS[tab || 'journal'];
|
||||||
|
const r = await apiFetch(url, { signal });
|
||||||
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
return (r.data || '').split('\n').filter(l => l.length > 0);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
| Parameter | Description |
|
||||||
|
|---|---|
|
||||||
|
| `name` | Model name (e.g., `'firewall'`, `'dnsmasq'`) |
|
||||||
|
| `definition.subsystem` | WS topic string. Use `'firewall'`, `'dnsmasq'`, etc. Use `'*'` to match all topics. |
|
||||||
|
| `definition.fetch(signal?, param?)` | Async function that fetches and returns data. Throws on error. Receives optional `AbortSignal` and optional parameter (e.g., tab key). |
|
||||||
|
| `definition.defaultData` | Optional initial data value (default: `null`) |
|
||||||
|
|
||||||
|
### `getModel(name)`
|
||||||
|
|
||||||
|
Get a reactive model by name. Returns the model object with `{ data, loading, refresh, error }` properties. Call in `init()` to access model state in `render()`.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In page init
|
||||||
|
init() {
|
||||||
|
return {
|
||||||
|
firewall: getModel('firewall'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// In render
|
||||||
|
render(state) {
|
||||||
|
const guard = renderGuard(state.firewall, 'Zones', 'Firewall zones', state.firewall.data?.zones);
|
||||||
|
if (guard) return guard;
|
||||||
|
|
||||||
|
const zones = state.firewall.data?.zones || [];
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `modelFetch(name, signal?, param?)`
|
||||||
|
|
||||||
|
Trigger a fetch for the named model. In-flight dedup ensures concurrent callers get the same promise. Updates `loading`/`refreshing` flags automatically.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Initial load
|
||||||
|
modelFetch('firewall');
|
||||||
|
|
||||||
|
// Post-mutation refresh
|
||||||
|
const r = await apiFetch('/api/firewall/zones', { method: 'POST', body });
|
||||||
|
if (r.ok) modelFetch('firewall');
|
||||||
|
|
||||||
|
// Parameterized fetch (e.g., tab-aware logs)
|
||||||
|
modelFetch('logs', 'journal');
|
||||||
|
modelFetch('logs', 'nginx-access');
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- If a fetch is already in progress for this model (and param), returns the existing promise (dedup).
|
||||||
|
- Sets `model.loading = true` on first fetch, `model.refreshing = true` on subsequent fetches.
|
||||||
|
- Clears `model.error` before fetch.
|
||||||
|
- On success, assigns result to `model.data`.
|
||||||
|
- On failure, stores error in `model.error`.
|
||||||
|
- Flags cleared in `finally` block.
|
||||||
|
- Does not abort in-progress fetches — other consumers may still need the data.
|
||||||
|
- The `param` argument is passed to `fetch(signal, param)` for parameterized models. Dedup key is `name: param`.
|
||||||
|
|
||||||
|
### `refreshByTopic(topic)`
|
||||||
|
|
||||||
|
Refresh all models whose subsystem topic matches. Called by `websocket.js` when a WS message arrives.
|
||||||
|
|
||||||
|
| Model `subsystem` | Topic | Match? |
|
||||||
|
|---|---|---|
|
||||||
|
| `'firewall'` | `'firewall'` | Yes |
|
||||||
|
| `'firewall'` | `'dnsmasq'` | No |
|
||||||
|
| `'*'` | `'firewall'` | Yes (always matches) |
|
||||||
|
| `'nginx'` | `'*'` | Yes (wildcard topic) |
|
||||||
|
|
||||||
|
### `collectLoadingModels(...models)`
|
||||||
|
|
||||||
|
Combine loading/refreshing/error from multiple models for composite `renderGuard` calls.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Pages that consume multiple models
|
||||||
|
render(state) {
|
||||||
|
const c = collectLoadingModels(state.nginx, state.acme);
|
||||||
|
const guard = renderGuard({ loading: c.loading, refreshing: c.refreshing, error: c.error },
|
||||||
|
'Proxy', 'Nginx reverse proxy');
|
||||||
|
if (guard) return guard;
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `{ loading, refreshing, error }` derived from the union of all passed models.
|
||||||
|
|
||||||
## Virtual DOM
|
## Virtual DOM
|
||||||
|
|
||||||
### `h(tag, props, ...children)`
|
### `h(tag, props, ...children)`
|
||||||
@@ -116,8 +265,8 @@ h('#comp', { component: MyPage, key: '/dashboard' }, [])
|
|||||||
|
|
||||||
| Prop | Behavior |
|
| Prop | Behavior |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `class` | String or object (`{ active: bool }` → truthy keys joined as class names) |
|
| `class` | String or object (`{ active: bool }` — truthy keys joined as class names) |
|
||||||
| `style` | String or object (`{ color: 'red' }` → applies to `el.style`) |
|
| `style` | String or object (`{ color: 'red' }` — applies to `el.style`) |
|
||||||
| `html` / `innerHTML` | Sets `innerHTML` directly |
|
| `html` / `innerHTML` | Sets `innerHTML` directly |
|
||||||
| `textContent` | Sets `textContent` directly |
|
| `textContent` | Sets `textContent` directly |
|
||||||
| `value` | On `<input>`, `<textarea>`, `<select>`: sets `.value`; otherwise sets attribute |
|
| `value` | On `<input>`, `<textarea>`, `<select>`: sets `.value`; otherwise sets attribute |
|
||||||
@@ -160,76 +309,59 @@ The render function executes on every reactive update. It can return a single VN
|
|||||||
|
|
||||||
### `definePage(def)`
|
### `definePage(def)`
|
||||||
|
|
||||||
Define a page component with reactive state, WebSocket topic subscriptions, async data loading, and rendering.
|
Define a page component with reactive state and rendering. Pages access data through models, not by fetching directly.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
export default definePage({
|
export default definePage({
|
||||||
// Return initial data. `loading`, `refreshing`, and `error` are auto-injected.
|
// Return initial state — models are obtained via getModel()
|
||||||
init() {
|
init() {
|
||||||
return { data: null };
|
return {
|
||||||
|
firewall: getModel('firewall'),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
// WebSocket topics to subscribe to on mount ('*' = all)
|
// Optional: one-time setup on mount (e.g., opening a modal dialog)
|
||||||
subscribe: ['firewall'],
|
// Not used for data loading — model layer handles that
|
||||||
|
async load(state) {
|
||||||
// Called on mount and when WS topic updates arrive
|
// Rarely needed
|
||||||
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)
|
// Called on every reactive update — return VNode(s)
|
||||||
render(state) {
|
render(state) {
|
||||||
const guard = renderGuard(state, 'Zones', 'Zone management', state.data);
|
const guard = renderGuard(state.firewall, 'Zones', 'Firewall zone management', state.firewall.data?.zones);
|
||||||
if (guard) return guard;
|
if (guard) return guard;
|
||||||
|
|
||||||
|
const zones = state.firewall.data?.zones?.available || [];
|
||||||
return [
|
return [
|
||||||
PageHeader({ title: 'Zones' }),
|
PageHeader({ title: 'Zones' }),
|
||||||
h('div', { class: 'card' }, esc(JSON.stringify(state.data))),
|
zones.map(z => h('div', { class: 'card', key: z }, esc(z))),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Optional: cleanup on unmount
|
||||||
|
onUnmount(state) {
|
||||||
|
// abort pending fetches, clear cached state
|
||||||
|
},
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
**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:
|
Pages get data from models reactive — they never call `apiFetch` in `load()`. The model layer fetches data, manages loading/error states, and triggers re-renders when data arrives.
|
||||||
|
|
||||||
```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
|
### Page Definition Properties
|
||||||
|
|
||||||
| Property | Required | Description |
|
| Property | Required | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `init()` | Yes | Returns initial state. Wrapped with `reactive()` by `definePage`. |
|
| `init()` | Yes | Returns initial state object. Wrapped with `reactive()` by `definePage`. Call `getModel(name)` here to access model data. |
|
||||||
| `subscribe` | No | Array of topic strings (e.g., `['firewall']`). Use `['*']` for all. WS auto-refresh calls `load()` on topic update. |
|
| `load(state)` | No | Optional one-time setup called on mount. Not used for data loading — use model layer instead. |
|
||||||
| `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. Read model data from `state.<model>.data`. |
|
||||||
| `render(state)` | Yes | Returns VNode(s) for the page. |
|
| `onUnmount(state)` | No | Called when page is unmounted. Use for custom cleanup (e.g., aborting page-local fetches). |
|
||||||
| `onUnmount(state)` | No | Called when page is unmounted. Use for custom cleanup. |
|
|
||||||
|
|
||||||
### Page Lifecycle
|
### Page Lifecycle
|
||||||
|
|
||||||
1. **Mount**: `init()` creates state → `load()` fires with fresh `AbortController` → WS subscriptions registered.
|
1. **Mount**: `init()` creates state → `load()` fires if defined → component tracked by key.
|
||||||
2. **Update**: Reactive state change → `render()` re-executes → VDOM diff patches DOM.
|
2. **Update**: Reactive state change (from model data update, navigation, etc.) → `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`.
|
3. **WS auto-refresh**: Topic message arrives → `refreshByTopic()` → `modelFetch()` for matching models → `model.data` update → reactivity triggers `render()`.
|
||||||
4. **Unmount**: Prior `load()` aborted → WS subscriptions removed → `onUnmount()` called → component entry destroyed.
|
4. **Unmount**: `onUnmount()` called if defined → component entry destroyed.
|
||||||
|
|
||||||
### `hComp(renderer, key)`
|
### `hComp(renderer, key)`
|
||||||
|
|
||||||
@@ -294,32 +426,36 @@ Start the WebSocket connection to the daemon at `ws://<host>/ws` (auto-detects `
|
|||||||
|
|
||||||
| Type | Fields | Effect |
|
| Type | Fields | Effect |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `versions` | `updated: [topic, …]` | Auto-refresh components subscribed to listed topics |
|
| `versions` | `updated: [topic, …]` | Refresh all models matching listed topics |
|
||||||
| `refresh` | `topics: [topic, …]` | Same as `versions` |
|
| `refresh` | `topics: [topic, …]` | Same as `versions` |
|
||||||
| `notify` | `topic` | Auto-refresh components subscribed to the topic |
|
| `notify` | `topic` | Refresh all models matching the topic |
|
||||||
| `status` | `topic` | Auto-refresh components subscribed to the topic |
|
| `status` | `topic` | Refresh all models matching the topic |
|
||||||
|
|
||||||
Components subscribed to `'*'` match all topics.
|
Model `subsystem: '*'` matches all topics.
|
||||||
|
|
||||||
### WS Auto-Refresh Flow
|
### WS Auto-Refresh Flow
|
||||||
|
|
||||||
When a WS message arrives for a subscribed topic:
|
When a WS message arrives for a topic:
|
||||||
1. Debounce starts/cancels (300ms).
|
1. `refreshByTopic(topic)` iterates registered models.
|
||||||
2. Any in-flight `load()` is aborted.
|
2. Matching models call `modelFetch(name)`.
|
||||||
3. After debounce, `load()` is called with a new `AbortController`.
|
3. Model fetch updates `model.data`, triggering reactivity and page re-renders.
|
||||||
4. The `entry.requestId` pattern ensures stale async results are discarded.
|
4. In-flight dedup prevents duplicate fetches.
|
||||||
|
|
||||||
|
Pages have no awareness of WS events. The model layer handles all WS-driven refresh.
|
||||||
|
|
||||||
### `onMessage(topics, handler)`
|
### `onMessage(topics, handler)`
|
||||||
|
|
||||||
Direct one-off subscription for code outside `definePage`:
|
Direct one-off subscription for code outside `definePage`:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const unsub = onMessage(['firewall'], (state) => {
|
const unsub = onMessage(['firewall'], (msg) => {
|
||||||
// handle message
|
// handle raw message
|
||||||
});
|
});
|
||||||
// Later: unsub();
|
// Later: unsub();
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Handler receives the parsed WS message object.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
### `apiFetch(url, options)`
|
### `apiFetch(url, options)`
|
||||||
@@ -358,41 +494,62 @@ function MainContent() {
|
|||||||
|
|
||||||
### `apiSubmit(config)`
|
### `apiSubmit(config)`
|
||||||
|
|
||||||
Build a form action for `formModal`. Collects body, validates, submits via `apiFetch`, toasts, and closes the modal on success.
|
Build a form action for `formModal`. Collects body, validates, submits via `apiFetch`, toasts, and closes the modal on success. After success, refreshes the named model(s).
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
apiSubmit({
|
apiSubmit({
|
||||||
url: '/api/firewall/zones',
|
url: '/api/firewall/zones',
|
||||||
method: 'POST', // optional, defaults to 'POST'
|
method: 'POST', // optional, defaults to 'POST'
|
||||||
body: () => ({ name: $val('zone-name') }),
|
body: () => ({ name: $val('zone-name') }),
|
||||||
validate: (b) => !b.name ? 'Name required' : null,
|
validate: (b) => !b.name ? 'Name required' : null,
|
||||||
successMsg: 'Zone created',
|
successMsg: 'Zone created',
|
||||||
reload: () => load(state), // optional, called after success toast
|
refresh: 'firewall', // model name(s) to refresh after success
|
||||||
|
closeModal: () => closeModal(), // optional, called after success toast
|
||||||
}),
|
}),
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns an object matching the `formModal` action shape. Spread it into the actions array: `...apiSubmit({ … })`.
|
Returns an array of action descriptors matching the `formModal` action shape. Spread it into the actions array: `...apiSubmit({ … })`.
|
||||||
|
|
||||||
### `checkAbort(entry, abortController)`
|
**Parameters:**
|
||||||
|
|
||||||
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.
|
| Parameter | Description |
|
||||||
|
|---|---|
|
||||||
|
| `url` | API URL |
|
||||||
|
| `method` | HTTP method (default: `'POST'`) |
|
||||||
|
| `body` | `() => body` function, or `undefined` for no body |
|
||||||
|
| `validate` | `(body) => string | null` — validation function |
|
||||||
|
| `successMsg` | Success toast message |
|
||||||
|
| `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` after success |
|
||||||
|
| `closeModal` | Optional function to call after success (e.g., `() => closeModal()`) |
|
||||||
|
| `submitText` | Submit button text (default: `'Submit'`) |
|
||||||
|
|
||||||
|
### `checkAbort(ac)`
|
||||||
|
|
||||||
|
**Deprecated.** Use model layer (`modelRegister` / `modelFetch`) for data fetching with abort handling and loading state management.
|
||||||
|
|
||||||
|
Create an abort-checking function from an `AbortController`. Returns `true` if the caller should bail out early. Used between sequential fetches in multi-fetch operations.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
if (checkAbort(entry, abortController)) return;
|
const isAborted = checkAbort(abortCtrl);
|
||||||
|
const r = await apiFetch('/api/first', { signal });
|
||||||
|
if (isAborted()) return;
|
||||||
|
const r2 = await apiFetch('/api/second', { signal });
|
||||||
```
|
```
|
||||||
|
|
||||||
### `refactorLoad(state, checkDone, fetchFn, opts)`
|
### `refactorLoad(state, dataKey, 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.
|
**Deprecated.** Use model layer (`modelRegister` / `modelFetch`) for data fetching with abort handling and loading state management.
|
||||||
|
|
||||||
|
Async load wrapper that encapsulates `loading`/`refreshing` flag management, abort checking, and staleness guards. Used for page-local fetches that don't go through the model layer.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import { refactorLoad } from '/static/hoover/index.js';
|
import { refactorLoad } from '/static/hoover/index.js';
|
||||||
|
|
||||||
async function load(state, abortController, entry) {
|
async function load(state, abortController, entry) {
|
||||||
await refactorLoad(state,
|
await refactorLoad(state,
|
||||||
// checkDone: truthy means existing data, use refreshing vs loading
|
// dataKey: truthy means existing data, use refreshing vs loading
|
||||||
s => s.items?.length,
|
s => s.items?.length,
|
||||||
// fetchFn: receives (state, signal, isAborted)
|
// fetchFn: receives (state, signal, isAborted)
|
||||||
// isAborted is a zero-arg function to re-check abort between sequential fetches
|
// isAborted is a zero-arg function to re-check abort between sequential fetches
|
||||||
async (s, signal, isAborted) => {
|
async (s, signal, isAborted) => {
|
||||||
const r = await apiFetch('/api/mydata', { signal });
|
const r = await apiFetch('/api/mydata', { signal });
|
||||||
@@ -409,14 +566,14 @@ async function load(state, abortController, entry) {
|
|||||||
| Parameter | Description |
|
| Parameter | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `state` | Page state object |
|
| `state` | Page state object |
|
||||||
| `checkDone(state)` | Returns truthy if data already exists (sets `refreshing` vs `loading`) |
|
| `dataKey(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 |
|
| `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.entry` | Router entry with `requestId` for staleness checks |
|
||||||
| `opts.abortController` | AbortController for cancellation |
|
| `opts.abortController` | AbortController for cancellation |
|
||||||
|
|
||||||
### `poll(props)`
|
### `poll(opts)`
|
||||||
|
|
||||||
Poll an API endpoint until a terminal state is reached. Returns an abort handle `() => void`.
|
Poll an API endpoint until a terminal state is reached.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import { poll } from '/static/hoover/index.js';
|
import { poll } from '/static/hoover/index.js';
|
||||||
@@ -429,7 +586,7 @@ poll({
|
|||||||
onErrorKey: (d) => d.status === 'failed',
|
onErrorKey: (d) => d.status === 'failed',
|
||||||
onComplete: (d) => {
|
onComplete: (d) => {
|
||||||
toast('Certificate issued', 'success');
|
toast('Certificate issued', 'success');
|
||||||
load(state);
|
modelFetch('acme');
|
||||||
},
|
},
|
||||||
onError: (d) => {
|
onError: (d) => {
|
||||||
toast('Issuance failed', 'error');
|
toast('Issuance failed', 'error');
|
||||||
@@ -442,8 +599,8 @@ poll({
|
|||||||
| Parameter | Description |
|
| Parameter | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `url` | Poll URL |
|
| `url` | Poll URL |
|
||||||
| `interval` | Poll interval in ms (default: `2000`) |
|
| `interval` | Poll interval in ms (default: `3000`) |
|
||||||
| `timeout` | Max poll time in ms (default: `120000`) |
|
| `timeout` | Max poll time in ms (default: `60000`) |
|
||||||
| `successKey` | `(data) => boolean` — when true, stops polling and calls `onComplete` |
|
| `successKey` | `(data) => boolean` — when true, stops polling and calls `onComplete` |
|
||||||
| `onErrorKey` | `(data) => boolean` — when true, stops polling and calls `onError` |
|
| `onErrorKey` | `(data) => boolean` — when true, stops polling and calls `onError` |
|
||||||
| `onComplete` | `(data) => void`, called on success |
|
| `onComplete` | `(data) => void`, called on success |
|
||||||
@@ -528,7 +685,7 @@ Flex button container with 8px gap. Accepts VNode children directly.
|
|||||||
```javascript
|
```javascript
|
||||||
ActionGroup(
|
ActionGroup(
|
||||||
h('button', { class: 'btn btn-primary', 'on:click': addFn }, 'Add'),
|
h('button', { class: 'btn btn-primary', 'on:click': addFn }, 'Add'),
|
||||||
ActionButton({ url: '/api/apply', label: 'Apply', reload: () => load(state) }),
|
ActionButton({ url: '/api/apply', label: 'Apply', refresh: 'firewall' }),
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -536,12 +693,23 @@ ActionGroup(
|
|||||||
|
|
||||||
Return early with loading/error/empty-state VNodes. Returns `null` when data is ready, allowing the page to render its content.
|
Return early with loading/error/empty-state VNodes. Returns `null` when data is ready, allowing the page to render its content.
|
||||||
|
|
||||||
|
**Single model:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const guard = renderGuard(state, 'Zones', 'Zone management', state.zones);
|
const guard = renderGuard(state.firewall, 'Zones', 'Zone management', state.firewall.data?.zones);
|
||||||
if (guard) return guard;
|
if (guard) return guard;
|
||||||
```
|
```
|
||||||
|
|
||||||
Checks `state.loading`, `state.error`, and `data` presence in that order. Uses `state.refreshing` to show "Refreshing…" instead of "Loading…".
|
**Multiple models (use `renderGuardMulti`):**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const guard = renderGuardMulti('Proxy', 'Nginx reverse proxy', state.nginx, state.acme);
|
||||||
|
if (guard) return guard;
|
||||||
|
```
|
||||||
|
|
||||||
|
`renderGuardMulti` internally calls `collectLoadingModels` then delegates to `renderGuard`. For fine-grained control over loading flags, `collectLoadingModels` is still available.
|
||||||
|
|
||||||
|
Checks `state.loading`, `state.error`, and data presence in that order. Uses `state.refreshing` to show "Refreshing…" instead of "Loading…".
|
||||||
|
|
||||||
### Data Display
|
### Data Display
|
||||||
|
|
||||||
@@ -582,32 +750,43 @@ Card container with optional header.
|
|||||||
|
|
||||||
#### `ConfirmDelete(props)`
|
#### `ConfirmDelete(props)`
|
||||||
|
|
||||||
Delete button with native `confirm()` dialog, then API `DELETE` call, toast, and optional reload.
|
Delete button with native `confirm()` dialog, then API `DELETE` call, toast, and model refresh.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
ConfirmDelete({
|
ConfirmDelete({
|
||||||
url: '/api/firewall/zones/myzone',
|
url: '/api/firewall/zones/myzone',
|
||||||
message: 'Delete zone myzone?',
|
message: 'Delete zone myzone?',
|
||||||
success: 'Zone deleted',
|
success: 'Zone deleted',
|
||||||
reload: () => load(state),
|
refresh: 'firewall',
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Parameter | Description |
|
||||||
|
|---|---|
|
||||||
|
| `url` | API DELETE URL |
|
||||||
|
| `message` | Confirmation prompt text |
|
||||||
|
| `success` | Success toast message (default: `'Removed'`) |
|
||||||
|
| `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` |
|
||||||
|
| `label` | Button text (default: `'Remove'`) |
|
||||||
|
| `body` | Optional JSON body to send with DELETE |
|
||||||
|
|
||||||
#### `ActionButton(props)`
|
#### `ActionButton(props)`
|
||||||
|
|
||||||
Inline button that POSTs to an API endpoint, toasts on result, and optionally reloads state. Supports toggle labels for on/off buttons.
|
Inline button that POSTs to an API endpoint, toasts on result, and optionally refreshes models. Supports toggle labels for on/off buttons.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
ActionButton({
|
ActionButton({
|
||||||
url: '/api/dhcp/apply',
|
url: '/api/dhcp/apply',
|
||||||
method: 'POST', // optional, defaults to 'POST'
|
method: 'POST', // optional, defaults to 'POST'
|
||||||
body: () => undefined, // optional
|
body: () => undefined, // optional
|
||||||
label: 'Apply',
|
label: 'Apply',
|
||||||
successMsg: 'Applied',
|
successMsg: 'Applied',
|
||||||
errorType: 'error', // optional, defaults to 'error'
|
errorType: 'error', // optional, defaults to 'error'
|
||||||
reload: () => load(state),
|
refresh: 'dnsmasq', // model name(s) to refresh
|
||||||
cls: 'btn btn-outline', // optional
|
cls: 'btn btn-outline', // optional
|
||||||
disabled: false,
|
disabled: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -618,8 +797,8 @@ ActionButton({
|
|||||||
labelOn: 'Disable',
|
labelOn: 'Disable',
|
||||||
labelOff: 'Enable',
|
labelOff: 'Enable',
|
||||||
condition: z.masquerade,
|
condition: z.masquerade,
|
||||||
reload: () => load(state),
|
refresh: 'firewall',
|
||||||
}),
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
**Parameters:**
|
**Parameters:**
|
||||||
@@ -634,26 +813,24 @@ ActionButton({
|
|||||||
| `condition` | Toggle condition for `labelOn`/`labelOff` |
|
| `condition` | Toggle condition for `labelOn`/`labelOff` |
|
||||||
| `successMsg` | Success toast message |
|
| `successMsg` | Success toast message |
|
||||||
| `errorType` | Toast type for errors (default: `'error'`) |
|
| `errorType` | Toast type for errors (default: `'error'`) |
|
||||||
| `reload` | `() => Promise`, called on success |
|
| `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` after success |
|
||||||
| `cls` | Button CSS classes (default: `'btn btn-outline'`) |
|
| `cls` | Button CSS classes (default: `'btn btn-outline'`) |
|
||||||
| `disabled` | Disabled state |
|
| `disabled` | Disabled state |
|
||||||
|
|
||||||
#### `ActionCell(props)`
|
#### `ActionCell(props)`
|
||||||
|
|
||||||
Standardizes "action button + ConfirmDelete" in a table cell. Replaces the common pattern of an edit button followed by a delete button.
|
Standardizes "action button + ConfirmDelete" in a table cell. Use for rows that need an edit action alongside a delete action.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
ActionCell({
|
ActionCell({
|
||||||
editLabel: 'Edit',
|
editLabel: 'Edit',
|
||||||
editClick: () => editDomain({ ...item, _s: state }),
|
editClick: () => editDomain({ ...d, _s: state }),
|
||||||
removeUrl: '/api/proxy/domains/' + enc(item.domain),
|
removeUrl: '/api/proxy/domains/' + enc(d.domain),
|
||||||
removeMessage: 'Remove proxy for ' + item.domain + '?',
|
removeMessage: 'Remove proxy for ' + d.domain + '?',
|
||||||
removeSuccess: 'Domain removed',
|
removeSuccess: 'Domain removed',
|
||||||
removeReload: () => load(state),
|
removeRefresh: 'proxy',
|
||||||
removeLabel: 'Delete', // optional, defaults to 'Remove'
|
removeLabel: 'Delete',
|
||||||
removeBody: undefined, // optional, JSON body for DELETE
|
})
|
||||||
editCls: 'btn btn-sm btn-outline', // optional, defaults to 'btn btn-sm btn-outline'
|
|
||||||
}),
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Parameters:**
|
**Parameters:**
|
||||||
@@ -665,7 +842,7 @@ ActionCell({
|
|||||||
| `removeUrl` | API DELETE URL |
|
| `removeUrl` | API DELETE URL |
|
||||||
| `removeMessage` | Confirmation prompt text |
|
| `removeMessage` | Confirmation prompt text |
|
||||||
| `removeSuccess` | Success toast message |
|
| `removeSuccess` | Success toast message |
|
||||||
| `removeReload` | Reload function |
|
| `removeRefresh` | Model name (`string`) or array of names (`string[]`) to refresh after delete |
|
||||||
| `removeLabel` | Delete button label (default: `'Remove'`) |
|
| `removeLabel` | Delete button label (default: `'Remove'`) |
|
||||||
| `removeBody` | Optional JSON body to send with DELETE |
|
| `removeBody` | Optional JSON body to send with DELETE |
|
||||||
| `editCls` | Override classes for edit button (default: `'btn btn-sm btn-outline'`) |
|
| `editCls` | Override classes for edit button (default: `'btn btn-sm btn-outline'`) |
|
||||||
@@ -728,38 +905,6 @@ ServiceStatus({ state: dmsk.state || 'down', label: 'Dnsmasq' })
|
|||||||
| `state` | Service state string (e.g. `'up'`, `'down'`) |
|
| `state` | Service state string (e.g. `'up'`, `'down'`) |
|
||||||
| `label` | Optional label text after the badge |
|
| `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)`
|
#### `MonoText(props)`
|
||||||
|
|
||||||
Monospace text with optional truncation. Renders as `<span class="mono-text">`.
|
Monospace text with optional truncation. Renders as `<span class="mono-text">`.
|
||||||
@@ -808,7 +953,12 @@ Table({
|
|||||||
rows: items.map(i => h('tr', null,
|
rows: items.map(i => h('tr', null,
|
||||||
h('td', null, esc(i.name)),
|
h('td', null, esc(i.name)),
|
||||||
h('td', null, StatusDot({ status: i.state })),
|
h('td', null, StatusDot({ status: i.state })),
|
||||||
h('td', null, ConfirmDelete({ url: `/api/item/${i.id}`, message: `Delete ${i.name}?`, reload: () => load(state) })),
|
h('td', null, ConfirmDelete({
|
||||||
|
url: '/api/item/' + enc(i.id),
|
||||||
|
message: 'Delete ' + esc(i.name) + '?',
|
||||||
|
success: 'Item removed',
|
||||||
|
refresh: 'firewall',
|
||||||
|
})),
|
||||||
)),
|
)),
|
||||||
emptyText: 'No items',
|
emptyText: 'No items',
|
||||||
})
|
})
|
||||||
@@ -876,10 +1026,10 @@ const addZone = QuickModal({
|
|||||||
validate: (b) => !b.name ? 'Name required' : null,
|
validate: (b) => !b.name ? 'Name required' : null,
|
||||||
successMsg: 'Zone created', // or (data) => string
|
successMsg: 'Zone created', // or (data) => string
|
||||||
},
|
},
|
||||||
reload: (data) => load(data), // called on success with data argument
|
refresh: 'firewall', // model name(s) to refresh after success
|
||||||
});
|
});
|
||||||
|
|
||||||
// Usage in render — pass state as data so reload can call load(state):
|
// Usage in render:
|
||||||
h('button', { 'on:click': () => addZone(state) }, 'Add Zone')
|
h('button', { 'on:click': () => addZone(state) }, 'Add Zone')
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -892,9 +1042,9 @@ h('button', { 'on:click': () => addZone(state) }, 'Add Zone')
|
|||||||
| `submit.url` | API URL or `(data) => string` |
|
| `submit.url` | API URL or `(data) => string` |
|
||||||
| `submit.method` | HTTP method (default: `'POST'`) |
|
| `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.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.validate` | `(body) => string | null`, validation function |
|
||||||
| `submit.successMsg` | Success toast message or `(data) => string` |
|
| `submit.successMsg` | Success toast message or `(data) => string` |
|
||||||
| `reload` | `(data) => Promise`, called after success; receives the same data argument passed to the modal |
|
| `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` after success |
|
||||||
| `handler` | Optional custom handler `(data, closeModal) => void` that bypasses apiSubmit |
|
| `handler` | Optional custom handler `(data, closeModal) => void` that bypasses apiSubmit |
|
||||||
| `submitLabel` | Submit button label (default: `'Submit'`) |
|
| `submitLabel` | Submit button label (default: `'Submit'`) |
|
||||||
|
|
||||||
@@ -910,7 +1060,7 @@ const editIface = MultiSelectModal({
|
|||||||
selected: zone.interfaces,
|
selected: zone.interfaces,
|
||||||
fieldKey: 'interfaces',
|
fieldKey: 'interfaces',
|
||||||
successMsg: 'Interfaces updated',
|
successMsg: 'Interfaces updated',
|
||||||
reload: () => load(state),
|
refresh: 'firewall',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Usage:
|
// Usage:
|
||||||
@@ -927,7 +1077,7 @@ h('button', { 'on:click': editIface }, 'Edit')
|
|||||||
| `selected` | Currently selected values (`string[]`) |
|
| `selected` | Currently selected values (`string[]`) |
|
||||||
| `fieldKey` | JSON key for the submitted field |
|
| `fieldKey` | JSON key for the submitted field |
|
||||||
| `successMsg` | Success toast message (default: `'Updated'`) |
|
| `successMsg` | Success toast message (default: `'Updated'`) |
|
||||||
| `reload` | `() => Promise`, called on success |
|
| `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` after success |
|
||||||
|
|
||||||
### Toast
|
### Toast
|
||||||
|
|
||||||
@@ -948,16 +1098,18 @@ Render the toast notification container. Include in the main render root. See AP
|
|||||||
|
|
||||||
## Versioned Imports
|
## 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.
|
Static assets in `app.js` are imported with querystring version pins (e.g., `?v=7`) to invalidate browser cache when the framework changes. Page imports also include version pins. The server handles caching headers; the version query string ensures browser cache invalidation.
|
||||||
|
|
||||||
Dev mode (`VACUUM_WALL_DEV` set) disables aggressive static asset caching.
|
Dev mode (`VACUUM_WALL_DEV` set) disables aggressive static asset caching.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- **Pages** export `definePage({ … })` as default. Each page in `webui/static/pages/`.
|
- **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.
|
- **Model-first data loading**: Pages get data from `getModel(name)` in `init()`. The model layer handles fetching, loading states, error handling, and WS-driven refresh. Pages never call `apiFetch` in `load()`.
|
||||||
- **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.
|
||||||
- **Render** pattern: `renderGuard` early return → data rendering. Always return VNode array or single VNode.
|
- **Multi-model pages**: Use `renderGuardMulti(title, subtitle, ...models)` for combined loading/error guard. `collectLoadingModels` is still exported for edge cases needing raw flags.
|
||||||
|
- **Mutation refresh**: UI components use `refresh: 'model_name'` to trigger `modelFetch()` after API mutations. Accepts single name or array.
|
||||||
- **Keys** on list items use unique identifiers (`item.id`), not array indices.
|
- **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.
|
- **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.
|
- **Modals**: Use `formModal` + `apiSubmit` for standard CRUD operations. Use `openModal` + custom render function for non-form content.
|
||||||
|
- **Log / stream data**: Pages that fetch raw text or streams (e.g., `logs.js`) can use the model layer with a parameterized fetch. Register the model with a `fetch(signal, param)` that selects the right URL based on `param`, and call `modelFetch('logs', tabKey)`.
|
||||||
|
|||||||
+15
-4
@@ -1,10 +1,21 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
systemctl restart nginx
|
systemctl restart nginx
|
||||||
systemctl restart vacuum-wall
|
sleep 1
|
||||||
systemctl restart vacuum-walld
|
systemctl restart vacuum-walld
|
||||||
|
sleep 1
|
||||||
|
systemctl restart vacuum-wall
|
||||||
|
|
||||||
systemctl status nginx
|
# Verify services are running
|
||||||
systemctl status vacuum-wall
|
failed=0
|
||||||
systemctl status vacuum-walld
|
for svc in nginx vacuum-walld vacuum-wall; do
|
||||||
|
if ! systemctl is-active --quiet "$svc"; then
|
||||||
|
echo "ERROR: $svc is not running" >&2
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$failed" -eq 1 ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from daemon.iface import (
|
|||||||
GET_FIREWALL_INTERFACES,
|
GET_FIREWALL_INTERFACES,
|
||||||
GET_FIREWALL_RICH_RULES,
|
GET_FIREWALL_RICH_RULES,
|
||||||
GET_FIREWALL_SERVICES,
|
GET_FIREWALL_SERVICES,
|
||||||
|
GET_FIREWALL_STATE,
|
||||||
GET_FIREWALL_ZONES,
|
GET_FIREWALL_ZONES,
|
||||||
GET_FIREWALL_ZONES_INFO,
|
GET_FIREWALL_ZONES_INFO,
|
||||||
PATCH_FIREWALL_CONFIG,
|
PATCH_FIREWALL_CONFIG,
|
||||||
@@ -194,6 +195,28 @@ def config_pending_bp():
|
|||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# State
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/state", methods=["GET"])
|
||||||
|
def get_state():
|
||||||
|
"""Retrieve current firewall state from the state store.
|
||||||
|
|
||||||
|
Endpoint:
|
||||||
|
GET /api/firewall/state
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with firewall state data or an error message.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return _ok(get(GET_FIREWALL_STATE))
|
||||||
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to get firewall state: %s", exc)
|
||||||
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Zones
|
# Zones
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+136
-12
@@ -1,16 +1,16 @@
|
|||||||
import { h, render, Link, hComp, ToastContainer, connect, requestUpdate, reactive } from '/static/hoover/index.js?v=6';
|
import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=7';
|
||||||
|
|
||||||
import DashboardPage from '/static/pages/dashboard.js?v=6';
|
import DashboardPage from '/static/pages/dashboard.js?v=7';
|
||||||
import InterfacesPage from '/static/pages/interfaces.js?v=6';
|
import InterfacesPage from '/static/pages/interfaces.js?v=7';
|
||||||
import ZonesPage from '/static/pages/zones.js?v=6';
|
import ZonesPage from '/static/pages/zones.js?v=7';
|
||||||
import RulesPage from '/static/pages/rules.js?v=6';
|
import RulesPage from '/static/pages/rules.js?v=7';
|
||||||
import NatPage from '/static/pages/nat.js?v=6';
|
import NatPage from '/static/pages/nat.js?v=7';
|
||||||
import DhcpPage from '/static/pages/dhcp.js?v=6';
|
import DhcpPage from '/static/pages/dhcp.js?v=7';
|
||||||
import ProxyPage from '/static/pages/proxy.js?v=6';
|
import ProxyPage from '/static/pages/proxy.js?v=7';
|
||||||
import CertsPage from '/static/pages/certs.js?v=6';
|
import CertsPage from '/static/pages/certs.js?v=7';
|
||||||
import WireguardPage from '/static/pages/wireguard.js?v=6';
|
import WireguardPage from '/static/pages/wireguard.js?v=7';
|
||||||
import LogsPage from '/static/pages/logs.js?v=6';
|
import LogsPage from '/static/pages/logs.js?v=7';
|
||||||
import NotFoundPage from '/static/pages/notfound.js?v=6';
|
import NotFoundPage from '/static/pages/notfound.js?v=7';
|
||||||
|
|
||||||
/* ── Navigation items ──────────────────────────────────────── */
|
/* ── Navigation items ──────────────────────────────────────── */
|
||||||
const Nav = [
|
const Nav = [
|
||||||
@@ -26,6 +26,130 @@ const Nav = [
|
|||||||
{ path: '/logs', label: 'Logs' },
|
{ path: '/logs', label: 'Logs' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/* ── Model registration ────────────────────────────────────── */
|
||||||
|
modelRegister('status', {
|
||||||
|
subsystem: 'status',
|
||||||
|
fetch: async () => {
|
||||||
|
const r = await apiFetch('/api/status/all');
|
||||||
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
return r.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
modelRegister('firewall', {
|
||||||
|
subsystem: 'firewall',
|
||||||
|
fetch: async () => {
|
||||||
|
const [cfg, zones, services, interfaces, state] = await Promise.allSettled([
|
||||||
|
apiFetch('/api/firewall/config'),
|
||||||
|
apiFetch('/api/firewall/zones'),
|
||||||
|
apiFetch('/api/firewall/services'),
|
||||||
|
apiFetch('/api/firewall/interfaces'),
|
||||||
|
apiFetch('/api/firewall/state'),
|
||||||
|
]);
|
||||||
|
const result = {};
|
||||||
|
if (cfg.status === 'fulfilled' && cfg.value.ok) result.config = cfg.value.data || {};
|
||||||
|
else if (cfg.status === 'rejected' || !cfg.value.ok) throw new Error(cfg.status === 'rejected' ? (cfg.reason?.message || 'Failed') : (cfg.value.error || 'Failed'));
|
||||||
|
if (zones.status === 'fulfilled' && zones.value.ok) result.zones = zones.value.data || {};
|
||||||
|
else if (zones.status === 'rejected' || !zones.value.ok) throw new Error(zones.status === 'rejected' ? (zones.reason?.message || 'Failed') : (zones.value.error || 'Failed'));
|
||||||
|
if (services.status === 'fulfilled' && services.value.ok) result.services = services.value.data || [];
|
||||||
|
else if (services.status === 'rejected' || !services.value.ok) throw new Error(services.status === 'rejected' ? (services.reason?.message || 'Failed') : (services.value.error || 'Failed'));
|
||||||
|
if (interfaces.status === 'fulfilled' && interfaces.value.ok) result.interfaces = interfaces.value.data || [];
|
||||||
|
else if (interfaces.status === 'rejected' || !interfaces.value.ok) throw new Error(interfaces.status === 'rejected' ? (interfaces.reason?.message || 'Failed') : (interfaces.value.error || 'Failed'));
|
||||||
|
if (state.status === 'fulfilled' && state.value.ok) result.state = state.value.data || {};
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
modelRegister('network', {
|
||||||
|
subsystem: 'networkd',
|
||||||
|
fetch: async () => {
|
||||||
|
const r = await apiFetch('/api/network/interfaces');
|
||||||
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
return r.data || { interfaces: {} };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
modelRegister('dnsmasq', {
|
||||||
|
subsystem: 'dnsmasq',
|
||||||
|
fetch: async () => {
|
||||||
|
const [cfg, status, leases] = await Promise.allSettled([
|
||||||
|
apiFetch('/api/dhcp/config'),
|
||||||
|
apiFetch('/api/dhcp/status'),
|
||||||
|
apiFetch('/api/dhcp/leases'),
|
||||||
|
]);
|
||||||
|
const result = {};
|
||||||
|
if (cfg.status === 'fulfilled' && cfg.value.ok) result.config = cfg.value.data || {};
|
||||||
|
else if (cfg.status === 'rejected' || !cfg.value.ok) throw new Error(cfg.status === 'rejected' ? (cfg.reason?.message || 'Failed') : (cfg.value.error || 'Failed'));
|
||||||
|
if (status.status === 'fulfilled' && status.value.ok) result.status = status.value.data || {};
|
||||||
|
else if (status.status === 'rejected' || !status.value.ok) throw new Error(status.status === 'rejected' ? (status.reason?.message || 'Failed') : (status.value.error || 'Failed'));
|
||||||
|
if (leases.status === 'fulfilled' && leases.value.ok) result.leases = leases.value.data || [];
|
||||||
|
else if (leases.status === 'rejected' || !leases.value.ok) throw new Error(leases.status === 'rejected' ? (leases.reason?.message || 'Failed') : (leases.value.error || 'Failed'));
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
modelRegister('nginx', {
|
||||||
|
subsystem: 'nginx',
|
||||||
|
fetch: async () => {
|
||||||
|
const r = await apiFetch('/api/proxy/domains');
|
||||||
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
return r.data || [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
modelRegister('acme', {
|
||||||
|
subsystem: 'acme',
|
||||||
|
fetch: async () => {
|
||||||
|
const r = await apiFetch('/api/certs/list');
|
||||||
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
return r.data || [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
modelRegister('wireguard', {
|
||||||
|
subsystem: 'wireguard',
|
||||||
|
fetch: async () => {
|
||||||
|
const [stR, pR, cfgR] = await Promise.allSettled([
|
||||||
|
apiFetch('/api/wireguard/status'),
|
||||||
|
apiFetch('/api/wireguard/peers'),
|
||||||
|
apiFetch('/api/wireguard/config'),
|
||||||
|
]);
|
||||||
|
const result = {};
|
||||||
|
if (stR.status === 'fulfilled' && stR.value.ok) result.status = stR.value.data || {};
|
||||||
|
else if (stR.status === 'rejected' || !stR.value.ok) throw new Error(stR.status === 'rejected' ? (stR.reason?.message || 'Failed') : (stR.value.error || 'Failed'));
|
||||||
|
if (pR.status === 'fulfilled' && pR.value.ok) result.peers = pR.value.data || [];
|
||||||
|
else if (pR.status === 'rejected' || !pR.value.ok) throw new Error(pR.status === 'rejected' ? (pR.reason?.message || 'Failed') : (pR.value.error || 'Failed'));
|
||||||
|
if (cfgR.status === 'fulfilled' && cfgR.value.ok) result.config = cfgR.value.data || {};
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const LOG_TABS = {
|
||||||
|
journal: '/api/logs/journal',
|
||||||
|
'nginx-access': '/api/logs/nginx/access',
|
||||||
|
'nginx-error': '/api/logs/nginx/error',
|
||||||
|
dnsmasq: '/api/logs/dnsmasq',
|
||||||
|
app: '/api/logs/app',
|
||||||
|
};
|
||||||
|
|
||||||
|
modelRegister('logs', {
|
||||||
|
subsystem: '*',
|
||||||
|
fetch: async (signal, tab) => {
|
||||||
|
const tabKey = tab || 'journal';
|
||||||
|
const url = LOG_TABS[tabKey];
|
||||||
|
if (!url) throw new Error('Unknown log tab: ' + tabKey);
|
||||||
|
const r = await apiFetch(url, { signal });
|
||||||
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
return { data: (r.data || '').split('\n').filter(l => l.length > 0), tab: tabKey };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Initial fetch ─────────────────────────────────────────── */
|
||||||
|
for (const name of ['status', 'firewall', 'network', 'dnsmasq', 'nginx', 'wireguard', 'acme']) {
|
||||||
|
modelFetch(name);
|
||||||
|
}
|
||||||
|
modelFetch('logs', 'journal');
|
||||||
|
|
||||||
/* ── Page map ──────────────────────────────────────────────── */
|
/* ── Page map ──────────────────────────────────────────────── */
|
||||||
const Pages = {
|
const Pages = {
|
||||||
dashboard: DashboardPage,
|
dashboard: DashboardPage,
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
* ToastContainer component for rendering queued toasts.
|
* ToastContainer component for rendering queued toasts.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { h } from './vdom.js?v=6';
|
import { h } from './vdom.js?v=7';
|
||||||
|
import { modelFetch } from './model.js?v=7';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JSON-friendly fetch wrapper.
|
* JSON-friendly fetch wrapper.
|
||||||
@@ -100,6 +101,8 @@ export function ToastContainer() {
|
|||||||
/**
|
/**
|
||||||
* Create an abort-checking function from an AbortController.
|
* Create an abort-checking function from an AbortController.
|
||||||
*
|
*
|
||||||
|
* @deprecated Use model layer (`modelRegister` / `modelFetch`) for data
|
||||||
|
* fetching with abort handling and loading state management.
|
||||||
* @param {AbortController} ac
|
* @param {AbortController} ac
|
||||||
* @returns {function} () => boolean
|
* @returns {function} () => boolean
|
||||||
*/
|
*/
|
||||||
@@ -112,6 +115,8 @@ export function checkAbort(ac) {
|
|||||||
*
|
*
|
||||||
* Sets loading=true before, loading=false after, tracks errors.
|
* Sets loading=true before, loading=false after, tracks errors.
|
||||||
*
|
*
|
||||||
|
* @deprecated Use model layer (`modelRegister` / `modelFetch`) for data
|
||||||
|
* fetching with abort handling and loading state management.
|
||||||
* @param {object} state - Reactive state object
|
* @param {object} state - Reactive state object
|
||||||
* @param {function} dataKey - (s) => any, current data to compare for refresh detection
|
* @param {function} dataKey - (s) => any, current data to compare for refresh detection
|
||||||
* @param {function} fetchFn - (state, signal, isAborted) => Promise
|
* @param {function} fetchFn - (state, signal, isAborted) => Promise
|
||||||
@@ -204,7 +209,7 @@ export async function poll(opts) {
|
|||||||
* @param {function} [opts.body] - () => object, body builder
|
* @param {function} [opts.body] - () => object, body builder
|
||||||
* @param {function} [opts.validate] - (body) => string|null, validation function
|
* @param {function} [opts.validate] - (body) => string|null, validation function
|
||||||
* @param {string} [opts.successMsg] - Success toast message
|
* @param {string} [opts.successMsg] - Success toast message
|
||||||
* @param {function} [opts.reload] - () => Promise, data reload function
|
* @param {string|string[]} [opts.refresh] - Model name(s) to refresh via modelFetch
|
||||||
* @param {string} [opts.submitText] - Submit button text (default: 'Submit')
|
* @param {string} [opts.submitText] - Submit button text (default: 'Submit')
|
||||||
* @returns {object[]} Array of action descriptors
|
* @returns {object[]} Array of action descriptors
|
||||||
*/
|
*/
|
||||||
@@ -215,7 +220,7 @@ export function apiSubmit(opts) {
|
|||||||
body,
|
body,
|
||||||
validate,
|
validate,
|
||||||
successMsg = 'Saved',
|
successMsg = 'Saved',
|
||||||
reload,
|
refresh,
|
||||||
submitText = 'Submit',
|
submitText = 'Submit',
|
||||||
closeModal,
|
closeModal,
|
||||||
} = opts;
|
} = opts;
|
||||||
@@ -235,7 +240,10 @@ export function apiSubmit(opts) {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
toast(successMsg, 'success');
|
toast(successMsg, 'success');
|
||||||
if (closeModal) closeModal();
|
if (closeModal) closeModal();
|
||||||
if (reload) await reload();
|
if (refresh) {
|
||||||
|
const models = Array.isArray(refresh) ? refresh : [refresh];
|
||||||
|
await Promise.all(models.map(m => modelFetch(m)));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
toast(res.error || 'Failed', 'error');
|
toast(res.error || 'Failed', 'error');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,72 +4,53 @@
|
|||||||
* Component wrapper: definePage, lifecycle hooks, state caching.
|
* Component wrapper: definePage, lifecycle hooks, state caching.
|
||||||
*
|
*
|
||||||
* definePage wraps a page definition into a renderer function compatible
|
* definePage wraps a page definition into a renderer function compatible
|
||||||
* with hoover's render engine. Handles reactive state creation, WS
|
* with hoover's render engine. Handles reactive state creation and
|
||||||
* subscription registration on mount, and cleanup on unmount.
|
* lifecycle management. Data loading is handled by the model layer.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* export default definePage({
|
* export default definePage({
|
||||||
* init() { return { data: null, loading: true, error: null }; },
|
* init() { return { firewall: getModel('firewall') }; },
|
||||||
* subscribe: ['*'], // WS topics to subscribe to
|
* async load(state) { ... }, // optional, for one-time setup
|
||||||
* async load(state) { ... }, // called on mount
|
|
||||||
* render(state) { return [vnodes],
|
* render(state) { return [vnodes],
|
||||||
* });
|
* });
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { reactive } from './reactivity.js?v=6';
|
import { reactive } from './reactivity.js?v=7';
|
||||||
import { h } from './vdom.js?v=6';
|
import { h } from './vdom.js?v=7';
|
||||||
import { _compExpandedCache } from './render.js?v=6';
|
import { _compExpandedCache } from './render.js?v=7';
|
||||||
|
|
||||||
/** Registry of mounted components: key → { state, subscriptions, loadAbort, entry, isLoading } */
|
/** Registry of mounted components: key → { state } */
|
||||||
const _mounted = new Map();
|
const _mounted = new Map();
|
||||||
|
|
||||||
/** Check whether a state object belongs to a currently mounted component.
|
|
||||||
* Used by websocket.js to skip auto-refresh for unmounted pages. */
|
|
||||||
export function isComponentStateMounted(state) {
|
|
||||||
for (const entry of _mounted.values()) {
|
|
||||||
if (entry.state === state) return true;
|
|
||||||
}
|
|
||||||
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.
|
|
||||||
* Set via setSubscribeFn() when the websocket module initializes.
|
|
||||||
*/
|
|
||||||
let _subscribeFn = null;
|
|
||||||
|
|
||||||
export function setSubscribeFn(fn) {
|
|
||||||
_subscribeFn = fn;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Define a page component.
|
* Define a page component.
|
||||||
*
|
*
|
||||||
* @param {object} def — Page definition
|
* @param {object} def — Page definition
|
||||||
* @param {function} def.init — Return initial state object
|
* @param {function} def.init — Return initial state object
|
||||||
* @param {string[]} [def.subscribe] — WS topics to subscribe to on mount
|
* @param {function} [def.load] — Optional one-time setup called on mount
|
||||||
* @param {function} def.load — Async function to load data into state
|
|
||||||
* @param {function} def.render — Render function that returns vnodes
|
* @param {function} def.render — Render function that returns vnodes
|
||||||
* @returns {object} — Component renderer compatible with h('#comp', ...)
|
* @returns {object} — Component renderer compatible with h('#comp', ...)
|
||||||
*/
|
*/
|
||||||
export function definePage(def) {
|
export function definePage(def) {
|
||||||
const state = reactive(def.init());
|
let state = null;
|
||||||
|
let stateInitialized = false;
|
||||||
|
|
||||||
const renderer = () => {
|
const renderer = () => {
|
||||||
|
if (!stateInitialized) {
|
||||||
|
state = reactive(def.init());
|
||||||
|
stateInitialized = true;
|
||||||
|
}
|
||||||
return def.render(state);
|
return def.render(state);
|
||||||
};
|
};
|
||||||
|
|
||||||
renderer._pageDef = {
|
renderer._pageDef = {
|
||||||
state,
|
get state() {
|
||||||
subscribe: def.subscribe || [],
|
if (!stateInitialized) {
|
||||||
|
state = reactive(def.init());
|
||||||
|
stateInitialized = true;
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
},
|
||||||
load: def.load || null,
|
load: def.load || null,
|
||||||
onUnmount: def.onUnmount || null,
|
onUnmount: def.onUnmount || null,
|
||||||
};
|
};
|
||||||
@@ -88,43 +69,18 @@ export function mountComponent(key, renderer) {
|
|||||||
let entry = _mounted.get(key);
|
let entry = _mounted.get(key);
|
||||||
|
|
||||||
if (entry) {
|
if (entry) {
|
||||||
// Re-mount: component already exists with its data and subscriptions.
|
// Re-mount: component already exists with its state.
|
||||||
// Don't abort or restart loads — that re-render was triggered by a
|
// Don't re-run load — that re-render was triggered by a reactive update.
|
||||||
// state change (load completion, reactive update, etc). Let existing
|
|
||||||
// in-flight loads complete naturally. WS handles auto-refresh.
|
|
||||||
return;
|
return;
|
||||||
} else {
|
|
||||||
// Fresh mount
|
|
||||||
entry = {
|
|
||||||
state: pd.state,
|
|
||||||
subscriptions: [],
|
|
||||||
loadAbort: null,
|
|
||||||
requestId: 0,
|
|
||||||
};
|
|
||||||
_mounted.set(key, entry);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear error on re-mount; load() decides loading vs refreshing
|
entry = { state: pd.state };
|
||||||
|
_mounted.set(key, entry);
|
||||||
|
|
||||||
pd.state.error = null;
|
pd.state.error = null;
|
||||||
|
|
||||||
// Fire load with fresh AbortController
|
|
||||||
if (pd.load) {
|
if (pd.load) {
|
||||||
if (entry.isLoading) return;
|
Promise.resolve().then(() => pd.load(pd.state));
|
||||||
const abortController = new AbortController();
|
|
||||||
entry.loadAbort = abortController;
|
|
||||||
entry.requestId++;
|
|
||||||
entry.isLoading = true;
|
|
||||||
Promise.resolve()
|
|
||||||
.then(() => pd.load(pd.state, abortController, entry))
|
|
||||||
.finally(() => { entry.isLoading = false; });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register WS subscriptions (only on fresh mount)
|
|
||||||
if (!entry.subscriptions.length && _subscribeFn && pd.subscribe.length) {
|
|
||||||
for (const topic of pd.subscribe) {
|
|
||||||
const unsub = _subscribeFn(renderer, topic, pd.load, pd.state);
|
|
||||||
if (unsub) entry.subscriptions.push(unsub);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,19 +94,6 @@ export function unmountComponent(key, renderer) {
|
|||||||
|
|
||||||
const pd = renderer._pageDef;
|
const pd = renderer._pageDef;
|
||||||
|
|
||||||
// Cancel load
|
|
||||||
if (entry.loadAbort) {
|
|
||||||
entry.loadAbort.abort();
|
|
||||||
}
|
|
||||||
// Invalidate any in-flight callbacks
|
|
||||||
entry.requestId++;
|
|
||||||
|
|
||||||
// Unsubscribe from WS
|
|
||||||
for (const unsub of entry.subscriptions) {
|
|
||||||
try { unsub(); } catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fire custom onUnmount
|
|
||||||
if (pd.onUnmount) {
|
if (pd.onUnmount) {
|
||||||
try { pd.onUnmount(entry.state); } catch (_) {}
|
try { pd.onUnmount(entry.state); } catch (_) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
* Data display components: Badge, StatusDot, Empty, Card.
|
* Data display components: Badge, StatusDot, Empty, Card.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { h } from '../vdom.js?v=6';
|
import { h } from '../vdom.js?v=7';
|
||||||
import { esc } from '../helpers.js?v=6';
|
import { esc } from '../helpers.js?v=7';
|
||||||
import { apiFetch, toast } from '../api.js?v=6';
|
import { apiFetch, toast } from '../api.js?v=7';
|
||||||
|
import { modelFetch } from '../model.js?v=7';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Colored badge/span.
|
* Colored badge/span.
|
||||||
@@ -62,13 +63,13 @@ export function Card(props = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A Remove button that confirms, deletes via API, toasts, and reloads.
|
* A Remove button that confirms, deletes via API, toasts, and refreshes models.
|
||||||
*
|
*
|
||||||
* @param {object} props
|
* @param {object} props
|
||||||
* @param {string} props.url - API DELETE URL
|
* @param {string} props.url - API DELETE URL
|
||||||
* @param {string} props.message - Confirmation prompt text
|
* @param {string} props.message - Confirmation prompt text
|
||||||
* @param {string} [props.success] - Success toast message (default: 'Removed')
|
* @param {string} [props.success] - Success toast message (default: 'Removed')
|
||||||
* @param {function} [props.reload] - Function to call on success (e.g., load)
|
* @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
|
||||||
* @param {string} [props.label] - Button text (default: 'Remove')
|
* @param {string} [props.label] - Button text (default: 'Remove')
|
||||||
* @param {object} [props.body] - Optional JSON body to send with DELETE
|
* @param {object} [props.body] - Optional JSON body to send with DELETE
|
||||||
*/
|
*/
|
||||||
@@ -81,7 +82,10 @@ export function ConfirmDelete(props = {}) {
|
|||||||
const r = await apiFetch(props.url, opts);
|
const r = await apiFetch(props.url, opts);
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
toast(props.success || 'Removed', 'success');
|
toast(props.success || 'Removed', 'success');
|
||||||
if (props.reload) await props.reload();
|
if (props.refresh) {
|
||||||
|
const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh];
|
||||||
|
names.forEach(n => modelFetch(n));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
toast(r.error || 'Failed', 'error');
|
toast(r.error || 'Failed', 'error');
|
||||||
}
|
}
|
||||||
@@ -90,7 +94,7 @@ export function ConfirmDelete(props = {}) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* An action button that POSTs to an API endpoint, toasts on result,
|
* An action button that POSTs to an API endpoint, toasts on result,
|
||||||
* and optionally reloads state. Supports toggle labels for on/off buttons.
|
* and optionally refreshes models. Supports toggle labels for on/off buttons.
|
||||||
*
|
*
|
||||||
* @param {object} props
|
* @param {object} props
|
||||||
* @param {string} props.url - API URL
|
* @param {string} props.url - API URL
|
||||||
@@ -102,7 +106,7 @@ export function ConfirmDelete(props = {}) {
|
|||||||
* @param {boolean} [props.condition] - Toggle condition for labelOn/labelOff
|
* @param {boolean} [props.condition] - Toggle condition for labelOn/labelOff
|
||||||
* @param {string} [props.successMsg] - Success toast message
|
* @param {string} [props.successMsg] - Success toast message
|
||||||
* @param {string} [props.errorType] - Toast type for errors (default: 'error')
|
* @param {string} [props.errorType] - Toast type for errors (default: 'error')
|
||||||
* @param {function} [props.reload] - () => Promise, called on success
|
* @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
|
||||||
* @param {string} [props.cls] - Button CSS classes (default: 'btn btn-outline')
|
* @param {string} [props.cls] - Button CSS classes (default: 'btn btn-outline')
|
||||||
* @param {boolean} [props.disabled] - Disabled state
|
* @param {boolean} [props.disabled] - Disabled state
|
||||||
*/
|
*/
|
||||||
@@ -122,7 +126,10 @@ export function ActionButton(props = {}) {
|
|||||||
const resp = await apiFetch(props.url, opts);
|
const resp = await apiFetch(props.url, opts);
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
if (props.successMsg) toast(props.successMsg, 'success');
|
if (props.successMsg) toast(props.successMsg, 'success');
|
||||||
if (props.reload) await props.reload();
|
if (props.refresh) {
|
||||||
|
const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh];
|
||||||
|
names.forEach(n => modelFetch(n));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
toast(resp.error || 'Failed', props.errorType || 'error');
|
toast(resp.error || 'Failed', props.errorType || 'error');
|
||||||
}
|
}
|
||||||
@@ -249,7 +256,7 @@ export function ServiceStatus(props = {}) {
|
|||||||
* @param {string} props.removeUrl - API DELETE URL
|
* @param {string} props.removeUrl - API DELETE URL
|
||||||
* @param {string} props.removeMessage - Confirmation prompt text
|
* @param {string} props.removeMessage - Confirmation prompt text
|
||||||
* @param {string} [props.removeSuccess] - Success toast message
|
* @param {string} [props.removeSuccess] - Success toast message
|
||||||
* @param {function} [props.removeReload] - Reload function
|
* @param {string|string[]} [props.removeRefresh] - Model name(s) to refresh
|
||||||
* @param {string} [props.removeLabel] - Delete button label (default: 'Remove')
|
* @param {string} [props.removeLabel] - Delete button label (default: 'Remove')
|
||||||
* @param {object} [props.removeBody] - Optional JSON body to send with DELETE
|
* @param {object} [props.removeBody] - Optional JSON body to send with DELETE
|
||||||
* @param {string} [props.editCls] - Override classes for edit button (default: 'btn btn-sm btn-outline')
|
* @param {string} [props.editCls] - Override classes for edit button (default: 'btn btn-sm btn-outline')
|
||||||
@@ -265,7 +272,7 @@ export function ActionCell(props = {}) {
|
|||||||
url: props.removeUrl,
|
url: props.removeUrl,
|
||||||
message: props.removeMessage,
|
message: props.removeMessage,
|
||||||
success: props.removeSuccess,
|
success: props.removeSuccess,
|
||||||
reload: props.removeReload,
|
refresh: props.removeRefresh,
|
||||||
label: props.removeLabel || 'Remove',
|
label: props.removeLabel || 'Remove',
|
||||||
body: props.removeBody,
|
body: props.removeBody,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
* Layout components: PageHeader, Tabs, SectionTitle, DataTableSection, ActionGroup.
|
* Layout components: PageHeader, Tabs, SectionTitle, DataTableSection, ActionGroup.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { h } from '../vdom.js?v=6';
|
import { h } from '../vdom.js?v=7';
|
||||||
import { Table } from './data.js?v=6';
|
import { Table } from './data.js?v=7';
|
||||||
|
import { collectLoadingModels } from '../model.js?v=7';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Page header with title, optional subtitle, and action buttons.
|
* Page header with title, optional subtitle, and action buttons.
|
||||||
@@ -25,14 +26,17 @@ export function PageHeader(props = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle loading/error/no-data states and return early if applicable.
|
* Handle loading/error/no-data states and return early if applicable.
|
||||||
* Returns null when data is ready for the page to render its content.
|
* Returns null when data is ready for the page to render its content.
|
||||||
*
|
*
|
||||||
|
* Accepts a model object (with loading/refreshing/error/data properties) as
|
||||||
|
* the `data` parameter to check the model's data property directly.
|
||||||
|
*
|
||||||
* @param {object} state - Page state with loading/error flags
|
* @param {object} state - Page state with loading/error flags
|
||||||
* @param {string} title - Page header title
|
* @param {string} title - Page header title
|
||||||
* @param {string} [subtitle] - Page header subtitle
|
* @param {string} [subtitle] - Page header subtitle
|
||||||
* @param {*} [data] - Data presence check for "no data" state
|
* @param {*} [data] - Data to check (or model object with .data property)
|
||||||
* @returns {VNode[]|null}
|
* @returns {VNode[]|null}
|
||||||
*/
|
*/
|
||||||
export function renderGuard(state, title, subtitle, data) {
|
export function renderGuard(state, title, subtitle, data) {
|
||||||
@@ -54,7 +58,7 @@ export function renderGuard(state, title, subtitle, data) {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if ((data === undefined || data === null) && !state.loading) {
|
if (isEmpty(data) && !state.loading) {
|
||||||
return [
|
return [
|
||||||
PageHeader({ title, subtitle }),
|
PageHeader({ title, subtitle }),
|
||||||
h('div', { class: 'card', key: 'no-data' },
|
h('div', { class: 'card', key: 'no-data' },
|
||||||
@@ -65,6 +69,33 @@ export function renderGuard(state, title, subtitle, data) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience wrapper for pages consuming multiple models.
|
||||||
|
* Internally calls collectLoadingModels then delegates to renderGuard.
|
||||||
|
*
|
||||||
|
* @param {string} title - Page header title
|
||||||
|
* @param {string} [subtitle] - Page header subtitle
|
||||||
|
* @param {...object} models - Model objects to combine
|
||||||
|
* @returns {VNode[]|null}
|
||||||
|
*/
|
||||||
|
export function renderGuardMulti(title, subtitle, ...models) {
|
||||||
|
const combined = collectLoadingModels(...models);
|
||||||
|
return renderGuard(combined, title, subtitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a value is "empty" for renderGuard's no-data check.
|
||||||
|
* @param {*} data
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isEmpty(data) {
|
||||||
|
if (data === null || data === undefined || data === '') return true;
|
||||||
|
if (Array.isArray(data)) return data.length === 0;
|
||||||
|
if (typeof data === 'object') return Object.keys(data).length === 0;
|
||||||
|
if (typeof data === 'number') return false;
|
||||||
|
return !data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tab bar component. Writes to state[prop] on tab click.
|
* Tab bar component. Writes to state[prop] on tab click.
|
||||||
* The caller is responsible for rendering tab body content.
|
* The caller is responsible for rendering tab body content.
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
* avoid fighting with the main render cycle.
|
* avoid fighting with the main render cycle.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { esc } from '../helpers.js?v=6';
|
import { esc } from '../helpers.js?v=7';
|
||||||
import { att_esc } from '../helpers.js?v=6';
|
import { att_esc } from '../helpers.js?v=7';
|
||||||
import { apiSubmit } from '../api.js?v=6';
|
import { apiSubmit } from '../api.js?v=7';
|
||||||
|
|
||||||
const _modalQueue = [];
|
const _modalQueue = [];
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ export function formModal(inner, title, fields, actions) {
|
|||||||
* @param {string[]} props.selected - Currently selected values
|
* @param {string[]} props.selected - Currently selected values
|
||||||
* @param {string} props.fieldKey - JSON key for the field
|
* @param {string} props.fieldKey - JSON key for the field
|
||||||
* @param {string} [props.successMsg] - Success toast message
|
* @param {string} [props.successMsg] - Success toast message
|
||||||
* @param {function} [props.reload] - () => Promise, called on success
|
* @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
|
||||||
* @returns {function} () => void, calls openModal
|
* @returns {function} () => void, calls openModal
|
||||||
*/
|
*/
|
||||||
export function MultiSelectModal(props = {}) {
|
export function MultiSelectModal(props = {}) {
|
||||||
@@ -138,7 +138,7 @@ export function MultiSelectModal(props = {}) {
|
|||||||
.map(o => o.value),
|
.map(o => o.value),
|
||||||
}),
|
}),
|
||||||
successMsg: props.successMsg || 'Updated',
|
successMsg: props.successMsg || 'Updated',
|
||||||
reload: props.reload,
|
refresh: props.refresh,
|
||||||
closeModal: () => closeModal(),
|
closeModal: () => closeModal(),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -160,7 +160,7 @@ export function MultiSelectModal(props = {}) {
|
|||||||
* @param {function} [props.submit.body] - (data) => object
|
* @param {function} [props.submit.body] - (data) => object
|
||||||
* @param {function} [props.submit.validate] - (body) => string|null
|
* @param {function} [props.submit.validate] - (body) => string|null
|
||||||
* @param {string|function} [props.submit.successMsg] - Toast message or (data) => string
|
* @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 {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
|
||||||
* @param {function} [props.handler] - Custom submit handler override (bypasses apiSubmit)
|
* @param {function} [props.handler] - Custom submit handler override (bypasses apiSubmit)
|
||||||
* @param {string} [props.submitLabel] - Submit button label (default: 'Submit')
|
* @param {string} [props.submitLabel] - Submit button label (default: 'Submit')
|
||||||
* @returns {function} (data) => void, calls openModal
|
* @returns {function} (data) => void, calls openModal
|
||||||
@@ -194,7 +194,7 @@ export function QuickModal(props = {}) {
|
|||||||
successMsg: typeof props.submit.successMsg === 'function'
|
successMsg: typeof props.submit.successMsg === 'function'
|
||||||
? props.submit.successMsg(data)
|
? props.submit.successMsg(data)
|
||||||
: (props.submit.successMsg || 'Done'),
|
: (props.submit.successMsg || 'Done'),
|
||||||
reload: props.reload ? () => props.reload(data) : undefined,
|
refresh: props.refresh || undefined,
|
||||||
closeModal: () => closeModal(),
|
closeModal: () => closeModal(),
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
* Uses the toast/dismissToast state from api.js.
|
* Uses the toast/dismissToast state from api.js.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { h } from '../vdom.js?v=6';
|
import { h } from '../vdom.js?v=7';
|
||||||
import { _toasts, dismissToast } from '../api.js?v=6';
|
import { _toasts, dismissToast } from '../api.js?v=7';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render all pending toast notifications.
|
* Render all pending toast notifications.
|
||||||
|
|||||||
@@ -5,37 +5,40 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/* ── Reactivity ──────────────────────────────────────────────── */
|
/* ── Reactivity ──────────────────────────────────────────────── */
|
||||||
export { reactive, requestUpdate } from './reactivity.js?v=6';
|
export { reactive, requestUpdate } from './reactivity.js?v=7';
|
||||||
|
|
||||||
/* ── VDOM ────────────────────────────────────────────────────── */
|
/* ── VDOM ────────────────────────────────────────────────────── */
|
||||||
export { h } from './vdom.js?v=6';
|
export { h } from './vdom.js?v=7';
|
||||||
|
|
||||||
/* ── Render ──────────────────────────────────────────────────── */
|
/* ── Render ──────────────────────────────────────────────────── */
|
||||||
export { render } from './render.js?v=6';
|
export { render } from './render.js?v=7';
|
||||||
|
|
||||||
/* ── Component ───────────────────────────────────────────────── */
|
/* ── Component ───────────────────────────────────────────────── */
|
||||||
export { definePage, hComp } from './component.js?v=6';
|
export { definePage, hComp } from './component.js?v=7';
|
||||||
|
|
||||||
/* ── Router ──────────────────────────────────────────────────── */
|
/* ── Router ──────────────────────────────────────────────────── */
|
||||||
export { createRouter, Link } from './router.js?v=6';
|
export { createRouter, Link } from './router.js?v=7';
|
||||||
|
|
||||||
/* ── WebSocket ───────────────────────────────────────────────── */
|
/* ── WebSocket ───────────────────────────────────────────────── */
|
||||||
export { connect, onMessage } from './websocket.js?v=6';
|
export { connect, onMessage } from './websocket.js?v=7';
|
||||||
|
|
||||||
/* ── API & Toast ─────────────────────────────────────────────── */
|
/* ── API & Toast ─────────────────────────────────────────────── */
|
||||||
export { apiFetch, toast, dismissToast, apiSubmit, refactorLoad, checkAbort, poll } from './api.js?v=6';
|
export { apiFetch, toast, dismissToast, apiSubmit, checkAbort, poll, refactorLoad } from './api.js?v=7';
|
||||||
|
|
||||||
|
/* ── Model ───────────────────────────────────────────────────── */
|
||||||
|
export { modelRegister, getModel, modelFetch, collectLoadingModels } from './model.js?v=7';
|
||||||
|
|
||||||
/* ── Helpers ─────────────────────────────────────────────────── */
|
/* ── Helpers ─────────────────────────────────────────────────── */
|
||||||
export { esc, att_esc, enc, $val, parseZones, downloadBlob } from './helpers.js?v=6';
|
export { esc, att_esc, enc, $val, parseZones, downloadBlob } from './helpers.js?v=7';
|
||||||
|
|
||||||
/* ── UI Components: Layout ───────────────────────────────────── */
|
/* ── UI Components: Layout ───────────────────────────────────── */
|
||||||
export { PageHeader, renderGuard, Tabs, SectionTitle, ActionGroup, DataTableSection } from './components/layout.js?v=6';
|
export { PageHeader, renderGuard, renderGuardMulti, Tabs, SectionTitle, ActionGroup, DataTableSection } from './components/layout.js?v=7';
|
||||||
|
|
||||||
/* ── UI Components: Data ─────────────────────────────────────── */
|
/* ── UI Components: Data ─────────────────────────────────────── */
|
||||||
export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, certStatusBadge, serviceStatusBadge, StatCard, StatusText, ServiceStatus, ActionCell, MonoText, ZoneSelect } from './components/data.js?v=6';
|
export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, certStatusBadge, serviceStatusBadge, StatCard, StatusText, ServiceStatus, ActionCell, MonoText, ZoneSelect } from './components/data.js?v=7';
|
||||||
|
|
||||||
/* ── UI Components: Modal ────────────────────────────────────── */
|
/* ── UI Components: Modal ────────────────────────────────────── */
|
||||||
export { openModal, closeModal, closeAllModals, formModal, MultiSelectModal, QuickModal } from './components/modal.js?v=6';
|
export { openModal, closeModal, closeAllModals, formModal, MultiSelectModal, QuickModal } from './components/modal.js?v=7';
|
||||||
|
|
||||||
/* ── UI Components: Toast ────────────────────────────────────── */
|
/* ── UI Components: Toast ────────────────────────────────────── */
|
||||||
export { ToastContainer } from './components/toast.js?v=6';
|
export { ToastContainer } from './components/toast.js?v=7';
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* Hoover — model.js
|
||||||
|
*
|
||||||
|
* Central reactive store for subsystem models. Each subsystem gets one
|
||||||
|
* reactive model with { data, loading, refreshing, error }. Hoover handles
|
||||||
|
* fetching, WS invalidation, loading states, and abort management.
|
||||||
|
*
|
||||||
|
* API:
|
||||||
|
* modelRegister(name, definition) — register at app bootstrap
|
||||||
|
* getModel(name) — return reactive model object
|
||||||
|
* modelFetch(name, signal?, param?) — trigger fetch with in-flight dedup
|
||||||
|
* refreshByTopic(topic) — WS callback: refresh all models matching topic
|
||||||
|
* collectLoadingModels(...models) — combine loading/refreshing/error
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { reactive } from './reactivity.js?v=7';
|
||||||
|
|
||||||
|
/** Registered models: name → { model, subsystem, fetch } */
|
||||||
|
const _models = new Map();
|
||||||
|
|
||||||
|
/** In-flight fetch promises for dedup: name → Promise */
|
||||||
|
const _fetchPromises = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a subsystem model.
|
||||||
|
*
|
||||||
|
* @param {string} name - Model name (e.g. 'firewall', 'dnsmasq')
|
||||||
|
* @param {object} definition
|
||||||
|
* @param {string} definition.subsystem - WS topic to listen for ('*' = all)
|
||||||
|
* @param {function} definition.fetch - async (signal?, param?) => Promise<data>
|
||||||
|
* @param {any} [definition.defaultData] - Initial data value (default: null)
|
||||||
|
* @returns {object} reactive model
|
||||||
|
*/
|
||||||
|
export function modelRegister(name, definition) {
|
||||||
|
const model = reactive({
|
||||||
|
data: definition.defaultData ?? null,
|
||||||
|
loading: true,
|
||||||
|
refreshing: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
_models.set(name, {
|
||||||
|
model,
|
||||||
|
subsystem: definition.subsystem,
|
||||||
|
fetch: definition.fetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a reactive model by name. Throws if not registered.
|
||||||
|
* @param {string} name
|
||||||
|
* @returns {object} reactive model
|
||||||
|
*/
|
||||||
|
export function getModel(name) {
|
||||||
|
const entry = _models.get(name);
|
||||||
|
if (!entry) throw new Error('Model not registered: ' + name);
|
||||||
|
return entry.model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build dedup key from model name and optional param. */
|
||||||
|
function _dedupKey(name, param) {
|
||||||
|
return param !== undefined ? `${name}:${String(param)}` : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger a fetch for the named model.
|
||||||
|
*
|
||||||
|
* In-flight dedup: if a fetch is already running, returns the existing
|
||||||
|
* promise. Models never abort in-progress fetches since other consumers
|
||||||
|
* may still need the data.
|
||||||
|
*
|
||||||
|
* @param {string} name - Model name
|
||||||
|
* @param {AbortSignal|*} [signalOrParam] - AbortSignal (backward compat) or param
|
||||||
|
* @param {AbortSignal} [signal] - AbortSignal when a param was provided
|
||||||
|
*/
|
||||||
|
export function modelFetch(name, signalOrParam, signal) {
|
||||||
|
const entry = _models.get(name);
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
const isSignal = signalOrParam instanceof AbortSignal || signalOrParam === undefined;
|
||||||
|
const param = isSignal ? undefined : signalOrParam;
|
||||||
|
const actualSignal = isSignal ? signalOrParam : signal;
|
||||||
|
|
||||||
|
const model = entry.model;
|
||||||
|
const isInitial = model.loading && model.data === null;
|
||||||
|
const key = _dedupKey(name, param);
|
||||||
|
|
||||||
|
if (_fetchPromises.has(key)) return _fetchPromises.get(key);
|
||||||
|
|
||||||
|
if (isInitial) model.loading = true;
|
||||||
|
else model.refreshing = true;
|
||||||
|
model.error = null;
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
|
try {
|
||||||
|
const data = await entry.fetch(actualSignal, param);
|
||||||
|
model.data = data;
|
||||||
|
} catch (e) {
|
||||||
|
model.error = e.message || 'Fetch failed';
|
||||||
|
} finally {
|
||||||
|
model.loading = false;
|
||||||
|
model.refreshing = false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
_fetchPromises.set(key, promise);
|
||||||
|
promise.finally(() => _fetchPromises.delete(key));
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh all models whose subsystem topic matches the given topic.
|
||||||
|
* Topic '*' matches every model. Model subsystem '*' matches every topic.
|
||||||
|
*/
|
||||||
|
export function refreshByTopic(topic) {
|
||||||
|
for (const [name, entry] of _models) {
|
||||||
|
if (entry.subsystem === '*') {
|
||||||
|
modelFetch(name);
|
||||||
|
} else if (entry.subsystem === topic || topic === '*') {
|
||||||
|
modelFetch(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combine loading/refreshing/error from multiple models.
|
||||||
|
* @param {...object} models
|
||||||
|
* @returns {{loading: boolean, refreshing: boolean, error: string|null}}
|
||||||
|
*/
|
||||||
|
export function collectLoadingModels(...models) {
|
||||||
|
return {
|
||||||
|
loading: models.some(m => m.loading),
|
||||||
|
refreshing: models.some(m => m.refreshing),
|
||||||
|
error: models.find(m => m.error)?.error ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,12 +5,12 @@
|
|||||||
* batched re-render loop integration with reactivity.js.
|
* batched re-render loop integration with reactivity.js.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { requestUpdate, setCommitFn } from './reactivity.js?v=6';
|
import { requestUpdate, setCommitFn } from './reactivity.js?v=7';
|
||||||
import {
|
import {
|
||||||
_vnodeDom, createDom, getDom, patchNode, sweepDom,
|
_vnodeDom, createDom, getDom, patchNode, sweepDom,
|
||||||
setMountFn, setUnmountFn,
|
setMountFn, setUnmountFn,
|
||||||
} from './vdom.js?v=6';
|
} from './vdom.js?v=7';
|
||||||
import { mountComponent, unmountComponent } from './component.js?v=6';
|
import { mountComponent, unmountComponent } from './component.js?v=7';
|
||||||
|
|
||||||
/** Container → previous root vnodes */
|
/** Container → previous root vnodes */
|
||||||
export const _renderSlots = new Map();
|
export const _renderSlots = new Map();
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
* navigation). Link component for client-side navigation.
|
* navigation). Link component for client-side navigation.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { reactive } from './reactivity.js?v=6';
|
import { reactive } from './reactivity.js?v=7';
|
||||||
import { h } from './vdom.js?v=6';
|
import { h } from './vdom.js?v=7';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hash-based router.
|
* Hash-based router.
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Hoover — websocket.js
|
* Hoover — websocket.js
|
||||||
*
|
*
|
||||||
* WebSocket connection manager with auto-reconnect, subscribe/unsubscribe
|
* WebSocket connection manager with auto-reconnect. WS messages are routed
|
||||||
* per component per topic, and version-track messages.
|
* to model-based refresh and direct onMessage handlers.
|
||||||
*
|
* Page-level subscribe/unsubscribe is replaced by the model layer.
|
||||||
* The _wsSubs Map stores entries keyed by renderer function so that
|
|
||||||
* auto-refresh messages from the backend can trigger page reloads.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { setSubscribeFn, isComponentStateMounted, getComponentEntry } from './component.js?v=6';
|
import { refreshByTopic } from './model.js?v=7';
|
||||||
|
|
||||||
const _wsSubs = new Map();
|
|
||||||
let _wsConn = null;
|
let _wsConn = null;
|
||||||
let _wsReconnectMs = 0;
|
let _wsReconnectMs = 0;
|
||||||
|
|
||||||
|
/** Direct onMessage handlers: { topics, handler, unsubscribed }[] */
|
||||||
|
const _directHandlers = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the WebSocket URL. Supports an override via `window.__WS_URL__`
|
* Build the WebSocket URL. Supports an override via `window.__WS_URL__`
|
||||||
* (useful for proxy setups). Falls back to port 9091 when the current
|
* (useful for proxy setups). Falls back to port 9091 when the current
|
||||||
@@ -52,57 +52,13 @@ 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.
|
* Route an incoming WS message to model refresh and direct handlers.
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* Expected message shapes:
|
* Expected message shapes:
|
||||||
* { type: 'versions', updated: ['firewall', 'dnsmasq', …] }
|
* { type: 'versions', updated: ['firewall', 'dnsmasq', …] }
|
||||||
* { type: 'notify', topic: 'firewall' }
|
* { type: 'notify', topic: 'firewall' }
|
||||||
* { type: 'status', topic: 'firewall', … }
|
* { type: 'status', topic: 'firewall', … }
|
||||||
*
|
|
||||||
* 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 = [];
|
||||||
@@ -115,88 +71,39 @@ function handleMessage(msg) {
|
|||||||
topics.push(msg.topic || '*');
|
topics.push(msg.topic || '*');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track which states have already been scheduled to avoid
|
// Refresh models for each topic
|
||||||
// double-scheduling when multiple subscriptions of the same
|
for (const topic of topics) {
|
||||||
// component match the same message.
|
refreshByTopic(topic);
|
||||||
const scheduled = new Set();
|
}
|
||||||
|
|
||||||
for (const s of _wsSubs.values()) {
|
// Notify direct onMessage handlers
|
||||||
if (s.unsubscribed || !isComponentStateMounted(s.state)) continue;
|
for (const h of _directHandlers) {
|
||||||
|
if (h.unsubscribed) continue;
|
||||||
const matched = s.topic === '*' || topics.some(t => t === s.topic || t === '*');
|
if (topics.some(t => h.topics.includes(t) || h.topics.includes('*'))) {
|
||||||
if (!matched) continue;
|
try { h.handler(msg); } catch (_) {}
|
||||||
|
}
|
||||||
if (scheduled.has(s.state)) continue;
|
|
||||||
scheduled.add(s.state);
|
|
||||||
scheduleReload(s.state);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribe a component to WS topics.
|
|
||||||
*
|
|
||||||
* Called by component.js on mount. Returns an unsubscribe function
|
|
||||||
* 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 {string} topic – Topic to listen for ('*' = all)
|
|
||||||
* @param {function} loadFn – Function to call when topic updates
|
|
||||||
* @param {object} state – Reactive state passed to loadFn
|
|
||||||
* @returns {function} unsubscribe
|
|
||||||
*/
|
|
||||||
function subscribe(componentFn, topic, loadFn, state) {
|
|
||||||
const key = componentFn + ':' + topic;
|
|
||||||
const entry = { componentFn, topic, loadFn, state, unsubscribed: false };
|
|
||||||
_wsSubs.set(key, entry);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
entry.unsubscribed = true;
|
|
||||||
// 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);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Register the subscribe function with component.js and kick off connection. */
|
|
||||||
setSubscribeFn(subscribe);
|
|
||||||
|
|
||||||
/** Start the WebSocket connection. */
|
|
||||||
export function connect() {
|
|
||||||
_wsConnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public subscribe API for direct one-off usage (e.g. from page code).
|
* Public subscribe API for direct one-off usage (e.g. from page code).
|
||||||
|
* Handler receives the raw parsed message when a matching topic arrives.
|
||||||
* @param {string|string[]} topics
|
* @param {string|string[]} topics
|
||||||
* @param {function} handler
|
* @param {function} handler
|
||||||
* @returns {function} unsubscribe
|
* @returns {function} unsubscribe
|
||||||
*/
|
*/
|
||||||
export function onMessage(topics, handler) {
|
export function onMessage(topics, handler) {
|
||||||
const tArray = Array.isArray(topics) ? topics : [topics];
|
const tArray = Array.isArray(topics) ? topics : [topics];
|
||||||
const fns = [];
|
const entry = { topics: tArray, handler, unsubscribed: false };
|
||||||
for (const t of tArray) {
|
_directHandlers.push(entry);
|
||||||
const entry = {
|
return () => {
|
||||||
componentFn: handler, topic: t, loadFn: handler, state: {},
|
entry.unsubscribed = true;
|
||||||
unsubscribed: false
|
const idx = _directHandlers.indexOf(entry);
|
||||||
};
|
if (idx !== -1) _directHandlers.splice(idx, 1);
|
||||||
_wsSubs.set(handler + ':' + t, entry);
|
};
|
||||||
fns.push(() => {
|
}
|
||||||
entry.unsubscribed = true;
|
|
||||||
if (_wsDebounceTimers.has(entry.state)) {
|
/** Start the WebSocket connection. */
|
||||||
clearTimeout(_wsDebounceTimers.get(entry.state));
|
export function connect() {
|
||||||
_wsDebounceTimers.delete(entry.state);
|
_wsConnect();
|
||||||
}
|
|
||||||
_wsSubs.delete(handler + ':' + t);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return () => fns.forEach(f => f());
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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=6"></script>
|
<script type="module" src="/static/app.js?v=7"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
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';
|
import { h, PageHeader, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionCell, certStatusBadge, poll } from '/static/hoover/index.js?v=7';
|
||||||
|
|
||||||
function issueCertModal(state) {
|
function issueCertModal(state) {
|
||||||
openModal((inner, idx) => {
|
openModal((inner, idx) => {
|
||||||
@@ -40,7 +40,7 @@ async function pollCertIssue(rid, state) {
|
|||||||
onErrorKey: (d) => d.status === 'failed',
|
onErrorKey: (d) => d.status === 'failed',
|
||||||
onComplete: (d) => {
|
onComplete: (d) => {
|
||||||
toast('Certificate issued for ' + (d.domain || rid), 'success');
|
toast('Certificate issued for ' + (d.domain || rid), 'success');
|
||||||
load(state);
|
modelFetch('acme');
|
||||||
},
|
},
|
||||||
onError: (d) => {
|
onError: (d) => {
|
||||||
toast('Issuance failed: ' + (d?.error || 'unknown'), 'error');
|
toast('Issuance failed: ' + (d?.error || 'unknown'), 'error');
|
||||||
@@ -48,30 +48,17 @@ async function pollCertIssue(rid, state) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load(state, abortController, entry) {
|
|
||||||
await refactorLoad(state,
|
|
||||||
s => s.certs?.length,
|
|
||||||
async (s, sig, isAborted) => {
|
|
||||||
const r = await apiFetch('/api/certs/list', { signal: sig });
|
|
||||||
if (isAborted()) return;
|
|
||||||
if (r.ok) s.certs = r.data || [];
|
|
||||||
else s.error = r.error;
|
|
||||||
},
|
|
||||||
{ entry, abortController },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default definePage({
|
export default definePage({
|
||||||
init() {
|
init() {
|
||||||
return { certs: [] };
|
return {
|
||||||
|
acme: getModel('acme'),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
subscribe: ['acme'],
|
|
||||||
load,
|
|
||||||
render(state) {
|
render(state) {
|
||||||
const guard = renderGuard(state, 'Certificates', 'ACME certificate management', state.certs);
|
const guard = renderGuard(state.acme, 'Certificates', 'ACME certificate management', state.acme.data);
|
||||||
if (guard) return guard;
|
if (guard) return guard;
|
||||||
|
|
||||||
const rows = state.certs.map(c => {
|
const rows = (state.acme.data || []).map(c => {
|
||||||
const badge = certStatusBadge({ expired: c.expired, daysRemaining: c.days_remaining });
|
const badge = certStatusBadge({ expired: c.expired, daysRemaining: c.days_remaining });
|
||||||
|
|
||||||
return h('tr', { key: c.domain },
|
return h('tr', { key: c.domain },
|
||||||
@@ -89,7 +76,7 @@ export default definePage({
|
|||||||
removeUrl: '/api/certs/' + enc(c.domain),
|
removeUrl: '/api/certs/' + enc(c.domain),
|
||||||
removeMessage: 'Remove certificate for ' + c.domain + '?',
|
removeMessage: 'Remove certificate for ' + c.domain + '?',
|
||||||
removeSuccess: 'Certificate removed',
|
removeSuccess: 'Certificate removed',
|
||||||
removeReload: () => load(state),
|
removeRefresh: 'acme',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -109,4 +96,4 @@ export default definePage({
|
|||||||
: Empty({ text: 'No certificates found. Issue a certificate to get started.' }),
|
: Empty({ text: 'No certificates found. Issue a certificate to get started.' }),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1,27 +1,16 @@
|
|||||||
import { h, PageHeader, apiFetch, definePage, renderGuard, refactorLoad, ServiceStatus, StatCard } from '/static/hoover/index.js?v=6';
|
import { h, PageHeader, definePage, getModel, renderGuard, ServiceStatus, StatCard } from '/static/hoover/index.js?v=7';
|
||||||
|
|
||||||
export default definePage({
|
export default definePage({
|
||||||
init() {
|
init() {
|
||||||
return { data: null };
|
return {
|
||||||
},
|
status: getModel('status'),
|
||||||
subscribe: ['firewall', 'dnsmasq', 'wireguard', 'acme', 'networkd'],
|
};
|
||||||
async load(state, abortController, entry) {
|
|
||||||
await refactorLoad(state,
|
|
||||||
s => s.data,
|
|
||||||
async (s, sig, isAborted) => {
|
|
||||||
const res = await apiFetch('/api/status/all', { signal: sig });
|
|
||||||
if (isAborted()) return;
|
|
||||||
if (res.ok) s.data = res.data;
|
|
||||||
else s.error = res.error;
|
|
||||||
},
|
|
||||||
{ entry, abortController },
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
render(state) {
|
render(state) {
|
||||||
const guard = renderGuard(state, 'Dashboard', 'System overview', state.data);
|
const guard = renderGuard(state.status, 'Dashboard', 'System overview', state.status.data);
|
||||||
if (guard) return guard;
|
if (guard) return guard;
|
||||||
|
|
||||||
const d = state.data;
|
const d = state.status.data;
|
||||||
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;
|
||||||
@@ -69,4 +58,4 @@ export default definePage({
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
+21
-49
@@ -1,4 +1,4 @@
|
|||||||
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';
|
import { h, PageHeader, Badge, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup, QuickModal } from '/static/hoover/index.js?v=7';
|
||||||
|
|
||||||
const addRange = QuickModal({
|
const addRange = QuickModal({
|
||||||
title: 'Add DHCP Range',
|
title: 'Add DHCP Range',
|
||||||
@@ -19,7 +19,7 @@ const addRange = QuickModal({
|
|||||||
validate: (b) => !b.start || !b.end ? 'Start and end are required' : null,
|
validate: (b) => !b.start || !b.end ? 'Start and end are required' : null,
|
||||||
successMsg: 'Range added',
|
successMsg: 'Range added',
|
||||||
},
|
},
|
||||||
reload: (s) => load(s),
|
refresh: 'dnsmasq',
|
||||||
});
|
});
|
||||||
|
|
||||||
const addLease = QuickModal({
|
const addLease = QuickModal({
|
||||||
@@ -39,7 +39,7 @@ const addLease = QuickModal({
|
|||||||
validate: (b) => !b.mac || !b.ip ? 'MAC and IP are required' : null,
|
validate: (b) => !b.mac || !b.ip ? 'MAC and IP are required' : null,
|
||||||
successMsg: 'Lease added',
|
successMsg: 'Lease added',
|
||||||
},
|
},
|
||||||
reload: (s) => load(s),
|
refresh: 'dnsmasq',
|
||||||
});
|
});
|
||||||
|
|
||||||
const addDns = QuickModal({
|
const addDns = QuickModal({
|
||||||
@@ -54,55 +54,27 @@ const addDns = QuickModal({
|
|||||||
validate: (b) => !b.name || !b.address ? 'Name and address are required' : null,
|
validate: (b) => !b.name || !b.address ? 'Name and address are required' : null,
|
||||||
successMsg: 'DNS record added',
|
successMsg: 'DNS record added',
|
||||||
},
|
},
|
||||||
reload: (s) => load(s),
|
refresh: 'dnsmasq',
|
||||||
});
|
});
|
||||||
|
|
||||||
async function load(state, abortController, entry) {
|
|
||||||
await refactorLoad(state,
|
|
||||||
s => Object.keys(s.config || {}).length,
|
|
||||||
async (s, sig, isAborted) => {
|
|
||||||
const [cfgR, stR, lsR] = await Promise.allSettled([
|
|
||||||
apiFetch('/api/dhcp/config', { signal: sig }),
|
|
||||||
apiFetch('/api/dhcp/status', { signal: sig }),
|
|
||||||
apiFetch('/api/dhcp/leases', { signal: sig }),
|
|
||||||
]);
|
|
||||||
if (isAborted()) return;
|
|
||||||
const errors = [];
|
|
||||||
if (cfgR.status === 'rejected') errors.push(cfgR.reason?.message || 'Failed');
|
|
||||||
else if (!cfgR.value.ok) errors.push(cfgR.value.error || 'Failed');
|
|
||||||
if (stR.status === 'rejected') errors.push(stR.reason?.message || 'Failed');
|
|
||||||
else if (!stR.value.ok) errors.push(stR.value.error || 'Failed');
|
|
||||||
if (lsR.status === 'rejected') errors.push(lsR.reason?.message || 'Failed');
|
|
||||||
else if (!lsR.value.ok) errors.push(lsR.value.error || 'Failed');
|
|
||||||
if (errors.length) {
|
|
||||||
s.error = errors[0];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
s.config = cfgR.value.data || {};
|
|
||||||
s.status = stR.value.data || {};
|
|
||||||
s.leases = lsR.value.data || [];
|
|
||||||
},
|
|
||||||
{ entry, abortController },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default definePage({
|
export default definePage({
|
||||||
init() {
|
init() {
|
||||||
return { config: {}, status: {}, leases: [], activeTab: 'ranges' };
|
return {
|
||||||
|
dnsmasq: getModel('dnsmasq'),
|
||||||
|
activeTab: 'ranges',
|
||||||
|
};
|
||||||
},
|
},
|
||||||
subscribe: ['dnsmasq'],
|
|
||||||
load,
|
|
||||||
render(state) {
|
render(state) {
|
||||||
const guard = renderGuard(state, 'DHCP & DNS', null, state.leases);
|
const guard = renderGuard(state.dnsmasq, 'DHCP & DNS', 'Dnsmasq management', state.dnsmasq.data);
|
||||||
if (guard) return guard;
|
if (guard) return guard;
|
||||||
|
|
||||||
const cfg = state.config || {};
|
const cfg = state.dnsmasq.data?.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 status = state.dnsmasq.data?.status || {};
|
||||||
|
|
||||||
const rangesRows = ranges.map((r, i) => h('tr', { key: (r.interface || '_g') + '-' + r.start + '-' + r.end },
|
const rangesRows = ranges.map((r) => 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)),
|
||||||
@@ -113,12 +85,12 @@ export default definePage({
|
|||||||
message: 'Remove range ' + r.start + ' - ' + r.end + '?',
|
message: 'Remove range ' + r.start + ' - ' + r.end + '?',
|
||||||
body: { interface: r.interface || '', start: r.start, end: r.end },
|
body: { interface: r.interface || '', start: r.start, end: r.end },
|
||||||
success: 'Range removed',
|
success: 'Range removed',
|
||||||
reload: () => load(state),
|
refresh: 'dnsmasq',
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
|
|
||||||
const leaseRows = staticLeases.map((l, i) => h('tr', { key: l.mac },
|
const leaseRows = staticLeases.map((l) => 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 || '-'),
|
||||||
@@ -127,12 +99,12 @@ export default definePage({
|
|||||||
url: '/api/dhcp/static-lease/' + enc(l.mac),
|
url: '/api/dhcp/static-lease/' + enc(l.mac),
|
||||||
message: 'Remove lease ' + l.mac + '?',
|
message: 'Remove lease ' + l.mac + '?',
|
||||||
success: 'Lease removed',
|
success: 'Lease removed',
|
||||||
reload: () => load(state),
|
refresh: 'dnsmasq',
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
|
|
||||||
const dnsRows = dnsRecords.map((rec, i) => h('tr', { key: rec.name },
|
const dnsRows = dnsRecords.map((rec) => 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,
|
||||||
@@ -140,7 +112,7 @@ export default definePage({
|
|||||||
url: '/api/dhcp/dns-record/' + enc(rec.name || ''),
|
url: '/api/dhcp/dns-record/' + enc(rec.name || ''),
|
||||||
message: 'Remove DNS record ' + (rec.name || 'unnamed') + '?',
|
message: 'Remove DNS record ' + (rec.name || 'unnamed') + '?',
|
||||||
success: 'Record removed',
|
success: 'Record removed',
|
||||||
reload: () => load(state),
|
refresh: 'dnsmasq',
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
@@ -154,13 +126,13 @@ export default definePage({
|
|||||||
url: '/api/dhcp/apply',
|
url: '/api/dhcp/apply',
|
||||||
successMsg: 'dnsmasq applied',
|
successMsg: 'dnsmasq applied',
|
||||||
label: 'Apply',
|
label: 'Apply',
|
||||||
reload: () => load(state),
|
refresh: 'dnsmasq',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
PageHeader({ title: 'DHCP & DNS', subtitle: 'Dnsmasq management', actions }),
|
PageHeader({ title: 'DHCP & DNS', subtitle: 'Dnsmasq management', actions }),
|
||||||
ServiceStatus({ state: statusUp.state || 'down', label: 'Dnsmasq' }),
|
ServiceStatus({ state: status.state || 'down', label: 'Dnsmasq' }),
|
||||||
Tabs({ state, tabs: tabNames }),
|
Tabs({ state, tabs: tabNames }),
|
||||||
state.activeTab === 'ranges'
|
state.activeTab === 'ranges'
|
||||||
? Table({ columns: ['Interface', 'Start', 'End', 'Lease', 'Action'], rows: rangesRows, emptyText: 'No DHCP ranges' }) : null,
|
? Table({ columns: ['Interface', 'Start', 'End', 'Lease', 'Action'], rows: rangesRows, emptyText: 'No DHCP ranges' }) : null,
|
||||||
@@ -169,7 +141,7 @@ export default definePage({
|
|||||||
state.activeTab === 'dns'
|
state.activeTab === 'dns'
|
||||||
? Table({ columns: ['Name', 'Address', 'Action'], rows: dnsRows, emptyText: 'No custom DNS records' }) : null,
|
? Table({ columns: ['Name', 'Address', 'Action'], rows: dnsRows, emptyText: 'No custom DNS records' }) : null,
|
||||||
state.activeTab === 'active'
|
state.activeTab === 'active'
|
||||||
? Table({ columns: ['MAC', 'IP', 'Hostname', 'Expires'], rows: (state.leases || []).map((l, i) => h('tr', { key: l.mac || i },
|
? Table({ columns: ['MAC', 'IP', 'Hostname', 'Expires'], rows: (state.dnsmasq.data?.leases || []).map((l) => h('tr', { key: l.mac || l.ip },
|
||||||
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 || '-')),
|
||||||
@@ -177,4 +149,4 @@ export default definePage({
|
|||||||
)), emptyText: 'No active leases' }) : null,
|
)), emptyText: 'No active leases' }) : null,
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { h, PageHeader, Table, renderGuard, enc, $val, apiFetch, toast, definePage, refactorLoad, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js?v=6';
|
import { h, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js?v=7';
|
||||||
|
|
||||||
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', {
|
||||||
@@ -7,7 +7,8 @@ async function changeZone(name, zone, state) {
|
|||||||
});
|
});
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
toast(name + ' \u2192 ' + zone, 'success');
|
toast(name + ' \u2192 ' + zone, 'success');
|
||||||
await load(state);
|
modelFetch('firewall');
|
||||||
|
modelFetch('network');
|
||||||
} else {
|
} else {
|
||||||
toast(r.error || 'Failed', 'error');
|
toast(r.error || 'Failed', 'error');
|
||||||
}
|
}
|
||||||
@@ -32,53 +33,44 @@ const cfgModalFn = QuickModal({
|
|||||||
}),
|
}),
|
||||||
successMsg: 'Config saved',
|
successMsg: 'Config saved',
|
||||||
},
|
},
|
||||||
reload: (s) => load(s),
|
refresh: ['firewall', 'network'],
|
||||||
});
|
});
|
||||||
|
|
||||||
async function load(state, abortController, entry) {
|
|
||||||
await refactorLoad(state,
|
|
||||||
s => s.ifaces?.length,
|
|
||||||
async (s, sig, isAborted) => {
|
|
||||||
const [fw, net] = await Promise.all([
|
|
||||||
apiFetch('/api/firewall/zones', { signal: sig }),
|
|
||||||
apiFetch('/api/network/interfaces', { signal: sig }),
|
|
||||||
]);
|
|
||||||
if (isAborted()) return;
|
|
||||||
if (fw.ok) s.zones = fw.data?.available || [];
|
|
||||||
else s.error = fw.error;
|
|
||||||
if (net.ok) {
|
|
||||||
const ifaceZone = {};
|
|
||||||
for (const [zoneName, ifaces] of Object.entries(fw.data?.active || {})) {
|
|
||||||
for (const name of (ifaces || [])) ifaceZone[name] = zoneName;
|
|
||||||
}
|
|
||||||
const ifacesObj = net.data?.interfaces || {};
|
|
||||||
s.ifaces = Object.entries(ifacesObj).map(([name, entry]) => ({
|
|
||||||
name,
|
|
||||||
mac: entry?.runtime?.mac || null,
|
|
||||||
ips: [...(entry?.config?.addresses || []), ...(entry?.runtime?.addresses || [])],
|
|
||||||
state: (entry?.runtime?.state || '').startsWith('routable') || (entry?.runtime?.state || '').startsWith('carrier') ? 'up' : 'down',
|
|
||||||
zone: ifaceZone[name] || null,
|
|
||||||
config: entry?.config || {},
|
|
||||||
}));
|
|
||||||
} else if (!s.error) {
|
|
||||||
s.error = net.error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ entry, abortController },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default definePage({
|
export default definePage({
|
||||||
init() {
|
init() {
|
||||||
return { ifaces: [], zones: [] };
|
return {
|
||||||
|
firewall: getModel('firewall'),
|
||||||
|
network: getModel('network'),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
subscribe: ['firewall', 'networkd'],
|
|
||||||
load,
|
|
||||||
render(state) {
|
render(state) {
|
||||||
const guard = renderGuard(state, 'Interfaces', 'Network interface management', state.ifaces.length);
|
const guard = renderGuardMulti('Interfaces', 'Network interface management', state.firewall, state.network);
|
||||||
if (guard) return guard;
|
if (guard) return guard;
|
||||||
|
|
||||||
const rows = state.ifaces.map(iface => {
|
const fwZones = state.firewall.data?.zones || {};
|
||||||
|
const netData = state.network.data?.interfaces || {};
|
||||||
|
const zones = fwZones.available || [];
|
||||||
|
const activeZones = fwZones.active || {};
|
||||||
|
|
||||||
|
const ifaces = Object.entries(netData).map(([name, entry]) => {
|
||||||
|
let zone = null;
|
||||||
|
for (const [zoneName, ifaces] of Object.entries(activeZones)) {
|
||||||
|
if ((ifaces || []).includes(name)) {
|
||||||
|
zone = zoneName;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
mac: entry?.runtime?.mac || null,
|
||||||
|
ips: [...(entry?.config?.addresses || []), ...(entry?.runtime?.addresses || [])],
|
||||||
|
state: (entry?.runtime?.state || '').startsWith('routable') || (entry?.runtime?.state || '').startsWith('carrier') ? 'up' : 'down',
|
||||||
|
zone,
|
||||||
|
config: entry?.config || {},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = 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')),
|
||||||
@@ -86,7 +78,7 @@ export default definePage({
|
|||||||
h('td', null, StatusText({ status: iface.state })),
|
h('td', null, StatusText({ status: iface.state })),
|
||||||
h('td', null,
|
h('td', null,
|
||||||
ZoneSelect({
|
ZoneSelect({
|
||||||
zones: state.zones,
|
zones,
|
||||||
value: iface.zone,
|
value: iface.zone,
|
||||||
onChange: (z) => changeZone(iface.name, z, state),
|
onChange: (z) => changeZone(iface.name, z, state),
|
||||||
}),
|
}),
|
||||||
@@ -108,4 +100,4 @@ export default definePage({
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
+33
-84
@@ -1,108 +1,57 @@
|
|||||||
import { h, PageHeader, Tabs, esc, definePage, refactorLoad } from '/static/hoover/index.js?v=6';
|
import { h, PageHeader, Tabs, esc, definePage, renderGuard, modelFetch, getModel } from '/static/hoover/index.js?v=7';
|
||||||
|
|
||||||
const logTabs = [
|
const logTabs = [
|
||||||
{ key: 'journal', label: 'Journal', url: '/api/logs/journal' },
|
{ key: 'journal', label: 'Journal' },
|
||||||
{ key: 'nginx-access', label: 'Nginx Access', url: '/api/logs/nginx/access' },
|
{ key: 'nginx-access', label: 'Nginx Access' },
|
||||||
{ key: 'nginx-error', label: 'Nginx Error', url: '/api/logs/nginx/error' },
|
{ key: 'nginx-error', label: 'Nginx Error' },
|
||||||
{ key: 'dnsmasq', label: 'Dnsmasq', url: '/api/logs/dnsmasq' },
|
{ key: 'dnsmasq', label: 'Dnsmasq' },
|
||||||
{ key: 'app', label: 'App', url: '/api/logs/app' },
|
{ key: 'app', label: 'App' },
|
||||||
];
|
];
|
||||||
|
|
||||||
async function fetchLog(state, url, signal) {
|
|
||||||
if (signal?.aborted) return;
|
|
||||||
const res = await fetch(url, { signal });
|
|
||||||
if (signal?.aborted) return;
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
const text = await res.text();
|
|
||||||
if (signal?.aborted) return;
|
|
||||||
state.lines = text.split('\n').filter(l => l.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default definePage({
|
export default definePage({
|
||||||
init() {
|
init() {
|
||||||
return { activeTab: 'journal', lines: [], loading: false, _abortCtrl: null };
|
return {
|
||||||
},
|
logs: getModel('logs'),
|
||||||
subscribe: [],
|
activeTab: 'journal',
|
||||||
async load(state, abortController, entry) {
|
};
|
||||||
await refactorLoad(state,
|
|
||||||
s => s.lines?.length,
|
|
||||||
async (s, sig, isAborted) => {
|
|
||||||
const tab = logTabs.find(t => t.key === s.activeTab) || logTabs[0];
|
|
||||||
await fetchLog(s, tab.url, sig);
|
|
||||||
},
|
|
||||||
{ entry, abortController },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onUnmount(state) {
|
|
||||||
state._abortCtrl?.abort();
|
|
||||||
state.lines = [];
|
|
||||||
},
|
},
|
||||||
render(state) {
|
render(state) {
|
||||||
const tab = logTabs.find(t => t.key === state.activeTab) || logTabs[0];
|
const logData = state.logs.data;
|
||||||
|
const stale = logData?.tab !== state.activeTab;
|
||||||
|
const guard = renderGuard(state.logs, 'Logs', 'System and service logs', stale ? undefined : logData?.data);
|
||||||
|
if (guard) return guard;
|
||||||
|
|
||||||
const lineVnodes = state.lines.map((line, i) =>
|
const lines = logData.data || [];
|
||||||
|
const tab = logTabs.find(t => t.key === state.activeTab) || logTabs[0];
|
||||||
|
const lineVnodes = lines.map((line, i) =>
|
||||||
h('div', { class: 'log-line', key: i }, esc(line))
|
h('div', { class: 'log-line', key: i }, esc(line))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const tabsBody = Tabs({
|
||||||
|
state,
|
||||||
|
tabs: logTabs.map(t => t.key),
|
||||||
|
formatLabel: (k) => {
|
||||||
|
const t = logTabs.find(t => t.key === k);
|
||||||
|
return t ? t.label : k.charAt(0).toUpperCase() + k.slice(1);
|
||||||
|
},
|
||||||
|
onTabClick: (key) => modelFetch('logs', key),
|
||||||
|
});
|
||||||
|
|
||||||
return [
|
return [
|
||||||
PageHeader({ title: 'Logs', subtitle: 'System & service logs' }),
|
PageHeader({ title: 'Logs', subtitle: 'System and service logs' }),
|
||||||
Tabs({
|
|
||||||
state,
|
|
||||||
tabs: logTabs.map(t => t.key),
|
|
||||||
formatLabel: (k) => {
|
|
||||||
const tab = logTabs.find(t => t.key === k);
|
|
||||||
return tab ? tab.label : k.charAt(0).toUpperCase() + k.slice(1);
|
|
||||||
},
|
|
||||||
onTabClick: async (key) => {
|
|
||||||
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' },
|
||||||
|
tabsBody,
|
||||||
h('div', { class: 'card-header' },
|
h('div', { class: 'card-header' },
|
||||||
h('span', null, tab.label),
|
h('span', null, tab.label),
|
||||||
h('button', {
|
h('button', {
|
||||||
class: 'btn btn-sm btn-outline',
|
class: 'btn btn-sm btn-outline',
|
||||||
style: 'float:right;',
|
style: 'float:right;',
|
||||||
'on:click': async () => {
|
'on:click': () => modelFetch('logs', state.activeTab),
|
||||||
state._abortCtrl?.abort();
|
}, '\u21BB'),
|
||||||
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')
|
|
||||||
),
|
),
|
||||||
h('div', { class: 'card-body log-body' },
|
h('div', { class: 'card-body log-body' },
|
||||||
state.loading && !state.refreshing
|
h('pre', null, lineVnodes),
|
||||||
? h('div', { class: 'loading' }, state.refreshing ? 'Refreshing...' : 'Loading...')
|
),
|
||||||
: state.error
|
|
||||||
? h('div', { class: 'error-msg' }, state.error)
|
|
||||||
: lineVnodes.length > 0
|
|
||||||
? h('pre', null, lineVnodes)
|
|
||||||
: h('div', { class: 'text-muted text-sm' }, 'No log lines available')
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|||||||
+14
-33
@@ -1,4 +1,4 @@
|
|||||||
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';
|
import { h, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete } from '/static/hoover/index.js?v=7';
|
||||||
|
|
||||||
const addFwd = QuickModal({
|
const addFwd = QuickModal({
|
||||||
title: 'Add Port Forward',
|
title: 'Add Port Forward',
|
||||||
@@ -11,7 +11,7 @@ const addFwd = QuickModal({
|
|||||||
],
|
],
|
||||||
submit: {
|
submit: {
|
||||||
url: '/api/firewall/forward-port',
|
url: '/api/firewall/forward-port',
|
||||||
body: (s) => ({
|
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(),
|
||||||
@@ -21,43 +21,23 @@ const addFwd = QuickModal({
|
|||||||
validate: (b) => !b.zone || !b.port || !b.proto ? 'Zone, port, and proto are required' : null,
|
validate: (b) => !b.zone || !b.port || !b.proto ? 'Zone, port, and proto are required' : null,
|
||||||
successMsg: 'Forward rule added',
|
successMsg: 'Forward rule added',
|
||||||
},
|
},
|
||||||
reload: (s) => load(s._s),
|
refresh: 'firewall',
|
||||||
});
|
});
|
||||||
|
|
||||||
async function load(state, abortController, entry) {
|
|
||||||
await refactorLoad(state,
|
|
||||||
s => Object.keys(s.config || {}).length,
|
|
||||||
async (s, sig, isAborted) => {
|
|
||||||
const r = await apiFetch('/api/firewall/config', { signal: sig });
|
|
||||||
if (isAborted()) return;
|
|
||||||
if (r.ok) s.config = r.data || {};
|
|
||||||
else s.error = r.error;
|
|
||||||
const zr = await apiFetch('/api/firewall/zones', { signal: sig });
|
|
||||||
if (isAborted()) return;
|
|
||||||
if (zr.ok) s.activeZones = Object.keys(zr.data?.active || {});
|
|
||||||
else if (!s.error) s.error = zr.error;
|
|
||||||
const sr = await apiFetch('/api/firewall/state', { signal: sig });
|
|
||||||
if (isAborted()) return;
|
|
||||||
if (sr.ok) s.stateData = sr.data;
|
|
||||||
},
|
|
||||||
{ entry, abortController },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default definePage({
|
export default definePage({
|
||||||
init() {
|
init() {
|
||||||
return { config: {}, activeZones: [], stateData: null };
|
return {
|
||||||
|
firewall: getModel('firewall'),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
subscribe: ['firewall'],
|
|
||||||
load,
|
|
||||||
render(state) {
|
render(state) {
|
||||||
const guard = renderGuard(state, 'NAT', 'Masquerade & port forwarding', state.config);
|
const guard = renderGuard(state.firewall, 'NAT', 'Masquerade & port forwarding', state.firewall.data?.config);
|
||||||
if (guard) return guard;
|
if (guard) return guard;
|
||||||
|
|
||||||
const cfg = state.config || {};
|
const cfg = state.firewall.data?.config || {};
|
||||||
const zoneData = cfg.zones || {};
|
const zoneData = cfg.zones || {};
|
||||||
|
|
||||||
const sIface = (state.stateData || {}).interfaces || [];
|
const sIface = (state.firewall.data?.state || {}).interfaces || [];
|
||||||
const masqZones = new Set(
|
const masqZones = new Set(
|
||||||
Object.entries(zoneData)
|
Object.entries(zoneData)
|
||||||
.filter(([, zcfg]) => !!zcfg.masquerade)
|
.filter(([, zcfg]) => !!zcfg.masquerade)
|
||||||
@@ -94,7 +74,7 @@ export default definePage({
|
|||||||
labelOn: 'Disable', labelOff: 'Enable', condition: masq,
|
labelOn: 'Disable', labelOff: 'Enable', condition: masq,
|
||||||
body: () => ({ zone, enable: !masq }),
|
body: () => ({ zone, enable: !masq }),
|
||||||
successMsg: 'Masquerade ' + (masq ? 'disabled' : 'enabled') + ' on ' + zone,
|
successMsg: 'Masquerade ' + (masq ? 'disabled' : 'enabled') + ' on ' + zone,
|
||||||
reload: () => load(state),
|
refresh: 'firewall',
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -117,7 +97,7 @@ export default definePage({
|
|||||||
url: '/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto),
|
url: '/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto),
|
||||||
message: 'Remove forward ' + zone + ':' + port + '/' + proto + '?',
|
message: 'Remove forward ' + zone + ':' + port + '/' + proto + '?',
|
||||||
success: 'Rule removed',
|
success: 'Rule removed',
|
||||||
reload: () => load(state),
|
refresh: 'firewall',
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
@@ -148,7 +128,8 @@ export default definePage({
|
|||||||
Card({ children: [
|
Card({ children: [
|
||||||
ActionGroup(
|
ActionGroup(
|
||||||
h('button', { class: 'btn btn-sm btn-primary',
|
h('button', { class: 'btn btn-sm btn-primary',
|
||||||
'on:click': () => addFwd({ zones: state.activeZones, _s: state }) }, 'Add Forward'),
|
'on:click': () => addFwd({ zones: Object.keys(zoneData) })
|
||||||
|
}, 'Add Forward'),
|
||||||
),
|
),
|
||||||
Table({
|
Table({
|
||||||
columns: ['Zone', 'Proto', 'Port', 'To Addr', 'To Port', 'Action'],
|
columns: ['Zone', 'Proto', 'Port', 'To Addr', 'To Port', 'Action'],
|
||||||
@@ -159,4 +140,4 @@ export default definePage({
|
|||||||
]}),
|
]}),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
import { h, PageHeader, definePage } from '/static/hoover/index.js?v=6';
|
import { h, PageHeader, definePage } from '/static/hoover/index.js?v=7';
|
||||||
|
|
||||||
export default definePage({
|
export default definePage({
|
||||||
init() {
|
init() {
|
||||||
return { path: '' };
|
return { path: location.hash.slice(1) || '' };
|
||||||
},
|
|
||||||
subscribe: [],
|
|
||||||
async load(state) {
|
|
||||||
state.path = location.hash.slice(1) || '';
|
|
||||||
},
|
},
|
||||||
render(state) {
|
render(state) {
|
||||||
return [
|
return [
|
||||||
|
|||||||
+15
-30
@@ -1,4 +1,4 @@
|
|||||||
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';
|
import { h, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, certStatusBadge, ActionGroup, QuickModal } from '/static/hoover/index.js?v=7';
|
||||||
|
|
||||||
const addDomain = QuickModal({
|
const addDomain = QuickModal({
|
||||||
title: 'Add Proxy Domain',
|
title: 'Add Proxy Domain',
|
||||||
@@ -21,7 +21,7 @@ const addDomain = QuickModal({
|
|||||||
validate: (b) => !b.domain || !b.backend_host || !b.backend_port ? 'Domain, host, and port are required' : null,
|
validate: (b) => !b.domain || !b.backend_host || !b.backend_port ? 'Domain, host, and port are required' : null,
|
||||||
successMsg: 'Domain added',
|
successMsg: 'Domain added',
|
||||||
},
|
},
|
||||||
reload: (s) => load(s),
|
refresh: ['nginx', 'acme'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const editDomain = QuickModal({
|
const editDomain = QuickModal({
|
||||||
@@ -35,7 +35,7 @@ const editDomain = QuickModal({
|
|||||||
submit: {
|
submit: {
|
||||||
url: (d) => '/api/proxy/domains/' + enc(d.domain),
|
url: (d) => '/api/proxy/domains/' + enc(d.domain),
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: (d) => ({
|
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(),
|
||||||
@@ -44,37 +44,22 @@ const editDomain = QuickModal({
|
|||||||
validate: (b) => !b.backend_host || !b.backend_port ? 'Host and port are required' : null,
|
validate: (b) => !b.backend_host || !b.backend_port ? 'Host and port are required' : null,
|
||||||
successMsg: 'Domain updated',
|
successMsg: 'Domain updated',
|
||||||
},
|
},
|
||||||
reload: (s) => load(s._s),
|
refresh: ['nginx', 'acme'],
|
||||||
});
|
});
|
||||||
|
|
||||||
async function load(state, abortController, entry) {
|
|
||||||
await refactorLoad(state,
|
|
||||||
s => s.domains?.length,
|
|
||||||
async (s, sig, isAborted) => {
|
|
||||||
const domainsR = await apiFetch('/api/proxy/domains', { signal: sig });
|
|
||||||
if (isAborted()) return;
|
|
||||||
if (domainsR.ok) s.domains = domainsR.data || [];
|
|
||||||
else s.error = domainsR.error;
|
|
||||||
const certsR = await apiFetch('/api/certs/list', { signal: sig });
|
|
||||||
if (isAborted()) return;
|
|
||||||
if (certsR.ok) s.certs = certsR.data || [];
|
|
||||||
else if (!s.error) s.error = certsR.error;
|
|
||||||
},
|
|
||||||
{ entry, abortController },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default definePage({
|
export default definePage({
|
||||||
init() {
|
init() {
|
||||||
return { domains: [], certs: [] };
|
return {
|
||||||
|
nginx: getModel('nginx'),
|
||||||
|
acme: getModel('acme'),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
subscribe: ['nginx', 'acme'],
|
|
||||||
load,
|
|
||||||
render(state) {
|
render(state) {
|
||||||
const guard = renderGuard(state, 'Proxy', 'Nginx reverse proxy', state.domains);
|
const guard = renderGuardMulti('Proxy', 'Nginx reverse proxy', state.nginx, state.acme);
|
||||||
if (guard) return guard;
|
if (guard) return guard;
|
||||||
|
|
||||||
const rows = state.domains.map(d => {
|
const domains = state.nginx.data || [];
|
||||||
|
const rows = domains.map(d => {
|
||||||
const certBadge = certStatusBadge({
|
const certBadge = certStatusBadge({
|
||||||
certStatus: d.cert_status,
|
certStatus: d.cert_status,
|
||||||
daysRemaining: d.days_remaining,
|
daysRemaining: d.days_remaining,
|
||||||
@@ -89,11 +74,11 @@ export default definePage({
|
|||||||
h('td', null, certBadge),
|
h('td', null, certBadge),
|
||||||
ActionCell({
|
ActionCell({
|
||||||
editLabel: 'Edit',
|
editLabel: 'Edit',
|
||||||
editClick: () => editDomain({ ...d, _s: state }),
|
editClick: () => editDomain(d),
|
||||||
removeUrl: '/api/proxy/domains/' + enc(d.domain),
|
removeUrl: '/api/proxy/domains/' + enc(d.domain),
|
||||||
removeMessage: 'Remove proxy for ' + d.domain + '?',
|
removeMessage: 'Remove proxy for ' + d.domain + '?',
|
||||||
removeSuccess: 'Domain removed',
|
removeSuccess: 'Domain removed',
|
||||||
removeReload: () => load(state),
|
removeRefresh: ['nginx', 'acme'],
|
||||||
removeLabel: 'Delete',
|
removeLabel: 'Delete',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -105,7 +90,7 @@ export default definePage({
|
|||||||
url: '/api/proxy/apply',
|
url: '/api/proxy/apply',
|
||||||
successMsg: 'Nginx applied & reloaded',
|
successMsg: 'Nginx applied & reloaded',
|
||||||
label: 'Apply',
|
label: 'Apply',
|
||||||
reload: () => load(state),
|
refresh: ['nginx', 'acme'],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -119,4 +104,4 @@ export default definePage({
|
|||||||
: 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.' }),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
+12
-28
@@ -1,4 +1,4 @@
|
|||||||
import { h, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, refactorLoad, MonoText, QuickModal } from '/static/hoover/index.js?v=6';
|
import { h, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, MonoText, QuickModal } from '/static/hoover/index.js?v=7';
|
||||||
|
|
||||||
const addRule = QuickModal({
|
const addRule = QuickModal({
|
||||||
title: 'Add Rich Rule',
|
title: 'Add Rich Rule',
|
||||||
@@ -8,41 +8,25 @@ const addRule = QuickModal({
|
|||||||
],
|
],
|
||||||
submit: {
|
submit: {
|
||||||
url: '/api/firewall/rich-rules',
|
url: '/api/firewall/rich-rules',
|
||||||
body: (s) => ({ zone: $val('rule-zone'), rule: ($val('rule-text') || '').trim() }),
|
body: () => ({ zone: $val('rule-zone'), rule: ($val('rule-text') || '').trim() }),
|
||||||
validate: (b) => !b.zone || !b.rule ? 'Zone and rule are required' : null,
|
validate: (b) => !b.zone || !b.rule ? 'Zone and rule are required' : null,
|
||||||
successMsg: 'Rule added',
|
successMsg: 'Rule added',
|
||||||
},
|
},
|
||||||
reload: (s) => load(s._s),
|
refresh: 'firewall',
|
||||||
});
|
});
|
||||||
|
|
||||||
async function load(state, abortController, entry) {
|
|
||||||
await refactorLoad(state,
|
|
||||||
s => Object.keys(s.config || {}).length,
|
|
||||||
async (s, sig, isAborted) => {
|
|
||||||
const r = await apiFetch('/api/firewall/config', { signal: sig });
|
|
||||||
if (isAborted()) return;
|
|
||||||
if (r.ok) s.config = r.data || {};
|
|
||||||
else s.error = r.error;
|
|
||||||
const zr = await apiFetch('/api/firewall/zones', { signal: sig });
|
|
||||||
if (isAborted()) return;
|
|
||||||
if (zr.ok) s.zones = Object.keys(zr.data?.active || {});
|
|
||||||
else if (!s.error) s.error = zr.error;
|
|
||||||
},
|
|
||||||
{ entry, abortController },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default definePage({
|
export default definePage({
|
||||||
init() {
|
init() {
|
||||||
return { config: {}, zones: [] };
|
return {
|
||||||
|
firewall: getModel('firewall'),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
subscribe: ['firewall'],
|
|
||||||
load,
|
|
||||||
render(state) {
|
render(state) {
|
||||||
const guard = renderGuard(state, 'Rules', 'Firewall rich rules', state.config);
|
const guard = renderGuard(state.firewall, 'Rules', 'Firewall rich rules', state.firewall.data?.config);
|
||||||
if (guard) return guard;
|
if (guard) return guard;
|
||||||
|
|
||||||
const cfg = state.config || {};
|
const cfg = state.firewall.data?.config || {};
|
||||||
|
const zones = state.firewall.data?.zones?.available || [];
|
||||||
const zoneData = cfg.zones || {};
|
const zoneData = cfg.zones || {};
|
||||||
const zoneRules = {};
|
const zoneRules = {};
|
||||||
Object.entries(zoneData).forEach(([zname, zcfg]) => {
|
Object.entries(zoneData).forEach(([zname, zcfg]) => {
|
||||||
@@ -67,7 +51,7 @@ export default definePage({
|
|||||||
url: '/api/firewall/rich-rules/' + enc(zone) + '/' + enc(ruleId || ''),
|
url: '/api/firewall/rich-rules/' + enc(zone) + '/' + enc(ruleId || ''),
|
||||||
message: 'Remove rule: ' + ruleText.substring(0, 40) + '...?',
|
message: 'Remove rule: ' + ruleText.substring(0, 40) + '...?',
|
||||||
success: 'Rule removed',
|
success: 'Rule removed',
|
||||||
reload: () => load(state),
|
refresh: 'firewall',
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -83,9 +67,9 @@ 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': () => addRule({ zones: state.zones, _s: state }), }, 'Add Rule'),
|
'on:click': () => addRule({ zones }), }, 'Add Rule'),
|
||||||
}),
|
}),
|
||||||
...(cards.length ? cards : [Empty({ text: 'No rich rules configured.' })]),
|
...(cards.length ? cards : [Empty({ text: 'No rich rules configured.' })]),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
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';
|
import { h, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionButton, ActionCell, MonoText, ActionGroup, QuickModal, downloadBlob } from '/static/hoover/index.js?v=7';
|
||||||
|
|
||||||
const addPeer = QuickModal({
|
const addPeer = QuickModal({
|
||||||
title: 'Add WireGuard Peer',
|
title: 'Add WireGuard Peer',
|
||||||
@@ -19,7 +19,7 @@ const addPeer = QuickModal({
|
|||||||
validate: (b) => !b.name ? 'Name is required' : null,
|
validate: (b) => !b.name ? 'Name is required' : null,
|
||||||
successMsg: 'Peer added',
|
successMsg: 'Peer added',
|
||||||
},
|
},
|
||||||
reload: (s) => load(s),
|
refresh: 'wireguard',
|
||||||
});
|
});
|
||||||
|
|
||||||
function downloadConfigModal(peerName, config, state) {
|
function downloadConfigModal(peerName, config, state) {
|
||||||
@@ -50,42 +50,21 @@ function downloadConfigModal(peerName, config, state) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load(state, abortController, entry) {
|
|
||||||
await refactorLoad(state,
|
|
||||||
s => s.peers?.length,
|
|
||||||
async (s, sig, isAborted) => {
|
|
||||||
const stR = await apiFetch('/api/wireguard/status', { signal: sig });
|
|
||||||
if (isAborted()) return;
|
|
||||||
if (stR.ok) s.status = stR.data || {};
|
|
||||||
else s.error = stR.error;
|
|
||||||
const pR = await apiFetch('/api/wireguard/peers', { signal: sig });
|
|
||||||
if (isAborted()) return;
|
|
||||||
if (pR.ok) s.peers = pR.data || [];
|
|
||||||
else if (!s.error) s.error = pR.error;
|
|
||||||
const cfgR = await apiFetch('/api/wireguard/config', { signal: sig });
|
|
||||||
if (isAborted()) return;
|
|
||||||
if (cfgR.ok) s.config = cfgR.data || {};
|
|
||||||
else if (!s.error) s.error = cfgR.error;
|
|
||||||
},
|
|
||||||
{ entry, abortController },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default definePage({
|
export default definePage({
|
||||||
init() {
|
init() {
|
||||||
return { status: {}, peers: [], config: {} };
|
return {
|
||||||
|
wireguard: getModel('wireguard'),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
subscribe: ['wireguard'],
|
|
||||||
load,
|
|
||||||
render(state) {
|
render(state) {
|
||||||
const guard = renderGuard(state, 'WireGuard', 'Tunnel: ' + ((state.status || {}).state || 'unknown') + ', Listen: ' + ((state.config?.interface || {}).listen_port || '-'), state.peers);
|
const guard = renderGuard(state.wireguard, 'WireGuard', null, state.wireguard.data?.peers);
|
||||||
if (guard) return guard;
|
if (guard) return guard;
|
||||||
|
|
||||||
const st = state.status || {};
|
const st = state.wireguard.data?.status || {};
|
||||||
const isUp = st.state === 'up';
|
const isUp = st.state === 'up';
|
||||||
const listenPort = (state.config?.interface || {}).listen_port || '-';
|
const listenPort = (state.wireguard.data?.config?.interface || {}).listen_port || '-';
|
||||||
|
|
||||||
const peerRows = state.peers.map(p => {
|
const peerRows = (state.wireguard.data?.peers || []).map(p => {
|
||||||
const hasHandshake = !!p.latest_handshake;
|
const hasHandshake = !!p.latest_handshake;
|
||||||
return h('tr', { key: p.name },
|
return h('tr', { key: p.name },
|
||||||
h('td', null,
|
h('td', null,
|
||||||
@@ -103,11 +82,11 @@ export default definePage({
|
|||||||
),
|
),
|
||||||
ActionCell({
|
ActionCell({
|
||||||
editLabel: 'Config',
|
editLabel: 'Config',
|
||||||
editClick: () => downloadConfigModal(p.name, state.config, state),
|
editClick: () => downloadConfigModal(p.name, state.wireguard.data?.config, state),
|
||||||
removeUrl: '/api/wireguard/peers/' + enc(p.name),
|
removeUrl: '/api/wireguard/peers/' + enc(p.name),
|
||||||
removeMessage: 'Remove peer ' + p.name + '?',
|
removeMessage: 'Remove peer ' + p.name + '?',
|
||||||
removeSuccess: 'Peer removed',
|
removeSuccess: 'Peer removed',
|
||||||
removeReload: () => load(state),
|
removeRefresh: 'wireguard',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -118,13 +97,13 @@ export default definePage({
|
|||||||
url: '/api/wireguard/' + (isUp ? 'down' : 'up'),
|
url: '/api/wireguard/' + (isUp ? 'down' : 'up'),
|
||||||
labelOn: 'Stop', labelOff: 'Start', condition: isUp,
|
labelOn: 'Stop', labelOff: 'Start', condition: isUp,
|
||||||
successMsg: 'Tunnel ' + (isUp ? 'stopped' : 'started'),
|
successMsg: 'Tunnel ' + (isUp ? 'stopped' : 'started'),
|
||||||
reload: () => load(state),
|
refresh: 'wireguard',
|
||||||
}),
|
}),
|
||||||
ActionButton({
|
ActionButton({
|
||||||
url: '/api/wireguard/apply',
|
url: '/api/wireguard/apply',
|
||||||
successMsg: 'Config applied',
|
successMsg: 'Config applied',
|
||||||
label: 'Apply',
|
label: 'Apply',
|
||||||
reload: () => load(state),
|
refresh: 'wireguard',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -143,4 +122,4 @@ export default definePage({
|
|||||||
: Empty({ text: 'No peers configured. Add a peer above.' }),
|
: Empty({ text: 'No peers configured. Add a peer above.' }),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
+22
-69
@@ -1,4 +1,4 @@
|
|||||||
import { h, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, apiFetch, toast, definePage, refactorLoad, MultiSelectModal, QuickModal } from '/static/hoover/index.js?v=6';
|
import { h, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, definePage, getModel, MultiSelectModal, QuickModal } from '/static/hoover/index.js?v=7';
|
||||||
|
|
||||||
const addZone = QuickModal({
|
const addZone = QuickModal({
|
||||||
title: 'Add Zone',
|
title: 'Add Zone',
|
||||||
@@ -12,75 +12,28 @@ const addZone = QuickModal({
|
|||||||
validate: (b) => !b.name ? 'Zone name required' : null,
|
validate: (b) => !b.name ? 'Zone name required' : null,
|
||||||
successMsg: 'Zone created',
|
successMsg: 'Zone created',
|
||||||
},
|
},
|
||||||
reload: (s) => load(s),
|
refresh: 'firewall',
|
||||||
});
|
});
|
||||||
|
|
||||||
async function load(state, abortController, entry) {
|
|
||||||
await refactorLoad(state,
|
|
||||||
s => Object.keys(s.zones || {}).length,
|
|
||||||
async (s, sig, isAborted) => {
|
|
||||||
const [zRes, svcRes, ifRes] = await Promise.allSettled([
|
|
||||||
apiFetch('/api/firewall/zones', { signal: sig }),
|
|
||||||
apiFetch('/api/firewall/services', { signal: sig }),
|
|
||||||
apiFetch('/api/firewall/interfaces', { signal: sig }),
|
|
||||||
]);
|
|
||||||
if (isAborted()) 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = zRes.value.data || {};
|
|
||||||
const activeZones = data.active || {};
|
|
||||||
const availableZones = data.available || [];
|
|
||||||
|
|
||||||
const detailPromises = availableZones.map(name =>
|
|
||||||
apiFetch('/api/firewall/zones/' + enc(name), { signal: sig })
|
|
||||||
);
|
|
||||||
const detailResults = await Promise.allSettled(detailPromises);
|
|
||||||
|
|
||||||
if (isAborted()) return;
|
|
||||||
|
|
||||||
const zones = {};
|
|
||||||
for (let i = 0; i < availableZones.length; i++) {
|
|
||||||
const name = availableZones[i];
|
|
||||||
const res = detailResults[i];
|
|
||||||
const detail = res.status === 'fulfilled' ? res.value : null;
|
|
||||||
if (detail && detail.ok) {
|
|
||||||
zones[name] = detail.data;
|
|
||||||
const activeIfaces = activeZones[name];
|
|
||||||
if (Array.isArray(activeIfaces)) {
|
|
||||||
zones[name].interfaces = activeIfaces;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.zones = zones;
|
|
||||||
s.services = svcRes.value.data || [];
|
|
||||||
s.interfaces = ifRes.value.data || [];
|
|
||||||
},
|
|
||||||
{ entry, abortController },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default definePage({
|
export default definePage({
|
||||||
init() {
|
init() {
|
||||||
return { zones: {}, services: [], interfaces: [] };
|
return {
|
||||||
|
firewall: getModel('firewall'),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
subscribe: ['firewall'],
|
|
||||||
load,
|
|
||||||
render(state) {
|
render(state) {
|
||||||
const guard = renderGuard(state, 'Zones', 'Firewall zone management', Object.keys(state.zones || {}).length);
|
const guard = renderGuard(state.firewall, 'Zones', 'Firewall zones', state.firewall.data?.zones);
|
||||||
if (guard) return guard;
|
if (guard) return guard;
|
||||||
|
|
||||||
const zoneCards = Object.entries(state.zones || []).map(([name, zdata]) => {
|
const zones = state.firewall.data?.zones?.available || [];
|
||||||
|
const activeZones = state.firewall.data?.zones?.active || {};
|
||||||
|
const zoneDetails = {};
|
||||||
|
for (const name of zones) {
|
||||||
|
const activeIfaces = activeZones[name];
|
||||||
|
zoneDetails[name] = { interfaces: Array.isArray(activeIfaces) ? activeIfaces : [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoneCards = Object.entries(zoneDetails).map(([name, zdata]) => {
|
||||||
const z = typeof zdata === 'object' ? zdata : {};
|
const z = typeof zdata === 'object' ? zdata : {};
|
||||||
const ifacesArr = Array.isArray(z.interfaces) ? z.interfaces : [];
|
const ifacesArr = Array.isArray(z.interfaces) ? z.interfaces : [];
|
||||||
const svcsArr = Array.isArray(z.services) ? z.services : [];
|
const svcsArr = Array.isArray(z.services) ? z.services : [];
|
||||||
@@ -110,29 +63,29 @@ export default definePage({
|
|||||||
'on:click': () => MultiSelectModal({
|
'on:click': () => MultiSelectModal({
|
||||||
title: 'Interfaces: ' + name,
|
title: 'Interfaces: ' + name,
|
||||||
url: '/api/firewall/zones/' + enc(name) + '/interfaces',
|
url: '/api/firewall/zones/' + enc(name) + '/interfaces',
|
||||||
options: state.interfaces,
|
options: state.firewall.data?.interfaces || [],
|
||||||
selected: ifacesArr,
|
selected: ifacesArr,
|
||||||
fieldKey: 'interfaces',
|
fieldKey: 'interfaces',
|
||||||
successMsg: 'Interfaces updated',
|
successMsg: 'Interfaces updated',
|
||||||
reload: () => load(state),
|
refresh: 'firewall',
|
||||||
})(),
|
})(),
|
||||||
}, 'Interfaces'),
|
}, 'Interfaces'),
|
||||||
h('button', { class: 'btn btn-sm btn-outline',
|
h('button', { class: 'btn btn-sm btn-outline',
|
||||||
'on:click': () => MultiSelectModal({
|
'on:click': () => MultiSelectModal({
|
||||||
title: 'Services: ' + name,
|
title: 'Services: ' + name,
|
||||||
url: '/api/firewall/zones/' + enc(name) + '/services',
|
url: '/api/firewall/zones/' + enc(name) + '/services',
|
||||||
options: state.services,
|
options: state.firewall.data?.services || [],
|
||||||
selected: svcsArr,
|
selected: svcsArr,
|
||||||
fieldKey: 'services',
|
fieldKey: 'services',
|
||||||
successMsg: 'Services updated',
|
successMsg: 'Services updated',
|
||||||
reload: () => load(state),
|
refresh: 'firewall',
|
||||||
})(),
|
})(),
|
||||||
}, 'Services'),
|
}, 'Services'),
|
||||||
ConfirmDelete({
|
ConfirmDelete({
|
||||||
url: '/api/firewall/zones/' + enc(name),
|
url: '/api/firewall/zones/' + enc(name),
|
||||||
message: 'Delete zone ' + name + '?',
|
message: 'Delete zone ' + name + '?',
|
||||||
success: 'Zone ' + name + ' deleted',
|
success: 'Zone ' + name + ' deleted',
|
||||||
reload: () => load(state),
|
refresh: 'firewall',
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -144,11 +97,11 @@ 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': () => addZone(state), }, 'Add Zone'),
|
'on:click': () => addZone(), }, 'Add Zone'),
|
||||||
}),
|
}),
|
||||||
zoneCards.length
|
zoneCards.length
|
||||||
? h('div', { class: 'card-grid' }, ...zoneCards)
|
? h('div', { class: 'card-grid' }, ...zoneCards)
|
||||||
: Empty({ text: 'No zones configured. Add a zone to get started.' }),
|
: Empty({ text: 'No zones configured. Add a zone to get started.' }),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user