/* 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 ``; } /* ── 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 = '
加载中…
'; const fn = PAGES[page] || PAGES.dashboard; fn().catch(e => { view.innerHTML = `
加载失败:${esc(e.message)}
`; }); } /* ── 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 = ` 运行 ${Math.floor(ov.system.uptime_s / 3600)}h${Math.floor(ov.system.uptime_s % 3600 / 60)}m ${esc(ov.system.go)} · ${esc(ov.system.os)}/${esc(ov.system.arch)} Turnstile ${st}/${total} 在线 ${fmtInt(ov.metrics.in_flight)}`; } /* ── pages ───────────────────────────────────────────────── */ const PAGES = {}; PAGES.dashboard = async () => { view.innerHTML = `
总请求
–
错误 / 空响应
–
Token(估算)
–
延迟 P50 / P95
–

模型用量

模型请求错误Token平均耗时
暂无数据

站点状态

最近请求

时间模型站点线路类型耗时Token状态
暂无请求
`; $("#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]) => ` ${esc(id)} ${fmtInt(v.requests)} ${fmtInt(v.errors)} ${fmtInt(v.prompt_tokens + v.completion_tokens)} ${v.requests ? fmtMs(v.sum_ms / v.requests) : "–"}`).join("") : '暂无数据'; $("#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 `
${esc(s.code.toUpperCase())} ${s.enabled ? "启用" : "停用"}
${esc(s.base_url)}
请求 ${fmtInt(ms ? ms.requests : 0)} · 错误 ${fmtInt(ms ? ms.errors : 0)} · Cookie ${ts.valid ? "有效 " + Math.round(ts.remaining_s / 60) + " 分钟" : "未建立"}
`; }).join("") || '
无站点
'; $$("[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 => ` ${timeStr(r.at)} ${esc(r.served_by || r.model)} ${esc(r.site)}${esc(r.route || "–")} ${r.stream ? "流式" : "同步"} ${fmtMs(r.duration_ms)} ${fmtInt((r.prompt_tokens || 0) + (r.completion_tokens || 0))} ${esc(r.status)}${r.error ? ` ${esc(r.error.slice(0, 60))}` : ""}`).join("") : '暂无请求'; } await refresh(); overviewTimer = setInterval(refresh, 5000); }; PAGES.models = async () => { const ov = await fetchOverview(); view.innerHTML = `

模型

bot_id / post_id 修改后会自动清除该模型的 nonce 缓存
ID标签站点上游模型路径botpost启用

站点

代码Base URLAJAX URL语言启用
`; $("#tb").innerHTML = ov.models.map(m => ` ${esc(m.id)} ${esc(m.site)}${esc(m.upstream_id)} ${switchHTML("sw-" + m.id, m.enabled)} `).join(""); $("#tb-sites").innerHTML = ov.sites.map(s => ` ${esc(s.code.toUpperCase())} ${switchHTML("sws-" + s.code, s.enabled)}
`).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 = `

下游 API Key

留空表示不鉴权;请求时使用 Authorization: Bearer <key> 或 X-Api-Key
Key状态
修改会立即生效并写入 config.toml(自动备份为 .bak)
`; const render = keys => { $("#tb").innerHTML = keys.length ? keys.map(k => ` ${esc(mask(k))}启用 `).join("") : '未配置任何 Key —— 当前不鉴权'; $$("[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; view.innerHTML = `

主线路

支持 http:// · https:// · socks5://

降级策略

${switchHTML("px-cross", p.cross_site_failover)}
主线路在 slow-start 内无首事件,或输出内容前失败时,自动切换到备用线路。

备用线路

#地址
当前生效顺序:${ov.cache.routes.map(r => `${esc(r.name)} → ${esc(r.proxy)}`).join(" ")}
`; const renderFb = list => { $("#tb-fb").innerHTML = list.length ? list.map((u, i) => ` ${i + 1}${esc(u)} `).join("") : '无备用线路'; $$("[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 || []); $("#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 () => { const ov = await fetchOverview(); const t = ov.turnstile.config; view.innerHTML = `

求解器配置

${switchHTML("ts-en", t.enabled)}
方式 1 · 求解服务 API

已验证 Cookie

缓存 TTL ${t.cookie_ttl_seconds}s · 求解失败重试 ${t.retries} 次 · 退避 ${t.retry_backoff_seconds}s

求解历史

时间站点线路耗时结果
`; $("#ts-status").innerHTML = (ov.turnstile.status || []).map(s => `
${esc(s.site.toUpperCase())} ${s.valid ? "有效" : "未建立"}
${s.valid ? "剩余 " + Math.round(s.remaining_s / 60) + " 分钟 · " + s.cookie_count + " 个 Cookie" : "下次请求时自动求解"}
`).join(""); const hist = ov.turnstile.history || []; $("#ts-hist").innerHTML = hist.length ? hist.slice().reverse().slice(0, 40).map(h => ` ${timeStr(h.at)}${esc(h.site)}${esc(h.proxy)} ${fmtMs(h.elapsed_ms)} ${h.ok ? "成功" : "失败"}${h.error ? ` ${esc(h.error.slice(0, 70))}` : ""}`).join("") : '尚无记录'; $$("[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 = `

请求

${switchHTML("pg-stream", true)}

响应

尚未发送请求
`; let ac = null, timer = null; const stopTimer = () => { if (timer) { clearInterval(timer); timer = null; } }; $("#pg-clear").onclick = () => { stopTimer(); ac = null; $("#pg-out").innerHTML = '尚未发送请求'; $("#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 = '已取消'; } else { out.innerHTML = `请求失败:${esc(e.message)}`; } } finish(); }; }; PAGES.logs = async () => { view.innerHTML = `
实时连接中…
`; 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 = `${timeStr(e.ts)} ${e.level} ${esc(e.msg)}${attrs ? ' ' + esc(attrs) + "" : ""}`; 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 = `

服务

上游

${switchHTML("st-ar", cfg.upstream.auto_refresh)}

危险操作

配置保存会自动备份原文件为 config.toml.bak;修改端口/密码需重启服务生效。
`; 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(); })(); })();