Files
cellular-proxy/ui/index.html
T
Hermes ccace51535 feat: 增量升级路径 --upgrade / --ui-only
- 已装机器默认自动走升级,保留 secret/网卡/账密
- 默认不重下 sing-box、不 rebind、不 verify
- scripts/upgrade.sh + install.sh 入口 + README
2026-07-21 12:48:00 +00:00

650 lines
25 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>数据出口代理 · 小岛控制台</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700;800;900&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet" />
<style>
/* animal-island-ui design tokens (from DESIGN_PROMPT / PROMPT.md) */
:root {
--primary: #19c8b9;
--primary-hover: #3dd4c6;
--primary-active: #11a89b;
--primary-bg: #e6f9f6;
--text: #794f27;
--text-body: #725d42;
--text-secondary: #9f927d;
--text-muted: #8a7b66;
--text-disabled: #c4b89e;
--bg: #f8f8f0;
--bg-content: rgb(247, 243, 223);
--bg-disabled: #f0ece2;
--border: #c4b89e;
--border-hover: #a89878;
--border-strong: #9f927d;
--success: #6fba2c;
--success-active: #5a9e1e;
--warning: #f5c31c;
--error: #e05a5a;
--error-active: #c94444;
--focus-yellow: #ffcc00;
--focus-yellow-d: #e0b800;
--shadow-btn: #bdaea0;
--shadow-soft: rgba(61, 52, 40, 0.06);
--r-sm: 12px;
--r-base: 18px;
--r-lg: 24px;
--r-pill: 50px;
--ease: cubic-bezier(0.4, 0, 0.2, 1);
--d-base: 0.25s;
--app-teal: #82d5bb;
--app-blue: #889df0;
--app-yellow: #f7cd67;
--app-green: #8ac68a;
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body {
font-family: Nunito, "Noto Sans SC", -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
font-weight: 500;
letter-spacing: 0.01em;
color: var(--text-body);
background:
radial-gradient(ellipse 80% 50% at 10% -10%, rgba(130, 213, 187, 0.35), transparent 55%),
radial-gradient(ellipse 60% 40% at 100% 0%, rgba(247, 205, 103, 0.28), transparent 50%),
radial-gradient(ellipse 50% 30% at 50% 100%, rgba(136, 157, 240, 0.18), transparent 55%),
var(--bg);
}
/* decorative dots (card pattern vibe) */
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
opacity: 0.35;
background-image: radial-gradient(circle at 20px 20px, rgba(159, 146, 125, 0.18) 2px, transparent 2.5px);
background-size: 28px 28px;
z-index: 0;
}
.page { position: relative; z-index: 1; max-width: 980px; margin: 0 auto; padding: 20px 16px 48px; }
/* Title ribbon (simplified swallowtail) */
.title-ribbon {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 10px 36px 10px 22px;
background: linear-gradient(180deg, #fff9e8 0%, var(--app-yellow) 100%);
color: var(--text);
font-weight: 900;
font-size: 1.05rem;
letter-spacing: 0.04em;
border: 2.5px solid #e0b85a;
border-radius: 8px 4px 4px 8px;
clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 50%, calc(100% - 18px) 100%, 0 100%);
box-shadow: 3px 4px 0 0 rgba(189, 174, 160, 0.55);
margin: 0;
}
.title-ribbon .leaf {
width: 18px; height: 18px;
background: var(--success);
border-radius: 60% 0 60% 0;
transform: rotate(-25deg);
box-shadow: inset -2px -2px 0 rgba(0,0,0,0.08);
flex-shrink: 0;
}
header.top {
display: flex; flex-wrap: wrap; gap: 14px;
align-items: center; justify-content: space-between;
margin-bottom: 18px;
}
.sub { color: var(--text-muted); font-size: 0.9rem; margin-top: 8px; font-weight: 600; }
.header-actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.pill {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 14px; border-radius: var(--r-pill);
font-size: 0.82rem; font-weight: 700; letter-spacing: 0.02em;
border: 2px solid var(--border-strong);
background: #fff;
color: var(--text-body);
}
.pill.ok { background: #eef8e3; border-color: var(--success); color: var(--success-active); }
.pill.bad { background: #fdecec; border-color: var(--error); color: var(--error-active); }
.pill.warn { background: #fff8df; border-color: var(--warning); color: #a07a08; }
/* Card */
.card {
background: var(--bg-content);
border: 2px solid var(--border-strong);
border-radius: 20px;
padding: 16px 18px;
margin-bottom: 14px;
transition: transform var(--d-base) var(--ease);
}
.card:hover { transform: translateY(-2px); }
.card h2 {
margin: 0 0 12px; font-size: 1rem; font-weight: 800;
color: var(--text); letter-spacing: 0.02em;
display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap;
}
.card h2 .hint { font-size: 0.8rem; font-weight: 600; color: var(--text-secondary); }
.banner {
background: linear-gradient(135deg, var(--primary-bg), #fff8df 70%);
border: 2px solid rgba(25, 200, 185, 0.45);
border-radius: 20px;
padding: 14px 16px;
margin-bottom: 14px;
color: var(--text-body);
line-height: 1.55;
font-weight: 600;
}
.banner strong { color: var(--text); }
.row { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; }
.field { display: flex; flex-direction: column; gap: 6px; min-width: 150px; flex: 1 1 160px; }
.field label {
font-size: 0.8rem; font-weight: 700; color: var(--text-muted);
letter-spacing: 0.03em;
}
/* Input — pill, yellow focus */
input[type="text"], input[type="password"], input:not([type]) {
height: 40px;
padding: 0 18px;
font-size: 14px;
font-weight: 500;
font-family: inherit;
color: var(--text-body);
background: var(--bg-content);
border: 2.5px solid var(--border);
border-radius: var(--r-pill);
outline: none;
transition: border-color var(--d-base) var(--ease), box-shadow var(--d-base) var(--ease);
min-width: 0;
width: 100%;
}
input:hover { border-color: var(--border-hover); }
input:focus {
border-color: var(--focus-yellow);
box-shadow: 0 0 0 3px rgba(255, 204, 0, 0.18);
}
input::placeholder { color: var(--text-disabled); font-weight: 400; }
/* Buttons */
button {
font-family: inherit;
font-weight: 700;
letter-spacing: 0.02em;
line-height: 1;
cursor: pointer;
border: 2px solid transparent;
transition: all var(--d-base) var(--ease);
}
.btn {
height: 40px;
padding: 0 18px;
border-radius: var(--r-pill);
font-size: 13px;
display: inline-flex; align-items: center; justify-content: center;
white-space: nowrap;
}
.btn-primary {
color: var(--text);
background: var(--bg);
border-color: var(--bg);
box-shadow: 0 5px 0 0 var(--shadow-btn);
}
.btn-primary:hover {
box-shadow: 0 6px 0 0 var(--shadow-btn);
transform: translateY(-1px);
}
.btn-primary:active {
box-shadow: 0 1px 0 0 var(--shadow-btn);
transform: translateY(2px);
}
.btn-primary:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.btn-default {
color: var(--text-body);
background: var(--bg);
border: 2px solid var(--border-strong);
box-shadow: 0 2px 4px 0 var(--shadow-soft);
}
.btn-default:hover {
color: var(--primary-active);
border-color: var(--primary);
box-shadow: 0 3px 10px 0 rgba(61, 52, 40, 0.10);
transform: translateY(-1px);
}
.btn-default:active { transform: translateY(0); }
.btn-danger {
color: #fff;
background: var(--error);
border-color: var(--error);
box-shadow: 0 5px 0 0 var(--error-active);
}
.btn-danger:hover { transform: translateY(-1px); box-shadow: 0 6px 0 0 var(--error-active); }
.btn-danger:active { transform: translateY(2px); box-shadow: 0 1px 0 0 var(--error-active); }
.btn-sm { height: 32px; padding: 0 14px; font-size: 12px; border-radius: 16px; }
.btn-mint {
color: #fff;
background: var(--primary);
border-color: var(--primary);
box-shadow: 0 4px 0 0 var(--primary-active);
}
.btn-mint:hover { background: var(--primary-hover); transform: translateY(-1px); box-shadow: 0 5px 0 0 var(--primary-active); }
.btn-mint:active { transform: translateY(2px); box-shadow: 0 1px 0 0 var(--primary-active); }
.muted { color: var(--text-muted); font-size: 0.85rem; font-weight: 600; }
.msg { margin-top: 10px; font-size: 0.85rem; font-weight: 700; min-height: 1.2em; }
.msg.ok { color: var(--success-active); }
.msg.bad { color: var(--error-active); }
/* Table */
.table-wrap { overflow: auto; border-radius: 16px; border: 2px solid var(--border); background: #fffef8; }
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
th, td { text-align: left; padding: 10px 12px; border-bottom: 1.5px solid rgba(196, 184, 158, 0.55); }
th {
color: var(--text-muted); font-weight: 700; font-size: 0.78rem;
letter-spacing: 0.04em; text-transform: uppercase;
background: rgba(247, 243, 223, 0.9);
}
tr:last-child td { border-bottom: none; }
tbody tr:hover td { background: rgba(230, 249, 246, 0.55); }
pre {
margin: 0; white-space: pre-wrap; word-break: break-all;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.8rem; font-weight: 500;
color: #f0e8d8;
background: #2b2118;
border: 2px solid #3d3028;
border-radius: 16px;
padding: 12px 14px;
max-height: 240px; overflow: auto;
}
.howto pre {
color: var(--text-body);
background: #fffef8;
border: 2px solid var(--border);
}
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
}
.stat {
background: #fffef8;
border: 2px solid var(--border);
border-radius: 16px;
padding: 12px 14px;
}
.stat .k { font-size: 0.75rem; font-weight: 700; color: var(--text-muted); letter-spacing: 0.04em; }
.stat .v { margin-top: 4px; font-size: 1.05rem; font-weight: 800; color: var(--text); word-break: break-all; }
.tag {
display: inline-block;
padding: 3px 10px;
border-radius: var(--r-pill);
font-size: 0.75rem;
font-weight: 700;
border: 1.5px solid var(--border-strong);
background: #fff;
color: var(--text-body);
}
.tag.teal { background: var(--primary-bg); border-color: var(--primary); color: var(--primary-active); }
.tag.green { background: #eef8e3; border-color: var(--success); color: var(--success-active); }
footer.foot {
margin-top: 22px;
text-align: center;
color: var(--text-secondary);
font-size: 0.8rem;
font-weight: 600;
}
.wave {
height: 28px;
margin: 18px 0 8px;
background:
radial-gradient(circle at 10px 18px, var(--app-teal) 8px, transparent 9px) 0 0 / 28px 28px,
radial-gradient(circle at 24px 18px, var(--app-blue) 6px, transparent 7px) 0 0 / 28px 28px;
opacity: 0.55;
border-radius: 20px;
}
@media (max-width: 560px) {
.btn { width: 100%; }
.header-actions { width: 100%; }
.title-ribbon { font-size: 0.95rem; }
}
</style>
</head>
<body>
<div class="page">
<header class="top">
<div>
<h1 class="title-ribbon"><span class="leaf" aria-hidden="true"></span>数据出口代理</h1>
<div class="sub">走代理 = 走运营商流量 · animal-island 风格控制台</div>
</div>
<div class="header-actions">
<span id="connState" class="pill warn">未连接</span>
<button type="button" class="btn btn-default" onclick="refreshAll()">刷新</button>
</div>
</header>
<section class="banner">
<strong>行为说明:</strong>系统默认仍可走 WiFi。
只要客户端使用本机 HTTP/SOCKS 代理,出口一律绑定<strong>数据网卡</strong>(流量)。
无需配置域名分流。面板视觉参考
<a href="https://github.com/guokaigdg/animal-island-ui" target="_blank" rel="noopener" style="color:var(--primary-active);font-weight:800">animal-island-ui</a>
设计 token(纯静态实现,无 React 依赖)。
</section>
<section class="card">
<h2>连接设置 <span class="hint">密钥仅存本浏览器 localStorage</span></h2>
<div class="row">
<div class="field">
<label>Clash API9090</label>
<input id="apiBase" type="text" placeholder="http://127.0.0.1:9090" />
</div>
<div class="field">
<label>管理 API9091</label>
<input id="adminBase" type="text" placeholder="http://127.0.0.1:9091" />
</div>
<div class="field">
<label>面板密钥 secret</label>
<input id="secret" type="password" placeholder="PANEL_SECRET" autocomplete="current-password" />
</div>
<div style="padding-bottom:1px">
<button type="button" class="btn btn-primary" onclick="saveAuth(); refreshAll()">保存并连接</button>
</div>
</div>
</section>
<section class="card">
<h2>
代理账号密码(7890)
<span id="proxyAuthState" class="pill warn">未知</span>
</h2>
<div class="row">
<div class="field">
<label>用户名</label>
<input id="proxyUser" type="text" placeholder="proxy" autocomplete="username" />
</div>
<div class="field">
<label>密码</label>
<input id="proxyPass" type="password" placeholder="新密码(不回显已有密码)" autocomplete="new-password" />
</div>
<div class="row" style="flex:0 0 auto;padding-bottom:1px">
<button type="button" class="btn btn-mint" onclick="saveProxyAuth()">保存并生效</button>
<button type="button" class="btn btn-default" onclick="clearProxyAuth()">清除鉴权</button>
<button type="button" class="btn btn-default" onclick="loadProxyAuth()">刷新状态</button>
</div>
</div>
<div class="muted" style="margin-top:10px">保存后会重写 settings、重生 sing-box 配置并重启服务(约 1~2 秒)。</div>
<div id="authMsg" class="msg"></div>
</section>
<section class="card">
<h2>状态</h2>
<div class="stat-grid">
<div class="stat"><div class="k">VERSION</div><div class="v" id="verInfo"></div></div>
<div class="stat"><div class="k">PROXY</div><div class="v">7890 · HTTP/SOCKS</div></div>
<div class="stat"><div class="k">OUTBOUND</div><div class="v"><span class="tag teal">cellular</span></div></div>
</div>
</section>
<section class="card">
<h2>出站 <span class="hint">应只有 cellular(数据网)</span></h2>
<div id="proxies" class="muted">加载中…</div>
</section>
<section class="card">
<h2>
当前连接
<button type="button" class="btn btn-default btn-sm" onclick="loadConnections()">刷新</button>
</h2>
<div class="table-wrap">
<table>
<thead>
<tr><th>目标</th><th>出站链</th><th>上传</th><th>下载</th><th></th></tr>
</thead>
<tbody id="connBody"></tbody>
</table>
</div>
</section>
<section class="card howto">
<h2>怎么用</h2>
<pre>1. 代理端口默认 7890HTTP + SOCKS5 同一端口)
2. 设备设置系统/应用代理 → Debian 的 LAN-IP:7890
3. 需要鉴权时在上方设置用户/密码,或: sudo cpxy auth --user u --pass p
4. 验证:直连 ifconfig.me 与 走代理 ifconfig.me 公网 IP 应不同
5. 改网卡:sudo cpxy rebind
6. 低内存:MEMORY_MAX_MB=64~96LOG_LEVEL=warn</pre>
</section>
<section class="card">
<h2>API 调试</h2>
<pre id="raw"></pre>
</section>
<div class="wave" aria-hidden="true"></div>
<footer class="foot">cellular-proxy · 视觉参考 animal-island-ui · 走代理 = 数据流量</footer>
</div>
<script>
const $ = (id) => document.getElementById(id);
function defaultBase() { return location.origin || "http://127.0.0.1:9090"; }
function defaultAdminBase() {
try {
const u = new URL(defaultBase());
u.port = "9091";
return u.origin;
} catch {
return "http://127.0.0.1:9091";
}
}
function loadAuth() {
$("apiBase").value = localStorage.getItem("cpxy_api") || defaultBase();
$("adminBase").value = localStorage.getItem("cpxy_admin") || defaultAdminBase();
$("secret").value = localStorage.getItem("cpxy_secret") || "";
}
function saveAuth() {
localStorage.setItem("cpxy_api", $("apiBase").value.trim().replace(/\/$/, ""));
localStorage.setItem("cpxy_admin", $("adminBase").value.trim().replace(/\/$/, ""));
localStorage.setItem("cpxy_secret", $("secret").value);
}
function base() { return ($("apiBase").value || defaultBase()).replace(/\/$/, ""); }
function adminBase() { return ($("adminBase").value || defaultAdminBase()).replace(/\/$/, ""); }
function headers() {
const h = { "Content-Type": "application/json" };
const s = $("secret").value;
if (s) h["Authorization"] = "Bearer " + s;
return h;
}
async function api(path, opt = {}) {
const r = await fetch(base() + path, { ...opt, headers: { ...headers(), ...(opt.headers || {}) } });
if (!r.ok) throw new Error(path + " " + r.status + " " + (await r.text()));
const t = await r.text();
try { return t ? JSON.parse(t) : {}; } catch { return t; }
}
async function adminApi(path, opt = {}) {
const r = await fetch(adminBase() + path, { ...opt, headers: { ...headers(), ...(opt.headers || {}) } });
const t = await r.text();
let data = {};
try { data = t ? JSON.parse(t) : {}; } catch { data = { raw: t }; }
if (!r.ok) throw new Error((data.error || data.message || t || r.status) + "");
return data;
}
function fmt(n) {
if (n == null) return "—";
const u = ["B", "KB", "MB", "GB"];
let i = 0, x = Number(n);
while (x >= 1024 && i < u.length - 1) { x /= 1024; i++; }
return x.toFixed(i ? 1 : 0) + " " + u[i];
}
function setAuthMsg(text, ok) {
const el = $("authMsg");
el.textContent = text || "";
el.className = "msg" + (ok === true ? " ok" : (ok === false ? " bad" : ""));
}
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
async function loadProxyAuth() {
try {
const data = await adminApi("/proxy-auth");
$("proxyUser").value = data.proxy_user || "";
if (data.auth_enabled) {
$("proxyAuthState").textContent = "已启用 · " + (data.proxy_user || "");
$("proxyAuthState").className = "pill ok";
setAuthMsg("当前已设置鉴权" + (data.proxy_pass_masked ? "(密码 " + data.proxy_pass_masked + "" : ""), true);
} else {
$("proxyAuthState").textContent = "无鉴权";
$("proxyAuthState").className = "pill warn";
setAuthMsg("当前 7890 无账号密码", null);
}
$("proxyPass").value = "";
} catch (e) {
$("proxyAuthState").textContent = "管理 API 不可用";
$("proxyAuthState").className = "pill bad";
setAuthMsg("无法连接管理 API9091)。确认 cellular-proxy-admin 已启动,或: sudo cpxy auth --user u --pass p。错误: " + e.message, false);
}
}
async function saveProxyAuth() {
saveAuth();
const user = $("proxyUser").value.trim();
const pass = $("proxyPass").value;
if (!user) { setAuthMsg("请填写用户名", false); return; }
if (!pass) { setAuthMsg("请填写密码(不会回显已有密码)", false); return; }
setAuthMsg("正在应用…", null);
try {
const data = await adminApi("/proxy-auth", {
method: "POST",
body: JSON.stringify({ user, pass })
});
setAuthMsg("已生效:用户 " + (data.proxy_user || user), true);
await loadProxyAuth();
} catch (e) {
setAuthMsg("保存失败: " + e.message, false);
}
}
async function clearProxyAuth() {
if (!confirm("确定清除代理账号密码?清除后 7890 无需鉴权。")) return;
saveAuth();
setAuthMsg("正在清除…", null);
try {
await adminApi("/proxy-auth", {
method: "POST",
body: JSON.stringify({ clear: true })
});
$("proxyUser").value = "";
$("proxyPass").value = "";
setAuthMsg("已清除鉴权", true);
await loadProxyAuth();
} catch (e) {
setAuthMsg("清除失败: " + e.message, false);
}
}
async function refreshAll() {
try {
const ver = await api("/version");
$("verInfo").textContent = ver.version || JSON.stringify(ver);
$("connState").textContent = "已连接";
$("connState").className = "pill ok";
$("raw").textContent = JSON.stringify(ver, null, 2);
await loadProxies();
await loadConnections();
await loadProxyAuth();
} catch (e) {
$("connState").textContent = "失败";
$("connState").className = "pill bad";
$("proxies").innerHTML = '<span class="muted">无法连接 API。请确认服务已启动,并填写正确 secret。<br/>' + esc(e.message) + "</span>";
await loadProxyAuth();
}
}
async function loadProxies() {
const data = await api("/proxies");
const proxies = data.proxies || {};
const names = Object.keys(proxies).filter(k => ["cellular", "DIRECT", "REJECT"].includes(k) || proxies[k].type === "Direct");
const keys = names.length ? names : Object.keys(proxies).slice(0, 15);
let html = '<div class="table-wrap"><table><thead><tr><th>名称</th><th>类型</th><th>测速</th></tr></thead><tbody>';
for (const name of keys) {
const p = proxies[name] || {};
const safe = esc(name);
html += `<tr>
<td><span class="tag ${name === "cellular" ? "teal" : ""}">${safe}</span></td>
<td>${esc(p.type || "")}</td>
<td><button type="button" class="btn btn-default btn-sm" data-delay="${safe}">测速</button> <span id="d-${safe}"></span></td>
</tr>`;
}
html += "</tbody></table></div>";
$("proxies").innerHTML = html;
$("proxies").querySelectorAll("[data-delay]").forEach(btn => {
btn.addEventListener("click", () => delay(btn.getAttribute("data-delay")));
});
}
async function delay(name) {
try {
const r = await api("/proxies/" + encodeURIComponent(name) + "/delay?timeout=5000&url=" +
encodeURIComponent("https://www.gstatic.com/generate_204"));
const el = document.getElementById("d-" + name);
if (el) el.innerHTML = r.delay != null
? `<span class="tag green">${r.delay}ms</span>`
: esc(JSON.stringify(r));
} catch (e) {
const el = document.getElementById("d-" + name);
if (el) el.textContent = e.message;
}
}
async function loadConnections() {
try {
const data = await api("/connections");
const conns = data.connections || [];
const body = $("connBody");
body.innerHTML = "";
conns.slice(0, 50).forEach(c => {
const tr = document.createElement("tr");
const host = (c.metadata && (c.metadata.host || c.metadata.destinationIP)) || "—";
const port = c.metadata && c.metadata.destinationPort ? ":" + c.metadata.destinationPort : "";
tr.innerHTML = `<td>${esc(host)}${esc(port)}</td>
<td>${esc(c.chains ? c.chains.join("→") : "—")}</td>
<td>${esc(fmt(c.upload))}</td><td>${esc(fmt(c.download))}</td>
<td><button type="button" class="btn btn-danger btn-sm" data-close="${esc(c.id)}">断开</button></td>`;
body.appendChild(tr);
});
body.querySelectorAll("[data-close]").forEach(btn => {
btn.addEventListener("click", () => closeConn(btn.getAttribute("data-close")));
});
if (!conns.length) body.innerHTML = '<tr><td colspan="5" class="muted">暂无连接</td></tr>';
} catch (e) {
$("connBody").innerHTML = '<tr><td colspan="5">' + esc(e.message) + "</td></tr>";
}
}
async function closeConn(id) {
await api("/connections/" + id, { method: "DELETE" });
loadConnections();
}
loadAuth();
refreshAll();
setInterval(() => { loadConnections().catch(() => {}); }, 5000);
</script>
</body>
</html>