Files
cellular-proxy/ui/index.html
T
Hermes aec388682b fix: 安装启动后不再卡住,保证脚本退出
- 去掉可能阻塞的 systemctl status 全量输出
- verify 使用 connect/max 超时;安装阶段 8s 快速验证
- 验证失败只告警,不阻塞安装结束
2026-07-21 11:26:30 +00:00

216 lines
9.3 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>
<style>
:root {
--bg: #0f1419; --card: #1a2332; --text: #e7ecf3; --muted: #8b9bb4;
--accent: #3d8bfd; --ok: #3dd68c; --warn: #f5a524; --bad: #f31260; --border: #2a3548;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg); color: var(--text); min-height: 100vh; }
header { padding: 16px 20px; border-bottom: 1px solid var(--border);
display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; }
h1 { font-size: 1.15rem; margin: 0; font-weight: 600; }
.muted { color: var(--muted); font-size: 0.85rem; }
main { padding: 16px; max-width: 960px; margin: 0 auto; display: grid; gap: 14px; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; }
.row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
label { font-size: 0.85rem; color: var(--muted); }
input, button { background: #0d121a; color: var(--text); border: 1px solid var(--border);
border-radius: 8px; padding: 8px 10px; font-size: 0.95rem; }
input { min-width: 160px; }
button { cursor: pointer; background: var(--accent); border: none; font-weight: 600; }
button.secondary { background: #2a3548; }
button.danger { background: var(--bad); }
.pill { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 0.8rem; background: #243044; }
.pill.ok { background: rgba(61,214,140,.15); color: var(--ok); }
.pill.bad { background: rgba(243,18,96,.15); color: var(--bad); }
.pill.warn { background: rgba(245,165,36,.15); color: var(--warn); }
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid var(--border); }
th { color: var(--muted); font-weight: 500; }
pre { margin: 0; white-space: pre-wrap; word-break: break-all; font-size: 0.8rem;
color: var(--muted); max-height: 220px; overflow: auto; }
.banner { background: rgba(61,139,253,.12); border: 1px solid rgba(61,139,253,.35);
border-radius: 10px; padding: 10px 12px; font-size: 0.9rem; line-height: 1.5; }
</style>
</head>
<body>
<header>
<div>
<h1>数据出口代理 <span class="muted">cellular-proxy</span></h1>
<div class="muted">走代理 = 走运营商流量 · 单进程轻量</div>
</div>
<div class="row">
<span id="connState" class="pill warn">未连接</span>
<button class="secondary" onclick="refreshAll()">刷新</button>
</div>
</header>
<main>
<section class="banner">
<strong>行为说明:</strong>系统默认仍可走 WiFi。
只要客户端使用本机 HTTP/SOCKS 代理,出口一律绑定<strong>数据网卡</strong>(流量)。
无需配置域名分流。
</section>
<section class="card">
<div class="row" style="margin-bottom:10px">
<div>
<label>API 地址</label><br/>
<input id="apiBase" placeholder="http://127.0.0.1:9090" />
</div>
<div>
<label>密钥 secret</label><br/>
<input id="secret" type="password" placeholder="PANEL_SECRET" />
</div>
<div style="align-self:end">
<button onclick="saveAuth(); refreshAll()">保存并连接</button>
</div>
</div>
<div class="muted">密钥仅存本浏览器 localStorage。</div>
</section>
<section class="card">
<div class="muted">版本</div>
<div id="verInfo"></div>
</section>
<section class="card">
<div class="row" style="justify-content:space-between;margin-bottom:8px">
<strong>出站</strong>
<span class="muted">应只有 cellular(数据网)</span>
</div>
<div id="proxies">加载中…</div>
</section>
<section class="card">
<div class="row" style="justify-content:space-between;margin-bottom:8px">
<strong>当前连接</strong>
<button class="secondary" onclick="loadConnections()">刷新</button>
</div>
<div style="overflow:auto">
<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">
<strong>怎么用</strong>
<pre>
1. 代理端口默认 7890HTTP + SOCKS5 同一端口)
2. 设备设置系统/应用代理 → Debian 的 LAN-IP:7890
3. 验证:直连 ifconfig.me 与 走代理 ifconfig.me 公网 IP 应不同
4. 改网卡:改 settings.conf 里 CELLULAR_IFACE 后 sudo cpxy generate
5. 低内存:MEMORY_MAX_MB=64~96LOG_LEVEL=warn
</pre>
</section>
<section class="card">
<strong>API 调试</strong>
<pre id="raw"></pre>
</section>
</main>
<script>
const $ = (id) => document.getElementById(id);
function defaultBase() { return location.origin || "http://127.0.0.1:9090"; }
function loadAuth() {
$("apiBase").value = localStorage.getItem("cpxy_api") || defaultBase();
$("secret").value = localStorage.getItem("cpxy_secret") || "";
}
function saveAuth() {
localStorage.setItem("cpxy_api", $("apiBase").value.trim().replace(/\/$/, ""));
localStorage.setItem("cpxy_secret", $("secret").value);
}
function base() { return ($("apiBase").value || defaultBase()).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; }
}
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];
}
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();
} catch (e) {
$("connState").textContent = "失败: " + e.message;
$("connState").className = "pill bad";
$("proxies").textContent = "无法连接 API。请确认服务已启动,并填写正确 secret。";
}
}
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 = "<table><thead><tr><th>名称</th><th>类型</th><th>测速</th></tr></thead><tbody>";
for (const name of keys) {
const p = proxies[name] || {};
html += `<tr><td>${name}</td><td>${p.type || ""}</td>
<td><button class="secondary" onclick="delay('${name}')">测速</button> <span id="d-${name}"></span></td></tr>`;
}
html += "</tbody></table>";
$("proxies").innerHTML = html;
}
async function delay(name) {
try {
const r = await api("/proxies/" + encodeURIComponent(name) + "/delay?timeout=5000&url=" +
encodeURIComponent("https://www.gstatic.com/generate_204"));
$("d-" + name).textContent = (r.delay != null ? r.delay + "ms" : JSON.stringify(r));
} catch (e) { $("d-" + name).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>${host}${port}</td><td>${c.chains ? c.chains.join("→") : "—"}</td>
<td>${fmt(c.upload)}</td><td>${fmt(c.download)}</td>
<td><button class="danger" onclick="closeConn('${c.id}')">断开</button></td>`;
body.appendChild(tr);
});
if (!conns.length) body.innerHTML = "<tr><td colspan=5 class=muted>暂无连接</td></tr>";
} catch (e) {
$("connBody").innerHTML = "<tr><td colspan=5>" + e.message + "</td></tr>";
}
}
async function closeConn(id) {
await api("/connections/" + id, { method: "DELETE" });
loadConnections();
}
loadAuth();
refreshAll();
setInterval(() => { loadConnections().catch(() => {}); }, 5000);
</script>
</body>
</html>