15 lines
7.4 KiB
JavaScript
15 lines
7.4 KiB
JavaScript
const rulesEl = document.querySelector('#rules'); const emptyEl = document.querySelector('#empty'); const editor = document.querySelector('#editor'); const form = document.querySelector('#ruleForm'); const template = document.querySelector('#ruleTemplate'); const openLogs = new Set(); let rules = [];
|
|
|
|
async function api(url, options = {}) { const endpoint = new URL(url, `${location.protocol}//${location.host}`); const response = await fetch(endpoint, { headers:{ 'content-type':'application/json', ...(options.headers || {}) }, ...options }); if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || `请求失败:${response.status}`); } return response.status === 204 ? null : response.json(); }
|
|
function setMode() { const udp = form.protocol.value === 'udp'; const bindOption = form.mode.querySelector('option[value="bind"]'); bindOption.disabled = udp; if (udp && form.mode.value === 'bind') form.mode.value = 'forward'; const bind = form.mode.value === 'bind'; const advanced = document.querySelector('#viewMode').value === 'advanced'; document.querySelectorAll('.target').forEach((el) => el.classList.toggle('hidden', bind)); document.querySelectorAll('.advanced').forEach((el) => el.classList.toggle('hidden', !advanced)); document.querySelector('.tcp-only').classList.toggle('hidden', udp || !advanced); document.querySelector('.webhook-body').classList.toggle('hidden', !advanced || form.webhookMethod.value !== 'post'); }
|
|
function text(value) { return value || '—'; }
|
|
function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (character) => ({ '&':'&', '<':'<', '>':'>', "'":''', '"':'"' })[character]); }
|
|
function render() { rulesEl.replaceChildren(); emptyEl.hidden = rules.length > 0; const running = rules.filter((r) => r.running).length; document.querySelector('#summary').textContent = `${running}/${rules.length} 条规则运行中`; for (const rule of rules) { const node = template.content.cloneNode(true); node.querySelector('.name').textContent = rule.name; node.querySelector('.meta').textContent = `${rule.protocol.toUpperCase()} · ${rule.mode === 'forward' ? '内置转发' : '路由器直转'} · 本地通道 ${rule.bindPort || '随机'}`; const mapping = node.querySelector('.mapping'); if (rule.state?.publicAddress) mapping.textContent = `公网映射 ${rule.state.publicAddress}:${rule.state.publicPort}`; else { mapping.textContent = rule.running ? '正在建立 STUN 映射…' : '规则未运行'; mapping.classList.add('offline'); }
|
|
const router = rule.routerState; const routerValue = !router ? (rule.routerMapping === 'none' ? '不使用' : '等待路由器响应') : `${router.type.toUpperCase()} ${router.externalAddress || 'WAN 地址未知'}:${router.externalPort}${router.matchesStunAddress ? '' : '(与 STUN 地址不一致)'}`; const details = [['通道健康', rule.health?.local === 'healthy' ? '正常' : rule.health?.detail || '等待建立'], ['公网探针', rule.health?.external === 'not-configured' ? '未配置,公网可达性未知' : rule.health?.external === 'healthy' ? '可从公网访问' : rule.health?.externalDetail || '等待探测'], ['最近验证', rule.health?.verifiedAt ? new Date(rule.health.verifiedAt).toLocaleString() : '—'], ['STUN 观测', rule.state?.publicAddress ? `${rule.state.publicAddress}:${rule.state.publicPort}` : '—'], ['路由器映射', routerValue], ['目标服务', rule.mode === 'forward' ? `${rule.targetHost}:${rule.targetPort}` : '由路由器端口转发决定'], ['保活 / 检测', `${rule.interval}s / 每 ${rule.checkEvery} 次`], ['本地绑定', rule.state?.privateAddress ? `${rule.state.privateAddress}:${rule.state.privatePort}` : '等待建立'], ['IP4P', rule.state?.ip4p || '—']]; node.querySelector('.details').innerHTML = details.map(([key, val]) => `<div class="detail"><div class="detail-label">${escapeHtml(key)}</div><div class="detail-value">${escapeHtml(text(val))}</div></div>`).join(''); const logDetails = node.querySelector('details'); logDetails.open = openLogs.has(rule.id); logDetails.addEventListener('toggle', () => { if (logDetails.open) openLogs.add(rule.id); else openLogs.delete(rule.id); }); node.querySelector('.logs').textContent = (rule.logs || []).map((log) => `${log.at} ${log.level.toUpperCase()} ${log.message}`).join('\n') || '暂无日志';
|
|
const toggle = node.querySelector('.toggle'); toggle.textContent = rule.running ? '停止' : '启动'; toggle.classList.toggle('secondary', rule.running); toggle.addEventListener('click', async () => { await api(`/api/rules/${rule.id}/${rule.running ? 'stop' : 'start'}`, { method:'POST' }); await load(); }); node.querySelector('.edit').addEventListener('click', () => openEditor(rule)); node.querySelector('.delete').addEventListener('click', async () => { if (confirm(`删除规则“${rule.name}”?`)) { await api(`/api/rules/${rule.id}`, { method:'DELETE' }); await load(); } }); rulesEl.append(node); } }
|
|
async function load() { try { rules = (await api('/api/rules')).rules; render(); } catch (error) { document.querySelector('#summary').textContent = error.message; } }
|
|
function openEditor(rule = null) { form.reset(); form.id.value = rule?.id || ''; form.name.value = rule?.name || ''; document.querySelector('#viewMode').value = rule ? 'advanced' : 'simple'; form.protocol.value = rule?.protocol || 'tcp'; form.bindPort.value = rule?.bindPort ?? 0; form.stunServer.value = rule?.stunServer || 'turn.cloudflare.com:3478'; form.keepaliveServer.value = rule?.keepaliveServer || 'www.cloudflare.com:80'; form.interval.value = rule?.interval || 10; form.checkEvery.value = rule?.checkEvery || 1; form.externalProbeUrl.value = rule?.externalProbeUrl || ''; form.externalProbeToken.value = rule?.externalProbeToken || ''; form.probeInterval.value = rule?.probeInterval || 10; form.webhookUrl.value = rule?.webhookUrl || ''; form.webhookMethod.value = rule?.webhookMethod || 'post'; form.webhookBody.value = rule?.webhookBody || ''; form.mode.value = rule?.mode || 'forward'; form.targetHost.value = rule?.targetHost || ''; form.targetPort.value = rule?.targetPort || ''; form.routerMapping.value = rule?.routerMapping || 'none'; form.firewallAuto.checked = rule?.firewallAuto || false; form.firewallNote.value = rule?.firewallNote || ''; form.enabled.checked = rule?.enabled ?? true; document.querySelector('#dialogTitle').textContent = rule ? '编辑穿透规则' : '添加穿透规则'; setMode(); editor.showModal(); }
|
|
document.querySelector('#newRule').addEventListener('click', () => openEditor()); document.querySelector('#closeEditor').addEventListener('click', () => editor.close()); document.querySelector('#cancelEditor').addEventListener('click', () => editor.close()); form.protocol.addEventListener('change', setMode); form.mode.addEventListener('change', setMode); form.webhookMethod.addEventListener('change', setMode); document.querySelector('#viewMode').addEventListener('change', setMode);
|
|
form.addEventListener('submit', async (event) => { event.preventDefault(); const data = Object.fromEntries(new FormData(form)); data.enabled = form.enabled.checked; data.firewallAuto = form.firewallAuto.checked; ['bindPort','interval','checkEvery','targetPort','probeInterval'].forEach((key) => { data[key] = Number(data[key] || 0); }); try { await api(form.id.value ? `/api/rules/${form.id.value}` : '/api/rules', { method: form.id.value ? 'PUT' : 'POST', body: JSON.stringify(data) }); editor.close(); await load(); } catch (error) { alert(error.message); } });
|
|
load(); setInterval(load, 5000);
|