160 lines
6.5 KiB
JavaScript
160 lines
6.5 KiB
JavaScript
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'),
|
|
),
|
|
]),
|
|
),
|
|
),
|
|
),
|
|
];
|
|
},
|
|
});
|