Add network management over SSH for Ubuntu (netplan) and Debian/Proxmox (ifupdown): interfaces, IPs, DHCP, gateway, DNS, bonds with LACP, bridges, VLANs, hostname and config files, applied with automatic rollback; UFW shows a hint when disabled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+268
-3
@@ -42,6 +42,7 @@ const ICONS = {
|
||||
upload: '<svg viewBox="0 0 24 24"><path d="M12 20V8M6 14l6-6 6 6M4 4h16"/></svg>',
|
||||
docker: '<svg viewBox="0 0 24 24"><path d="M2 12h19c-.6 4.5-4 8-10 8-5 0-8-3-9-8z"/><path d="M5 12V9h3v3M8 12V9h3v3M11 12V9h3v3M8 9V6h3v3M21 12c.5-1.5 0-3-1-3.5"/></svg>',
|
||||
wall: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="1"/><path d="M3 9.3h18M3 14.7h18M9 4v5.3M15 4v5.3M6 9.3v5.4M12 9.3v5.4M18 9.3v5.4M9 14.7V20M15 14.7V20"/></svg>',
|
||||
network: '<svg viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="5" rx="1"/><rect x="3" y="16" width="6" height="5" rx="1"/><rect x="15" y="16" width="6" height="5" rx="1"/><path d="M12 8v4M6 16v-4h12v4"/></svg>',
|
||||
logs: '<svg viewBox="0 0 24 24"><path d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"/></svg>',
|
||||
};
|
||||
const COLORS = ['#6e7bff', '#3ecf8e', '#ff5f6d', '#ffb454', '#c792ea', '#56d6d6', '#ff79c6', '#8b91a5', '#4f9dff', '#e0a100'];
|
||||
@@ -390,7 +391,7 @@ function hostMenu(hst) {
|
||||
return [
|
||||
{ label: hst.protocol === 'rdp' ? T('Connect in tab') : T('Connect'), icon: 'play', run: () => connectHost(hst) },
|
||||
...(hst.protocol === 'rdp' ? [{ label: T('Open in separate window'), icon: 'screen', run: () => launchRdp(hst) }] : []),
|
||||
...(hst.protocol !== 'rdp' ? [{ label: T('Open SFTP'), icon: 'folder', run: () => openSftp(hst) }, { label: T('Docker containers'), icon: 'docker', run: () => openDocker(hst) }, { label: T('Firewall'), icon: 'wall', run: () => openFirewall(hst) }] : []),
|
||||
...(hst.protocol !== 'rdp' ? [{ label: T('Open SFTP'), icon: 'folder', run: () => openSftp(hst) }, { label: T('Docker containers'), icon: 'docker', run: () => openDocker(hst) }, { label: T('Firewall'), icon: 'wall', run: () => openFirewall(hst) }, { label: T('Network'), icon: 'network', run: () => openNetwork(hst) }] : []),
|
||||
'-',
|
||||
{ label: T('Edit'), icon: 'edit', run: () => editHost(hst) },
|
||||
{ label: T('Duplicate'), icon: 'dup', run: async () => { const { id, createdAt, updatedAt, ...rest } = hst; await call('vault:upsert', 'hosts', { ...rest, label: (hst.label || hst.address) + T(' (copy)') }); reload(); } },
|
||||
@@ -1161,7 +1162,7 @@ function addTab(session) {
|
||||
tab.onclick = (e) => { if (e.target.closest('.x')) return closeTab(session.id); activateTab(session.id); };
|
||||
tab.onauxclick = (e) => { if (e.button === 1) closeTab(session.id); };
|
||||
tab.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
|
||||
...(session.host ? [{ label: T('Duplicate'), icon: 'dup', run: () => ({ sftp: openSftp, docker: openDocker, firewall: openFirewall }[session.kind] || openTerminal)(session.host) }] : []),
|
||||
...(session.host ? [{ label: T('Duplicate'), icon: 'dup', run: () => ({ sftp: openSftp, docker: openDocker, firewall: openFirewall, network: openNetwork }[session.kind] || openTerminal)(session.host) }] : []),
|
||||
...(session.kind === 'ssh' ? [{ label: T('Reconnect'), icon: 'refresh', run: () => session.reconnect() }] : []),
|
||||
{ label: T('Rename'), icon: 'edit', run: async () => { const n = await promptBox(T('Rename tab'), T('Title'), session.title); if (n) { session.title = n; $('span:nth-child(2)', tab).textContent = n; } } },
|
||||
'-', { label: T('Close'), icon: 'close', run: () => closeTab(session.id) },
|
||||
@@ -1208,7 +1209,7 @@ class TerminalSession {
|
||||
this.el = h(`<div class="session">
|
||||
<div class="sbar"><div class="info">${avatar(host, 22).replace('<span class="proto">SSH</span>', '')}<span>${esc(hostSub(host))}</span></div>
|
||||
<button class="btn ghost sm" data-a="sftp" title="${T('SFTP for this host')}">${ICONS.folder}SFTP</button>
|
||||
${host.execCommand ? '' : `<button class="btn ghost sm" data-a="docker" title="${T('Docker containers')}">${ICONS.docker}Docker</button><button class="btn ghost sm" data-a="firewall" title="${T('Firewall')}">${ICONS.wall}${T('Firewall')}</button>`}
|
||||
${host.execCommand ? '' : `<button class="btn ghost sm" data-a="docker" title="${T('Docker containers')}">${ICONS.docker}Docker</button><button class="btn ghost sm" data-a="firewall" title="${T('Firewall')}">${ICONS.wall}${T('Firewall')}</button><button class="btn ghost sm" data-a="network" title="${T('Network')}">${ICONS.network}${T('Network')}</button>`}
|
||||
<button class="btn ghost sm" data-a="snip" title="Snippets">${ICONS.code}Snippets</button>
|
||||
<button class="btn ghost sm" data-a="find" title="${T('Search (Ctrl+Shift+F)')}">${ICONS.search}</button>
|
||||
</div>
|
||||
@@ -1268,6 +1269,7 @@ class TerminalSession {
|
||||
if (a === 'sftp') openSftp(this.host);
|
||||
if (a === 'docker') openDocker(this.host);
|
||||
if (a === 'firewall') openFirewall(this.host);
|
||||
if (a === 'network') openNetwork(this.host);
|
||||
if (a === 'snip') { $('.snip-panel', this.el).classList.toggle('open'); this.renderSnips(); this.doFit(); }
|
||||
if (a === 'snipnew') editSnippet();
|
||||
if (a === 'find') this.toggleFind();
|
||||
@@ -1842,6 +1844,7 @@ class FirewallSession {
|
||||
this.backend = b;
|
||||
$$('.backends button', this.el).forEach((x) => x.classList.toggle('active', x.dataset.b === b));
|
||||
$('[data-a=save]', this.el).style.display = b === 'ufw' ? 'none' : '';
|
||||
$('[data-a=add]', this.el).disabled = false;
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
@@ -1894,6 +1897,11 @@ class FirewallSession {
|
||||
}
|
||||
}
|
||||
this.body.append(head);
|
||||
$('[data-a=add]', this.el).disabled = !on;
|
||||
if (!on) {
|
||||
this.body.append(h(`<div class="pane-empty" style="height:auto;padding:48px 20px"><div class="fw-off">${ICONS.wall}<b>${T('UFW is disabled')}</b><span>${T('Enable UFW to view or add rules.')}</span></div></div>`));
|
||||
return;
|
||||
}
|
||||
if (!d.rules.length) { this.body.append(h(`<div class="pane-empty" style="height:auto;padding:40px"><div style="color:var(--muted)">${T('No rules yet.')}</div></div>`)); return; }
|
||||
const t = h(`<table class="docker-table fw-table"><thead><tr><th>#</th><th>${T('To')}</th><th>${T('Action')}</th><th>${T('From')}</th><th>${T('Comment')}</th><th></th></tr></thead><tbody></tbody></table>`);
|
||||
for (const r of d.rules) {
|
||||
@@ -1971,6 +1979,262 @@ class FirewallSession {
|
||||
}
|
||||
function openFirewall(host) { return new FirewallSession(host); }
|
||||
|
||||
// ============================================================ Netzwerk (Ubuntu netplan / Debian ifupdown über SSH)
|
||||
const NET_KINDS = { ethernet: 'Ethernet', bond: 'Bond', bridge: 'Bridge', vlan: 'VLAN' };
|
||||
const BOND_MODES = [['802.3ad', '802.3ad (LACP)'], ['active-backup', 'active-backup'], ['balance-rr', 'balance-rr'], ['balance-xor', 'balance-xor'], ['balance-tlb', 'balance-tlb'], ['balance-alb', 'balance-alb'], ['broadcast', 'broadcast']];
|
||||
const VIRTUAL_IF = /^(veth|docker\d|br-[0-9a-f]{12}|tap\d|fwbr|fwpr|fwln|vnet|virbr|cali|flannel|cni|kube|tun|wg|tailscale|zt)/;
|
||||
const splitList = (v) => String(v || '').split(/[\s,;]+/).map((x) => x.trim()).filter(Boolean);
|
||||
|
||||
class NetworkSession {
|
||||
constructor(host) {
|
||||
this.kind = 'network'; this.id = uid(); this.host = host;
|
||||
this.title = `${T('Network')} · ${host.label || host.address}`;
|
||||
this.data = null; this.showVirtual = false;
|
||||
this.el = h(`<div class="session network">
|
||||
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div>
|
||||
<label class="check" style="margin:0 6px"><input type="checkbox" data-a="virtual"/>${T('Show virtual interfaces')}</label>
|
||||
<button class="btn ghost sm" data-a="files">${ICONS.file}${T('Config files')}</button>
|
||||
<button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
|
||||
<button class="btn primary sm" data-a="new" disabled>${ICONS.plus}${T('New interface')}</button>
|
||||
</div>
|
||||
<div class="docker-body net-body"><div class="pane-empty"><div class="spinner" style="width:30px;height:30px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .9s linear infinite"></div><div>${esc(T('Connecting to {host} …', { host: host.address }))}</div></div></div></div>`);
|
||||
this.body = $('.net-body', this.el);
|
||||
this.el.addEventListener('click', (e) => {
|
||||
const a = e.target.closest('[data-a]')?.dataset.a;
|
||||
if (a === 'refresh') this.refresh();
|
||||
if (a === 'files') this.files();
|
||||
if (a === 'new') ctxMenu(e.clientX, e.clientY, [
|
||||
{ label: T('New bond'), icon: 'plus', run: () => this.edit(null, 'bond') },
|
||||
{ label: T('New bridge'), icon: 'plus', run: () => this.edit(null, 'bridge') },
|
||||
{ label: T('New VLAN'), icon: 'plus', run: () => this.edit(null, 'vlan') },
|
||||
]);
|
||||
});
|
||||
$('[data-a=virtual]', this.el).onchange = (e) => { this.showVirtual = e.target.checked; this.draw(); };
|
||||
this.unsub = api.on('network:closed', (sid) => { if (sid === this.id && !this.applying) { setTabState(this, 'err'); this.error(T('Connection closed.')); } });
|
||||
addTab(this);
|
||||
this.open();
|
||||
}
|
||||
|
||||
async open() {
|
||||
try {
|
||||
this.apply(await api.call('network:open', this.id, hostRef(this.host)));
|
||||
setTabState(this, 'on');
|
||||
} catch (e) { setTabState(this, 'err'); this.error(e.message); }
|
||||
}
|
||||
|
||||
error(msg) {
|
||||
this.body.innerHTML = `<div class="pane-empty"><div style="color:var(--red);max-width:560px;text-align:center">${esc(msg)}</div></div>`;
|
||||
const b = h(`<button class="btn primary">${ICONS.refresh}${T('Try again')}</button>`);
|
||||
b.onclick = () => { closeTab(this.id); openNetwork(this.host); };
|
||||
$('.pane-empty', this.body).append(b);
|
||||
}
|
||||
|
||||
apply(d) {
|
||||
this.data = d;
|
||||
const be = { netplan: 'netplan', ifupdown: 'ifupdown', networkmanager: 'NetworkManager', networkd: 'systemd-networkd' }[d.backend] || d.backend;
|
||||
$('.rt', this.el).textContent = ` · ${d.os || ''} · ${be}`;
|
||||
$('[data-a=new]', this.el).disabled = !d.editable;
|
||||
this.draw();
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
try { this.apply(await api.call('network:read', this.id)); } catch (e) { toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
cfg(name) { return this.data.config.find((c) => c.name === name); }
|
||||
|
||||
draw() {
|
||||
const d = this.data;
|
||||
const scroll = this.body.scrollTop;
|
||||
this.body.innerHTML = '';
|
||||
const wrap = h('<div class="net-wrap"></div>');
|
||||
if (d.rolledBack) wrap.append(h(`<div class="net-banner warn">${ICONS.refresh}<span>${esc(T('The last network change was rolled back automatically ({time}) because MrTerm could not reconnect.', { time: d.rolledBack }))}</span></div>`));
|
||||
if (!d.editable) wrap.append(h(`<div class="net-banner">${ICONS.network}<span>${esc(d.note || T('Editing is supported for Ubuntu (netplan) and Debian/Proxmox (ifupdown). This host uses {backend}, so interfaces are shown read-only. Config files can still be edited.', { backend: d.backend }))}</span></div>`));
|
||||
|
||||
// System: Hostname, Gateway, DNS
|
||||
const gw = d.routes.map((r) => `${esc(r.via || '—')} <span class="muted">(${esc(r.dev)})</span>`).join('<br>') || `<span class="muted">${T('none')}</span>`;
|
||||
const sys = h(`<div class="net-sys">
|
||||
<div><label>${T('Hostname')}</label><div class="v"><b>${esc(d.hostname)}</b><button class="btn ghost sm" data-s="host">${ICONS.edit}</button></div></div>
|
||||
<div><label>${T('Default gateway')}</label><div class="v">${gw}</div></div>
|
||||
<div><label>${T('DNS servers')}</label><div class="v">${esc(d.dns.servers.join(', ') || '—')}${d.dns.search.length ? `<br><span class="muted">${T('Search')}: ${esc(d.dns.search.join(' '))}</span>` : ''}${d.dns.editable && !d.dns.servers.includes('127.0.0.53') ? `<button class="btn ghost sm" data-s="dns">${ICONS.edit}</button>` : ''}</div>
|
||||
${d.dns.servers.includes('127.0.0.53') ? `<div class="hint">${T('systemd-resolved is used: set DNS servers per interface.')}</div>` : ''}</div></div>`);
|
||||
sys.onclick = (e) => { const a = e.target.closest('[data-s]')?.dataset.s; if (a === 'host') this.hostname(); if (a === 'dns') this.resolv(); };
|
||||
wrap.append(sys);
|
||||
|
||||
// Schnittstellen: live + nur konfigurierte
|
||||
const live = d.interfaces.filter((i) => this.showVirtual || !VIRTUAL_IF.test(i.name));
|
||||
const names = new Set(live.map((i) => i.name));
|
||||
const cfgOnly = d.config.filter((c) => !d.interfaces.some((i) => i.name === c.name)).map((c) => ({ name: c.name, kind: c.kind, state: 'ABSENT', addrs: [], master: '' }));
|
||||
const order = { bond: 1, bridge: 2, vlan: 3, ethernet: 0 };
|
||||
const list = [...live, ...cfgOnly.filter((c) => !names.has(c.name))].sort((a, b) => ((order[this.kindOf(a)] ?? 4) - (order[this.kindOf(b)] ?? 4)) || a.name.localeCompare(b.name, undefined, { numeric: true }));
|
||||
const grid = h('<div class="net-grid"></div>');
|
||||
for (const i of list) grid.append(this.card(i));
|
||||
wrap.append(grid);
|
||||
this.body.append(wrap);
|
||||
this.body.scrollTop = scroll;
|
||||
}
|
||||
|
||||
kindOf(i) { return this.cfg(i.name)?.kind || (['bond', 'bridge', 'vlan'].includes(i.kind) ? i.kind : 'ethernet'); }
|
||||
|
||||
card(i) {
|
||||
const c = this.cfg(i.name);
|
||||
const kind = this.kindOf(i);
|
||||
const up = i.state === 'UP' || i.state === 'UNKNOWN';
|
||||
const summary = !c ? `<span class="muted">${T('Not configured')}</span>`
|
||||
: c.method4 === 'dhcp' ? 'DHCP' : c.method4 === 'static' ? `${T('Static')} ${esc(c.addresses.join(', '))}${c.gateway ? ` → ${esc(c.gateway)}` : ''}` : `<span class="muted">${T('No IPv4')}</span>`;
|
||||
const b = i.bonding;
|
||||
const bondInfo = b ? `<div class="net-bond"><div><span class="muted">${T('Mode')}:</span> ${esc(b.mode || '')}${b.lacpRate ? ` · LACP ${esc(b.lacpRate)}` : ''}${b.hashPolicy ? ` · ${esc(b.hashPolicy)}` : ''}</div>
|
||||
${b.slaves.map((s) => `<div class="slave"><span class="dot ${s.mii === 'up' ? 'on' : 'err'}"></span>${esc(s.name)} <span class="muted">${esc(s.speed || '')}${s.aggregator ? ` · Agg ${esc(s.aggregator)}` : ''}</span></div>`).join('')}</div>` : '';
|
||||
const el = h(`<div class="net-card ${i.state === 'ABSENT' ? 'absent' : ''}">
|
||||
<div class="nh"><span class="dot ${i.state === 'ABSENT' ? '' : up ? 'on' : 'err'}"></span><b>${esc(i.name)}</b><span class="kind">${NET_KINDS[kind] || esc(i.kind || '')}</span>
|
||||
${i.master ? `<span class="muted">→ ${esc(i.master)}</span>` : ''}<span class="grow"></span>
|
||||
${this.data.editable ? `<button class="btn ghost sm" data-n="edit" title="${T('Edit')}">${ICONS.edit}</button>` : ''}
|
||||
${this.data.editable && c && kind !== 'ethernet' ? `<button class="btn ghost sm" data-n="del" title="${T('Delete')}">${ICONS.trash}</button>` : ''}</div>
|
||||
<div class="nm muted">${i.mac ? esc(i.mac) : ''}${i.mtu ? ` · MTU ${i.mtu}` : ''}${i.state === 'ABSENT' ? T('Configured, but not present') : ''}</div>
|
||||
<div class="na">${i.addrs.map((a) => `<div>${esc(a.addr)}${a.dynamic ? ' <span class="tag">DHCP</span>' : ''}</div>`).join('') || `<div class="muted">${T('No address')}</div>`}</div>
|
||||
${bondInfo}
|
||||
<div class="ncfg"><span class="muted">${T('Configured')}:</span> ${summary}</div></div>`);
|
||||
el.onclick = (e) => {
|
||||
const a = e.target.closest('[data-n]')?.dataset.n;
|
||||
if (a === 'edit') this.edit(i.name, kind);
|
||||
if (a === 'del') this.remove(c);
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
// Anwenden mit Rollback-Anzeige
|
||||
async run(label, fn) {
|
||||
this.applying = true;
|
||||
const ov = h(`<div class="overlay"><div class="spinner"></div><div class="msg">${esc(label)}</div><div class="msg" style="font-size:12px;color:var(--faint)">${T('MrTerm reconnects to confirm the change. Without confirmation the server restores the previous configuration after 90 seconds.')}</div></div>`);
|
||||
this.el.append(ov);
|
||||
try {
|
||||
const r = await fn();
|
||||
ov.remove();
|
||||
if (r?.confirmed) { toast(r.direct ? T('Saved') : T('Network change applied and confirmed'), 'ok'); await this.refresh(); }
|
||||
else {
|
||||
setTabState(this, 'err');
|
||||
this.body.innerHTML = `<div class="pane-empty"><div style="color:var(--orange);max-width:560px;text-align:center">${T('MrTerm could not reconnect after the change. The server restores the previous configuration automatically within 90 seconds.')}</div></div>`;
|
||||
const b = h(`<button class="btn primary">${ICONS.refresh}${T('Reconnect')}</button>`);
|
||||
b.onclick = () => { closeTab(this.id); openNetwork(this.host); };
|
||||
$('.pane-empty', this.body).append(b);
|
||||
}
|
||||
} catch (e) { ov.remove(); toast(e.message, 'error'); }
|
||||
this.applying = false;
|
||||
}
|
||||
|
||||
async confirmApply(what) {
|
||||
const r = await modal({ title: T('Apply network change?'), text: what,
|
||||
body: `<p style="margin-top:0">${T('The change is applied immediately. If MrTerm cannot reconnect within 90 seconds, the server restores the previous configuration automatically.')}</p>
|
||||
<div class="field"><label>${T('Reconnect via')}</label><input name="verify" value="${esc(this.host.address)}"/><div class="hint">${T('Change this if the change affects the address MrTerm uses to connect.')}</div></div>`,
|
||||
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Apply'), value: 'form', cls: 'primary' }] });
|
||||
return r ? (r.verify.trim() === this.host.address ? '' : r.verify.trim()) : null;
|
||||
}
|
||||
|
||||
edit(name, kind) {
|
||||
const d = this.data;
|
||||
const isNew = !name;
|
||||
const m = structuredClone(this.cfg(name) || { name: name || '', kind, method4: 'none', addresses: [], gateway: '', dns: [], search: [], mtu: '', method6: 'auto', addresses6: [], gateway6: '',
|
||||
bond: kind === 'bond' ? { members: [], mode: '802.3ad', lacpRate: 'fast', hashPolicy: 'layer3+4', miimon: 100 } : undefined,
|
||||
bridge: kind === 'bridge' ? { members: [], stp: false } : undefined, vlan: kind === 'vlan' ? { id: '', link: '' } : undefined });
|
||||
if (!this.cfg(name) && name) { const live = d.interfaces.find((i) => i.name === name); if (live?.addrs.some((a) => a.dynamic)) m.method4 = 'dhcp'; }
|
||||
const phys = [...new Set([...d.interfaces.filter((i) => !VIRTUAL_IF.test(i.name) && (i.kind === 'ethernet' || i.kind === 'bond' || i.kind === 'vlan')).map((i) => i.name), ...d.config.filter((c) => c.kind !== 'bridge').map((c) => c.name)])].filter((x) => x !== m.name).sort();
|
||||
const memberBox = (sel, filter) => {
|
||||
const box = h('<div class="vpn-hosts"></div>');
|
||||
phys.filter(filter).forEach((x) => box.append(h(`<label class="check"><input type="checkbox" data-m="${esc(x)}" ${sel.includes(x) ? 'checked' : ''}/>${esc(x)}<span class="muted" style="margin-left:6px;font-size:11px">${esc(d.interfaces.find((i) => i.name === x)?.mac || '')}</span></label>`)));
|
||||
if (!box.children.length) box.append(h(`<div class="hint">${T('No suitable interfaces found.')}</div>`));
|
||||
return box;
|
||||
};
|
||||
const fields = [];
|
||||
if (isNew) fields.push(field(T('Name'), 'name', kind === 'bond' ? 'bond0' : kind === 'bridge' ? (d.backend === 'ifupdown' ? 'vmbr1' : 'br0') : '', { placeholder: kind === 'vlan' ? 'eno1.100' : '', hint: kind === 'vlan' ? T('Usually <parent>.<VLAN ID>, e.g. eno1.100') : '' }));
|
||||
let members = null;
|
||||
if (m.kind === 'bond') {
|
||||
members = memberBox(m.bond.members, (x) => this.kindOf({ name: x, kind: d.interfaces.find((i) => i.name === x)?.kind }) === 'ethernet');
|
||||
fields.push(heading(T('Bond')), h(`<label class="flabel">${T('Members')}</label>`), members,
|
||||
field(T('Mode'), 'bmode', m.bond.mode, { type: 'select', options: BOND_MODES }),
|
||||
row(field(T('LACP rate'), 'lacp', m.bond.lacpRate || 'slow', { type: 'select', options: [['fast', 'fast (1 s)'], ['slow', 'slow (30 s)']] }),
|
||||
field(T('Hash policy'), 'hash', m.bond.hashPolicy || 'layer2', { type: 'select', options: ['layer2', 'layer2+3', 'layer3+4', 'encap2+3', 'encap3+4'].map((x) => [x, x]) })),
|
||||
field(T('MII monitoring (ms)'), 'miimon', m.bond.miimon ?? 100, { type: 'number' }));
|
||||
}
|
||||
if (m.kind === 'bridge') {
|
||||
members = memberBox(m.bridge.members, () => true);
|
||||
fields.push(heading(T('Bridge')), h(`<label class="flabel">${T('Ports')}</label>`), members, check(T('Spanning Tree (STP)'), 'stp', m.bridge.stp));
|
||||
}
|
||||
if (m.kind === 'vlan') fields.push(heading('VLAN'), row(field(T('VLAN ID'), 'vid', m.vlan.id, { type: 'number', placeholder: '100' }), field(T('Parent interface'), 'vlink', m.vlan.link, { type: 'select', options: [['', '—'], ...phys.map((x) => [x, x])] })));
|
||||
const v4 = h('<div></div>');
|
||||
v4.append(field(T('IPv4 addresses (one per line, CIDR)'), 'addresses', m.addresses.join('\n'), { type: 'textarea', placeholder: '192.168.1.10/24' }), field(T('Gateway'), 'gateway', m.gateway, { placeholder: '192.168.1.1' }));
|
||||
const v6 = h('<div></div>');
|
||||
v6.append(field(T('IPv6 addresses (one per line, CIDR)'), 'addresses6', m.addresses6.join('\n'), { type: 'textarea', placeholder: '2001:db8::10/64' }), field(T('IPv6 gateway'), 'gateway6', m.gateway6));
|
||||
fields.push(heading('IPv4'), field(T('Method'), 'method4', m.method4, { type: 'select', options: [['dhcp', 'DHCP'], ['static', T('Static')], ['none', T('No IPv4 address')]] }), v4,
|
||||
heading('DNS'), row(field(T('DNS servers'), 'dns', m.dns.join(', '), { placeholder: '1.1.1.1, 9.9.9.9' }), field(T('Search domains'), 'search', m.search.join(' '), { placeholder: 'example.lan' })),
|
||||
heading('IPv6'), field(T('Method'), 'method6', m.method6, { type: 'select', options: [['auto', T('Automatic (SLAAC)')], ['dhcp', 'DHCPv6'], ['static', T('Static')], ['none', T('Disabled')]] }), v6,
|
||||
heading(T('Advanced')), field('MTU', 'mtu', m.mtu, { type: 'number', placeholder: '1500' }));
|
||||
const form = openDrawer(isNew ? T('New {kind}', { kind: NET_KINDS[kind] }) : `${NET_KINDS[m.kind]} ${m.name}`, fields, async () => {
|
||||
const f = formValues(form);
|
||||
const out = { ...m, name: isNew ? f.name.trim() : m.name, method4: f.method4, addresses: f.method4 === 'static' ? splitList(f.addresses) : [], gateway: f.method4 === 'static' ? f.gateway.trim() : '',
|
||||
dns: splitList(f.dns), search: splitList(f.search), mtu: f.mtu, method6: f.method6, addresses6: f.method6 === 'static' ? splitList(f.addresses6) : [], gateway6: f.method6 === 'static' ? f.gateway6.trim() : '' };
|
||||
const sel = members ? $$('[data-m]', members).filter((x) => x.checked).map((x) => x.dataset.m) : [];
|
||||
if (m.kind === 'bond') { if (!sel.length) throw new Error(T('Select at least one member.')); out.bond = { members: sel, mode: f.bmode, lacpRate: f.bmode === '802.3ad' ? f.lacp : '', hashPolicy: f.hash, miimon: f.miimon }; }
|
||||
if (m.kind === 'bridge') out.bridge = { members: sel, stp: f.stp };
|
||||
if (m.kind === 'vlan') { out.vlan = { id: f.vid, link: f.vlink }; if (!f.vlink) throw new Error(T('Select the parent interface.')); }
|
||||
if (isNew && d.config.some((c) => c.name === out.name)) throw new Error(T('An interface with this name already exists.'));
|
||||
const verify = await this.confirmApply(`${NET_KINDS[out.kind]} ${out.name}`);
|
||||
if (verify === null) return false;
|
||||
closeDrawer();
|
||||
this.run(T('Applying network configuration …'), () => api.call('network:save', this.id, out, verify));
|
||||
return false;
|
||||
});
|
||||
const sync = () => {
|
||||
v4.style.display = $('[name=method4]', form).value === 'static' ? '' : 'none';
|
||||
v6.style.display = $('[name=method6]', form).value === 'static' ? '' : 'none';
|
||||
const bm = $('[name=bmode]', form);
|
||||
if (bm) $('[name=lacp]', form).closest('.field').style.display = bm.value === '802.3ad' ? '' : 'none';
|
||||
};
|
||||
form.addEventListener('change', sync);
|
||||
sync();
|
||||
}
|
||||
|
||||
async remove(c) {
|
||||
if (!(await confirmBox(T('Delete {kind} {name}?', { kind: NET_KINDS[c.kind], name: c.name }), T('The interface is removed from the configuration. Members keep their current settings.')))) return;
|
||||
const verify = await this.confirmApply(`${T('Delete')} ${c.name}`);
|
||||
if (verify === null) return;
|
||||
this.run(T('Applying network configuration …'), () => api.call('network:remove', this.id, c, verify));
|
||||
}
|
||||
|
||||
async hostname() {
|
||||
const n = await promptBox(T('Change hostname'), T('Hostname'), this.data.hostname);
|
||||
if (!n || n === this.data.hostname) return;
|
||||
try { await call('network:hostname', this.id, n.trim()); toast(T('Hostname changed'), 'ok'); this.refresh(); } catch {}
|
||||
}
|
||||
|
||||
async resolv() {
|
||||
const r = await modal({ title: T('DNS servers'), body: `<div class="field"><label>${T('DNS servers')}</label><input name="s" value="${esc(this.data.dns.servers.join(', '))}"/></div><div class="field"><label>${T('Search domains')}</label><input name="d" value="${esc(this.data.dns.search.join(' '))}"/></div><div class="hint">${T('Written to /etc/resolv.conf.')}</div>`,
|
||||
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Save'), value: 'form', cls: 'primary' }] });
|
||||
if (!r) return;
|
||||
try { await call('network:resolv', this.id, splitList(r.s), splitList(r.d)); toast(T('Saved'), 'ok'); this.refresh(); } catch {}
|
||||
}
|
||||
|
||||
async files() {
|
||||
const list = [...new Set([...this.data.files, '/etc/hosts', ...(this.data.dns.editable ? ['/etc/resolv.conf'] : [])])];
|
||||
const f = await new Promise((res) => ctxMenu(innerWidth - 320, 90, list.map((p) => ({ label: p, icon: 'file', run: () => res(p) }))));
|
||||
let content;
|
||||
try { content = await call('network:readFile', this.id, f); } catch { return; }
|
||||
const net = !/^\/etc\/(hosts|resolv\.conf)$/.test(f);
|
||||
const form = openDrawer(f, [field(T('Content'), 'content', content, { type: 'textarea', hint: net ? T('Saving applies the file with automatic rollback.') : '' })], async () => {
|
||||
const v = formValues(form).content;
|
||||
if (!net) { this.run(T('Saving …'), () => api.call('network:writeFile', this.id, f, v, '')); return; }
|
||||
const verify = await this.confirmApply(f);
|
||||
if (verify === null) return false;
|
||||
closeDrawer();
|
||||
this.run(T('Applying network configuration …'), () => api.call('network:writeFile', this.id, f, v, verify));
|
||||
return false;
|
||||
});
|
||||
const ta = $('textarea', form); ta.classList.add('mono'); ta.style.minHeight = '60vh';
|
||||
}
|
||||
|
||||
dispose() { this.unsub(); api.call('network:close', this.id).catch(() => {}); }
|
||||
}
|
||||
function openNetwork(host) { return new NetworkSession(host); }
|
||||
|
||||
// ============================================================ Command Palette / Quick Connect
|
||||
function openPalette() {
|
||||
if ($('.palette')) return;
|
||||
@@ -1991,6 +2255,7 @@ function openPalette() {
|
||||
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--folder)">${ICONS.folder}</div>`, label: `SFTP: ${x.label || x.address}`, run: () => openSftp(x) });
|
||||
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--accent-2)">${ICONS.docker}</div>`, label: `Docker: ${x.label || x.address}`, run: () => openDocker(x) });
|
||||
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--orange)">${ICONS.wall}</div>`, label: `${T('Firewall')}: ${x.label || x.address}`, run: () => openFirewall(x) });
|
||||
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--accent)">${ICONS.network}</div>`, label: `${T('Network')}: ${x.label || x.address}`, run: () => openNetwork(x) });
|
||||
});
|
||||
S.vault.snippets.filter((s) => q && s.label.toLowerCase().includes(q)).slice(0, 5).forEach((s) => items.push({ grp: 'Snippets', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.code}</div>`, label: s.label, sub: s.command, run: () => runSnippet(s) }));
|
||||
[[T('New host'), () => { activateTab('home'); editHost({}); }], [T('Generate key'), generateKey], [T('Settings'), () => $('[data-view=settings]').click()]]
|
||||
|
||||
Reference in New Issue
Block a user