pp03 / admin.html
Xunjunaa's picture
feat: factory_mode 三模式(intercept/mock/real) — 空壳工厂页, 签发与目标站页面解耦
8088875
Raw History Blame Contribute Delete
21.3 kB
<!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: #0d1117; --panel: #161b22; --border: #30363d;
--fg: #e6edf3; --dim: #8b949e; --accent: #58a6ff;
--ok: #3fb950; --warn: #d29922; --err: #f85149;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--fg);
font: 14px/1.5 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
padding: 16px; max-width: 1280px; margin: 0 auto; }
h1 { font-size: 18px; display: flex; align-items: center; gap: 10px; }
h2 { font-size: 14px; color: var(--dim); margin-bottom: 10px;
text-transform: uppercase; letter-spacing: .05em; }
.sub { color: var(--dim); font-size: 12px; font-weight: normal; }
header { display: flex; align-items: center; justify-content: space-between;
flex-wrap: wrap; gap: 10px; margin-bottom: 16px; }
.pill { display: inline-block; padding: 2px 10px; border-radius: 12px;
font-size: 12px; font-weight: 600; }
.pill.ok { background: #1a3a24; color: var(--ok); }
.pill.bad { background: #3d1d1d; color: var(--err); }
.card { background: var(--panel); border: 1px solid var(--border);
border-radius: 8px; padding: 14px; }
.grid { display: grid; gap: 12px; margin-bottom: 12px; }
.grid.c4 { grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); }
.grid.c2 { grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); }
.big { font-size: 26px; font-weight: 700; }
.kv { color: var(--dim); font-size: 12px; margin-top: 4px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); }
th { color: var(--dim); font-weight: 500; }
input, button, select { background: #0d1117; color: var(--fg);
border: 1px solid var(--border); border-radius: 6px; padding: 6px 10px;
font: inherit; }
button { cursor: pointer; background: #21262d; }
button:hover { border-color: var(--accent); }
button.primary { background: #1f6feb; border-color: #1f6feb; color: #fff; }
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.chip { display: inline-block; background: #21262d; border: 1px solid var(--border);
border-radius: 12px; padding: 1px 9px; margin: 2px; font-size: 12px;
font-family: ui-monospace, Consolas, monospace; }
.chip.denied { border-color: var(--err); color: var(--err); }
.scroll { max-height: 260px; overflow-y: auto; }
.bars { display: flex; align-items: flex-end; gap: 2px; height: 26px; }
.bar { width: 5px; background: var(--accent); border-radius: 1px 1px 0 0;
min-height: 1px; opacity: .85; }
.st200 { color: var(--ok); } .st4xx { color: var(--warn); } .st5xx { color: var(--err); }
.mono { font-family: ui-monospace, Consolas, monospace; }
pre { white-space: pre-wrap; word-break: break-all; background: #0d1117;
border: 1px solid var(--border); border-radius: 6px; padding: 10px;
font-size: 12px; max-height: 300px; overflow-y: auto; }
#login { position: fixed; inset: 0; display: flex; align-items: center;
justify-content: center; background: rgba(13,17,23,.96); z-index: 9; }
#login .card { width: 360px; }
.hidden { display: none !important; }
#refreshNote { color: var(--dim); font-size: 12px; }
</style>
</head>
<body>
<div id="login">
<div class="card">
<h2 style="margin-bottom:12px">🔑 登录控制台</h2>
<div class="row" style="margin-bottom:8px">
<input id="keyInput" type="password" placeholder="管理密钥" style="flex:1">
<button class="primary" onclick="saveKeys()">进入</button>
</div>
<div class="kv" id="loginErr" style="color:var(--err)"></div>
<div class="kv" style="margin-top:6px">单一密钥, 读/写操作通用。</div>
</div>
</div>
<header>
<h1>🎛 控制台 <span id="statusPill" class="pill bad">…</span>
<span class="sub" id="uptime"></span></h1>
<div class="row">
<span id="refreshNote">每 2s 自动刷新</span>
<button onclick="manualRefresh()">刷新</button>
</div>
</header>
<div class="grid c4">
<div class="card"><h2>货架现货</h2>
<div class="big"><span id="stock">-</span><span class="sub"> / <span id="cap">-</span></span></div>
<div class="kv" id="shelfMeta">在签 -, 最老现货 -</div></div>
<div class="card"><h2>签发统计</h2>
<div class="big" id="signed">-</div>
<div class="kv">失败 <span id="signFailed">-</span> · 平均耗时 <span id="avgLat">-</span>s · 超产丢弃 <span id="surplus">-</span></div></div>
<div class="card"><h2>取货消耗</h2>
<div class="big" id="served">-</div>
<div class="kv">过期丢弃 <span id="expired">-</span></div></div>
<div class="card"><h2>工人页</h2>
<div class="big"><span id="workerCount">-</span><span class="sub"> / 期望 <span id="workerDesired">-</span></span></div>
<div class="kv">浏览器通道 <span id="channel">-</span></div></div>
</div>
<div class="grid c2">
<!-- 左: 工人明细 + 控制 -->
<div style="display:grid; gap:12px; align-content:start">
<div class="card">
<h2>工人页明细</h2>
<table>
<thead><tr><th>#</th><th>状态</th><th>已签</th><th>失败</th><th>最近耗时</th><th>签发曲线</th></tr></thead>
<tbody id="workerRows"></tbody>
</table>
</div>
<div class="card">
<h2>控制</h2>
<div class="row" style="margin-bottom:10px">
工人页数: <select id="scaleSel"></select>
<button class="primary" onclick="scale()">应用</button>
<span class="kv" id="scaleMsg"></span>
</div>
<div class="row" style="margin-bottom:10px">
工厂模式: <select id="modeSel">
<option value="intercept">intercept · 空壳页/内存拦截(推荐)</option>
<option value="mock">mock · 空壳页/本地HTTPS</option>
<option value="real">real · 真实工厂页(保底)</option>
</select>
<button onclick="switchMode()">切换</button>
<span class="kv" id="modeMsg"></span>
</div>
<div class="row" style="margin-bottom:10px">
工厂页: <select id="factorySel" style="max-width:340px"></select>
<button onclick="switchFactory()">切换</button>
<span class="kv" id="factoryMsg"></span>
</div>
<div class="kv" style="margin-top:8px">
切换工厂页会退役全部工人页并按新页重建(货架现货不受影响, token 通用)。
intercept/mock 模式下工厂页仅决定空壳页 origin(host), 不真实加载;
real 模式才真实访问页面。上游模型之外的完整 URL 也可手动填入:
<input id="factoryCustom" class="mono" placeholder="https://… 或 /owner/model" style="width:100%;margin-top:6px">
</div>
</div>
<div class="card">
<h2>测试对话(走中转全链路)</h2>
<div class="row" style="margin-bottom:8px">
<input id="testModel" list="routableList" placeholder="模型" style="flex:2; min-width:220px">
<datalist id="routableList"></datalist>
</div>
<div class="row" style="margin-bottom:8px">
<input id="testPrompt" placeholder="prompt" value="回一个 pong" style="flex:2">
<button class="primary" onclick="testChat()" id="testBtn">运行</button>
</div>
<div class="kv" id="testMeta"></div>
<pre id="testOut" class="hidden"></pre>
</div>
</div>
<!-- 右: 上游模型 + 请求日志 -->
<div style="display:grid; gap:12px; align-content:start">
<div class="card">
<h2>上游模型 (<span id="routableN">-</span>) <span class="sub">/v1/models 原样, 能否调用以上游实时回答为准</span></h2>
<div id="routableChips" class="scroll mono"><span class="sub">加载中…</span></div>
</div>
<div class="card">
<h2>最近请求</h2>
<div class="scroll" style="max-height:340px">
<table>
<thead><tr><th>时间</th><th>模型</th><th>流式</th><th>状态</th><th>耗时</th><th>备注</th></tr></thead>
<tbody id="reqRows"></tbody>
</table>
</div>
</div>
<div class="card">
<h2>参数控制台(热改, 立即生效)</h2>
<div id="cfgMsg" class="kv" style="margin-bottom:8px"></div>
<table>
<thead><tr><th>参数</th><th>当前</th><th>新值</th><th></th></tr></thead>
<tbody id="cfgRows"></tbody>
</table>
<div class="row" style="margin-top:8px">
<button class="primary" onclick="applyConfigAll()">全部写入</button>
<span class="kv">当前工厂页: <span id="factoryPage" class="mono"></span> · 模式: <span id="factoryModeNow" class="mono">—</span></span>
</div>
</div>
</div>
</div>
<script>
let KEY = localStorage.getItem('console_key') || ''; // 单一管理密钥, 读写通用
function saveKeys() {
KEY = document.getElementById('keyInput').value.trim();
localStorage.setItem('console_key', KEY);
refresh(true);
loadModels();
}
document.getElementById('keyInput').addEventListener('keydown', e => {
if (e.key === 'Enter') saveKeys();
});
async function api(path, opts) {
const r = await fetch(path, Object.assign({
method: opts && opts.method || 'GET',
headers: { 'Authorization': 'Bearer ' + KEY,
'Content-Type': 'application/json' }
}, opts));
if (r.status === 401) {
document.getElementById('login').classList.remove('hidden');
document.getElementById('loginErr').textContent =
KEY ? '密钥无效, 请重试' : '';
throw new Error('401');
}
return r;
}
const $ = id => document.getElementById(id);
const fmt = (v, u='') => v === null || v === undefined ? '—' : v + u;
function stClass(s) { return s === 200 ? 'st200' : s >= 500 ? 'st5xx' : 'st4xx'; }
function bars(arr, max) {
if (!arr || !arr.length) return '<span class="sub">—</span>';
const m = Math.max(...arr, 0.1);
return '<div class="bars">' + arr.map(v =>
`<div class="bar" style="height:${Math.max(1, v / m * 24)}px" title="${v}s"></div>`
).join('') + '</div>';
}
// ---- 上游模型列表(/v1/models 原样, 每会话拉一次) ----
let UPSTREAM_MODELS = [];
async function loadModels() {
try {
const d = await (await api('/v1/models')).json();
UPSTREAM_MODELS = (d.data || []).map(m => m.id).filter(Boolean).sort();
$('routableN').textContent = UPSTREAM_MODELS.length;
$('routableChips').innerHTML = UPSTREAM_MODELS.map(m => `<span class="chip">${m}</span>`).join('')
|| '<span class="sub">空</span>';
if (!$('routableList').options.length)
$('routableList').innerHTML = UPSTREAM_MODELS.map(m => `<option value="${m}">`).join('');
buildFactorySel();
} catch (e) {
$('routableChips').innerHTML = '<span class="sub">模型列表拉取失败</span>';
}
}
function buildFactorySel() {
if (!UPSTREAM_MODELS.length) return;
const cur = $('factoryPage').textContent;
const curPath = '/' + cur.replace(/^https?:\/\/[^/]+\//, '').replace(/\/$/, '');
$('factorySel').innerHTML =
`<option value="">— 选择工厂页 —</option>` +
UPSTREAM_MODELS.map(m => {
const u = 'https://deepinfra.com/' + m;
return `<option value="${u}" ${u === cur || m === curPath ? 'selected' : ''}>${m}</option>`;
}).join('');
}
async function refresh(first) {
try {
const s = await (await api('/api/stats')).json();
document.getElementById('login').classList.add('hidden');
// 状态
const pill = $('statusPill');
pill.textContent = s.ready ? 'READY' : 'WARMING';
pill.className = 'pill ' + (s.ready ? 'ok' : 'bad');
$('uptime').textContent = `运行 ${Math.floor(s.uptime_s/60)}m${s.uptime_s%60}s`;
// 卡片
$('stock').textContent = s.shelf.stock;
$('cap').textContent = s.shelf.capacity;
$('shelfMeta').textContent =
`在签 ${s.shelf.inflight_signs}, 最老现货 ${fmt(s.shelf.oldest_age_s,'s')} / 寿限 ${s.config.token_max_age}s`;
$('signed').textContent = s.signing.signed_total;
$('signFailed').textContent = s.signing.failed_total;
$('avgLat').textContent = fmt(s.signing.avg_latency);
$('surplus').textContent = s.shelf.surplus_dropped;
$('served').textContent = s.shelf.served_total;
$('expired').textContent = s.shelf.expired_dropped;
$('workerCount').textContent = s.workers.length;
$('workerDesired').textContent = s.workers_desired;
$('channel').textContent = s.browser_channel || '—';
// 工人页数下拉: 1..max, 当前值选中
if (first || document.activeElement !== $('scaleSel')) {
$('scaleSel').innerHTML = Array.from({length: s.workers_max},
(_, i) => `<option value="${i+1}" ${i+1 === s.workers_desired ? 'selected' : ''}>${i+1}</option>`).join('');
}
// 工人表
const stateTxt = {ready:'🟢 空闲', busy:'🔵 签发中', starting:'🟡 启动中', retiring:'⚪ 退役中'};
$('workerRows').innerHTML = s.workers.map(w =>
`<tr><td>${w.id}</td><td>${stateTxt[w.state]||w.state}</td>
<td>${w.signed}</td><td>${w.sign_failed}</td>
<td>${fmt(w.last_sign_latency,'s')}</td>
<td>${bars(w.latencies)}</td></tr>`).join('') ||
'<tr><td colspan="6" class="sub">无</td></tr>';
// 上游模型/否定缓存区块已移除; 模型列表由 loadModels() 每会话拉一次
// 工厂页下拉: 由 loadModels() 填充; 这里只回显当前工厂页
$('factoryPage').textContent = s.config.factory_page;
// 工厂模式回显(不抢占用户正在操作的下拉)
$('factoryModeNow').textContent = s.config.factory_mode || '—';
if (document.activeElement !== $('modeSel')) {
$('modeSel').value = s.config.factory_mode || 'intercept';
}
// 配置(可热改参数的输入框 + 当前值); type: num=数字, text=字符串
const c = s.config;
const CFIELDS = [
['shelf_size', '货架容量(上限库存)', 'num', 100000],
['worker_pages', '工人页数(签发并发)', 'num', s.workers_max],
['max_worker_pages', '工人页数硬上限', 'num', 256],
['token_max_age', 'token寿命(s, ≤300)', 'num', 300],
['idle_pause', '闲置静默(s)', 'num', 86400],
['max_retry', '上游错误重试次数(0=不重试, 不封顶)', 'num', null],
['retry_statuses', '重试状态码(逗号分隔)', 'text', null],
];
$('cfgRows').innerHTML = CFIELDS.map(([k, label, type, mx]) => {
const cur = c[k];
const input = type === 'num'
? `<input id="in_${k}" type="number" min="0" ${mx != null ? `max="${mx}"` : ''} style="width:90px" data-k="${k}">`
: `<input id="in_${k}" type="text" class="mono" style="width:150px" data-k="${k}">`;
return `<tr><td class="mono">${label}<div class="sub" style="font-size:11px">${k}</div></td>
<td class="mono" id="cur_${k}">${cur}</td>
<td>${input}</td>
<td><button onclick="applyConfigOne('${k}')">写入</button></td></tr>`;
}).join('');
$('in_worker_pages').max = s.workers_max;
// 请求日志
const rq = await (await api('/api/requests')).json();
$('reqRows').innerHTML = rq.requests.map(r =>
`<tr><td class="mono">${r.ts}</td><td class="mono">${r.model||'—'}</td>
<td>${r.stream?'':'非'}流式</td>
<td class="${stClass(r.status)}">${r.status}</td>
<td class="mono">${fmt(r.latency_ms,'ms')}</td>
<td class="sub" title="${(r.err||'').replace(/"/g,'')}">${(r.err||'').slice(0,40)||''}</td></tr>`
).join('') || '<tr><td colspan="6" class="sub">暂无</td></tr>';
} catch (e) { /* 401 已处理; 其他忽略等下一轮 */ }
}
async function scale() {
try {
const r = await (await api('/api/pool/scale', {
method: 'POST', body: JSON.stringify({ pages: +$('scaleSel').value })
})).json();
$('scaleMsg').textContent = `已提交, 期望 ${r.desired}, 当前 ${r.active}`;
refresh();
} catch (e) {}
}
async function switchMode() {
const mode = $('modeSel').value;
const desc = {intercept: 'route 拦截, 空壳页纯内存承载(不访问目标站)',
mock: '域名映射 + 本地 HTTPS 空壳页(将回收重建浏览器)',
real: '真实加载工厂页(旧行为)'}[mode];
if (!confirm(`切换工厂模式到 ${mode} ?\n${desc}\n现有工人页将退役并按新模式重建, 货架现货保留。`)) return;
try {
const r = await (await api('/api/config', {
method: 'PUT', body: JSON.stringify({ factory_mode: mode })
})).json();
if (r.applied && r.applied.factory_mode) {
$('modeMsg').textContent = `✅ 已切换 -> ${r.applied.factory_mode}`;
} else {
$('modeMsg').textContent = `⚠️ ${JSON.stringify(r.skipped||{})}`;
}
refresh();
} catch (e) { $('modeMsg').textContent = '切换失败'; }
}
async function switchFactory() {
const custom = $('factoryCustom').value.trim();
const url = custom || $('factorySel').value;
if (!url) { $('factoryMsg').textContent = '请先选择模型或填写 URL'; return; }
if (!confirm(`切换工厂页到 ${url} ?\n现有工人页将退役并按新页重建, 货架现货保留。`)) return;
try {
const r = await (await api('/api/config', {
method: 'PUT', body: JSON.stringify({ factory_page: url })
})).json();
if (r.applied && r.applied.factory_page) {
$('factoryMsg').textContent = `✅ 已切换 -> ${r.applied.factory_page}, 工人页重建中`;
} else {
$('factoryMsg').textContent = `⚠️ ${JSON.stringify(r.skipped||{})}`;
}
$('factoryCustom').value = '';
refresh();
} catch (e) { $('factoryMsg').textContent = '切换失败'; }
}
// ---- 参数热改 ----
function cfgBodyFromInput(k) {
const el = $('in_' + k);
if (!el || el.value === '') return null;
const isNum = el.type === 'number';
if (isNum && isNaN(+el.value)) return { err: '请填数字' };
return { val: isNum ? +el.value : el.value.trim() };
}
async function applyConfigOne(k) {
const parsed = cfgBodyFromInput(k);
if (!parsed) { $('cfgMsg').textContent = '请填新值'; return; }
if (parsed.err) { $('cfgMsg').textContent = parsed.err; return; }
if (k === 'worker_pages') { // worker_pages 单独走 scale 路径
await scaleTo(+parsed.val); return;
}
const body = {}; body[k] = parsed.val;
try {
const r = await (await api('/api/config', {
method: 'PUT', body: JSON.stringify(body)
})).json();
showCfgMsg(r);
refresh();
} catch (e) { $('cfgMsg').textContent = '写入失败'; }
}
async function applyConfigAll() {
const body = {};
let bad = '';
['shelf_size','worker_pages','max_worker_pages','token_max_age',
'idle_pause','max_retry','retry_statuses'].forEach(k => {
const parsed = cfgBodyFromInput(k);
if (!parsed) return;
if (parsed.err) { bad = `${k}: ${parsed.err}`; return; }
body[k] = parsed.val;
});
if (bad) { $('cfgMsg').textContent = bad; return; }
if (Object.keys(body).length === 0) { $('cfgMsg').textContent = '无新值'; return; }
try {
const r = await (await api('/api/config', {
method: 'PUT', body: JSON.stringify(body)
})).json();
if (body.worker_pages != null) await scaleTo(body.worker_pages);
showCfgMsg(r);
refresh();
} catch (e) { $('cfgMsg').textContent = '写入失败'; }
}
async function scaleTo(n) {
try {
await api('/api/pool/scale', { method: 'POST', body: JSON.stringify({ pages: n }) });
$('cfgMsg').textContent = `worker_pages 已提交: 期望 ${n}`;
} catch (e) {}
}
function showCfgMsg(r) {
$('cfgMsg').innerHTML =
(r.applied && Object.keys(r.applied).length
? `✅ 已应用: ${Object.entries(r.applied).map(([k,v])=>k+'='+v).join(', ')}` : '')
+ (r.skipped && Object.keys(r.skipped).length
? ` ⚠️ 跳过: ${Object.entries(r.skipped).map(([k,v])=>k+'('+v+')').join(', ')}` : '');
}
async function testChat() {
const btn = $('testBtn'); btn.disabled = true; btn.textContent = '运行中…';
$('testMeta').textContent = ''; $('testOut').classList.add('hidden');
try {
const r = await (await api('/api/test/chat', {
method: 'POST',
body: JSON.stringify({ model: $('testModel').value, prompt: $('testPrompt').value })
})).json();
if (r.ok) {
$('testMeta').innerHTML =
`✅ <span class="st200">成功</span> · 等token ${r.t_token_s}s · 上游 ${r.t_upstream_s}s · 共 ${r.t_total_s}s` +
(r.usage ? ` · tokens ${r.usage.total_tokens||'?'}` : '');
$('testOut').textContent = r.content;
} else {
$('testMeta').innerHTML =
`❌ <span class="st5xx">失败</span> @ ${r.stage}` +
(r.status ? ` HTTP ${r.status}` : '') +
(r.t_token_s != null ? ` · 等token ${r.t_token_s}s` : '');
$('testOut').textContent = r.error || '';
}
$('testOut').classList.remove('hidden');
} catch (e) {}
btn.disabled = false; btn.textContent = '运行';
refresh();
}
function manualRefresh() { refresh(); }
refresh(true);
loadModels();
setInterval(refresh, 2000);
</script>
</body>
</html>