refactor: replace Jinja templates with static frontend pages
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js';
|
||||
|
||||
function issueCertModal(state) {
|
||||
openModal((inner, idx) => {
|
||||
formModal(inner, 'Issue Certificate',
|
||||
[
|
||||
{ label: 'Domain', id: 'ic-domain', placeholder: 'example.com' },
|
||||
{ label: 'Email (optional)', id: 'ic-email', placeholder: 'you@example.com' },
|
||||
],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Issue', cls: 'btn-primary', action: 's', handler: async () => {
|
||||
const domain = ($val('ic-domain') || '').trim();
|
||||
if (!domain) { toast('Domain is required', 'error'); return; }
|
||||
const body = {
|
||||
domain,
|
||||
email: ($val('ic-email') || '').trim() || undefined,
|
||||
};
|
||||
const resp = await apiFetch('/api/certs/issue/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (resp.ok) {
|
||||
toast('Issuance started for ' + domain, 'success');
|
||||
closeModal(idx);
|
||||
const rid = resp.data?.request_id;
|
||||
if (rid) pollCertIssue(rid, state);
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function pollCertIssue(rid, state) {
|
||||
let done = false;
|
||||
const timer = setInterval(async () => {
|
||||
if (done) return clearInterval(timer);
|
||||
const r = await apiFetch('/api/certs/issue/' + enc(rid));
|
||||
if (r.ok && r.data) {
|
||||
if (r.data.status === 'completed') {
|
||||
done = true;
|
||||
clearInterval(timer);
|
||||
toast('Certificate issued for ' + (r.data.domain || rid), 'success');
|
||||
await load(state);
|
||||
} else if (r.data.status === 'failed') {
|
||||
done = true;
|
||||
clearInterval(timer);
|
||||
toast('Issuance failed: ' + (r.data.error || 'unknown'), 'error');
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
async function load(state) {
|
||||
try {
|
||||
const r = await apiFetch('/api/certs/list');
|
||||
if (r.ok) state.certs = r.data || [];
|
||||
else state.error = r.error;
|
||||
} catch (e) {
|
||||
state.error = String(e);
|
||||
}
|
||||
state.loading = false;
|
||||
}
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return { certs: [], loading: true, error: null };
|
||||
},
|
||||
subscribe: ['acme'],
|
||||
load,
|
||||
render(state) {
|
||||
if (state.loading) {
|
||||
return [
|
||||
PageHeader({ title: 'Certificates' }),
|
||||
h('div', { class: 'card', key: 'loading' },
|
||||
h('div', { class: 'card-body loading' }, 'Loading...'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return [
|
||||
PageHeader({ title: 'Certificates' }),
|
||||
h('div', { class: 'card', key: 'error' },
|
||||
h('div', { class: 'card-body error-msg' }, state.error),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const rows = state.certs.map(c => {
|
||||
const days = c.days_remaining;
|
||||
let badge;
|
||||
if (c.expired || (days !== undefined && days <= 0)) {
|
||||
badge = Badge({ text: 'Expired', variant: 'danger' });
|
||||
} else if (days !== undefined && days <= 30) {
|
||||
badge = Badge({ text: days + 'd left', variant: 'warning' });
|
||||
} else {
|
||||
badge = Badge({ text: days !== undefined ? days + 'd left' : 'N/A', variant: 'success' });
|
||||
}
|
||||
|
||||
return h('tr', { key: c.domain },
|
||||
h('td', null, h('strong', null, esc(c.domain || 'unknown'))),
|
||||
h('td', { class: 'text-sm' }, esc(c.issuer || '-')),
|
||||
h('td', null, esc(c.expiry || 'N/A')),
|
||||
h('td', null, badge),
|
||||
h('td', null,
|
||||
h('button', { class: 'btn btn-sm btn-outline', style: 'margin-right:4px;',
|
||||
'on:click': async () => {
|
||||
const resp = await apiFetch('/api/certs/' + enc(c.domain) + '/renew', { method: 'POST' });
|
||||
if (resp.ok) toast('Renewal started for ' + c.domain, 'success');
|
||||
else toast(resp.error || 'Failed', 'error');
|
||||
}}, 'Renew'),
|
||||
h('button', { class: 'btn btn-sm btn-danger',
|
||||
'on:click': async () => {
|
||||
if (!confirm('Remove certificate for ' + c.domain + '?')) return;
|
||||
const resp = await apiFetch('/api/certs/' + enc(c.domain), { method: 'DELETE' });
|
||||
if (resp.ok) {
|
||||
toast('Certificate removed', 'success');
|
||||
await load(state);
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
}}, 'Remove'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return [
|
||||
PageHeader({
|
||||
title: 'Certificates',
|
||||
subtitle: 'ACME certificate management',
|
||||
actions: h('button', { class: 'btn btn-primary',
|
||||
'on:click': () => issueCertModal(state) }, 'Issue Certificate'),
|
||||
}),
|
||||
rows.length
|
||||
? h('div', { class: 'card' }, h('table', { class: 'table' },
|
||||
h('thead', null,
|
||||
h('tr', null,
|
||||
h('th', null, 'Domain'),
|
||||
h('th', null, 'Issuer'),
|
||||
h('th', null, 'Expiry'),
|
||||
h('th', null, 'Status'),
|
||||
h('th', { style: 'width:120px;' }, 'Actions'),
|
||||
),
|
||||
),
|
||||
h('tbody', null, ...rows),
|
||||
))
|
||||
: Empty({ text: 'No certificates found. Issue a certificate to get started.' }),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js';
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return { data: null, loading: true, error: null };
|
||||
},
|
||||
subscribe: ['*'],
|
||||
async load(state) {
|
||||
try {
|
||||
const res = await apiFetch('/api/status/all');
|
||||
if (res.ok) state.data = res.data;
|
||||
else state.error = res.error;
|
||||
} catch (e) {
|
||||
state.error = String(e);
|
||||
}
|
||||
state.loading = false;
|
||||
},
|
||||
render(state) {
|
||||
if (state.loading) {
|
||||
return [
|
||||
PageHeader({ title: 'Dashboard', subtitle: 'System overview' }),
|
||||
h('div', { class: 'card', key: 'loading' },
|
||||
h('div', { class: 'card-body loading' }, 'Loading...'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return [
|
||||
PageHeader({ title: 'Dashboard', subtitle: 'System overview' }),
|
||||
h('div', { class: 'card', key: 'error' },
|
||||
h('div', { class: 'card-body error-msg' }, state.error),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const d = state.data;
|
||||
if (!d) {
|
||||
return [
|
||||
PageHeader({ title: 'Dashboard', subtitle: 'System overview' }),
|
||||
h('div', { class: 'card', key: 'no-data' },
|
||||
h('div', { class: 'card-body loading' }, 'No data available'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const fwZones = (d.firewall?.zones) || {};
|
||||
const net = d.net || {};
|
||||
const nCount = Object.keys(net).length;
|
||||
const upI = Object.values(net).filter(i => i.state === 'up');
|
||||
const upC = upI.length;
|
||||
const certs = d.certs || [];
|
||||
const certW = certs.filter(c => c.expired || c.days_remaining <= 30);
|
||||
const dmsk = d.dnsmasq?.status || {};
|
||||
const dmskUp = dmsk.state === 'up';
|
||||
const wUp = (d.wg?.state || 'down') === 'up';
|
||||
const wP = (d.wg || {}).peers || [];
|
||||
|
||||
return [
|
||||
PageHeader({ title: 'Dashboard', subtitle: 'System overview' }),
|
||||
h('div', { class: 'grid grid-4' },
|
||||
h('div', { class: 'stat-card' },
|
||||
h('div', { class: 'label' }, 'Active Zones'),
|
||||
h('div', { class: 'value' }, Object.keys(fwZones).length),
|
||||
h('div', { class: 'meta' }, Object.keys(fwZones).join(', ') || 'None'),
|
||||
),
|
||||
h('div', { class: 'stat-card' },
|
||||
h('div', { class: 'label' }, 'Interfaces Up'),
|
||||
h('div', { class: 'value' }, upC + '/' + nCount),
|
||||
h('div', { class: 'meta' }, upI.map(i => i.name).join(', ') || 'None up'),
|
||||
),
|
||||
h('div', { class: 'stat-card' },
|
||||
h('div', { class: 'label' }, 'WireGuard'),
|
||||
h('div', { class: 'value' }, String(d.wg?.state || 'unknown')),
|
||||
h('div', { class: 'meta' }, wP.length + ' peers'),
|
||||
),
|
||||
h('div', { class: 'stat-card' },
|
||||
h('div', { class: 'label' }, 'Certificates'),
|
||||
h('div', { class: 'value' }, certs.length),
|
||||
h('div', { class: 'meta' }, certW.length + ' expiring/expired'),
|
||||
),
|
||||
),
|
||||
h('div', { class: 'grid grid-2' },
|
||||
h('div', { class: 'card' },
|
||||
h('div', { class: 'card-header' }, 'Services'),
|
||||
h('div', { class: 'card-body' },
|
||||
h('ul', { class: 'service-list' },
|
||||
h('li', null,
|
||||
StatusDot({ status: dmskUp ? 'success' : 'danger' }),
|
||||
' Dnsmasq ',
|
||||
Badge({ text: dmsk.state || 'down', variant: dmskUp ? 'success' : 'danger' }),
|
||||
),
|
||||
h('li', null,
|
||||
StatusDot({ status: wUp ? 'success' : 'danger' }),
|
||||
' WireGuard ',
|
||||
Badge({ text: String(d.wg?.state || 'down'), variant: wUp ? 'success' : 'danger' }),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,326 @@
|
||||
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js';
|
||||
|
||||
function addRangeModal(state) {
|
||||
openModal((inner, idx) => {
|
||||
formModal(inner, 'Add DHCP Range',
|
||||
[
|
||||
{ label: 'Interface (optional)', id: 'r-iface', placeholder: 'Leave empty for global' },
|
||||
{ label: 'Start IP', id: 'r-start', placeholder: '192.168.1.100' },
|
||||
{ label: 'End IP', id: 'r-end', placeholder: '192.168.1.200' },
|
||||
{ label: 'Lease Time', id: 'r-lease', placeholder: '12h' },
|
||||
],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Add', cls: 'btn-primary', action: 's', handler: async () => {
|
||||
const body = {
|
||||
interface: ($val('r-iface') || '').trim() || undefined,
|
||||
start: ($val('r-start') || '').trim(),
|
||||
end: ($val('r-end') || '').trim(),
|
||||
lease_time: ($val('r-lease') || '').trim() || '12h',
|
||||
};
|
||||
if (!body.start || !body.end) {
|
||||
toast('Start and end are required', 'error');
|
||||
return;
|
||||
}
|
||||
const resp = await apiFetch('/api/dhcp/ranges', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (resp.ok) {
|
||||
toast('Range added', 'success');
|
||||
closeModal(idx);
|
||||
await load(state);
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function addLeaseModal(state) {
|
||||
openModal((inner, idx) => {
|
||||
formModal(inner, 'Add Static Lease',
|
||||
[
|
||||
{ label: 'MAC', id: 'l-mac', placeholder: 'aa:bb:cc:dd:ee:ff' },
|
||||
{ label: 'IP', id: 'l-ip', placeholder: '192.168.1.50' },
|
||||
{ label: 'Hostname (optional)', id: 'l-host', placeholder: 'device-name' },
|
||||
],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Add', cls: 'btn-primary', action: 's', handler: async () => {
|
||||
const body = {
|
||||
mac: ($val('l-mac') || '').trim(),
|
||||
ip: ($val('l-ip') || '').trim(),
|
||||
hostname: ($val('l-host') || '').trim() || undefined,
|
||||
};
|
||||
if (!body.mac || !body.ip) {
|
||||
toast('MAC and IP are required', 'error');
|
||||
return;
|
||||
}
|
||||
const resp = await apiFetch('/api/dhcp/static-lease', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (resp.ok) {
|
||||
toast('Lease added', 'success');
|
||||
closeModal(idx);
|
||||
await load(state);
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function addDnsModal(state) {
|
||||
openModal((inner, idx) => {
|
||||
formModal(inner, 'Add DNS Record',
|
||||
[
|
||||
{ label: 'Name', id: 'd-name', placeholder: 'host.local' },
|
||||
{ label: 'Address', id: 'd-addr', placeholder: '192.168.1.10' },
|
||||
],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Add', cls: 'btn-primary', action: 's', handler: async () => {
|
||||
const body = {
|
||||
name: ($val('d-name') || '').trim(),
|
||||
address: ($val('d-addr') || '').trim(),
|
||||
};
|
||||
if (!body.name || !body.address) {
|
||||
toast('Name and address are required', 'error');
|
||||
return;
|
||||
}
|
||||
const resp = await apiFetch('/api/dhcp/dns-record', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (resp.ok) {
|
||||
toast('DNS record added', 'success');
|
||||
closeModal(idx);
|
||||
await load(state);
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function load(state) {
|
||||
try {
|
||||
const cfgR = await apiFetch('/api/dhcp/config');
|
||||
if (cfgR.ok) state.config = cfgR.data || {};
|
||||
const stR = await apiFetch('/api/dhcp/status');
|
||||
if (stR.ok) state.status = stR.data || {};
|
||||
const lsR = await apiFetch('/api/dhcp/leases');
|
||||
if (lsR.ok) state.leases = lsR.data || [];
|
||||
} catch (e) {
|
||||
state.error = String(e);
|
||||
}
|
||||
state.loading = false;
|
||||
}
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return { config: {}, status: {}, leases: [], loading: true, error: null, activeTab: 'ranges' };
|
||||
},
|
||||
subscribe: ['dnsmasq'],
|
||||
load,
|
||||
render(state) {
|
||||
if (state.loading) {
|
||||
return [
|
||||
PageHeader({ title: 'DHCP & DNS' }),
|
||||
h('div', { class: 'card', key: 'loading' },
|
||||
h('div', { class: 'card-body loading' }, 'Loading...'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return [
|
||||
PageHeader({ title: 'DHCP & DNS' }),
|
||||
h('div', { class: 'card', key: 'error' },
|
||||
h('div', { class: 'card-body error-msg' }, state.error),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const cfg = state.config || {};
|
||||
const ranges = cfg.ranges || [];
|
||||
const staticLeases = cfg.static_leases || [];
|
||||
const dnsRecords = cfg.dns_records || [];
|
||||
const statusUp = state.status || {};
|
||||
const isUp = statusUp.state === 'up';
|
||||
|
||||
const rangesRows = ranges.map((r, i) => h('tr', { key: i },
|
||||
h('td', null, r.interface || '(global)'),
|
||||
h('td', null, esc(r.start)),
|
||||
h('td', null, esc(r.end)),
|
||||
h('td', null, esc(r.lease_time || '12h')),
|
||||
h('td', null,
|
||||
h('button', { class: 'btn btn-sm btn-danger',
|
||||
'on:click': async () => {
|
||||
if (!confirm('Remove range ' + r.start + ' - ' + r.end + '?')) return;
|
||||
const resp = await apiFetch('/api/dhcp/ranges', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ interface: r.interface || '', start: r.start, end: r.end }),
|
||||
});
|
||||
if (resp.ok) {
|
||||
toast('Range removed', 'success');
|
||||
await load(state);
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
}}, 'Remove'),
|
||||
),
|
||||
));
|
||||
|
||||
const leaseRows = staticLeases.map((l, i) => h('tr', { key: i },
|
||||
h('td', null, esc(l.mac)),
|
||||
h('td', null, esc(l.ip)),
|
||||
h('td', null, l.hostname || '-'),
|
||||
h('td', null,
|
||||
h('button', { class: 'btn btn-sm btn-danger',
|
||||
'on:click': async () => {
|
||||
if (!confirm('Remove lease ' + l.mac + '?')) return;
|
||||
const resp = await apiFetch('/api/dhcp/static-lease/' + enc(l.mac), { method: 'DELETE' });
|
||||
if (resp.ok) {
|
||||
toast('Lease removed', 'success');
|
||||
await load(state);
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
}}, 'Remove'),
|
||||
),
|
||||
));
|
||||
|
||||
const dnsRows = dnsRecords.map((rec, i) => h('tr', { key: i },
|
||||
h('td', null, h('strong', null, esc(rec.name || 'unnamed'))),
|
||||
h('td', { class: 'text-sm' }, esc(rec.address || '-')),
|
||||
h('td', null,
|
||||
h('button', { class: 'btn btn-sm btn-danger',
|
||||
'on:click': async () => {
|
||||
if (!confirm('Remove DNS record ' + (rec.name || 'unnamed') + '?')) return;
|
||||
const resp = await apiFetch('/api/dhcp/dns-record/' + enc(rec.name || ''), { method: 'DELETE' });
|
||||
if (resp.ok) {
|
||||
toast('Record removed', 'success');
|
||||
await load(state);
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
}}, 'Remove'),
|
||||
),
|
||||
));
|
||||
|
||||
const tabNames = ['ranges', 'leases', 'dns', 'active'];
|
||||
const actions = h('div', { style: 'display:flex;gap:8px;' },
|
||||
h('button', { class: 'btn btn-primary', 'on:click': () => addRangeModal(state) }, 'Add Range'),
|
||||
h('button', { class: 'btn btn-outline', 'on:click': () => addLeaseModal(state) }, 'Static Lease'),
|
||||
h('button', { class: 'btn btn-outline', 'on:click': () => addDnsModal(state) }, 'DNS Record'),
|
||||
h('button', { class: 'btn btn-outline',
|
||||
'on:click': async () => {
|
||||
const resp = await apiFetch('/api/dhcp/apply', { method: 'POST' });
|
||||
if (resp.ok) toast('dnsmasq applied', 'success');
|
||||
else toast(resp.error || 'Failed', 'error');
|
||||
}}, 'Apply'),
|
||||
);
|
||||
|
||||
return [
|
||||
PageHeader({ title: 'DHCP & DNS', subtitle: 'Dnsmasq management', actions }),
|
||||
h('div', null,
|
||||
StatusDot({ status: isUp ? 'success' : 'danger' }),
|
||||
' Dnsmasq ',
|
||||
Badge({ text: statusUp.state || 'unknown', variant: isUp ? 'success' : 'danger' }),
|
||||
),
|
||||
h('div', { class: 'tabs' },
|
||||
tabNames.map(t => h('span', {
|
||||
class: 'tab ' + (state.activeTab === t ? 'active' : ''),
|
||||
'on:click': () => { state.activeTab = t; },
|
||||
style: 'cursor:pointer;',
|
||||
}, t.charAt(0).toUpperCase() + t.slice(1))),
|
||||
),
|
||||
state.activeTab === 'ranges'
|
||||
? h('div', { class: 'card' }, h('table', { class: 'table' },
|
||||
h('thead', null,
|
||||
h('tr', null,
|
||||
h('th', null, 'Interface'),
|
||||
h('th', null, 'Start'),
|
||||
h('th', null, 'End'),
|
||||
h('th', null, 'Lease'),
|
||||
h('th', { style: 'width:80px;' }, 'Action'),
|
||||
),
|
||||
),
|
||||
h('tbody', null,
|
||||
...(rangesRows.length ? rangesRows : [
|
||||
h('tr', null, h('td', { colspan: 5, class: 'text-muted text-sm' }, 'No DHCP ranges')),
|
||||
]),
|
||||
),
|
||||
)) : null,
|
||||
state.activeTab === 'leases'
|
||||
? h('div', { class: 'card' }, h('table', { class: 'table' },
|
||||
h('thead', null,
|
||||
h('tr', null,
|
||||
h('th', null, 'MAC'),
|
||||
h('th', null, 'IP'),
|
||||
h('th', null, 'Hostname'),
|
||||
h('th', { style: 'width:80px;' }, 'Action'),
|
||||
),
|
||||
),
|
||||
h('tbody', null,
|
||||
...(leaseRows.length ? leaseRows : [
|
||||
h('tr', null, h('td', { colspan: 4, class: 'text-muted text-sm' }, 'No static leases')),
|
||||
]),
|
||||
),
|
||||
)) : null,
|
||||
state.activeTab === 'dns'
|
||||
? h('div', { class: 'card' }, h('table', { class: 'table' },
|
||||
h('thead', null,
|
||||
h('tr', null,
|
||||
h('th', null, 'Name'),
|
||||
h('th', null, 'Address'),
|
||||
h('th', { style: 'width:80px;' }, 'Action'),
|
||||
),
|
||||
),
|
||||
h('tbody', null,
|
||||
...(dnsRows.length ? dnsRows : [
|
||||
h('tr', null, h('td', { colspan: 3, class: 'text-muted text-sm' }, 'No custom DNS records')),
|
||||
]),
|
||||
),
|
||||
)) : null,
|
||||
state.activeTab === 'active'
|
||||
? h('div', { class: 'card' }, h('table', { class: 'table' },
|
||||
h('thead', null,
|
||||
h('tr', null,
|
||||
h('th', null, 'MAC'),
|
||||
h('th', null, 'IP'),
|
||||
h('th', null, 'Hostname'),
|
||||
h('th', null, 'Expires'),
|
||||
),
|
||||
),
|
||||
h('tbody', null,
|
||||
(state.leases || []).map((l, i) => h('tr', { key: i },
|
||||
h('td', null, esc(l.mac || '-')),
|
||||
h('td', null, esc(l.ip || '-')),
|
||||
h('td', null, esc(l.hostname || '-')),
|
||||
h('td', null, esc(l.expires || '-')),
|
||||
)),
|
||||
),
|
||||
)) : null,
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js';
|
||||
|
||||
async function changeZone(name, zone, state) {
|
||||
const r = await apiFetch('/api/firewall/zones/' + enc(zone) + '/interfaces', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ interfaces: [name] }),
|
||||
});
|
||||
if (r.ok) {
|
||||
toast(name + ' \u2192 ' + zone, 'success');
|
||||
await load(state);
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function cfgModal(name, state) {
|
||||
openModal((inner, idx) => {
|
||||
formModal(inner, 'Config: ' + name,
|
||||
[
|
||||
{ label: 'Addresses (comma-separated)', id: 'cfg-addrs', placeholder: '192.168.1.1/24' },
|
||||
{ label: 'Gateway', id: 'cfg-gw' },
|
||||
{ label: 'DNS (comma-separated)', id: 'cfg-dns', placeholder: '1.1.1.1, 8.8.8.8' },
|
||||
],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Save', cls: 'btn-primary', action: 's', handler: async () => {
|
||||
const body = {
|
||||
addresses: ($val('cfg-addrs') || '').split(',').map(s => s.trim()).filter(Boolean),
|
||||
gateway: ($val('cfg-gw') || '').trim() || undefined,
|
||||
dns: ($val('cfg-dns') || '').split(',').map(s => s.trim()).filter(Boolean),
|
||||
};
|
||||
const r = await apiFetch('/api/network/interfaces/' + enc(name), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (r.ok) {
|
||||
toast('Config saved', 'success');
|
||||
closeModal(idx);
|
||||
await load(state);
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function load(state) {
|
||||
try {
|
||||
const [fw, net] = await Promise.all([
|
||||
apiFetch('/api/firewall/zones'),
|
||||
apiFetch('/api/network/interfaces'),
|
||||
]);
|
||||
// Extract zone names from available zones (for the dropdown)
|
||||
state.zones = fw.ok ? (fw.data?.available || []) : [];
|
||||
if (net.ok) {
|
||||
// Build reverse zone map: interface name → zone name, from active zones
|
||||
const ifaceZone = {};
|
||||
for (const [zoneName, ifaces] of Object.entries(fw.data?.active || {})) {
|
||||
for (const name of (ifaces || [])) ifaceZone[name] = zoneName;
|
||||
}
|
||||
// Transform { interfaces: { name: { config, runtime } }, timestamp }
|
||||
// → array of { name, mac, ips, state, zone }
|
||||
const ifacesObj = net.data?.interfaces || {};
|
||||
state.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,
|
||||
}));
|
||||
} else {
|
||||
state.error = net.error;
|
||||
}
|
||||
} catch (e) {
|
||||
state.error = String(e);
|
||||
}
|
||||
state.loading = false;
|
||||
}
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return { ifaces: [], zones: [], loading: true, error: null };
|
||||
},
|
||||
subscribe: ['firewall', 'networkd'],
|
||||
load,
|
||||
render(state) {
|
||||
if (state.loading) {
|
||||
return [
|
||||
PageHeader({ title: 'Interfaces' }),
|
||||
h('div', { class: 'card', key: 'loading' },
|
||||
h('div', { class: 'card-body loading' }, 'Loading...'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return [
|
||||
PageHeader({ title: 'Interfaces' }),
|
||||
h('div', { class: 'card', key: 'error' },
|
||||
h('div', { class: 'card-body error-msg' }, state.error),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const rows = state.ifaces.map(iface => {
|
||||
return h('tr', { key: iface.name },
|
||||
h('td', null, h('strong', null, iface.name)),
|
||||
h('td', { class: 'text-muted' }, String(iface.mac || 'N/A')),
|
||||
h('td', null, (iface.ips || []).join(', ') || 'N/A'),
|
||||
h('td', null,
|
||||
StatusDot({ status: iface.state }),
|
||||
' ' + (iface.state === 'up' ? 'Up' : 'Down'),
|
||||
),
|
||||
h('td', null,
|
||||
h('select', {
|
||||
'on:change': (e) => changeZone(iface.name, e.target.value, state),
|
||||
}, state.zones.map(z =>
|
||||
h('option', { value: z, selected: z === iface.zone }, z),
|
||||
)),
|
||||
h('button', {
|
||||
class: 'btn btn-sm btn-outline',
|
||||
style: 'margin-left:8px',
|
||||
'on:click': () => cfgModal(iface.name, state),
|
||||
}, 'Config'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return [
|
||||
PageHeader({ title: 'Interfaces', subtitle: 'Network interface management' }),
|
||||
h('div', { class: 'card' },
|
||||
h('table', { class: 'table' },
|
||||
h('thead', null,
|
||||
h('tr', null,
|
||||
h('th', null, 'Name'),
|
||||
h('th', null, 'MAC'),
|
||||
h('th', null, 'IPs'),
|
||||
h('th', null, 'State'),
|
||||
h('th', null, 'Zone / Actions'),
|
||||
),
|
||||
),
|
||||
h('tbody', null,
|
||||
...(rows.length ? rows : [
|
||||
h('tr', null,
|
||||
h('td', { colspan: 5, class: 'text-muted text-sm' }, 'No interfaces found'),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js';
|
||||
|
||||
const logTabs = [
|
||||
{ key: 'journal', label: 'Journal', url: '/api/logs/journal' },
|
||||
{ key: 'nginx-access', label: 'Nginx Access', url: '/api/logs/nginx/access' },
|
||||
{ key: 'nginx-error', label: 'Nginx Error', url: '/api/logs/nginx/error' },
|
||||
{ key: 'dnsmasq', label: 'Dnsmasq', url: '/api/logs/dnsmasq' },
|
||||
{ key: 'app', label: 'App', url: '/api/logs/app' },
|
||||
];
|
||||
|
||||
|
||||
async function fetchLog(state, url) {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const text = await res.text();
|
||||
state.lines = text.split('\n').filter(l => l.length > 0);
|
||||
} catch (e) {
|
||||
state.error = String(e);
|
||||
}
|
||||
state.loading = false;
|
||||
}
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return { activeTab: 'journal', lines: [], loading: false, error: null };
|
||||
},
|
||||
subscribe: [],
|
||||
async load(state) {
|
||||
const tab = logTabs.find(t => t.key === state.activeTab) || logTabs[0];
|
||||
await fetchLog(state, tab.url);
|
||||
},
|
||||
onUnmount(state) {
|
||||
state.lines = [];
|
||||
},
|
||||
render(state) {
|
||||
const tab = logTabs.find(t => t.key === state.activeTab) || logTabs[0];
|
||||
|
||||
const lineVnodes = state.lines.map((line, i) =>
|
||||
h('div', { class: 'log-line', key: i }, esc(line))
|
||||
);
|
||||
|
||||
return [
|
||||
PageHeader({ title: 'Logs', subtitle: 'System & service logs' }),
|
||||
h('div', { class: 'tabs', key: 'log-tabs' },
|
||||
logTabs.map(t => h('span', {
|
||||
class: 'tab ' + (state.activeTab === t.key ? 'active' : ''),
|
||||
'on:click': async () => {
|
||||
state.activeTab = t.key;
|
||||
await fetchLog(state, t.url);
|
||||
},
|
||||
style: 'cursor:pointer;',
|
||||
}, t.label))
|
||||
),
|
||||
h('div', { class: 'card', key: 'log-card' },
|
||||
h('div', { class: 'card-header' },
|
||||
h('span', null, tab.label),
|
||||
h('button', {
|
||||
class: 'btn btn-sm btn-outline',
|
||||
style: 'float:right;',
|
||||
'on:click': async () => {
|
||||
await fetchLog(state, tab.url);
|
||||
},
|
||||
}, '\u21BB')
|
||||
),
|
||||
h('div', { class: 'card-body log-body' },
|
||||
state.loading
|
||||
? h('div', { class: 'loading' }, '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')
|
||||
)
|
||||
),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js';
|
||||
|
||||
function addFwdModal(zones, state) {
|
||||
openModal((inner, idx) => {
|
||||
formModal(inner, 'Add Port Forward',
|
||||
[
|
||||
{ label: 'Zone', id: 'fwd-zone', tag: 'select', options: zones.map(z => [z, z === zones[0]]) },
|
||||
{ label: 'Port', id: 'fwd-port', type: 'number' },
|
||||
{ label: 'Protocol', id: 'fwd-proto', placeholder: 'tcp or udp' },
|
||||
{ label: 'To Address', id: 'fwd-toaddr', placeholder: '192.168.1.100' },
|
||||
{ label: 'To Port (optional)', id: 'fwd-toport', type: 'number' },
|
||||
],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Add', cls: 'btn-primary', action: 's', handler: async () => {
|
||||
const body = {
|
||||
zone: $val('fwd-zone'),
|
||||
port: parseInt($val('fwd-port')),
|
||||
proto: ($val('fwd-proto') || 'tcp').trim(),
|
||||
toaddr: ($val('fwd-toaddr') || '').trim() || undefined,
|
||||
toport: $val('fwd-toport') ? parseInt($val('fwd-toport')) : undefined,
|
||||
};
|
||||
if (!body.zone || !body.port || !body.proto) {
|
||||
toast('Zone, port, and proto are required', 'error');
|
||||
return;
|
||||
}
|
||||
const r = await apiFetch('/api/firewall/forward-port', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (r.ok) {
|
||||
toast('Forward rule added', 'success');
|
||||
closeModal(idx);
|
||||
await load(state);
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function load(state) {
|
||||
try {
|
||||
const r = await apiFetch('/api/firewall/config');
|
||||
if (r.ok) state.config = r.data || {};
|
||||
const zr = await apiFetch('/api/firewall/zones');
|
||||
if (zr.ok) state.activeZones = Object.keys(zr.data?.active || {});
|
||||
} catch (e) {
|
||||
state.error = String(e);
|
||||
}
|
||||
state.loading = false;
|
||||
}
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return { config: {}, activeZones: [], loading: true, error: null };
|
||||
},
|
||||
subscribe: ['firewall'],
|
||||
load,
|
||||
render(state) {
|
||||
if (state.loading) {
|
||||
return [
|
||||
PageHeader({ title: 'NAT', subtitle: 'Masquerade & port forwarding' }),
|
||||
h('div', { class: 'card', key: 'loading' },
|
||||
h('div', { class: 'card-body loading' }, 'Loading...'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return [
|
||||
PageHeader({ title: 'NAT' }),
|
||||
h('div', { class: 'card', key: 'error' },
|
||||
h('div', { class: 'card-body error-msg' }, state.error),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const cfg = state.config || {};
|
||||
const zoneData = cfg.zones || {};
|
||||
|
||||
const masqRows = Object.entries(zoneData).map(([zone, zcfg]) => {
|
||||
const masq = !!zcfg.masquerade;
|
||||
return h('tr', { key: 'm-' + zone },
|
||||
h('td', null, h('strong', null, zone)),
|
||||
h('td', null, Badge({ text: masq ? 'Enabled' : 'Disabled', variant: masq ? 'success' : 'info' })),
|
||||
h('td', null,
|
||||
h('button', { class: 'btn btn-sm btn-outline',
|
||||
'on:click': async () => {
|
||||
const r = await apiFetch('/api/firewall/masquerade', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ zone, enable: !masq }),
|
||||
});
|
||||
if (r.ok) {
|
||||
toast('Masquerade ' + (masq ? 'disabled' : 'enabled') + ' on ' + zone, 'success');
|
||||
await load(state);
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
}
|
||||
}}, masq ? 'Disable' : 'Enable'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const fwRows = [];
|
||||
Object.entries(zoneData).forEach(([zone, zcfg]) => {
|
||||
const forwards = zcfg.forward_ports || [];
|
||||
forwards.forEach((fwd, i) => {
|
||||
fwRows.push(h('tr', { key: 'f-' + zone + '-' + i },
|
||||
h('td', null, h('strong', null, zone)),
|
||||
h('td', null, Badge({ text: fwd['proxy-protocol'] || fwd.proto || 'tcp', variant: 'info' })),
|
||||
h('td', null, fwd.port),
|
||||
h('td', null, fwd['to-addr'] || fwd.toaddr || '-'),
|
||||
h('td', null, fwd['to-port'] || fwd.toport || '-'),
|
||||
h('td', null,
|
||||
h('button', { class: 'btn btn-sm btn-danger',
|
||||
'on:click': async () => {
|
||||
const port = fwd.port, proto = fwd['proxy-protocol'] || fwd.proto;
|
||||
if (!confirm('Remove forward ' + zone + ':' + port + '/' + proto + '?')) return;
|
||||
const r = await apiFetch('/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto), { method: 'DELETE' });
|
||||
if (r.ok) {
|
||||
toast('Rule removed', 'success');
|
||||
await load(state);
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
}
|
||||
}}, 'Remove'),
|
||||
),
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
return [
|
||||
PageHeader({ title: 'NAT', subtitle: 'Masquerade & port forwarding' }),
|
||||
h('h3', { class: 'section-title' }, 'Masquerade'),
|
||||
h('div', { class: 'card' },
|
||||
h('table', { class: 'table' },
|
||||
h('thead', null,
|
||||
h('tr', null,
|
||||
h('th', null, 'Zone'),
|
||||
h('th', null, 'Status'),
|
||||
h('th', { style: 'width:100px;' }, 'Action'),
|
||||
),
|
||||
),
|
||||
h('tbody', null,
|
||||
...(masqRows.length ? masqRows : [
|
||||
h('tr', null, h('td', { colspan: 3, class: 'text-muted' }, 'No zones')),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
h('h3', { class: 'section-title' }, 'Port Forwarding'),
|
||||
h('div', { class: 'card' },
|
||||
h('div', { style: 'padding:0.75rem;', class: 'flex' },
|
||||
h('button', { class: 'btn btn-sm btn-primary',
|
||||
'on:click': () => addFwdModal(state.activeZones, state) }, 'Add Forward'),
|
||||
),
|
||||
h('table', { class: 'table' },
|
||||
h('thead', null,
|
||||
h('tr', null,
|
||||
h('th', null, 'Zone'),
|
||||
h('th', null, 'Proto'),
|
||||
h('th', null, 'Port'),
|
||||
h('th', null, 'To Addr'),
|
||||
h('th', null, 'To Port'),
|
||||
h('th', { style: 'width:80px;' }, 'Action'),
|
||||
),
|
||||
),
|
||||
h('tbody', null,
|
||||
...(fwRows.length ? fwRows : [
|
||||
h('tr', null,
|
||||
h('td', { colspan: 6, class: 'text-muted text-sm' }, 'No port forwarding rules'),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js';
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return { path: '' };
|
||||
},
|
||||
subscribe: [],
|
||||
async load(state) {
|
||||
state.path = location.hash.slice(1) || '';
|
||||
},
|
||||
render(state) {
|
||||
return [
|
||||
PageHeader({ title: '404' }),
|
||||
h('div', { class: 'card' },
|
||||
h('div', { class: 'card-body text-muted' }, 'Page not found: ' + state.path),
|
||||
),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js';
|
||||
|
||||
function addDomainModal(state) {
|
||||
openModal((inner, idx) => {
|
||||
formModal(inner, 'Add Proxy Domain',
|
||||
[
|
||||
{ label: 'Domain', id: 'p-domain', placeholder: 'example.com' },
|
||||
{ label: 'Backend Host', id: 'p-host', placeholder: '192.168.1.10' },
|
||||
{ label: 'Backend Port', id: 'p-port', type: 'number', placeholder: '8080' },
|
||||
{ label: 'Protocol', id: 'p-proto', placeholder: 'http or https' },
|
||||
{ label: 'Cert (optional)', id: 'p-cert', placeholder: 'acme' },
|
||||
],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Add', cls: 'btn-primary', action: 's', handler: async () => {
|
||||
const body = {
|
||||
domain: ($val('p-domain') || '').trim(),
|
||||
backend_host: ($val('p-host') || '').trim(),
|
||||
backend_port: parseInt($val('p-port')),
|
||||
backend_proto: ($val('p-proto') || 'http').trim() || 'http',
|
||||
cert: ($val('p-cert') || '').trim() || undefined,
|
||||
};
|
||||
if (!body.domain || !body.backend_host || !body.backend_port) {
|
||||
toast('Domain, host, and port are required', 'error');
|
||||
return;
|
||||
}
|
||||
const resp = await apiFetch('/api/proxy/domains', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (resp.ok) {
|
||||
toast('Domain added', 'success');
|
||||
closeModal(idx);
|
||||
await load(state);
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function editDomainModal(domain, state) {
|
||||
openModal((inner, idx) => {
|
||||
formModal(inner, 'Edit: ' + domain.domain,
|
||||
[
|
||||
{ label: 'Backend Host', id: 'pe-host', value: domain.backend_host || '' },
|
||||
{ label: 'Backend Port', id: 'pe-port', type: 'number', value: domain.backend_port || '' },
|
||||
{ label: 'Protocol', id: 'pe-proto', value: domain.backend_proto || domain.protocol || 'http' },
|
||||
{ label: 'Cert (optional)', id: 'pe-cert', value: domain.cert || '' },
|
||||
],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Save', cls: 'btn-primary', action: 's', handler: async () => {
|
||||
const body = {
|
||||
backend_host: ($val('pe-host') || '').trim(),
|
||||
backend_port: parseInt($val('pe-port')),
|
||||
backend_proto: ($val('pe-proto') || 'http').trim(),
|
||||
cert: ($val('pe-cert') || '').trim() || undefined,
|
||||
};
|
||||
const resp = await apiFetch('/api/proxy/domains/' + enc(domain.domain), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (resp.ok) {
|
||||
toast('Domain updated', 'success');
|
||||
closeModal(idx);
|
||||
await load(state);
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function load(state) {
|
||||
try {
|
||||
const domainsR = await apiFetch('/api/proxy/domains');
|
||||
if (domainsR.ok) state.domains = domainsR.data || [];
|
||||
const certsR = await apiFetch('/api/certs/list');
|
||||
if (certsR.ok) state.certs = certsR.data || [];
|
||||
} catch (e) {
|
||||
state.error = String(e);
|
||||
}
|
||||
state.loading = false;
|
||||
}
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return { domains: [], certs: [], loading: true, error: null };
|
||||
},
|
||||
subscribe: ['nginx', 'acme'],
|
||||
load,
|
||||
render(state) {
|
||||
if (state.loading) {
|
||||
return [
|
||||
PageHeader({ title: 'Proxy' }),
|
||||
h('div', { class: 'card', key: 'loading' },
|
||||
h('div', { class: 'card-body loading' }, 'Loading...'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return [
|
||||
PageHeader({ title: 'Proxy' }),
|
||||
h('div', { class: 'card', key: 'error' },
|
||||
h('div', { class: 'card-body error-msg' }, state.error),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const rows = state.domains.map(d => {
|
||||
let certBadge = Badge({ text: 'No cert', variant: 'info' });
|
||||
if (d.cert_status === 'valid' || d.cert_status === 'active') {
|
||||
certBadge = Badge({ text: 'Valid', variant: 'success' });
|
||||
} else if (d.cert_status === 'expired' || (d.days_remaining !== undefined && d.days_remaining <= 0)) {
|
||||
certBadge = Badge({ text: 'Expired', variant: 'danger' });
|
||||
} else if (d.days_remaining !== undefined && d.days_remaining <= 30) {
|
||||
certBadge = Badge({ text: d.days_remaining + 'd', variant: 'warning' });
|
||||
} else if (d.days_remaining !== undefined) {
|
||||
certBadge = Badge({ text: d.days_remaining + 'd', variant: 'success' });
|
||||
}
|
||||
|
||||
return h('tr', { key: d.domain },
|
||||
h('td', null, h('strong', null, esc(d.domain))),
|
||||
h('td', null, esc(d.backend_host || '-')),
|
||||
h('td', null, d.backend_port || '-'),
|
||||
h('td', null, Badge({ text: d.backend_proto || d.protocol || 'http', variant: 'info' })),
|
||||
h('td', null, certBadge),
|
||||
h('td', null,
|
||||
h('button', { class: 'btn btn-sm btn-outline', style: 'margin-right:4px;',
|
||||
'on:click': () => editDomainModal(d, state) }, 'Edit'),
|
||||
h('button', { class: 'btn btn-sm btn-danger',
|
||||
'on:click': async () => {
|
||||
if (!confirm('Remove proxy for ' + d.domain + '?')) return;
|
||||
const r = await apiFetch('/api/proxy/domains/' + enc(d.domain), { method: 'DELETE' });
|
||||
if (r.ok) {
|
||||
toast('Domain removed', 'success');
|
||||
await load(state);
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
}
|
||||
}}, 'Delete'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const actions = h('div', { style: 'display:flex;gap:8px;' },
|
||||
h('button', { class: 'btn btn-primary', 'on:click': () => addDomainModal(state) }, 'Add Domain'),
|
||||
h('button', { class: 'btn btn-outline',
|
||||
'on:click': async () => {
|
||||
const resp = await apiFetch('/api/proxy/apply', { method: 'POST' });
|
||||
if (resp.ok) toast('Nginx applied & reloaded', 'success');
|
||||
else toast(resp.error || 'Failed', 'error');
|
||||
}}, 'Apply'),
|
||||
);
|
||||
|
||||
return [
|
||||
PageHeader({ title: 'Proxy', subtitle: 'Nginx reverse proxy', actions }),
|
||||
rows.length
|
||||
? h('div', { class: 'card' }, h('table', { class: 'table' },
|
||||
h('thead', null,
|
||||
h('tr', null,
|
||||
h('th', null, 'Domain'),
|
||||
h('th', null, 'Backend Host'),
|
||||
h('th', null, 'Port'),
|
||||
h('th', null, 'Proto'),
|
||||
h('th', null, 'Cert'),
|
||||
h('th', { style: 'width:140px;' }, 'Actions'),
|
||||
),
|
||||
),
|
||||
h('tbody', null, ...rows),
|
||||
))
|
||||
: Empty({ text: 'No proxy domains configured. Add a domain to start terminating SSL.' }),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js';
|
||||
|
||||
function addRuleModal(zones, state) {
|
||||
openModal((inner, idx) => {
|
||||
formModal(inner, 'Add Rich Rule',
|
||||
[
|
||||
{ label: 'Zone', id: 'rule-zone', tag: 'select', options: zones.map(z => [z, z === zones[0]]) },
|
||||
{ label: 'Rule (XML)', id: 'rule-text', tag: 'textarea', placeholder: 'rule family="ipv4" source address="192.168.1.0/24" accept' },
|
||||
],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Add', cls: 'btn-primary', action: 's', handler: async () => {
|
||||
const zone = $val('rule-zone');
|
||||
const rule = ($val('rule-text') || '').trim();
|
||||
if (!zone || !rule) { toast('Zone and rule are required', 'error'); return; }
|
||||
const r = await apiFetch('/api/firewall/rich-rules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ zone, rule }),
|
||||
});
|
||||
if (r.ok) {
|
||||
toast('Rule added', 'success');
|
||||
closeModal(idx);
|
||||
await load(state);
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function load(state) {
|
||||
try {
|
||||
const r = await apiFetch('/api/firewall/config');
|
||||
if (r.ok) state.config = r.data || {};
|
||||
else state.error = r.error;
|
||||
const zr = await apiFetch('/api/firewall/zones');
|
||||
if (zr.ok) state.zones = Object.keys(zr.data?.active || {});
|
||||
} catch (e) {
|
||||
state.error = String(e);
|
||||
}
|
||||
state.loading = false;
|
||||
}
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return { config: {}, loading: true, error: null, zones: [] };
|
||||
},
|
||||
subscribe: ['firewall'],
|
||||
load,
|
||||
render(state) {
|
||||
if (state.loading) {
|
||||
return [
|
||||
PageHeader({ title: 'Rules', subtitle: 'Firewall rich rules' }),
|
||||
h('div', { class: 'card', key: 'loading' },
|
||||
h('div', { class: 'card-body loading' }, 'Loading...'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return [
|
||||
PageHeader({ title: 'Rules' }),
|
||||
h('div', { class: 'card', key: 'error' },
|
||||
h('div', { class: 'card-body error-msg' }, state.error),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const cfg = state.config || {};
|
||||
const zoneData = cfg.zones || {};
|
||||
const zoneRules = {};
|
||||
Object.entries(zoneData).forEach(([zname, zcfg]) => {
|
||||
const rr = zcfg.rich_rules || [];
|
||||
if (rr.length) zoneRules[zname] = rr;
|
||||
});
|
||||
|
||||
const cards = Object.entries(zoneRules).map(([zone, rules]) => {
|
||||
return h('div', { class: 'card', key: zone },
|
||||
h('div', { class: 'card-header' }, 'Zone: ' + esc(zone)),
|
||||
h('div', { class: 'card-body' },
|
||||
h('table', { class: 'table' },
|
||||
h('thead', null,
|
||||
h('tr', null,
|
||||
h('th', null, '#'),
|
||||
h('th', null, 'Rule'),
|
||||
h('th', { style: 'width:80px;' }, 'Action'),
|
||||
),
|
||||
),
|
||||
h('tbody', null,
|
||||
(Array.isArray(rules) ? rules : []).map((entry, i) => {
|
||||
const ruleId = typeof entry === 'object' ? entry.id : null;
|
||||
const ruleText = typeof entry === 'object' ? (entry.rule || '') : String(entry);
|
||||
return h('tr', { key: i },
|
||||
h('td', { class: 'text-muted' }, i + 1),
|
||||
h('td', { style: 'font-family:monospace;font-size:12px;word-break:break-all;' }, esc(ruleText)),
|
||||
h('td', null,
|
||||
h('button', { class: 'btn btn-sm btn-danger',
|
||||
'on:click': async () => {
|
||||
if (!confirm('Remove rule: ' + ruleText.substring(0, 40) + '...?')) return;
|
||||
const r = await apiFetch('/api/firewall/rich-rules/' + enc(zone) + '/' + enc(ruleId || ''), { method: 'DELETE' });
|
||||
if (r.ok) {
|
||||
toast('Rule removed', 'success');
|
||||
await load(state);
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
}
|
||||
}}, 'Remove'),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return [
|
||||
PageHeader({
|
||||
title: 'Rules',
|
||||
subtitle: 'Firewall rich rules',
|
||||
actions: h('button', { class: 'btn btn-primary',
|
||||
'on:click': () => addRuleModal(state.zones, state) }, 'Add Rule'),
|
||||
}),
|
||||
...(cards.length ? cards : [Empty({ text: 'No rich rules configured.' })]),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js';
|
||||
|
||||
function addPeerModal(state) {
|
||||
openModal((inner, idx) => {
|
||||
formModal(inner, 'Add WireGuard Peer',
|
||||
[
|
||||
{ label: 'Name', id: 'wg-name', placeholder: 'client-name' },
|
||||
{ label: 'Endpoint (optional)', id: 'wg-endpoint', placeholder: '1.2.3.4:51820' },
|
||||
{ label: 'Allowed IPs (optional)', id: 'wg-allowed', placeholder: '10.0.0.2/32' },
|
||||
{ label: 'Persistent Keepalive (optional)', id: 'wg-keepalive', type: 'number', placeholder: '25' },
|
||||
],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Add', cls: 'btn-primary', action: 's', handler: async () => {
|
||||
const body = {
|
||||
name: ($val('wg-name') || '').trim(),
|
||||
endpoint: ($val('wg-endpoint') || '').trim() || undefined,
|
||||
allowed_ips: ($val('wg-allowed') || '').trim() ? [($val('wg-allowed') || '').trim()] : [],
|
||||
persistent_keepalive: $val('wg-keepalive') ? parseInt($val('wg-keepalive')) : undefined,
|
||||
};
|
||||
if (!body.name) { toast('Name is required', 'error'); return; }
|
||||
const resp = await apiFetch('/api/wireguard/peers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (resp.ok) {
|
||||
toast('Peer added', 'success');
|
||||
closeModal(idx);
|
||||
await load(state);
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function downloadConfigModal(peerName, config, state) {
|
||||
openModal((inner, idx) => {
|
||||
formModal(inner, 'Download Config for ' + peerName,
|
||||
[{ label: 'Server Endpoint', id: 'wg-srv-endpoint', placeholder: 'vpn.example.com:51820' }],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Generate', cls: 'btn-primary', action: 's', handler: async () => {
|
||||
const endpoint = ($val('wg-srv-endpoint') || '').trim();
|
||||
if (!endpoint) { toast('Server endpoint is required', 'error'); return; }
|
||||
const resp = await apiFetch('/api/wireguard/generate-client', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: peerName, server_endpoint: endpoint }),
|
||||
});
|
||||
if (resp.ok && resp.data?.config) {
|
||||
const blob = new Blob([resp.data.config], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = peerName + '.conf';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast('Config downloaded', 'success');
|
||||
closeModal(idx);
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function load(state) {
|
||||
try {
|
||||
const stR = await apiFetch('/api/wireguard/status');
|
||||
if (stR.ok) state.status = stR.data || {};
|
||||
const pR = await apiFetch('/api/wireguard/peers');
|
||||
if (pR.ok) state.peers = pR.data || [];
|
||||
const cfgR = await apiFetch('/api/wireguard/config');
|
||||
if (cfgR.ok) state.config = cfgR.data || {};
|
||||
} catch (e) {
|
||||
state.error = String(e);
|
||||
}
|
||||
state.loading = false;
|
||||
}
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return { status: {}, peers: [], config: {}, loading: true, error: null };
|
||||
},
|
||||
subscribe: ['wireguard'],
|
||||
load,
|
||||
render(state) {
|
||||
if (state.loading) {
|
||||
return [
|
||||
PageHeader({ title: 'WireGuard' }),
|
||||
h('div', { class: 'card', key: 'loading' },
|
||||
h('div', { class: 'card-body loading' }, 'Loading...'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return [
|
||||
PageHeader({ title: 'WireGuard' }),
|
||||
h('div', { class: 'card', key: 'error' },
|
||||
h('div', { class: 'card-body error-msg' }, state.error),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const st = state.status || {};
|
||||
const isUp = st.state === 'up';
|
||||
const listenPort = (state.config?.interface || {}).listen_port || '-';
|
||||
|
||||
const peerRows = state.peers.map(p => {
|
||||
const hasHandshake = !!p.latest_handshake;
|
||||
return h('tr', { key: p.name },
|
||||
h('td', null,
|
||||
StatusDot({ status: hasHandshake ? 'success' : 'danger' }),
|
||||
h('strong', null, esc(p.name || 'unnamed')),
|
||||
),
|
||||
h('td', { style: 'font-family:monospace;font-size:11px;' },
|
||||
esc((p.public_key || 'N/A').substring(0, 20)) +
|
||||
(p.public_key && p.public_key.length > 20 ? '...' : ''),
|
||||
),
|
||||
h('td', { class: 'text-sm' }, esc(p.allowed_ips || '-')),
|
||||
h('td', { class: 'text-sm' }, esc(p.endpoint || '-')),
|
||||
h('td', { class: 'text-sm' }, esc(p.latest_handshake || 'Never')),
|
||||
h('td', { class: 'text-sm' },
|
||||
'Recv: ' + esc(p.transfer_recv || '0'),
|
||||
h('br'),
|
||||
'Sent: ' + esc(p.transfer_sent || '0'),
|
||||
),
|
||||
h('td', null,
|
||||
h('button', { class: 'btn btn-sm btn-outline', style: 'margin-right:4px;',
|
||||
'on:click': () => downloadConfigModal(p.name, state.config, state) }, 'Config'),
|
||||
h('button', { class: 'btn btn-sm btn-danger',
|
||||
'on:click': async () => {
|
||||
if (!confirm('Remove peer ' + p.name + '?')) return;
|
||||
const resp = await apiFetch('/api/wireguard/peers/' + enc(p.name), { method: 'DELETE' });
|
||||
if (resp.ok) {
|
||||
toast('Peer removed', 'success');
|
||||
await load(state);
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
}}, 'Remove'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const actions = h('div', { style: 'display:flex;gap:8px;' },
|
||||
h('button', { class: 'btn btn-primary', 'on:click': () => addPeerModal(state) }, 'Add Peer'),
|
||||
h('button', { class: 'btn btn-outline',
|
||||
'on:click': async () => {
|
||||
const resp = await apiFetch('/api/wireguard/' + (isUp ? 'down' : 'up'), { method: 'POST' });
|
||||
if (resp.ok) {
|
||||
toast('Tunnel ' + (isUp ? 'stopped' : 'started'), 'success');
|
||||
await load(state);
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
}}, isUp ? 'Stop' : 'Start'),
|
||||
h('button', { class: 'btn btn-outline',
|
||||
'on:click': async () => {
|
||||
const resp = await apiFetch('/api/wireguard/apply', { method: 'POST' });
|
||||
if (resp.ok) {
|
||||
toast('Config applied', 'success');
|
||||
await load(state);
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
}}, 'Apply'),
|
||||
);
|
||||
|
||||
return [
|
||||
PageHeader({
|
||||
title: 'WireGuard',
|
||||
subtitle: 'Tunnel: ' + (st.state || 'unknown') + ', Listen: ' + listenPort,
|
||||
actions,
|
||||
}),
|
||||
h('div', null,
|
||||
StatusDot({ status: isUp ? 'success' : 'danger' }),
|
||||
' ',
|
||||
Badge({ text: st.state || 'down', variant: isUp ? 'success' : 'danger' }),
|
||||
),
|
||||
peerRows.length
|
||||
? h('div', { class: 'card' }, h('table', { class: 'table' },
|
||||
h('thead', null,
|
||||
h('tr', null,
|
||||
h('th', null, 'Peer'),
|
||||
h('th', null, 'Public Key'),
|
||||
h('th', null, 'Allowed IPs'),
|
||||
h('th', null, 'Endpoint'),
|
||||
h('th', null, 'Handshake'),
|
||||
h('th', null, 'Transfer'),
|
||||
h('th', { style: 'width:120px;' }, 'Actions'),
|
||||
),
|
||||
),
|
||||
h('tbody', null, ...peerRows),
|
||||
))
|
||||
: Empty({ text: 'No peers configured. Add a peer above.' }),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js';
|
||||
|
||||
function addZoneModal(state) {
|
||||
openModal((inner, idx) => {
|
||||
formModal(inner, 'Add Zone',
|
||||
[
|
||||
{ label: 'Zone name', id: 'zone-name', placeholder: 'e.g. internal' },
|
||||
{ label: 'Target', id: 'zone-target', placeholder: 'default (usually leave as default)' },
|
||||
],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Create', cls: 'btn-primary', action: 's', handler: async () => {
|
||||
const name = ($val('zone-name') || '').trim();
|
||||
if (!name) { toast('Zone name required', 'error'); return; }
|
||||
const target = ($val('zone-target') || '').trim() || 'default';
|
||||
const r = await apiFetch('/api/firewall/zones', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, target }),
|
||||
});
|
||||
if (r.ok) {
|
||||
toast('Zone ' + name + ' created', 'success');
|
||||
closeModal(idx);
|
||||
await load(state);
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function zoneIfaceModal(zoneName, state) {
|
||||
const zdata = state.zones?.[zoneName] || {};
|
||||
const current = Array.isArray(zdata.interfaces) ? zdata.interfaces : [];
|
||||
const allIfaces = Array.isArray(state.interfaces) ? state.interfaces : [];
|
||||
openModal((inner, idx) => {
|
||||
formModal(inner, 'Interfaces: ' + zoneName,
|
||||
[
|
||||
{
|
||||
label: 'Interfaces', id: 'z-iface-select', tag: 'select',
|
||||
options: allIfaces.map(i => [i, current.includes(i)]),
|
||||
},
|
||||
],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Save', cls: 'btn-primary', action: 's', handler: async () => {
|
||||
const sel = document.getElementById('z-iface-select');
|
||||
const selected = Array.from(sel.selectedOptions).map(o => o.value);
|
||||
const r = await apiFetch('/api/firewall/zones/' + enc(zoneName) + '/interfaces', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ interfaces: selected }),
|
||||
});
|
||||
if (r.ok) {
|
||||
toast('Interfaces updated', 'success');
|
||||
closeModal(idx);
|
||||
await load(state);
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function zoneSvcModal(zoneName, state) {
|
||||
const zdata = state.zones?.[zoneName] || {};
|
||||
const current = Array.isArray(zdata.services) ? zdata.services : [];
|
||||
const all = Array.isArray(state.services) ? state.services : [];
|
||||
openModal((inner, idx) => {
|
||||
formModal(inner, 'Services: ' + zoneName,
|
||||
[
|
||||
{
|
||||
label: 'Services', id: 'z-svc-select', tag: 'select',
|
||||
options: all.map(s => [s, current.includes(s)]),
|
||||
},
|
||||
],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Save', cls: 'btn-primary', action: 's', handler: async () => {
|
||||
const sel = document.getElementById('z-svc-select');
|
||||
const selected = Array.from(sel.selectedOptions).map(o => o.value);
|
||||
const r = await apiFetch('/api/firewall/zones/' + enc(zoneName) + '/services', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ services: selected }),
|
||||
});
|
||||
if (r.ok) {
|
||||
toast('Services updated', 'success');
|
||||
closeModal(idx);
|
||||
await load(state);
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function load(state) {
|
||||
try {
|
||||
const [zRes, svcRes, ifRes] = await Promise.all([
|
||||
apiFetch('/api/firewall/zones'),
|
||||
apiFetch('/api/firewall/services'),
|
||||
apiFetch('/api/firewall/interfaces'),
|
||||
]);
|
||||
|
||||
if (zRes.ok) {
|
||||
const data = zRes.data || {};
|
||||
const activeZones = data.active || {};
|
||||
const availableZones = data.available || [];
|
||||
|
||||
const detailPromises = availableZones.map(name =>
|
||||
apiFetch('/api/firewall/zones/' + enc(name)).catch(() => null)
|
||||
);
|
||||
const detailResults = await Promise.all(detailPromises);
|
||||
|
||||
const zones = {};
|
||||
for (let i = 0; i < availableZones.length; i++) {
|
||||
const name = availableZones[i];
|
||||
const detail = detailResults[i];
|
||||
if (detail && detail.ok) {
|
||||
zones[name] = detail.data;
|
||||
const activeIfaces = activeZones[name];
|
||||
if (Array.isArray(activeIfaces)) {
|
||||
zones[name].interfaces = activeIfaces;
|
||||
}
|
||||
}
|
||||
}
|
||||
state.zones = zones;
|
||||
}
|
||||
|
||||
if (svcRes.ok) state.services = svcRes.data || [];
|
||||
if (ifRes.ok) state.interfaces = ifRes.data || [];
|
||||
} catch (e) {
|
||||
state.error = String(e);
|
||||
}
|
||||
state.loading = false;
|
||||
}
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return { zones: {}, services: [], interfaces: [], loading: true, error: null };
|
||||
},
|
||||
subscribe: ['firewall'],
|
||||
load,
|
||||
render(state) {
|
||||
if (state.loading) {
|
||||
return [
|
||||
PageHeader({ title: 'Zones', subtitle: 'Firewall zone management' }),
|
||||
h('div', { class: 'card', key: 'loading' },
|
||||
h('div', { class: 'card-body loading' }, 'Loading...'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return [
|
||||
PageHeader({ title: 'Zones' }),
|
||||
h('div', { class: 'card', key: 'error' },
|
||||
h('div', { class: 'card-body error-msg' }, state.error),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const zoneCards = Object.entries(state.zones || []).map(([name, zdata]) => {
|
||||
const z = typeof zdata === 'object' ? zdata : {};
|
||||
const ifacesArr = Array.isArray(z.interfaces) ? z.interfaces : [];
|
||||
const svcsArr = Array.isArray(z.services) ? z.services : [];
|
||||
return h('div', { class: 'card', key: name, style: 'position:relative;' },
|
||||
h('div', { style: 'display:flex;justify-content:space-between;align-items:flex-start;' },
|
||||
h('div', null,
|
||||
h('h3', { style: 'font-size:16px;color:var(--accent);' }, name),
|
||||
h('div', { class: 'text-muted text-sm', style: 'margin-bottom:10px;' },
|
||||
z.target ? 'Target: ' + esc(z.target) : '',
|
||||
),
|
||||
),
|
||||
),
|
||||
h('div', { class: 'text-sm mb-4' },
|
||||
h('div', { class: 'text-muted', style: 'margin-bottom:4px;' }, 'Interfaces'),
|
||||
ifacesArr.length
|
||||
? ifacesArr.map(i => Badge({ text: esc(i) }))
|
||||
: h('span', { class: 'text-muted' }, 'None'),
|
||||
),
|
||||
h('div', { class: 'text-sm mb-4' },
|
||||
h('div', { class: 'text-muted', style: 'margin-bottom:4px;' }, 'Services'),
|
||||
svcsArr.length
|
||||
? svcsArr.map(s => Badge({ text: esc(s), variant: 'success' }))
|
||||
: h('span', { class: 'text-muted' }, 'None'),
|
||||
),
|
||||
h('div', { style: 'display:flex;gap:6px;' },
|
||||
h('button', { class: 'btn btn-sm btn-outline',
|
||||
'on:click': () => zoneIfaceModal(name, state) }, 'Interfaces'),
|
||||
h('button', { class: 'btn btn-sm btn-outline',
|
||||
'on:click': () => zoneSvcModal(name, state) }, 'Services'),
|
||||
h('button', { class: 'btn btn-sm btn-danger', style: 'margin-left:auto;',
|
||||
'on:click': async () => {
|
||||
if (!confirm('Delete zone ' + name + '?')) return;
|
||||
const r = await apiFetch('/api/firewall/zones/' + enc(name), { method: 'DELETE' });
|
||||
if (r.ok) {
|
||||
toast('Zone ' + name + ' deleted', 'success');
|
||||
await load(state);
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
}
|
||||
}}, 'Delete'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return [
|
||||
PageHeader({
|
||||
title: 'Zones',
|
||||
subtitle: 'Firewall zones',
|
||||
actions: h('button', { class: 'btn btn-primary',
|
||||
'on:click': () => addZoneModal(state) }, 'Add Zone'),
|
||||
}),
|
||||
zoneCards.length
|
||||
? h('div', { class: 'card-grid' }, ...zoneCards)
|
||||
: Empty({ text: 'No zones configured. Add a zone to get started.' }),
|
||||
];
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user