import {
html, useState, useEffect, useCallback, useRef,
} from './vendor/htm-preact.js';
import { t } from './i18n.js';
import * as platform from './platform.js';
import { Icon } from './icon.js';
// ── Node management (D5) ────────────────────────────────────────────────────
//
// Electron-only: talks to the local node daemon via its loopback HTTP API
// (platform.node.call), not over MNP/WebRTC. The MNP protocol types remain
// for potential future browser-side use.
function NodeServicePanel({ onChanged }) {
const [info, setInfo] = useState(null);
const [busy, setBusy] = useState('');
const [err, setErr] = useState('');
const refresh = useCallback(async () => {
try {
const r = await platform.node.service.status();
setInfo(r);
setErr('');
} catch (e) {
setErr(platform.bridgeMessage(e));
}
}, []);
useEffect(() => {
if (!platform.node.service.available) return;
refresh();
const timer = setInterval(refresh, 5000);
return () => clearInterval(timer);
}, [refresh]);
const act = useCallback(async (name, fn) => {
setBusy(name);
setErr('');
try {
await fn();
await refresh();
if (onChanged) onChanged();
} catch (e) {
setErr(platform.bridgeMessage(e));
} finally {
setBusy('');
}
}, [refresh, onChanged]);
// "off" / "signin" / "service" -- derived from the status payload, no new
// backend field needed: mode/autostart already distinguish all three.
const startupMode = (i) => {
if (!i) return 'off';
if (i.mode === 'service') return i.mode;
return i.autostart ? 'signin' : 'off';
};
// Switching mode itself — the installer's own radio page only runs once,
// at install time, so this is the only way back in if service mode was
// declined there, or out if it is no longer wanted. One elevation, task +
// firewall together, same script the installer runs.
//
// The two mechanisms are mutually exclusive by construction here: never
// both installed at once, which would start the daemon twice (once at
// boot via the Scheduled Task, again at sign-in via the Startup .vbs).
// Always remove whichever one is currently active before installing the
// target, so every transition -- not just the two that used to be
// separate toggles -- keeps that invariant.
const changeStartupMode = useCallback((target) => {
const current = startupMode(info);
if (target === current) return;
act('startupMode', async () => {
if (current === 'service') await platform.node.serviceMode.remove();
else if (current === 'signin') await platform.node.autostart.remove();
if (target === 'service') await platform.node.serviceMode.install();
else if (target === 'signin') await platform.node.autostart.install();
});
}, [act, info]);
if (!platform.node.service.available) return null;
if (!info || info.supported === false) {
return html`
${label}
${err && html`
${err}
`}
act('start', () => platform.node.start())}>
${busy === 'start' ? t('node.service_starting') : t('node.service_start')}
${info.installed && html`
act('stop', () => platform.node.service.stop())}>
${busy === 'stop' ? t('node.service_stopping') : t('node.service_stop')}
act('restart', () => platform.node.service.restart())}>
${busy === 'restart' ? t('node.service_restarting') : t('node.service_restart')}
`}
${info.mode === 'service' && html`
${t('node.service_mode_hint')}
`}
${showStartupRow && html`
${t('node.startup_mode_label')}
changeStartupMode(e.target.value)}>
${t('node.startup_mode_off')}
${t('node.startup_mode_signin')}
${t('node.startup_mode_service')}
${busy === 'startupMode' && html`
${t('node.startup_mode_updating')}
`}
${!info.canElevate && html`
${t('node.startup_mode_service_unavailable_hint')}
`}
`}
`;
}
async function nodeCall(method, path, body) {
return platform.node.call(method, path, body);
}
// Hand a generated file to the user: native Save As on the desktop, a blob
// download link everywhere else. Small text only (CSV export) — not the
// large-file download path in file-utils.js.
async function saveCsv(filename, text) {
const bytes = new TextEncoder().encode(text);
try {
const target = await platform.nativeSave(filename, { auto: false });
if (target && target.writable) {
if (target.open) await target.open();
await target.writable.write(bytes);
await target.writable.close();
return;
}
} catch { /* fall through to the blob path */ }
const url = URL.createObjectURL(new Blob([bytes], { type: 'text/csv' }));
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
export function NodePage({ groups }) {
const [status, setStatus] = useState('idle');
const [error, setError] = useState('');
const [nodeGroups, setNodeGroups] = useState([]);
const [busy, setBusy] = useState(false);
const [actionMsg, setActionMsg] = useState('');
const [roster, setRoster] = useState(null);
const [rosterGroup, setRosterGroup] = useState('');
const [denylist, setDenylist] = useState(null);
const [showDenylist, setShowDenylist] = useState(false);
const [nodeSettings, setNodeSettings] = useState(null);
const [editSettings, setEditSettings] = useState(null);
const [savingSettings, setSavingSettings] = useState(false);
const [editStun, setEditStun] = useState(null);
const [savingStun, setSavingStun] = useState(false);
const [stunInput, setStunInput] = useState('');
const [editIce, setEditIce] = useState(null);
const [savingIce, setSavingIce] = useState(false);
const [iceInput, setIceInput] = useState('');
const [operatorPaired, setOperatorPaired] = useState(false);
const [pairBusy, setPairBusy] = useState(false);
const [pairStatus, setPairStatus] = useState('');
const [nodeInfo, setNodeInfo] = useState(null);
const [peers, setPeers] = useState(null);
const [audit, setAudit] = useState(null);
const [auditEvent, setAuditEvent] = useState('');
const [auditPageSize, setAuditPageSize] = useState(100);
const [auditPage, setAuditPage] = useState(0);
const [auditHasMore, setAuditHasMore] = useState(false);
const [auditExporting, setAuditExporting] = useState(false);
const [cacheCount, setCacheCount] = useState(null);
const [nodeRoster, setNodeRoster] = useState(null);
const [unlinkBusy, setUnlinkBusy] = useState(false);
const [tab, setTab] = useState('overview');
const fetchStatus = useCallback(async () => {
setStatus('connecting');
setError('');
try {
const detection = await platform.node.detect();
if (!detection.detected) {
setError(t('node.offline'));
setStatus('error');
return;
}
const result = await nodeCall('GET', '/api/groups');
setNodeGroups(result.groups || []);
setOperatorPaired(!!result.operator_paired);
setNodeSettings(result.settings || null);
try { setNodeInfo(await nodeCall('GET', '/api/status')); } catch {}
setStatus('connected');
} catch (err) {
setError(platform.bridgeMessage(err));
setStatus('error');
}
}, []);
useEffect(() => { fetchStatus(); }, [fetchStatus]);
useEffect(() => {
if (nodeSettings && !editSettings) setEditSettings({ ...nodeSettings });
if (nodeSettings && !editStun) setEditStun([...(nodeSettings.stun_servers || [])]);
if (nodeSettings && !editIce) setEditIce([...(nodeSettings.ice_interfaces || [])]);
}, [nodeSettings]);
const refresh = useCallback(async () => {
try {
const result = await nodeCall('GET', '/api/groups');
setNodeGroups(result.groups || []);
setOperatorPaired(!!result.operator_paired);
setNodeSettings(result.settings || null);
try { setNodeInfo(await nodeCall('GET', '/api/status')); } catch {}
} catch {}
}, []);
const addRoot = useCallback(async (groupId) => {
const chosen = await platform.rootPicker.choose();
if (!chosen) return;
setBusy(true);
setActionMsg('');
try {
await nodeCall('POST', `/api/groups/${groupId}/roots`, { path: chosen.path });
await nodeCall('POST', '/api/reload');
await refresh();
setActionMsg(t('node.root_added'));
} catch (err) {
setActionMsg(platform.bridgeMessage(err));
} finally {
setBusy(false);
}
}, [refresh]);
const removeRoot = useCallback(async (groupId, rootName) => {
if (!confirm(t('node.root_remove_confirm', { name: rootName }))) return;
setBusy(true);
setActionMsg('');
try {
await nodeCall('DELETE', `/api/groups/${groupId}/roots/${encodeURIComponent(rootName)}`);
await nodeCall('POST', '/api/reload');
await refresh();
setActionMsg(t('node.root_removed'));
} catch (err) {
setActionMsg(platform.bridgeMessage(err));
} finally {
setBusy(false);
}
}, [refresh]);
const loadRoster = useCallback(async (groupId) => {
setBusy(true);
try {
const result = await nodeCall('GET', `/api/roster?group_id=${groupId}`);
setRoster(result);
setRosterGroup(groupId);
} catch (err) {
setActionMsg(platform.bridgeMessage(err));
} finally {
setBusy(false);
}
}, []);
const unpinMember = useCallback(async (userId) => {
if (!confirm(t('node.unpin_confirm', { name: userId }))) return;
setBusy(true);
setActionMsg('');
try {
await nodeCall('POST', `/api/members/${userId}/unpin`);
setActionMsg(t('node.unpin_done'));
if (rosterGroup) await loadRoster(rosterGroup);
} catch (err) {
setActionMsg(platform.bridgeMessage(err));
} finally {
setBusy(false);
}
}, [rosterGroup, loadRoster]);
const rotateGek = useCallback(async (groupId) => {
if (!confirm(t('node.gek_rotate_confirm'))) return;
setBusy(true);
setActionMsg('');
try {
await nodeCall('POST', `/api/groups/${groupId}/gek?rotate=true`);
setActionMsg(t('node.gek_rotated'));
} catch (err) {
setActionMsg(platform.bridgeMessage(err));
} finally {
setBusy(false);
}
}, []);
const loadDenylist = useCallback(async () => {
setBusy(true);
try {
const result = await nodeCall('GET', '/api/denylist');
setDenylist(result);
setShowDenylist(true);
} catch (err) {
setActionMsg(platform.bridgeMessage(err));
} finally {
setBusy(false);
}
}, []);
const clearDenylist = useCallback(async (subject) => {
const label = subject || t('node.denylist_clear_all');
if (!confirm(t('node.denylist_clear_confirm', { subject: label }))) return;
setBusy(true);
setActionMsg('');
try {
await nodeCall('POST', `/api/denylist/clear?subject=${encodeURIComponent(subject)}`);
setActionMsg(t('node.denylist_cleared'));
await loadDenylist();
} catch (err) {
setActionMsg(platform.bridgeMessage(err));
} finally {
setBusy(false);
}
}, [loadDenylist]);
const detachGroup = useCallback(async (name) => {
if (!confirm(t('node.detach_confirm', { name }))) return;
setBusy(true);
setActionMsg('');
try {
await nodeCall('POST', '/api/groups/detach', { name });
await nodeCall('POST', '/api/reload');
setActionMsg(t('node.detached'));
await refresh();
} catch (err) {
setActionMsg(platform.bridgeMessage(err));
} finally {
setBusy(false);
}
}, [refresh]);
const reloadConfig = useCallback(async () => {
setBusy(true);
setActionMsg('');
try {
await nodeCall('POST', '/api/reload');
setActionMsg(t('node.reloaded'));
await refresh();
} catch (err) {
setActionMsg(platform.bridgeMessage(err));
} finally {
setBusy(false);
}
}, [refresh]);
const saveSettings = useCallback(async () => {
if (!editSettings) return;
setSavingSettings(true);
setActionMsg('');
try {
await nodeCall('PUT', '/api/node-settings', editSettings);
setNodeSettings({ ...editSettings });
setActionMsg(t('node.settings_saved'));
} catch (err) {
setActionMsg(platform.bridgeMessage(err));
} finally {
setSavingSettings(false);
}
}, [editSettings]);
const saveStun = useCallback(async () => {
if (!editStun) return;
setSavingStun(true);
setActionMsg('');
try {
await nodeCall('PUT', '/api/node-settings', { stun_servers: editStun });
setNodeSettings(s => s ? { ...s, stun_servers: [...editStun] } : s);
setActionMsg(t('node.stun_saved'));
} catch (err) {
setActionMsg(platform.bridgeMessage(err));
} finally {
setSavingStun(false);
}
}, [editStun]);
const addStunServer = useCallback(() => {
const url = stunInput.trim();
if (!url) return;
if (!url.startsWith('stun:')) {
setActionMsg(t('node.stun_invalid'));
return;
}
if (editStun && editStun.includes(url)) {
setActionMsg(t('node.stun_duplicate'));
return;
}
setEditStun(s => [...(s || []), url]);
setStunInput('');
setActionMsg('');
}, [stunInput, editStun]);
const removeStunServer = useCallback((idx) => {
setEditStun(s => s.filter((_, i) => i !== idx));
}, []);
const moveStunServer = useCallback((idx, dir) => {
setEditStun(s => {
const a = [...s];
const target = idx + dir;
if (target < 0 || target >= a.length) return a;
[a[idx], a[target]] = [a[target], a[idx]];
return a;
});
}, []);
const resetStunDefaults = useCallback(() => {
// Keep in step with meshbay_node.config.DEFAULT_STUN_SERVERS.
setEditStun([
'stun:stun.l.google.com:19302',
'stun:stun1.l.google.com:19302',
'stun:stun.cloudflare.com:3478',
]);
setActionMsg('');
}, []);
const saveIce = useCallback(async () => {
if (!editIce) return;
setSavingIce(true);
setActionMsg('');
try {
await nodeCall('PUT', '/api/node-settings', { ice_interfaces: editIce });
setNodeSettings(s => s ? { ...s, ice_interfaces: [...editIce] } : s);
setActionMsg(t('node.ice_saved'));
} catch (err) {
setActionMsg(platform.bridgeMessage(err));
} finally {
setSavingIce(false);
}
}, [editIce]);
const addIceInterface = useCallback(() => {
const name = iceInput.trim();
if (!name) return;
if (editIce && editIce.includes(name)) {
setActionMsg(t('node.ice_duplicate'));
return;
}
setEditIce(s => [...(s || []), name]);
setIceInput('');
setActionMsg('');
}, [iceInput, editIce]);
const removeIceInterface = useCallback((idx) => {
setEditIce(s => s.filter((_, i) => i !== idx));
}, []);
const resetIceAuto = useCallback(() => {
setEditIce([]);
setActionMsg('');
}, []);
const doPairOperator = useCallback(async () => {
setPairBusy(true);
setPairStatus('');
try {
const result = await nodeCall('POST', '/api/operator/pair');
if (result && result.code) {
await platform.node.setPairingCode(result.code);
}
setPairStatus('paired');
setOperatorPaired(true);
await refresh();
} catch (err) {
setPairStatus(platform.bridgeMessage(err));
} finally {
setPairBusy(false);
}
}, [refresh]);
const loadPeers = useCallback(async () => {
setBusy(true);
try {
const r = await nodeCall('GET', '/api/peers');
setPeers(r.peers || []);
} catch (err) {
setActionMsg(platform.bridgeMessage(err));
} finally {
setBusy(false);
}
}, []);
const loadAudit = useCallback(async () => {
setBusy(true);
try {
const offset = auditPage * auditPageSize;
let path = `/api/audit?limit=${auditPageSize}&offset=${offset}`;
if (auditEvent) path += `&event=${encodeURIComponent(auditEvent)}`;
const r = await nodeCall('GET', path);
setAudit(r.entries || []);
setAuditHasMore(!!r.has_more);
} catch (err) {
setActionMsg(platform.bridgeMessage(err));
} finally {
setBusy(false);
}
}, [auditEvent, auditPage, auditPageSize]);
const exportAuditCsv = useCallback(async () => {
setAuditExporting(true);
setActionMsg('');
try {
// Every entry that matches the current event filter, not just one page.
// Walk the log newest-first in blocks; de-dupe by id so an event written
// mid-export (which shifts rows to a higher offset) cannot duplicate one.
const PAGE = 1000;
const MAX_PAGES = 1000; // 1M-row stop, so a bug cannot spin forever
const evq = auditEvent ? `&event=${encodeURIComponent(auditEvent)}` : '';
const seen = new Set();
const rows = [];
for (let page = 0; page < MAX_PAGES; page++) {
const r = await nodeCall(
'GET', `/api/audit?limit=${PAGE}&offset=${page * PAGE}${evq}`);
const batch = r.entries || [];
for (const e of batch) {
if (!seen.has(e.id)) { seen.add(e.id); rows.push(e); }
}
if (!r.has_more || batch.length === 0) break;
}
const cols = ['timestamp', 'event', 'user', 'user_id', 'ip',
'group', 'group_id', 'detail'];
const esc = (v) => {
const s = v == null ? '' : String(v);
return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
};
const lines = [cols.join(',')];
for (const e of rows) {
lines.push([
new Date(e.timestamp * 1000).toISOString(),
e.event, e.username || '', e.user_id || '', e.ip || '',
e.group_name || '', e.group_id || '', e.detail || '',
].map(esc).join(','));
}
const csv = lines.join('\r\n') + '\r\n';
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
await saveCsv(`node-audit-${stamp}.csv`, csv);
setActionMsg(t('node.audit_exported', { n: rows.length }));
} catch (err) {
setActionMsg(platform.bridgeMessage(err));
} finally {
setAuditExporting(false);
}
}, [auditEvent]);
const loadCache = useCallback(async () => {
setBusy(true);
try {
const r = await nodeCall('GET', '/api/index-cache');
setCacheCount(r.count ?? 0);
} catch (err) {
setActionMsg(platform.bridgeMessage(err));
} finally {
setBusy(false);
}
}, []);
const pruneCache = useCallback(async () => {
setBusy(true);
setActionMsg('');
try {
const r = await nodeCall('POST', '/api/index-cache/prune');
setCacheCount(r.kept ?? null);
setActionMsg(t('node.maintenance_pruned', { n: r.removed ?? 0 }));
} catch (err) {
setActionMsg(platform.bridgeMessage(err));
} finally {
setBusy(false);
}
}, []);
const loadNodeRoster = useCallback(async () => {
setBusy(true);
try {
const r = await nodeCall('GET', '/api/roster');
setNodeRoster(r);
} catch (err) {
setActionMsg(platform.bridgeMessage(err));
} finally {
setBusy(false);
}
}, []);
const unpinNodeMember = useCallback(async (userId) => {
if (!confirm(t('node.unpin_confirm', { name: userId }))) return;
setBusy(true);
setActionMsg('');
try {
await nodeCall('POST', `/api/members/${userId}/unpin`);
setActionMsg(t('node.unpin_done'));
await loadNodeRoster();
} catch (err) {
setActionMsg(platform.bridgeMessage(err));
} finally {
setBusy(false);
}
}, [loadNodeRoster]);
const unlinkNode = useCallback(async () => {
if (!confirm(t('node.unlink_confirm'))) return;
setUnlinkBusy(true);
setActionMsg('');
try {
await nodeCall('DELETE', '/api/unlink');
setActionMsg(t('node.unlink_done'));
await refresh();
} catch (err) {
setActionMsg(platform.bridgeMessage(err));
} finally {
setUnlinkBusy(false);
}
}, [refresh]);
// Each read-only tab loads itself on open — no Load button.
useEffect(() => {
if (status !== 'connected') return;
if (tab === 'overview') loadCache();
else if (tab === 'roster') loadNodeRoster();
else if (tab === 'peers') loadPeers();
}, [tab, status, loadCache, loadNodeRoster, loadPeers]);
// Audit reloads on tab open and whenever the filter or page changes
// (loadAudit's identity tracks auditEvent / auditPage / auditPageSize).
useEffect(() => {
if (status === 'connected' && tab === 'audit') loadAudit();
}, [tab, status, loadAudit]);
if (status === 'idle' || status === 'connecting') {
return html`
${t('node.title')}
<${NodeServicePanel} onChanged=${fetchStatus} />
${' '}${t('status.connecting')}
`;
}
if (status === 'error') {
return html`
${t('node.title')}
<${NodeServicePanel} onChanged=${fetchStatus} />
${error}
${t('node.retry')}
`;
}
return html`
<${NodeServicePanel} onChanged=${fetchStatus} />
${actionMsg && html`
${actionMsg}
`}
${!operatorPaired && html`
${t('node.pair_needed')}
${pairBusy ? t('node.settings_saving') : t('node.pair_button')}
${pairStatus === 'paired'
? html`
${t('node.pair_success')}
`
: pairStatus ? html`
${pairStatus}
` : null}
`}
${['overview', 'groups', 'roster', 'peers', 'audit', 'settings'].map(k => html`
setTab(k)}>${t('node.tab_' + k)}
`)}
${tab === 'overview' && nodeInfo && html`
${t('node.overview')}
${t('node.overview_version')} ${nodeInfo.version || '—'}
${t('node.overview_node_id')}
${nodeInfo.endpoint_hint || nodeInfo.pk_node_ed25519 || '—'}
${t('node.overview_quic_port')} ${nodeInfo.quic_port || '—'}
${t('node.overview_hub')} ${nodeInfo.hub_url || '—'}
`}
${tab === 'groups' && (() => {
const hubIds = new Set((groups || []).map(g => g.id));
return nodeGroups.map(g => {
const stale = !hubIds.has(g.id);
return html`
${(g.roots || []).map(r => html`
<${Icon} name="folder" />
${r.name}
${r.writable && html`
${t('node.root_rw')} `}
${r.removable && html`
${t('node.removable')} `}
${r.ejected ? html`
${t('group.root_ejected')} `
: !r.available && html`
${t('node.unavailable')} `}
${/* Removing a writable root is allowed now — several can be
writable, and a group with none is a valid read-only
group. The last root is still the one that cannot go. */''}
${(g.roots || []).length > 1 && html`
removeRoot(g.id, r.name)}>
${t('node.remove_root')} `}
`)}
addRoot(g.id)}>
<${Icon} name="folder-plus" /> ${t('node.add_root')}
${t('node.gek')}
${g.has_gek ? (g.visibility !== 'public' ? html`
rotateGek(g.id)}>
${t('node.gek_rotate')}
` : html`
${t('node.gek_public_hint')}
`) : html`
${t('node.gek_init_hint')}
`}
${roster && rosterGroup === g.id && html`
${(roster.members || []).length === 0 ? html`
${t('node.roster_empty')}
` : html`
${t('node.roster_user')}
${t('node.roster_role')}
${t('node.roster_status')}
${t('node.roster_via')}
${(roster.members || []).map(m => html`
${m.username || m.user_id.slice(0, 12)}
${m.role}
${m.status}
${m.pinned_via || ''}
${m.pk_ed25519 && html`
unpinMember(m.user_id)}>
${t('node.unpin')}
`}
`)}
`}
${(roster.invites || []).length > 0 && html`
${t('node.roster_invites',
{ n: roster.invites.length })}
`}
`}
${stale && html`
detachGroup(g.name)}>
${t('node.detach_group')}
`}
`;
});
})()}
${tab === 'roster' && html`
${t('node.node_roster')}
${t('node.node_roster_hint')}
${!nodeRoster ? html`
${t('node.loading')}
` : (nodeRoster.members || []).length === 0 ? html`
${t('node.roster_empty')}
` : html`
`}
`}
${tab === 'peers' && html`
${t('node.peers_title')}
${!peers ? html`
${t('node.loading')}
` : peers.length === 0 ? html`
${t('node.peers_empty')}
` : html`
`}
`}
${tab === 'audit' && html`
{ setAuditEvent(e.target.value); setAuditPage(0); }}>
${t('node.audit_all_events')}
handshake
file_download
file_upload
file_delete
stream_video
chat_message
disconnect
auth_failed
{ setAuditPageSize(parseInt(e.target.value) || 100); setAuditPage(0); }}>
50
100
200
500
${!audit ? html`
${t('node.loading')}
` : audit.length === 0 ? html`
${t('node.audit_empty')}
` : html`
`}
${audit && html`
`}
`}
${tab === 'overview' && html`
${t('node.maintenance')}
${cacheCount === null ? html`
${t('node.loading')}
` : html`
${t('node.maintenance_cache', { n: cacheCount })}
${t('node.maintenance_prune')}
`}
`}
${tab === 'settings' && editSettings && html`
${t('node.settings')}
${t('node.settings_edit_hint')}
${savingSettings ? t('node.settings_saving') : t('node.settings_save')}
`}
${tab === 'settings' && editStun && html`
${t('node.stun_servers')}
${t('node.stun_hint')}
${editStun.length === 0 ? html`
${t('node.stun_empty')}
` : html`
${editStun.map((url, idx) => html`
${url}
moveStunServer(idx, -1)}
title=${t('node.stun_move_up')}>▲
moveStunServer(idx, 1)}
title=${t('node.stun_move_down')}>▼
removeStunServer(idx)}
title=${t('node.stun_remove')}>✕
`)}
`}
setStunInput(e.target.value)}
onKeyDown=${e => { if (e.key === 'Enter') addStunServer(); }} />
${t('node.stun_add')}
${savingStun ? t('node.stun_saving') : t('node.stun_save')}
${t('node.stun_reset')}
`}
${tab === 'settings' && editIce && html`
${t('node.ice_interfaces')}
${editIce.length === 0
? t('node.ice_auto_hint')
: t('node.ice_manual_hint')}
${editIce.length > 0 && html`
${editIce.map((name, idx) => html`
${name}
removeIceInterface(idx)}
title=${t('node.ice_remove')}>✕
`)}
`}
setIceInput(e.target.value)}
onKeyDown=${e => { if (e.key === 'Enter') addIceInterface(); }} />
${t('node.ice_add')}
${savingIce ? t('node.ice_saving') : t('node.ice_save')}
${editIce.length > 0 && html`
${t('node.ice_reset_auto')}
`}
`}
${tab === 'settings' && html`
${showDenylist && denylist && html`
${denylist.count === 0 ? html`
${t('node.denylist_empty')}
` : html`
${t('node.denylist_count',
{ n: denylist.count })}
${(denylist.users || []).map(u => html`
user: ${u}
clearDenylist(u)}>
${t('node.denylist_remove')}
`)}
${(denylist.groups || []).map(g => html`
group: ${g}
clearDenylist(g)}>
${t('node.denylist_remove')}
`)}
${(denylist.jtis || []).map(j => html`
token: ${j.slice(0, 16)}
clearDenylist(j)}>
${t('node.denylist_remove')}
`)}
clearDenylist('')}>
${t('node.denylist_clear_all')}
`}
`}
`}
${tab === 'settings' && nodeInfo && nodeInfo.status !== 'waiting_for_node_key' && html`
${t('node.unlink_title')}
${t('node.unlink_hint')}
${t('node.unlink_button')}
`}
`;
}