- proxypool: 站点级出口绑定 proxies = [条目名, "sub:订阅名"],粘性选路、 失败切换、后台健康探测;支持 Xray 分享链接 / 订阅 / 裸 ip:port 与 socks5 端点, 托管 Xray 核心(可自动下载);站点未绑定时回退 [proxy] 主/备线路 - upstream: RouteSource 抽象 + routesFor/poolReport;求解与请求共用同一出口 - admin: 代理池面板与维护动作;overview 暴露池状态
936 lines
53 KiB
JavaScript
936 lines
53 KiB
JavaScript
/* dsfree2api web console */
|
||
(function () {
|
||
"use strict";
|
||
|
||
const $ = (s, r) => (r || document).querySelector(s);
|
||
const $$ = (s, r) => Array.from((r || document).querySelectorAll(s));
|
||
const view = $("#view");
|
||
const TITLES = {
|
||
dashboard: "仪表盘", models: "模型与站点", keys: "API Key", proxy: "代理线路",
|
||
turnstile: "Turnstile", playground: "对话调试", logs: "实时日志", settings: "设置"
|
||
};
|
||
|
||
let overviewTimer = null;
|
||
let logStop = null;
|
||
let lastOverview = null;
|
||
|
||
/* ── helpers ─────────────────────────────────────────────── */
|
||
function toast(msg, kind) {
|
||
const t = $("#toast");
|
||
t.textContent = msg;
|
||
t.className = "toast " + (kind || "");
|
||
clearTimeout(t._h);
|
||
t._h = setTimeout(() => t.classList.add("hidden"), 3200);
|
||
}
|
||
|
||
async function api(path, opts) {
|
||
opts = opts || {};
|
||
const init = { method: opts.method || (opts.body !== undefined ? "POST" : "GET"), headers: {} };
|
||
if (opts.signal) init.signal = opts.signal;
|
||
if (opts.body !== undefined) {
|
||
init.headers["Content-Type"] = "application/json";
|
||
init.body = typeof opts.body === "string" ? opts.body : JSON.stringify(opts.body);
|
||
}
|
||
const res = await fetch(path, init);
|
||
let data = null;
|
||
try { data = await res.json(); } catch (e) { data = null; }
|
||
if (res.status === 401 && !path.endsWith("/login")) {
|
||
showLogin();
|
||
throw new Error("unauthorized");
|
||
}
|
||
if (!res.ok) {
|
||
const msg = (data && (data.error || data.detail)) || res.statusText;
|
||
throw new Error(typeof msg === "string" ? msg : JSON.stringify(msg));
|
||
}
|
||
return data;
|
||
}
|
||
|
||
function esc(s) {
|
||
return String(s == null ? "" : s).replace(/[&<>"']/g, c =>
|
||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||
}
|
||
const fmtInt = n => (n || 0).toLocaleString("en-US");
|
||
const fmtPct = (a, b) => b ? (a * 100 / b).toFixed(1) + "%" : "0%";
|
||
const fmtMs = ms => ms >= 1000 ? (ms / 1000).toFixed(2) + "s" : Math.round(ms) + "ms";
|
||
const fmtBytes = n => n > 1048576 ? (n / 1048576).toFixed(1) + "MB" : n > 1024 ? (n / 1024).toFixed(1) + "KB" : n + "B";
|
||
const timeStr = t => new Date(t).toLocaleTimeString("zh-CN", { hour12: false });
|
||
|
||
function switchHTML(id, checked) {
|
||
return `<label class="switch"><input type="checkbox" id="${id}" ${checked ? "checked" : ""}><span class="slider"></span></label>`;
|
||
}
|
||
|
||
/* ── auth ────────────────────────────────────────────────── */
|
||
async function ensureSession() {
|
||
try {
|
||
const s = await api("/api/session");
|
||
if (s.authenticated) { $("#login").classList.add("hidden"); return true; }
|
||
} catch (e) { /* ignore */ }
|
||
showLogin();
|
||
return false;
|
||
}
|
||
function showLogin() {
|
||
$("#login").classList.remove("hidden");
|
||
setTimeout(() => $("#login-password").focus(), 50);
|
||
}
|
||
async function doLogin() {
|
||
const pw = $("#login-password").value;
|
||
if (!pw) return;
|
||
try {
|
||
await api("/api/login", { method: "POST", body: { password: pw } });
|
||
$("#login-error").textContent = "";
|
||
$("#login").classList.add("hidden");
|
||
$("#login-password").value = "";
|
||
route();
|
||
} catch (e) {
|
||
$("#login-error").textContent = e.message === "invalid password" ? "密码错误" : e.message;
|
||
}
|
||
}
|
||
|
||
/* ── router ──────────────────────────────────────────────── */
|
||
function route() {
|
||
if (overviewTimer) { clearInterval(overviewTimer); overviewTimer = null; }
|
||
if (logStop) { logStop(); logStop = null; }
|
||
const hash = location.hash.replace(/^#\/?/, "") || "dashboard";
|
||
const page = (hash.split("?")[0] || "dashboard");
|
||
$$("#nav a").forEach(a => a.classList.toggle("active", a.dataset.page === page));
|
||
$("#page-title").textContent = TITLES[page] || page;
|
||
view.innerHTML = '<div class="empty">加载中…</div>';
|
||
const fn = PAGES[page] || PAGES.dashboard;
|
||
fn().catch(e => { view.innerHTML = `<div class="empty">加载失败:${esc(e.message)}</div>`; });
|
||
}
|
||
|
||
/* ── overview poll ───────────────────────────────────────── */
|
||
async function fetchOverview() {
|
||
const ov = await api("/api/overview");
|
||
lastOverview = ov;
|
||
renderBadges(ov);
|
||
return ov;
|
||
}
|
||
function renderBadges(ov) {
|
||
const st = (ov.turnstile.status || []).filter(s => s.valid).length;
|
||
const total = (ov.sites || []).filter(s => s.enabled).length;
|
||
$("#sys-version").textContent = "v" + (ov.system.version || "?");
|
||
$("#sys-badges").innerHTML = `
|
||
<span class="badge ok">运行 ${Math.floor(ov.system.uptime_s / 3600)}h${Math.floor(ov.system.uptime_s % 3600 / 60)}m</span>
|
||
<span class="badge">${esc(ov.system.go)} · ${esc(ov.system.os)}/${esc(ov.system.arch)}</span>
|
||
<span class="badge ${st ? "ok" : "warn"}">Turnstile ${st}/${total}</span>
|
||
<span class="badge">在线 ${fmtInt(ov.metrics.in_flight)}</span>`;
|
||
}
|
||
|
||
/* ── pages ───────────────────────────────────────────────── */
|
||
const PAGES = {};
|
||
|
||
PAGES.dashboard = async () => {
|
||
view.innerHTML = `
|
||
<div class="grid g4">
|
||
<div class="card"><div class="stat-label">总请求</div><div class="stat-value" id="s-req">–</div><div class="stat-sub" id="s-rps"></div></div>
|
||
<div class="card"><div class="stat-label">错误 / 空响应</div><div class="stat-value" id="s-err">–</div><div class="stat-sub" id="s-errpct"></div></div>
|
||
<div class="card"><div class="stat-label">Token(估算)</div><div class="stat-value" id="s-tok">–</div><div class="stat-sub" id="s-toksub"></div></div>
|
||
<div class="card"><div class="stat-label">延迟 P50 / P95</div><div class="stat-value" id="s-lat">–</div><div class="stat-sub" id="s-latsub"></div></div>
|
||
</div>
|
||
<div class="grid g2">
|
||
<div class="card"><h3>模型用量</h3><div class="table-wrap"><table>
|
||
<thead><tr><th>模型</th><th class="num">请求</th><th class="num">错误</th><th class="num">Token</th><th class="num">平均耗时</th></tr></thead>
|
||
<tbody id="tb-models"><tr><td colspan="5" class="empty">暂无数据</td></tr></tbody>
|
||
</table></div></div>
|
||
<div class="card"><h3>站点状态</h3><div id="site-cards" class="grid" style="gap:10px"></div></div>
|
||
</div>
|
||
<div class="card"><div class="toolbar" style="margin-bottom:10px"><h3 style="margin:0">最近请求</h3>
|
||
<div><button class="btn small" id="btn-invalidate">刷新会话缓存</button>
|
||
<button class="btn small danger" id="btn-reset">清空统计</button></div></div>
|
||
<div class="table-wrap"><table>
|
||
<thead><tr><th>时间</th><th>模型</th><th>站点</th><th>线路</th><th>类型</th><th class="num">耗时</th><th class="num">Token</th><th>状态</th></tr></thead>
|
||
<tbody id="tb-recent"><tr><td colspan="8" class="empty">暂无请求</td></tr></tbody>
|
||
</table></div>
|
||
</div>`;
|
||
|
||
$("#btn-invalidate").onclick = async () => { await api("/api/actions", { body: { action: "invalidate" } }); toast("已清除 nonce 与 cookie 缓存", "ok"); };
|
||
$("#btn-reset").onclick = async () => { await api("/api/actions", { body: { action: "reset_metrics" } }); toast("统计已清空", "ok"); refresh(); };
|
||
|
||
async function refresh() {
|
||
let ov;
|
||
try { ov = await fetchOverview(); } catch (e) { return; }
|
||
const m = ov.metrics;
|
||
$("#s-req").textContent = fmtInt(m.requests);
|
||
$("#s-rps").textContent = `${m.rps.toFixed(2)} req/s · 流式 ${fmtInt(m.streams)} · 运行中 ${fmtInt(m.in_flight)}`;
|
||
$("#s-err").textContent = fmtInt(m.errors);
|
||
$("#s-err").className = "stat-value " + (m.errors ? "err" : "ok");
|
||
$("#s-errpct").textContent = `错误率 ${fmtPct(m.errors, m.requests)} · 空响应 ${fmtInt(m.empty)} · 拒绝 ${fmtInt(m.unauthorized)} · 限流 ${fmtInt(m.rate_limited)}`;
|
||
$("#s-tok").textContent = fmtInt(m.prompt_tokens + m.completion_tokens);
|
||
$("#s-toksub").textContent = `输入 ${fmtInt(m.prompt_tokens)} · 输出 ${fmtInt(m.completion_tokens)}`;
|
||
$("#s-lat").textContent = m.latency.count ? `${fmtMs(m.latency.p50)} / ${fmtMs(m.latency.p95)}` : "–";
|
||
$("#s-latsub").textContent = m.latency.count ? `样本 ${m.latency.count} · 最大 ${fmtMs(m.latency.max)}` : "尚无样本";
|
||
|
||
const rows = Object.entries(m.models || {});
|
||
$("#tb-models").innerHTML = rows.length ? rows.map(([id, v]) => `
|
||
<tr><td><span class="tag">${esc(id)}</span></td>
|
||
<td class="num">${fmtInt(v.requests)}</td>
|
||
<td class="num" style="color:${v.errors ? "var(--err)" : "inherit"}">${fmtInt(v.errors)}</td>
|
||
<td class="num">${fmtInt(v.prompt_tokens + v.completion_tokens)}</td>
|
||
<td class="num">${v.requests ? fmtMs(v.sum_ms / v.requests) : "–"}</td></tr>`).join("")
|
||
: '<tr><td colspan="5" class="empty">暂无数据</td></tr>';
|
||
|
||
$("#site-cards").innerHTML = (ov.sites || []).map(s => {
|
||
const ts = (ov.turnstile.status || []).find(x => x.site === s.code) || {};
|
||
const ms = m.sites && m.sites[s.code];
|
||
const pct = ts.remaining_s > 0 ? Math.max(0, Math.min(100, ts.remaining_s / (ov.turnstile.config.cookie_ttl_seconds || 10800) * 100)) : 0;
|
||
return `<div class="card" style="padding:12px">
|
||
<div class="toolbar">
|
||
<div><b>${esc(s.code.toUpperCase())}</b> <span class="pill ${s.enabled ? "on" : "off"}">${s.enabled ? "启用" : "停用"}</span>
|
||
<div class="dim">${esc(s.base_url)}</div></div>
|
||
<div class="right"><button class="btn small" data-probe="${esc(s.code)}">探测</button>
|
||
<button class="btn small" data-quota="${esc(s.code)}">额度</button>
|
||
<button class="btn small" data-rotate="${esc(s.code)}">重置</button></div>
|
||
</div>
|
||
<div class="dim" style="margin-top:8px">请求 ${fmtInt(ms ? ms.requests : 0)} · 错误 ${fmtInt(ms ? ms.errors : 0)} · Cookie ${ts.valid ? "有效 " + Math.round(ts.remaining_s / 60) + " 分钟" : "未建立"}</div>
|
||
<div class="bar"><i style="width:${pct}%"></i></div></div>`;
|
||
}).join("") || '<div class="empty">无站点</div>';
|
||
|
||
$$("[data-probe]").forEach(b => b.onclick = async () => {
|
||
b.disabled = true; b.textContent = "…";
|
||
try {
|
||
const r = await api("/api/actions", { body: { action: "probe_site", site: b.dataset.probe } });
|
||
toast(r.ok ? `${b.dataset.probe}: HTTP ${r.status} · ${r.ms}ms · 对话容器 ${r.chat_container ? "有" : "无"}` : `${b.dataset.probe}: ${r.error || "失败"}`, r.ok ? "ok" : "err");
|
||
} catch (e) { toast(e.message, "err"); }
|
||
b.disabled = false; b.textContent = "探测";
|
||
});
|
||
|
||
$$("[data-quota]").forEach(b => b.onclick = async () => {
|
||
b.disabled = true; b.textContent = "…";
|
||
try {
|
||
const r = await api("/api/actions", { body: { action: "site_balance", site: b.dataset.quota } });
|
||
const period = r.reset_period === "daily" ? "每日" : (r.reset_period || "");
|
||
toast(r.ok ? `${b.dataset.quota}: 剩余 ${fmtInt(r.remaining)} / ${fmtInt(r.limit)} · 已用 ${fmtInt(r.used)} · ${period}重置` : `${b.dataset.quota}: ${r.error || "失败"}`, r.ok ? "ok" : "err");
|
||
} catch (e) { toast(e.message, "err"); }
|
||
b.disabled = false; b.textContent = "额度";
|
||
});
|
||
|
||
$$("[data-rotate]").forEach(b => b.onclick = async () => {
|
||
b.disabled = true; b.textContent = "…";
|
||
try {
|
||
const r = await api("/api/actions", { body: { action: "rotate_guest", site: b.dataset.rotate } });
|
||
toast(r.ok ? `${b.dataset.rotate}: 已换新访客身份,剩余 ${fmtInt(r.remaining)} / ${fmtInt(r.limit)}` : `${b.dataset.rotate}: ${r.error || "失败"}`, r.ok ? "ok" : "err");
|
||
} catch (e) { toast(e.message, "err"); }
|
||
b.disabled = false; b.textContent = "重置";
|
||
});
|
||
|
||
const rec = (m.recent || []).slice(0, 30);
|
||
$("#tb-recent").innerHTML = rec.length ? rec.map(r => `
|
||
<tr><td class="dim">${timeStr(r.at)}</td>
|
||
<td><span class="tag">${esc(r.served_by || r.model)}</span></td>
|
||
<td>${esc(r.site)}</td><td class="dim">${esc(r.route || "–")}</td>
|
||
<td>${r.stream ? "流式" : "同步"}</td>
|
||
<td class="num">${fmtMs(r.duration_ms)}</td>
|
||
<td class="num">${fmtInt((r.prompt_tokens || 0) + (r.completion_tokens || 0))}</td>
|
||
<td><span class="pill ${r.status === "ok" ? "on" : "off"}">${esc(r.status)}</span>${r.error ? ` <span class="dim">${esc(r.error.slice(0, 60))}</span>` : ""}</td></tr>`).join("")
|
||
: '<tr><td colspan="8" class="empty">暂无请求</td></tr>';
|
||
}
|
||
await refresh();
|
||
overviewTimer = setInterval(refresh, 5000);
|
||
};
|
||
|
||
PAGES.models = async () => {
|
||
const ov = await fetchOverview();
|
||
view.innerHTML = `
|
||
<div class="card"><div class="toolbar"><h3 style="margin:0">模型</h3>
|
||
<div class="dim">bot_id / post_id 修改后会自动清除该模型的 nonce 缓存</div></div>
|
||
<div class="table-wrap mt"><table>
|
||
<thead><tr><th>ID</th><th>标签</th><th>站点</th><th>上游模型</th><th>路径</th><th class="num">bot</th><th class="num">post</th><th>启用</th><th></th></tr></thead>
|
||
<tbody id="tb"></tbody></table></div></div>
|
||
<div class="card"><h3>站点</h3><div class="table-wrap"><table>
|
||
<thead><tr><th>代码</th><th>Base URL</th><th>AJAX URL</th><th>语言</th><th>启用</th></tr></thead>
|
||
<tbody id="tb-sites"></tbody></table></div></div>`;
|
||
|
||
$("#tb").innerHTML = ov.models.map(m => `
|
||
<tr data-id="${esc(m.id)}">
|
||
<td><span class="tag">${esc(m.id)}</span></td>
|
||
<td><input data-f="label" value="${esc(m.label)}" style="min-width:130px"></td>
|
||
<td>${esc(m.site)}</td><td class="dim">${esc(m.upstream_id)}</td>
|
||
<td><input data-f="page_path" value="${esc(m.page_path)}" style="width:80px"></td>
|
||
<td class="num"><input data-f="bot_id" type="number" value="${m.bot_id}" style="width:90px;text-align:right"></td>
|
||
<td class="num"><input data-f="post_id" type="number" value="${m.post_id}" style="width:90px;text-align:right"></td>
|
||
<td>${switchHTML("sw-" + m.id, m.enabled)}</td>
|
||
<td><button class="btn small primary" data-save="${esc(m.id)}">保存</button></td>
|
||
</tr>`).join("");
|
||
|
||
$("#tb-sites").innerHTML = ov.sites.map(s => `
|
||
<tr data-code="${esc(s.code)}">
|
||
<td><b>${esc(s.code.toUpperCase())}</b></td>
|
||
<td><input data-f="base_url" value="${esc(s.base_url)}" style="min-width:190px"></td>
|
||
<td><input data-f="ajax_url" value="${esc(s.ajax_url)}" style="min-width:230px"></td>
|
||
<td><input data-f="language" value="${esc(s.language)}" style="min-width:200px"></td>
|
||
<td>${switchHTML("sws-" + s.code, s.enabled)}
|
||
<div class="mt"><button class="btn small primary" data-savesite="${esc(s.code)}">保存</button></div></td>
|
||
</tr>`).join("");
|
||
|
||
$$("[data-save]").forEach(b => b.onclick = async () => {
|
||
const tr = b.closest("tr"), id = b.dataset.save;
|
||
const g = f => $(`[data-f="${f}"]`, tr).value;
|
||
try {
|
||
await api("/api/actions", { body: { action: "update_model", id, label: g("label"), page_path: g("page_path"), bot_id: +g("bot_id"), post_id: +g("post_id") } });
|
||
toast("模型已保存", "ok");
|
||
} catch (e) { toast(e.message, "err"); }
|
||
});
|
||
$$("[data-savesite]").forEach(b => b.onclick = async () => {
|
||
const tr = b.closest("tr"), code = b.dataset.savesite;
|
||
const g = f => $(`[data-f="${f}"]`, tr).value;
|
||
try {
|
||
await api("/api/actions", { body: { action: "update_site", code, base_url: g("base_url"), ajax_url: g("ajax_url"), language: g("language") } });
|
||
toast("站点已保存", "ok");
|
||
} catch (e) { toast(e.message, "err"); }
|
||
});
|
||
ov.models.forEach(m => {
|
||
$("#sw-" + CSS.escape(m.id)).onchange = async ev => {
|
||
try { await api("/api/actions", { body: { action: "toggle_model", id: m.id, enabled: ev.target.checked } }); toast("已更新", "ok"); }
|
||
catch (e) { toast(e.message, "err"); ev.target.checked = !ev.target.checked; }
|
||
};
|
||
});
|
||
ov.sites.forEach(s => {
|
||
$("#sws-" + CSS.escape(s.code)).onchange = async ev => {
|
||
try { await api("/api/actions", { body: { action: "toggle_site", id: s.code, enabled: ev.target.checked } }); toast("已更新", "ok"); }
|
||
catch (e) { toast(e.message, "err"); ev.target.checked = !ev.target.checked; }
|
||
};
|
||
});
|
||
};
|
||
|
||
PAGES.keys = async () => {
|
||
const ov = await fetchOverview();
|
||
view.innerHTML = `
|
||
<div class="card"><div class="toolbar">
|
||
<div><h3 style="margin:0">下游 API Key</h3><div class="dim mt">留空表示不鉴权;请求时使用 <code>Authorization: Bearer <key></code> 或 <code>X-Api-Key</code></div></div>
|
||
<button class="btn primary" id="btn-gen">生成新 Key</button></div>
|
||
<div class="table-wrap mt"><table>
|
||
<thead><tr><th>Key</th><th>状态</th><th></th></tr></thead><tbody id="tb"></tbody></table></div>
|
||
<div class="dim mt">修改会立即生效并写入 config.toml(自动备份为 .bak)</div></div>`;
|
||
const render = keys => {
|
||
$("#tb").innerHTML = keys.length ? keys.map(k => `
|
||
<tr><td><code>${esc(mask(k))}</code></td><td><span class="pill on">启用</span></td>
|
||
<td class="right"><button class="btn small" data-copy="${esc(k)}">复制</button>
|
||
<button class="btn small danger" data-del="${esc(k)}">删除</button></td></tr>`).join("")
|
||
: '<tr><td colspan="3" class="empty">未配置任何 Key —— 当前不鉴权</td></tr>';
|
||
$$("[data-copy]").forEach(b => b.onclick = () => { navigator.clipboard && navigator.clipboard.writeText(b.dataset.copy); toast("已复制", "ok"); });
|
||
$$("[data-del]").forEach(b => b.onclick = async () => {
|
||
if (!confirm("删除该 Key?")) return;
|
||
try { await api("/api/actions", { body: { action: "delete_key", key: b.dataset.del } }); toast("已删除", "ok"); PAGES.keys(); }
|
||
catch (e) { toast(e.message, "err"); }
|
||
});
|
||
};
|
||
render(ov.keys || []);
|
||
$("#btn-gen").onclick = async () => {
|
||
try {
|
||
const r = await api("/api/actions", { body: { action: "create_key" } });
|
||
if (r.ok) { toast("已生成", "ok"); PAGES.keys(); }
|
||
} catch (e) { toast(e.message, "err"); }
|
||
};
|
||
};
|
||
const mask = k => k.length > 16 ? k.slice(0, 7) + "…" + k.slice(-6) : k;
|
||
|
||
PAGES.proxy = async () => {
|
||
const ov = await fetchOverview();
|
||
const p = ov.proxy;
|
||
const pool = ov.proxypool || { xray: {}, entries: [], subscriptions: [], sticky: {} };
|
||
view.innerHTML = `
|
||
<div class="grid g2">
|
||
<div class="card"><h3>主线路</h3>
|
||
<div class="field"><label>PROXY_URL(留空 = 直连)</label><input id="px-primary" value="${esc(p.url)}" placeholder="socks5://user:pass@host:port"></div>
|
||
<div class="row">
|
||
<button class="btn primary" id="px-save">保存</button>
|
||
<button class="btn" id="px-test">连通性测试</button>
|
||
</div>
|
||
<div class="dim mt">支持 http:// · https:// · socks5://</div>
|
||
</div>
|
||
<div class="card"><h3>降级策略</h3>
|
||
<div class="row">
|
||
<div class="field"><label>slow-start(秒)</label><input id="px-slow" type="number" step="0.5" value="${p.slow_start}"></div>
|
||
<div class="field"><label>每站点并发</label><input id="px-conc" type="number" value="${p.max_concurrent}"></div>
|
||
<div class="field"><label>每 Key 请求/分钟(0=不限)</label><input id="px-rate" type="number" value="${p.rate_per_minute}"></div>
|
||
</div>
|
||
<div class="row mt">
|
||
<div class="field"><label>跨站配额切换</label>${switchHTML("px-cross", p.cross_site_failover)}</div>
|
||
<button class="btn primary right" id="px-savelim">保存</button>
|
||
</div>
|
||
<div class="dim mt">主线路在 slow-start 内无首事件,或输出内容前失败时,自动切换到备用线路。</div>
|
||
</div>
|
||
</div>
|
||
<div class="card"><div class="toolbar"><h3 style="margin:0">备用线路</h3>
|
||
<div class="row"><input id="px-fb" placeholder="socks5://..." style="width:280px"><button class="btn" id="px-add">添加</button></div></div>
|
||
<div class="table-wrap mt"><table><thead><tr><th>#</th><th>地址</th><th></th></tr></thead><tbody id="tb-fb"></tbody></table></div>
|
||
<div class="dim mt">当前生效顺序:${ov.cache.routes.map(r => `<span class="tag">${esc(r.name)} → ${esc(r.proxy)}</span>`).join(" ")}</div>
|
||
</div>
|
||
<div class="grid g2 mt">
|
||
<div class="card"><h3>代理池 · Xray 核心</h3>
|
||
<div class="row">
|
||
<div class="field"><label>池开关</label>${switchHTML("pool-en", pool.enabled)}</div>
|
||
<div class="field"><label>检查间隔(秒)</label><input id="pool-check-int" type="number" min="10" value="${pool.check_interval_seconds || 120}"></div>
|
||
<div class="field"><label>检查超时(秒)</label><input id="pool-check-to" type="number" min="2" value="${pool.check_timeout_seconds || 10}"></div>
|
||
</div>
|
||
<div class="row">
|
||
<div class="field"><label>健康检查 URL</label><input id="pool-check-url" value="${esc(pool.check_url || "")}" placeholder="https://www.gstatic.com/generate_204"></div>
|
||
<div class="field"><label>裸 ip:port 默认协议</label><input id="pool-scheme" value="${esc(pool.default_scheme || "http")}" placeholder="http / socks5"></div>
|
||
</div>
|
||
<div class="row">
|
||
<div class="field"><label>Xray 路径(留空 = 自动)</label><input id="pool-xray-path" value="${esc(pool.xray_path || "")}" placeholder="留空自动下载到数据目录"></div>
|
||
<div class="field"><label>版本(留空 = latest)</label><input id="pool-xray-ver" value="${esc(pool.xray_version || "")}"></div>
|
||
<div class="field"><label>自动下载</label>${switchHTML("pool-xray-auto", pool.xray_auto_download)}</div>
|
||
</div>
|
||
<div class="row mt">
|
||
<button class="btn primary" id="pool-save">保存池配置</button>
|
||
<button class="btn" id="pool-refresh">刷新节点与健康检查</button>
|
||
</div>
|
||
<div class="dim mt">Xray:<span class="pill ${pool.xray.running ? "on" : "off"}">${pool.xray.running ? "运行中 · pid " + pool.xray.pid : "未运行"}</span>
|
||
${pool.xray.error ? `<span style="color:var(--err)"> ${esc(pool.xray.error)}</span>` : ""}
|
||
${pool.last_error ? `<br><span style="color:var(--err)">${esc(pool.last_error)}</span>` : ""}</div>
|
||
</div>
|
||
<div class="card"><h3>节点与端点</h3>
|
||
<div class="row"><input id="pool-name" placeholder="名称(字母数字-_)" style="width:180px">
|
||
<input id="pool-link" placeholder="vless://… 或 ip:port 或 socks5://user:pass@host:port" style="flex:1;min-width:200px">
|
||
<button class="btn" id="pool-add">添加</button></div>
|
||
<div class="table-wrap mt"><table><thead><tr><th>名称</th><th>类型</th><th>目标</th><th>健康</th><th class="num">延迟</th><th></th></tr></thead><tbody id="tb-pool"></tbody></table></div>
|
||
<div class="dim mt">分享链接(vless / vmess / trojan / ss)由内置 Xray 核心承载;ip:port / socks5:// 等端点直接使用。</div>
|
||
</div>
|
||
</div>
|
||
<div class="card mt"><div class="toolbar"><h3 style="margin:0">订阅</h3>
|
||
<div class="row"><input id="sub-name" placeholder="名称" style="width:150px">
|
||
<input id="sub-url" placeholder="https://…/sub?token=…" style="width:360px">
|
||
<input id="sub-int" type="number" min="5" value="60" title="刷新间隔(分钟)" style="width:90px">
|
||
<button class="btn" id="sub-add">添加订阅</button></div></div>
|
||
<div class="table-wrap mt"><table><thead><tr><th>名称</th><th>地址</th><th class="num">间隔</th><th class="num">节点</th><th>上次拉取</th><th></th></tr></thead><tbody id="tb-subs"></tbody></table></div>
|
||
</div>
|
||
<div class="card mt"><h3>站点绑定(按顺序,粘性选路)</h3>
|
||
<div class="table-wrap"><table><thead><tr><th>站点</th><th>绑定(逗号分隔:名称 或 sub:订阅名)</th><th>当前出口</th><th></th></tr></thead><tbody id="tb-bind"></tbody></table></div>
|
||
<div class="dim mt">未绑定、或绑定节点全部不健康时,回退到主线路 / 备用线路。Cookie 与出口 IP 绑定:切换出口后该站点会在下一轮巡检重新求解 Turnstile。</div>
|
||
</div>`;
|
||
|
||
const renderFb = list => {
|
||
$("#tb-fb").innerHTML = list.length ? list.map((u, i) => `
|
||
<tr><td class="num">${i + 1}</td><td><code>${esc(u)}</code></td>
|
||
<td class="right"><button class="btn small danger" data-rm="${esc(u)}">移除</button></td></tr>`).join("")
|
||
: '<tr><td colspan="3" class="empty">无备用线路</td></tr>';
|
||
$$("[data-rm]").forEach(b => b.onclick = async () => {
|
||
try { await api("/api/actions", { body: { action: "remove_fallback", url: b.dataset.rm } }); PAGES.proxy(); }
|
||
catch (e) { toast(e.message, "err"); }
|
||
});
|
||
};
|
||
renderFb(p.fallbacks || []);
|
||
|
||
const renderPool = () => {
|
||
const entries = pool.entries || [];
|
||
$("#tb-pool").innerHTML = entries.length ? entries.map(e => {
|
||
const health = e.parse_error ? `<span class="pill off" title="${esc(e.parse_error)}">解析失败</span>`
|
||
: e.checked ? (e.healthy ? '<span class="pill on">健康</span>' : `<span class="pill off" title="${esc(e.last_error || "")}">不可用</span>`)
|
||
: '<span class="pill">未探测</span>';
|
||
return `<tr><td><code>${esc(e.name)}</code>${e.source ? ` <span class="dim">← ${esc(e.source)}</span>` : ""}</td>
|
||
<td>${esc(e.kind === "url" ? e.scheme : (e.scheme || "xray"))}</td>
|
||
<td class="dim">${esc(e.target || "")}</td>
|
||
<td>${health}</td><td class="num">${e.latency_ms ? e.latency_ms + "ms" : "–"}</td>
|
||
<td class="right"><button class="btn small" data-ptoggle="${esc(e.name)}" data-pen="${e.enabled ? "0" : "1"}">${e.enabled ? "停用" : "启用"}</button>
|
||
${e.source ? "" : `<button class="btn small danger" data-pdel="${esc(e.name)}">删除</button>`}</td></tr>`;
|
||
}).join("") : '<tr><td colspan="6" class="empty">尚无节点 —— 添加分享链接或 ip:port 端点</td></tr>';
|
||
$$("[data-ptoggle]").forEach(b => b.onclick = async () => {
|
||
try { await api("/api/actions", { body: { action: "pool_toggle_entry", name: b.dataset.ptoggle, enabled: b.dataset.pen === "1" } }); PAGES.proxy(); }
|
||
catch (e) { toast(e.message, "err"); }
|
||
});
|
||
$$("[data-pdel]").forEach(b => b.onclick = async () => {
|
||
if (!confirm("删除节点 " + b.dataset.pdel + "?")) return;
|
||
try { await api("/api/actions", { body: { action: "pool_delete_entry", name: b.dataset.pdel } }); PAGES.proxy(); }
|
||
catch (e) { toast(e.message, "err"); }
|
||
});
|
||
|
||
const subs = pool.subscriptions || [];
|
||
$("#tb-subs").innerHTML = subs.length ? subs.map(s => `
|
||
<tr><td><code>${esc(s.name)}</code></td><td class="dim">${esc(s.url)}</td>
|
||
<td class="num">${s.interval_minutes || 60}m</td><td class="num">${s.nodes || 0}</td>
|
||
<td class="dim">${s.fetched_at ? timeStr(s.fetched_at * 1000) : "未拉取"}${s.last_error ? ` <span style="color:var(--err)">${esc(String(s.last_error).slice(0, 60))}</span>` : ""}</td>
|
||
<td class="right"><button class="btn small" data-stoggle="${esc(s.name)}" data-sen="${s.enabled ? "0" : "1"}">${s.enabled ? "停用" : "启用"}</button>
|
||
<button class="btn small danger" data-sdel="${esc(s.name)}">删除</button></td></tr>`).join("")
|
||
: '<tr><td colspan="6" class="empty">尚无订阅</td></tr>';
|
||
$$("[data-stoggle]").forEach(b => b.onclick = async () => {
|
||
try { await api("/api/actions", { body: { action: "pool_toggle_sub", name: b.dataset.stoggle, enabled: b.dataset.sen === "1" } }); PAGES.proxy(); }
|
||
catch (e) { toast(e.message, "err"); }
|
||
});
|
||
$$("[data-sdel]").forEach(b => b.onclick = async () => {
|
||
if (!confirm("删除订阅 " + b.dataset.sdel + "?")) return;
|
||
try { await api("/api/actions", { body: { action: "pool_delete_sub", name: b.dataset.sdel } }); PAGES.proxy(); }
|
||
catch (e) { toast(e.message, "err"); }
|
||
});
|
||
|
||
const sticky = pool.sticky || {};
|
||
$("#tb-bind").innerHTML = (ov.sites || []).map(s => {
|
||
const cur = sticky[s.code];
|
||
const curEntry = entries.find(e => e.name === cur);
|
||
return `<tr><td><b>${esc(s.code.toUpperCase())}</b></td>
|
||
<td><input data-bind="${esc(s.code)}" value="${esc((s.proxies || []).join(", "))}" placeholder="留空 = 全局线路"></td>
|
||
<td class="dim">${cur ? `${esc(cur)} → ${esc(curEntry ? curEntry.target : "?")}` : "全局线路"}</td>
|
||
<td class="right"><button class="btn small" data-bsave="${esc(s.code)}">保存</button></td></tr>`;
|
||
}).join("");
|
||
$$("[data-bsave]").forEach(b => b.onclick = async () => {
|
||
const input = $(`[data-bind="${CSS.escape(b.dataset.bsave)}"]`);
|
||
const list = input.value.split(",").map(x => x.trim()).filter(Boolean);
|
||
try { await api("/api/actions", { body: { action: "pool_set_site", site: b.dataset.bsave, proxies: list } }); toast("已保存", "ok"); PAGES.proxy(); }
|
||
catch (e) { toast(e.message, "err"); }
|
||
});
|
||
};
|
||
renderPool();
|
||
|
||
$("#pool-save").onclick = async () => {
|
||
try {
|
||
await api("/api/actions", { body: {
|
||
action: "pool_set_config",
|
||
enabled: $("#pool-en").checked,
|
||
check_interval_seconds: +$("#pool-check-int").value || 120,
|
||
check_timeout_seconds: +$("#pool-check-to").value || 10,
|
||
check_url: $("#pool-check-url").value.trim(),
|
||
default_scheme: $("#pool-scheme").value.trim() || "http",
|
||
xray_path: $("#pool-xray-path").value.trim(),
|
||
xray_version: $("#pool-xray-ver").value.trim(),
|
||
xray_auto_download: $("#pool-xray-auto").checked } });
|
||
toast("池配置已保存", "ok"); PAGES.proxy();
|
||
} catch (e) { toast(e.message, "err"); }
|
||
};
|
||
$("#pool-refresh").onclick = async () => {
|
||
const b = $("#pool-refresh"); b.disabled = true; b.textContent = "刷新中…";
|
||
try { await api("/api/actions", { body: { action: "pool_refresh" } }); toast("已在后台拉取订阅并检查健康,稍后重进本页查看", "ok"); PAGES.proxy(); }
|
||
catch (e) { toast(e.message, "err"); b.disabled = false; b.textContent = "刷新节点与健康检查"; }
|
||
};
|
||
$("#pool-add").onclick = async () => {
|
||
try {
|
||
await api("/api/actions", { body: { action: "pool_add_entry", name: $("#pool-name").value.trim(), link: $("#pool-link").value.trim() } });
|
||
toast("已添加", "ok"); PAGES.proxy();
|
||
} catch (e) { toast(e.message, "err"); }
|
||
};
|
||
$("#sub-add").onclick = async () => {
|
||
try {
|
||
await api("/api/actions", { body: { action: "pool_add_sub", name: $("#sub-name").value.trim(), url: $("#sub-url").value.trim(), interval_minutes: +$("#sub-int").value || 60 } });
|
||
toast("已添加", "ok"); PAGES.proxy();
|
||
} catch (e) { toast(e.message, "err"); }
|
||
};
|
||
|
||
$("#px-save").onclick = async () => {
|
||
try {
|
||
await api("/api/config", { method: "PUT", body: await merged(c => { c.proxy.url = $("#px-primary").value.trim(); }) });
|
||
toast("主线路已保存", "ok"); PAGES.proxy();
|
||
} catch (e) { toast(e.message, "err"); }
|
||
};
|
||
$("#px-test").onclick = async () => {
|
||
const b = $("#px-test"); b.disabled = true; b.textContent = "测试中…";
|
||
try {
|
||
const r = await api("/api/actions", { body: { action: "test_proxy", url: $("#px-primary").value.trim() } });
|
||
toast(r.ok ? `连通 · HTTP ${r.status} · ${r.ms}ms` : (r.error || "失败"), r.ok ? "ok" : "err");
|
||
} catch (e) { toast(e.message, "err"); }
|
||
b.disabled = false; b.textContent = "连通性测试";
|
||
};
|
||
$("#px-savelim").onclick = async () => {
|
||
try {
|
||
await api("/api/actions", { body: {
|
||
action: "set_limits", slow_start: +$("#px-slow").value,
|
||
max_concurrent: +$("#px-conc").value, rate_per_minute: +$("#px-rate").value,
|
||
cross_site_failover: $("#px-cross").checked } });
|
||
toast("已保存", "ok");
|
||
} catch (e) { toast(e.message, "err"); }
|
||
};
|
||
$("#px-add").onclick = async () => {
|
||
const v = $("#px-fb").value.trim(); if (!v) return;
|
||
try { await api("/api/actions", { body: { action: "add_fallback", url: v } }); $("#px-fb").value = ""; PAGES.proxy(); }
|
||
catch (e) { toast(e.message, "err"); }
|
||
};
|
||
};
|
||
|
||
async function merged(mutate) {
|
||
const cfg = await api("/api/config");
|
||
mutate(cfg);
|
||
return cfg;
|
||
}
|
||
|
||
PAGES.turnstile = async () => {
|
||
if (overviewTimer) { clearInterval(overviewTimer); overviewTimer = null; }
|
||
const ov = await fetchOverview();
|
||
const t = ov.turnstile.config;
|
||
view.innerHTML = `
|
||
<div class="grid g2">
|
||
<div class="card"><h3>求解器配置</h3>
|
||
<div class="row">
|
||
<div class="field"><label>Token 获取方式</label><select id="ts-provider">
|
||
<option value="api">方式 1:求解服务 API</option>
|
||
<option value="browser">方式 2:本地浏览器自动获取</option>
|
||
<option value="manual">方式 3:手动导入 Cookie</option>
|
||
</select></div>
|
||
<div class="field"><label>启用</label>${switchHTML("ts-en", t.enabled)}</div>
|
||
</div>
|
||
<div id="ts-api" class="ts-panel">
|
||
<div class="ts-panel-title">方式 1 · 求解服务 API</div>
|
||
<div class="field"><label>求解服务 API URL</label><input id="ts-url" value="${esc(t.api_url)}"></div>
|
||
<div class="field"><label>API Key(留空保持不变)</label><input id="ts-key" value="" placeholder="已配置,输入以覆盖"></div>
|
||
<div class="row">
|
||
<div class="field"><label>Sitekey</label><input id="ts-sitekey" value="${esc(t.sitekey)}"></div>
|
||
<div class="field"><label>Action</label><input id="ts-action" value="${esc(t.action)}"></div>
|
||
</div>
|
||
</div>
|
||
<div id="ts-browser" class="ts-panel" style="display:none">
|
||
<div class="ts-panel-title">方式 2 · 本地浏览器自动获取</div>
|
||
<div class="field"><label>浏览器路径(Chrome / Edge 可执行文件)</label>
|
||
<input id="ts-bpath" value="${esc(t.browser_path || "")}"
|
||
placeholder="C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"></div>
|
||
<div class="row">
|
||
<div class="field"><label>无头模式</label>${switchHTML("ts-bhead", !!t.browser_headless)}</div>
|
||
<div class="field"><label>时区(可选,按代理出口对齐)</label>
|
||
<input id="ts-btz" value="${esc(t.browser_timezone || "")}" placeholder="Asia/Tokyo"></div>
|
||
</div>
|
||
<div class="row">
|
||
<div class="field"><label>Locale(可选)</label>
|
||
<input id="ts-bloc" value="${esc(t.browser_locale || "")}" placeholder="ja-JP"></div>
|
||
<div class="field"><label>用户数据目录(可选)</label>
|
||
<input id="ts-bprof" value="${esc(t.browser_user_data_dir || "")}" placeholder="留空 = 临时 profile"></div>
|
||
</div>
|
||
<div class="dim">CDP 直连浏览器,无需 Playwright / Docker;求解期间会短暂打开一个窗口(无头模式除外)。</div>
|
||
</div>
|
||
<div id="ts-manual" class="ts-panel" style="display:none">
|
||
<div class="ts-panel-title">方式 3 · 手动导入 Cookie</div>
|
||
<div class="row">
|
||
<div class="field"><label>Cookie TTL(秒)</label><input id="ts-ttl" type="number" value="${t.cookie_ttl_seconds}"></div>
|
||
<div class="field"><label>站点</label><select id="im-site">${
|
||
(ov.sites || []).map(s => `<option value="${esc(s.code)}">${esc(s.code.toUpperCase())} · ${esc(s.base_url)}</option>`).join("")
|
||
}</select></div>
|
||
</div>
|
||
<div class="field"><label>Cookie(name=value; name2=value2)</label><textarea id="im-cookies" style="min-height:96px"
|
||
placeholder="在已通过验证的浏览器里执行 document.cookie,或从开发者工具复制请求头 Cookie"></textarea></div>
|
||
<div class="dim">Cookie 与出口 IP / UA 绑定:请在同一代理线路的浏览器中获取。导入后在 TTL 内免求解,状态见右侧「已验证 Cookie」。</div>
|
||
<button class="btn primary mt" id="im-save">导入</button>
|
||
</div>
|
||
<div class="dim" id="ts-hint" style="margin-top:10px"></div>
|
||
<button class="btn primary mt" id="ts-save">保存配置</button>
|
||
</div>
|
||
<div class="card"><h3>已验证 Cookie</h3>
|
||
<div id="ts-status" class="grid" style="gap:10px"></div>
|
||
<div class="row mt">
|
||
<button class="btn" id="ts-refresh">强制刷新全部</button>
|
||
<button class="btn danger" id="ts-clear">清空 Cookie</button>
|
||
</div>
|
||
<div class="dim mt">缓存 TTL ${t.cookie_ttl_seconds}s · 求解失败重试 ${t.retries} 次 · 退避 ${t.retry_backoff_seconds}s</div>
|
||
</div>
|
||
</div>
|
||
<div class="card mt"><h3>求解历史</h3><div class="table-wrap"><table>
|
||
<thead><tr><th>时间</th><th>站点</th><th>线路</th><th class="num">耗时</th><th>结果</th></tr></thead>
|
||
<tbody id="ts-hist"></tbody></table></div></div>`;
|
||
|
||
$("#ts-status").innerHTML = (ov.turnstile.status || []).map(s => `
|
||
<div class="card" style="padding:12px"><div class="toolbar">
|
||
<div><b>${esc(s.site.toUpperCase())}</b> <span class="pill ${s.valid ? "on" : "off"}">${s.valid ? "有效" : "未建立"}</span></div>
|
||
<button class="btn small" data-rf="${esc(s.site)}">刷新</button></div>
|
||
<div class="dim mt">${s.valid ? "剩余 " + Math.round(s.remaining_s / 60) + " 分钟 · " + s.cookie_count + " 个 Cookie" : "下次请求时自动求解"}</div>
|
||
</div>`).join("");
|
||
|
||
const hist = ov.turnstile.history || [];
|
||
$("#ts-hist").innerHTML = hist.length ? hist.slice().reverse().slice(0, 40).map(h => `
|
||
<tr><td class="dim">${timeStr(h.at)}</td><td>${esc(h.site)}</td><td class="dim">${esc(h.proxy)}</td>
|
||
<td class="num">${fmtMs(h.elapsed_ms)}</td>
|
||
<td><span class="pill ${h.ok ? "on" : "off"}">${h.ok ? "成功" : "失败"}</span>${h.error ? ` <span class="dim">${esc(h.error.slice(0, 70))}</span>` : ""}</td></tr>`).join("")
|
||
: '<tr><td colspan="5" class="empty">尚无记录</td></tr>';
|
||
|
||
$$("[data-rf]").forEach(b => b.onclick = async () => {
|
||
b.disabled = true;
|
||
try { await api("/api/actions", { body: { action: "refresh_cookies", site: b.dataset.rf } }); toast("已作废,将在下次请求时重新求解", "ok"); PAGES.turnstile(); }
|
||
catch (e) { toast(e.message, "err"); }
|
||
b.disabled = false;
|
||
});
|
||
$("#ts-provider").value = t.provider || "api";
|
||
const TS_HINTS = {
|
||
api: ["已开启:调用求解服务 API 获取 Turnstile Token", "已关闭:跳过 Turnstile 求解,直接请求上游站点"],
|
||
browser: ["已开启:用下方配置的本地浏览器自动求解 Token", "已关闭:跳过 Turnstile 求解,直接请求上游站点"],
|
||
manual: ["手动方式:不自动求解,导入已通过验证的 Cookie 即可(TTL 内免求解)", "已关闭:跳过 Turnstile 求解,直接请求上游站点"],
|
||
};
|
||
const syncProvider = () => {
|
||
const p = $("#ts-provider").value;
|
||
$("#ts-api").style.display = p === "api" ? "" : "none";
|
||
$("#ts-browser").style.display = p === "browser" ? "" : "none";
|
||
$("#ts-manual").style.display = p === "manual" ? "" : "none";
|
||
$("#ts-refresh").style.display = p === "manual" ? "none" : "";
|
||
const hints = TS_HINTS[p] || TS_HINTS.api;
|
||
$("#ts-hint").textContent = $("#ts-en").checked ? hints[0] : hints[1];
|
||
};
|
||
$("#ts-provider").onchange = syncProvider;
|
||
$("#ts-en").onchange = syncProvider;
|
||
syncProvider();
|
||
$("#ts-save").onclick = async () => {
|
||
try {
|
||
await api("/api/actions", { body: {
|
||
action: "set_turnstile", enabled: $("#ts-en").checked,
|
||
provider: $("#ts-provider").value,
|
||
api_url: $("#ts-url").value.trim(),
|
||
api_key: $("#ts-key").value.trim(), sitekey: $("#ts-sitekey").value.trim(),
|
||
challenge_action: $("#ts-action").value.trim(), cookie_ttl_seconds: +$("#ts-ttl").value,
|
||
browser_path: $("#ts-bpath").value.trim(),
|
||
browser_headless: $("#ts-bhead").checked,
|
||
browser_user_data_dir: $("#ts-bprof").value.trim(),
|
||
browser_timezone: $("#ts-btz").value.trim(),
|
||
browser_locale: $("#ts-bloc").value.trim() } });
|
||
toast("已保存", "ok"); PAGES.turnstile();
|
||
} catch (e) { toast(e.message, "err"); }
|
||
};
|
||
$("#ts-refresh").onclick = async () => {
|
||
try { await api("/api/actions", { body: { action: "invalidate" } }); toast("已作废全部 cookie", "ok"); PAGES.turnstile(); }
|
||
catch (e) { toast(e.message, "err"); }
|
||
};
|
||
$("#ts-clear").onclick = async () => {
|
||
try { await api("/api/actions", { body: { action: "clear_cookies" } }); toast("已清空 Cookie", "ok"); PAGES.turnstile(); }
|
||
catch (e) { toast(e.message, "err"); }
|
||
};
|
||
$("#im-save").onclick = async () => {
|
||
try {
|
||
const r = await api("/api/actions", { body: {
|
||
action: "import_cookies", site: $("#im-site").value, cookies: $("#im-cookies").value } });
|
||
toast(`已导入 ${r.cookie_count} 个 Cookie,${timeStr(r.expires_at * 1000)} 到期`, "ok");
|
||
PAGES.turnstile();
|
||
} catch (e) { toast(e.message, "err"); }
|
||
};
|
||
};
|
||
|
||
PAGES.playground = async () => {
|
||
const ov = await fetchOverview();
|
||
const enabled = ov.models.filter(m => m.enabled);
|
||
view.innerHTML = `
|
||
<div class="grid g2" style="align-items:start">
|
||
<div class="card"><h3>请求</h3>
|
||
<div class="row">
|
||
<div class="field"><label>模型</label><select id="pg-model">${enabled.map(m => `<option value="${esc(m.id)}">${esc(m.id)}</option>`).join("")}</select></div>
|
||
<div class="field" style="flex:0 0 auto"><label>流式</label>${switchHTML("pg-stream", true)}</div>
|
||
</div>
|
||
<div class="field"><label>System 提示</label><textarea id="pg-system" style="min-height:60px">你是一个乐于助人的助手。</textarea></div>
|
||
<div class="field"><label>Tools 定义(JSON,可留空)</label><textarea id="pg-tools" style="min-height:80px" placeholder='[{"type":"function","function":{"name":"get_weather","description":"获取天气","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}]'></textarea></div>
|
||
<div class="field"><label>输入</label><textarea id="pg-input" style="min-height:70px" placeholder="输入消息…"></textarea></div>
|
||
<div class="row"><button class="btn primary" id="pg-send">发送</button>
|
||
<button class="btn danger" id="pg-cancel" disabled>取消</button>
|
||
<button class="btn" id="pg-clear">清空输出</button>
|
||
<span class="dim" id="pg-meta"></span></div>
|
||
</div>
|
||
<div class="card"><h3>响应</h3><div class="out-box" id="pg-out"><span class="dim">尚未发送请求</span></div></div>
|
||
</div>`;
|
||
|
||
let ac = null, timer = null;
|
||
const stopTimer = () => { if (timer) { clearInterval(timer); timer = null; } };
|
||
|
||
$("#pg-clear").onclick = () => { stopTimer(); ac = null; $("#pg-out").innerHTML = '<span class="dim">尚未发送请求</span>'; $("#pg-meta").textContent = ""; };
|
||
$("#pg-cancel").onclick = () => { if (ac) ac.abort(); };
|
||
$("#pg-send").onclick = async () => {
|
||
const input = $("#pg-input").value.trim();
|
||
if (!input) { toast("请输入内容", "err"); return; }
|
||
let tools = null;
|
||
const raw = $("#pg-tools").value.trim();
|
||
if (raw) { try { tools = JSON.parse(raw); } catch (e) { toast("Tools JSON 解析失败", "err"); return; } }
|
||
|
||
const messages = [{ role: "system", content: $("#pg-system").value }, { role: "user", content: input }];
|
||
const model = $("#pg-model").value;
|
||
const stream = $("#pg-stream").checked;
|
||
const out = $("#pg-out");
|
||
out.innerHTML = "";
|
||
|
||
const status = document.createElement("span");
|
||
status.className = "dim";
|
||
status.textContent = "连接中… 0.0s";
|
||
const caret = document.createElement("span"); caret.className = "caret";
|
||
out.appendChild(status); out.appendChild(caret);
|
||
|
||
ac = new AbortController();
|
||
const btn = $("#pg-send"), cancel = $("#pg-cancel");
|
||
btn.disabled = true; btn.textContent = "请求中…";
|
||
cancel.disabled = false;
|
||
$("#pg-meta").textContent = "";
|
||
const t0 = Date.now();
|
||
const tick = () => { status.textContent = `连接中… ${((Date.now() - t0) / 1000).toFixed(1)}s`; };
|
||
stopTimer(); timer = setInterval(tick, 100);
|
||
|
||
const finish = () => {
|
||
stopTimer(); ac = null;
|
||
btn.disabled = false; btn.textContent = "发送";
|
||
cancel.disabled = true;
|
||
if (!$("#pg-meta").textContent) $("#pg-meta").textContent = `${fmtMs(Date.now() - t0)}`;
|
||
};
|
||
const firstText = () => { if (status.parentNode) status.remove(); };
|
||
|
||
try {
|
||
if (stream) {
|
||
const res = await fetch("/api/playground", { method: "POST", headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ model, messages, stream: true, tools }), signal: ac.signal });
|
||
if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.error || res.statusText); }
|
||
out.appendChild(caret);
|
||
const reader = res.body.getReader(), dec = new TextDecoder();
|
||
let buf = "", first = null;
|
||
for (;;) {
|
||
const { done, value } = await reader.read(); if (done) break;
|
||
buf += dec.decode(value, { stream: true });
|
||
let idx;
|
||
while ((idx = buf.indexOf("\n\n")) >= 0) {
|
||
const frame = buf.slice(0, idx); buf = buf.slice(idx + 2);
|
||
if (!frame.startsWith("data: ")) continue;
|
||
const payload = frame.slice(6);
|
||
if (payload === "[DONE]") continue;
|
||
let j; try { j = JSON.parse(payload); } catch (e) { continue; }
|
||
if (j.error) { firstText(); out.insertBefore(document.createTextNode("\n[错误] " + j.error), caret); continue; }
|
||
if (j.delta) { if (!first) first = Date.now(); firstText(); out.insertBefore(document.createTextNode(j.delta), caret); }
|
||
if (j.done) { $("#pg-meta").textContent = `${j.ms}ms · ${j.site} · ${j.route}`; }
|
||
}
|
||
}
|
||
caret.remove();
|
||
} else {
|
||
const j = await api("/api/playground", { body: { model, messages, stream: false, tools }, signal: ac.signal });
|
||
out.textContent = j.text;
|
||
$("#pg-meta").textContent = `${j.ms}ms · ${j.site} · ${j.route} · prompt ${j.prompt_chars} 字符`;
|
||
}
|
||
} catch (e) {
|
||
caret.remove();
|
||
if (e && e.name === "AbortError") {
|
||
out.innerHTML = '<span class="dim">已取消</span>';
|
||
} else {
|
||
out.innerHTML = `<span style="color:var(--err)">请求失败:${esc(e.message)}</span>`;
|
||
}
|
||
}
|
||
finish();
|
||
};
|
||
};
|
||
|
||
PAGES.logs = async () => {
|
||
view.innerHTML = `
|
||
<div class="card"><div class="toolbar">
|
||
<div class="row">
|
||
<div class="field" style="margin:0"><label>最低级别</label>
|
||
<select id="lg-level" style="width:120px"><option>DEBUG</option><option selected>INFO</option><option>WARN</option><option>ERROR</option></select></div>
|
||
<button class="btn" id="lg-toggle">暂停</button>
|
||
<button class="btn danger" id="lg-clear">清屏</button>
|
||
</div>
|
||
<div class="dim" id="lg-state">实时连接中…</div>
|
||
</div>
|
||
<div class="log-box mt" id="lg-box"></div></div>`;
|
||
|
||
const box = $("#lg-box");
|
||
let paused = false, es = null, gen = 0;
|
||
const minVal = { DEBUG: -4, INFO: 0, WARN: 4, ERROR: 8 };
|
||
|
||
function append(e) {
|
||
if (paused) return;
|
||
const sel = $("#lg-level");
|
||
if (sel && minVal[e.level] < minVal[sel.value]) return;
|
||
const line = document.createElement("div");
|
||
line.className = "log-line";
|
||
const attrs = e.attrs ? Object.entries(e.attrs).map(([k, v]) => `${k}=${v}`).join(" ") : "";
|
||
line.innerHTML = `<span class="t">${timeStr(e.ts)}</span> <span class="lv lv-${e.level}">${e.level}</span> ${esc(e.msg)}${attrs ? ' <span class="t">' + esc(attrs) + "</span>" : ""}`;
|
||
box.appendChild(line);
|
||
while (box.children.length > 600) box.removeChild(box.firstChild);
|
||
box.scrollTop = box.scrollHeight;
|
||
}
|
||
|
||
function stopES() { if (es) { es.close(); es = null; } }
|
||
|
||
// (Re)load history and reopen the SSE stream with the currently selected
|
||
// minimum level. Never re-render the page here: rebuilding the select would
|
||
// reset it to INFO before the value is read, and would leak the old stream.
|
||
// History comes solely from the server replay on connect (and on every
|
||
// automatic reconnect), so the box is cleared in onopen instead of fetching
|
||
// the JSON endpoint separately — otherwise entries show up twice.
|
||
async function load() {
|
||
const my = ++gen;
|
||
const level = $("#lg-level").value;
|
||
stopES();
|
||
box.innerHTML = "";
|
||
es = new EventSource("/api/logs?stream=1&level=" + level);
|
||
es.onmessage = ev => { if (my !== gen) return; try { append(JSON.parse(ev.data)); } catch (e) {} };
|
||
es.onerror = () => { if (my !== gen) return; const st = $("#lg-state"); if (st) st.textContent = "连接中断,自动重连中…"; };
|
||
es.onopen = () => {
|
||
if (my !== gen) return;
|
||
box.innerHTML = "";
|
||
const st = $("#lg-state"); if (st) st.textContent = "实时连接中…";
|
||
};
|
||
logStop = () => { gen++; stopES(); };
|
||
}
|
||
|
||
$("#lg-toggle").onclick = e => { paused = !paused; e.target.textContent = paused ? "继续" : "暂停"; };
|
||
$("#lg-clear").onclick = () => { box.innerHTML = ""; };
|
||
$("#lg-level").onchange = load;
|
||
await load();
|
||
};
|
||
|
||
PAGES.settings = async () => {
|
||
const cfg = await api("/api/config");
|
||
view.innerHTML = `
|
||
<div class="grid g2">
|
||
<div class="card"><h3>服务</h3>
|
||
<div class="row">
|
||
<div class="field"><label>监听地址</label><input id="st-host" value="${esc(cfg.server.host)}"></div>
|
||
<div class="field"><label>端口</label><input id="st-port" type="number" value="${cfg.server.port}"></div>
|
||
</div>
|
||
<div class="field"><label>日志级别</label><select id="st-log">
|
||
${["DEBUG", "INFO", "WARN", "ERROR"].map(l => `<option ${cfg.server.log_level === l ? "selected" : ""}>${l}</option>`).join("")}
|
||
</select></div>
|
||
<div class="row">
|
||
<div class="field"><label>管理台地址</label><input id="st-ah" value="${esc(cfg.admin.host)}"></div>
|
||
<div class="field"><label>管理台端口</label><input id="st-ap" type="number" value="${cfg.admin.port}"></div>
|
||
</div>
|
||
<div class="field"><label>管理台密码(留空保持不变)</label><input id="st-pw" type="password" placeholder="••••••••"></div>
|
||
<button class="btn primary" id="st-save">保存服务设置</button>
|
||
</div>
|
||
<div class="card"><h3>上游</h3>
|
||
<div class="row">
|
||
<div class="field"><label>请求超时(秒)</label><input id="st-to" type="number" value="${cfg.upstream.timeout}"></div>
|
||
<div class="field"><label>流式超时(秒)</label><input id="st-sto" type="number" value="${cfg.upstream.stream_timeout}"></div>
|
||
</div>
|
||
<div class="row">
|
||
<div class="field"><label>配置缓存 TTL(秒)</label><input id="st-ttl" type="number" value="${cfg.upstream.config_ttl_seconds}"></div>
|
||
<div class="field"><label>会话刷新重试</label><input id="st-retry" type="number" value="${cfg.upstream.refresh_retries}"></div>
|
||
</div>
|
||
<div class="row">
|
||
<div class="field"><label>失败退避(秒)</label><input id="st-back" type="number" step="0.1" value="${cfg.upstream.retry_backoff_seconds}"></div>
|
||
<div class="field"><label>自动刷新会话</label>${switchHTML("st-ar", cfg.upstream.auto_refresh)}</div>
|
||
</div>
|
||
<div class="field"><label>User-Agent</label><input id="st-ua" value="${esc(cfg.upstream.user_agent)}"></div>
|
||
<div class="field"><label>数据目录</label><input id="st-dir" value="${esc(cfg.runtime.data_dir)}"></div>
|
||
<button class="btn primary" id="st-save2">保存上游设置</button>
|
||
</div>
|
||
</div>
|
||
<div class="card"><h3>危险操作</h3>
|
||
<div class="row">
|
||
<button class="btn danger" id="st-reset">清空统计</button>
|
||
<button class="btn danger" id="st-inv">作废全部会话缓存</button>
|
||
</div>
|
||
<div class="dim mt">配置保存会自动备份原文件为 <code>config.toml.bak</code>;修改端口/密码需重启服务生效。</div>
|
||
</div>`;
|
||
|
||
const put = async mutate => {
|
||
try {
|
||
const c = await api("/api/config");
|
||
mutate(c);
|
||
await api("/api/config", { method: "PUT", body: c });
|
||
toast("已保存并写入 config.toml", "ok");
|
||
return true;
|
||
} catch (e) { toast(e.message, "err"); return false; }
|
||
};
|
||
$("#st-save").onclick = () => put(c => {
|
||
c.server.host = $("#st-host").value;
|
||
c.server.port = +$("#st-port").value;
|
||
c.server.log_level = $("#st-log").value;
|
||
c.admin.host = $("#st-ah").value;
|
||
c.admin.port = +$("#st-ap").value;
|
||
if ($("#st-pw").value) c.admin.password = $("#st-pw").value;
|
||
});
|
||
$("#st-save2").onclick = () => put(c => {
|
||
c.upstream.timeout = +$("#st-to").value;
|
||
c.upstream.stream_timeout = +$("#st-sto").value;
|
||
c.upstream.config_ttl_seconds = +$("#st-ttl").value;
|
||
c.upstream.refresh_retries = +$("#st-retry").value;
|
||
c.upstream.retry_backoff_seconds = +$("#st-back").value;
|
||
c.upstream.auto_refresh = $("#st-ar").checked;
|
||
c.upstream.user_agent = $("#st-ua").value;
|
||
c.runtime.data_dir = $("#st-dir").value;
|
||
});
|
||
$("#st-reset").onclick = async () => { try { await api("/api/actions", { body: { action: "reset_metrics" } }); toast("已清空", "ok"); } catch (e) { toast(e.message, "err"); } };
|
||
$("#st-inv").onclick = async () => { try { await api("/api/actions", { body: { action: "invalidate" } }); toast("已作废", "ok"); } catch (e) { toast(e.message, "err"); } };
|
||
};
|
||
|
||
/* ── boot ────────────────────────────────────────────────── */
|
||
window.addEventListener("hashchange", route);
|
||
$("#login-btn").onclick = doLogin;
|
||
$("#login-password").addEventListener("keydown", e => { if (e.key === "Enter") doLogin(); });
|
||
$("#logout-btn").onclick = async () => { try { await api("/api/logout", { method: "POST" }); } catch (e) {} location.hash = "#/dashboard"; showLogin(); };
|
||
|
||
(async function init() {
|
||
if (await ensureSession()) route();
|
||
})();
|
||
})();
|