@walkhi/code-relax 0.1.0-beta.21 → 0.1.0-beta.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/migrate-relay-db.mjs +4 -2
- package/dist/bin/self-relay-server.mjs +20 -19
- package/dist/shared/app-server-events.cjs +107 -100
- package/dist/shared/service-endpoints.cjs +21 -0
- package/dist/shared/timeline-sync.cjs +94 -5
- package/dist/skills/code-relax/SKILL.md +21 -18
- package/dist/src/lan-socket-server.mjs +50 -10
- package/dist/src/self-relay/admin-page.mjs +18 -11
- package/dist/src/self-relay/admin-server.mjs +1 -2
- package/dist/src/self-relay/connector.mjs +29 -4
- package/dist/src/self-relay/diagnostics.mjs +54 -0
- package/dist/src/self-relay/lifecycle.mjs +160 -160
- package/dist/src/self-relay/mysql-state.mjs +2 -1
- package/dist/src/self-relay/server.mjs +22 -23
- package/dist/src/self-relay/usage-daily.mjs +55 -0
- package/dist/src/self-relay/usage-meter.mjs +42 -31
- package/dist/src/server.mjs +100 -203
- package/dist/src/shared-app-server.mjs +132 -116
- package/dist/src/shared-thread-stream.mjs +56 -50
- package/dist/src/turn-model-history.mjs +47 -0
- package/package.json +1 -1
- package/dist/src/self-relay/admin-usage-page.mjs +0 -28
- package/dist/src/self-relay/usage-diagnostics.mjs +0 -46
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
2
|
import crypto from 'node:crypto';
|
|
3
3
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
4
|
+
import timelineSync from '../shared/timeline-sync.cjs';
|
|
4
5
|
import { createFileUploadSession, FILE_CHUNK_BYTES, MAX_FILE_BYTES, MAX_FILE_COUNT, MAX_TOTAL_BYTES } from './file-upload-session.mjs';
|
|
5
6
|
|
|
6
7
|
// One authenticated connection carries requests and explicitly owned subscriptions.
|
|
@@ -104,8 +105,10 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
|
|
|
104
105
|
}
|
|
105
106
|
authenticated = true; clearTimeout(authTimer); send({ type: 'ready', version: 1 }); return;
|
|
106
107
|
}
|
|
107
|
-
const { id, type } = message;
|
|
108
|
-
if (!Number.isSafeInteger(id) || id < 1) { ws.close(1008, 'Invalid request id'); return; }
|
|
108
|
+
const { id, type } = message;
|
|
109
|
+
if (!Number.isSafeInteger(id) || id < 1) { ws.close(1008, 'Invalid request id'); return; }
|
|
110
|
+
if (type === 'response-ack') { active.get(id)?.acknowledge?.(message.batch); return; }
|
|
111
|
+
if (type === 'cancel') { active.get(id)?.end(); return; }
|
|
109
112
|
if (type === 'unsubscribe') { const reply = active.get(id); if (reply?.subscription) reply.end(); return; }
|
|
110
113
|
if (!['request', 'subscribe'].includes(type) || active.has(id)) { ws.close(1008, 'Invalid request'); return; }
|
|
111
114
|
const subscription = type === 'subscribe';
|
|
@@ -121,21 +124,58 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
|
|
|
121
124
|
(message.body !== undefined && (!message.body || typeof message.body !== 'object' || Array.isArray(message.body)))) {
|
|
122
125
|
send({ type: 'response', id, status: 400, body: { error: '无效的连接请求' } }); return;
|
|
123
126
|
}
|
|
124
|
-
const reply = new EventEmitter();
|
|
125
|
-
|
|
127
|
+
const reply = new EventEmitter();
|
|
128
|
+
let waiting, batch = 0;
|
|
129
|
+
const sendTimeline = async (status, body, event) => {
|
|
130
|
+
batch = 0;
|
|
131
|
+
try {
|
|
132
|
+
for (const pieces of timelineSync.timelineBatches(body)) {
|
|
133
|
+
if (reply.writableEnded) return;
|
|
134
|
+
await new Promise((resolve, reject) => {
|
|
135
|
+
const timer = setTimeout(() => reject(new Error('历史接收确认超时')), 20000);
|
|
136
|
+
waiting = { resolve, reject, timer, batch: ++batch };
|
|
137
|
+
send({ type: event ? 'event-part' : 'response-part', id, batch, pieces });
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
if (!reply.writableEnded) {
|
|
141
|
+
send(event ? { type: 'event', id, event, streamed: true } : { type: 'response', id, status, streamed: true });
|
|
142
|
+
if (!event) reply.end();
|
|
143
|
+
}
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (!reply.writableEnded) {
|
|
146
|
+
send(event ? { type: 'event', id, event: 'bridge-error', data: { error: error.message } }
|
|
147
|
+
: { type: 'response', id, status: 500, body: { error: error.message } });
|
|
148
|
+
reply.end();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
Object.assign(reply, {
|
|
126
153
|
subscription, headersSent: false, writableEnded: false, destroyed: false,
|
|
127
|
-
json(status, body) {
|
|
128
|
-
if (this.writableEnded) return;
|
|
129
|
-
|
|
130
|
-
|
|
154
|
+
json(status, body) {
|
|
155
|
+
if (this.writableEnded) return;
|
|
156
|
+
if (status < 400 && body?.schemaVersion === 1 && Array.isArray(body.turns)) {
|
|
157
|
+
void sendTimeline(status, body); return;
|
|
158
|
+
}
|
|
159
|
+
send({ type: 'response', id, status, body }); this.end();
|
|
160
|
+
},
|
|
161
|
+
acknowledge(sequence) {
|
|
162
|
+
if (!waiting || sequence !== waiting.batch) return;
|
|
163
|
+
const completed = waiting; waiting = null; clearTimeout(completed.timer);
|
|
164
|
+
this.emit('progress'); completed.resolve();
|
|
165
|
+
},
|
|
131
166
|
startEvents() {
|
|
132
167
|
if (this.writableEnded) return;
|
|
133
168
|
this.headersSent = true; send({ type: 'subscribed', id });
|
|
134
169
|
},
|
|
135
|
-
event(event, data) {
|
|
170
|
+
event(event, data) {
|
|
171
|
+
if (this.writableEnded) return;
|
|
172
|
+
if (event === 'update' && data?.schemaVersion === 1) return sendTimeline(200, data, event);
|
|
173
|
+
send({ type: 'event', id, event, data });
|
|
174
|
+
},
|
|
136
175
|
end() {
|
|
137
176
|
if (this.writableEnded) return;
|
|
138
|
-
this.writableEnded = this.destroyed = true; active.delete(id);
|
|
177
|
+
this.writableEnded = this.destroyed = true; active.delete(id);
|
|
178
|
+
if (waiting) { clearTimeout(waiting.timer); waiting.reject(new Error('历史读取已取消')); waiting = null; }
|
|
139
179
|
if (subscription) send({ type: 'end', id });
|
|
140
180
|
this.emit('close');
|
|
141
181
|
},
|
|
@@ -21,16 +21,17 @@ button{background:#e0f0e5;color:#235d3a;border-color:#a9cbb4}button:hover{backgr
|
|
|
21
21
|
#metrics{grid-template-columns:repeat(5,minmax(0,1fr))}.relay-heading{margin-bottom:14px}.resource-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.resource-head .hint{white-space:nowrap}.resource-card .throughput{border-top:1px solid #dce5df;padding-top:10px;margin-top:12px}.resource-card .throughput .metric{display:flex;flex-direction:row-reverse;align-items:center;justify-content:space-between;gap:8px;padding:8px 10px}.resource-card .throughput .metric strong{font-size:16px;margin:0;white-space:nowrap}
|
|
22
22
|
@media(max-width:800px){#metrics{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
|
23
23
|
@media(max-width:600px){.resource-card .throughput .metric strong{font-size:14px}}
|
|
24
|
-
</style><style>main:not(.authenticated)>:not(#adminLogin){display:none!important}#adminLogin{max-width:430px;margin:10vh auto}#adminLogin[hidden]{display:none}.usage-
|
|
25
|
-
<section id="adminLogin" class="card"><h1>管理员登录</h1><p class="subtle">使用已授予管理权限的 Code Relax 邮箱账号</p><div class="field"><label for="loginEmail">邮箱</label><input id="loginEmail" type="email" autocomplete="email"></div><div class="field"><label for="loginCode">邮件验证码</label><input id="loginCode" inputmode="numeric" maxlength="6" autocomplete="one-time-code"></div><div class="actions"><button id="sendLoginCode" type="button">发送验证码</button><button id="verifyLogin" type="button">登录后台</button></div><p id="loginError" class="status error" role="alert"></p></section>
|
|
26
|
-
<header class="page-head"><h1>中继管理</h1><nav class="nav" aria-label="管理页面"><button type="button" data-page="overview" aria-current="page">运行概览</button><button type="button" data-page="announcements">系统公告</button><button type="button" data-page="settings">容量设置</button><
|
|
24
|
+
</style><style>main:not(.authenticated)>:not(#adminLogin){display:none!important}#adminLogin{max-width:430px;margin:10vh auto}#adminLogin[hidden]{display:none}.usage-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:16px}.usage-views,.usage-pages{display:flex;align-items:center;gap:8px}.usage-views button[aria-pressed=true]{background:#286e43;color:#fff}.usage-rows{display:grid;gap:8px}.usage-row{display:grid;gap:8px;padding:12px 14px;border:1px solid var(--admin-row-border);border-radius:var(--admin-control-radius);background:var(--admin-row)}.usage-row-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;min-width:0}.usage-row-head span{min-width:0;overflow-wrap:anywhere}.usage-row-head small{flex-shrink:0;white-space:nowrap}.usage-row strong{font-variant-numeric:tabular-nums}.usage-row small{text-align:right}.usage-pages{margin-top:16px}</style></head><body><main>
|
|
25
|
+
<section id="adminLogin" class="card" hidden><h1>管理员登录</h1><p class="subtle">使用已授予管理权限的 Code Relax 邮箱账号</p><div class="field"><label for="loginEmail">邮箱</label><input id="loginEmail" type="email" autocomplete="email"></div><div class="field"><label for="loginCode">邮件验证码</label><input id="loginCode" inputmode="numeric" maxlength="6" autocomplete="one-time-code"></div><div class="actions"><button id="sendLoginCode" type="button">发送验证码</button><button id="verifyLogin" type="button">登录后台</button></div><p id="loginError" class="status error" role="alert"></p></section>
|
|
26
|
+
<header class="page-head"><h1>中继管理</h1><nav class="nav" aria-label="管理页面"><button type="button" data-page="overview" aria-current="page">运行概览</button><button type="button" data-page="announcements">系统公告</button><button type="button" data-page="settings">容量设置</button><button type="button" data-page="usage">使用统计</button><button id="adminLogout" type="button" class="secondary">退出后台</button></nav></header>
|
|
27
27
|
<section id="overview" class="page stack"><section class="card" aria-label="中继连接与转发"><div class="relay-heading"><h2>中继连接与转发</h2><span id="healthText" class="state">读取中</span></div><div id="metrics" class="metrics"></div></section><div class="grid"><section class="card resource-card" aria-label="服务器资源"><div class="card-head resource-head"><h2>服务器资源</h2><span id="sampleTime" class="hint">等待采样</span></div><div id="serverMetrics" class="metrics"></div><div class="throughput" id="throughput"></div></section><section class="card quota-card"><div class="card-head"><h2>流量包</h2><span id="trafficNote" class="hint">等待更新</span></div><p id="trafficUsage" class="traffic-usage">—</p><div id="trafficTimeline" class="quota-timeline" hidden><div class="quota-heading"><strong id="trafficRemaining">流量剩余 —</strong><span id="trafficPace" class="quota-pace">—×</span><span id="timeRemaining">时间剩余 —</span></div><div id="quotaTrack" class="quota-track" role="img"><span id="quotaFill" class="quota-fill"></span><span id="quotaNow" class="quota-now"></span></div><div class="quota-period"><span id="trafficEnd">到期 —</span><span id="trafficStart">本期开始 —</span></div></div></section></div></section>
|
|
28
28
|
<section id="announcements" class="page" hidden><section class="card"><div class="announcement-toolbar"><div><h2>公告管理</h2><p id="announcementCount" class="hint">正在读取</p></div><button id="newAnnouncement" type="button">新建公告</button></div><div class="announcement-controls"><input id="announcementSearch" type="search" aria-label="搜索公告" placeholder="搜索标题或正文"><select id="announcementFilter" aria-label="筛选状态"><option value="all">全部状态</option><option value="active">发布中</option><option value="draft">草稿</option><option value="withdrawn">已撤下</option></select></div><div id="announcementList" class="announcement-list" style="margin-top:14px"></div></section></section>
|
|
29
29
|
<dialog id="announcementEditor" aria-labelledby="editorHeading"><div class="editor-head"><h2 id="editorHeading">新建公告</h2><button id="closeEditor" class="secondary" type="button" aria-label="关闭">关闭</button></div><div class="field"><label for="title">标题</label><input id="title" maxlength="80" placeholder="输入公告标题"></div><div class="field"><label for="message">正文</label><textarea id="message" maxlength="2000" placeholder="输入公告内容"></textarea></div><p id="editorNote" class="hint"></p><p id="editorError" class="status error" role="alert" hidden></p><div id="editorActions" class="editor-actions"><button id="saveDraft" class="secondary" type="button">保存草稿</button><button id="publish" type="button">发布公告</button></div></dialog>
|
|
30
|
-
<section id="settings" class="page stack" hidden><section class="card"><div class="card-head"><h2>服务器转发容量</h2><p class="hint">只限制经 WSS 转发的普通会话,WebRTC 直连与白名单转发不占用此额度</p></div><div class="field"><label for="limit">最大并发会话</label><input id="limit" type="number" min="1" max="256"></div><button id="saveLimit" type="button">保存设置</button><p class="note">当前上限是人工配置值,不随 CPU 或带宽自动改变。</p></section></section
|
|
30
|
+
<section id="settings" class="page stack" hidden><section class="card"><div class="card-head"><h2>服务器转发容量</h2><p class="hint">只限制经 WSS 转发的普通会话,WebRTC 直连与白名单转发不占用此额度</p></div><div class="field"><label for="limit">最大并发会话</label><input id="limit" type="number" min="1" max="256"></div><button id="saveLimit" type="button">保存设置</button><p class="note">当前上限是人工配置值,不随 CPU 或带宽自动改变。</p></section></section>
|
|
31
|
+
<section id="usage" class="page" hidden><section class="card"><div class="usage-toolbar"><div><h2>今日使用统计</h2><p id="usageCount" class="hint">正在读取</p></div><div class="usage-views"><button id="usageUsers" type="button" class="secondary" aria-pressed="true">用户</button><button id="usageComputers" type="button" class="secondary" aria-pressed="false">工作站</button><button id="usageRefresh" type="button" class="secondary">刷新</button></div></div><div id="usageRows" class="usage-rows"></div><div class="usage-pages"><button id="usagePrev" type="button" class="secondary">上一页</button><span id="usagePageText" class="hint"></span><button id="usageNext" type="button" class="secondary">下一页</button></div><p id="usageError" class="status error" role="alert"></p></section></section><p id="result" class="status" role="status" aria-live="polite"></p>
|
|
31
32
|
</main><script>
|
|
32
33
|
const el=id=>document.getElementById(id);let snapshot=null;
|
|
33
|
-
const request=async(path,options={})=>{const token=
|
|
34
|
+
const request=async(path,options={})=>{const token=localStorage.getItem('codeRelaxAdminToken');const headers={...options.headers,...(token?{Authorization:'Bearer '+token}:{})};const response=await fetch(path,{...options,headers});const body=await response.json();if(!response.ok){const message=body.error||('HTTP '+response.status);if(path.startsWith('/admin/api/')&&(response.status===401||response.status===403)){localStorage.removeItem('codeRelaxAdminToken');showLogin(message)}throw new Error(message)}return body};
|
|
34
35
|
function setResult(message,error=false){el('result').textContent=message;el('result').classList.toggle('error',error)}
|
|
35
36
|
function node(tag,className,value){const item=document.createElement(tag);if(className)item.className=className;if(value!==undefined)item.textContent=value;return item}
|
|
36
37
|
function metric(value,label,extra,tone){const item=node('div','metric'+(tone?' '+tone:''));item.append(node('strong','',value),node('span','',label));if(extra)item.append(node('small','',extra));return item}
|
|
@@ -61,19 +62,25 @@ else if(item.status==='active'){actions.append(action('查看','secondary',()=>o
|
|
|
61
62
|
else{actions.append(action('查看','secondary',()=>openEditor(item)),action('复制','secondary',()=>openEditor(item,true),!available),action('删除','danger',()=>{if(confirm('确定删除这条已撤下的公告?'))write('DELETE',path,{},'公告记录已删除')},!available))}
|
|
62
63
|
row.append(summary,status,when,actions);list.append(row)}}
|
|
63
64
|
function render(state){snapshot=state;renderOverview(state);renderAnnouncements(state);el('saveLimit').disabled=state.relayAvailable===false;if(document.activeElement!==el('limit'))el('limit').value=state.maxRelaySessions??''}
|
|
64
|
-
async function load(){try{render(await request('/admin/api/status'));setResult('')}catch(error){setResult(error.message,true)}}
|
|
65
|
+
async function load(){try{render(await request('/admin/api/status'));setResult('')}catch(error){snapshot=null;el('healthText').textContent='状态暂不可用';el('healthText').className='state down';for(const id of ['metrics','serverMetrics','throughput','announcementList'])el(id).replaceChildren();el('trafficTimeline').hidden=true;el('trafficUsage').textContent='—';el('announcementCount').textContent='暂时无法读取';el('saveLimit').disabled=true;setResult(error.message,true)}}
|
|
65
66
|
async function write(method,path,body,message){if(writing)return false;writing=true;try{await request(path,{method,headers:{'Content-Type':'application/json','X-Code-Relax-Admin':'1'},body:JSON.stringify(body)});await load();setResult(message);return true}catch(error){setResult(error.message,true);if(el('announcementEditor').open){el('editorError').textContent=error.message;el('editorError').hidden=false}return false}finally{writing=false}}
|
|
66
|
-
|
|
67
|
+
let usageView='user',usagePage=1,usageTotal=0;
|
|
68
|
+
function usageDuration(value){const seconds=Math.floor(Number(value)||0),hours=Math.floor(seconds/3600),minutes=Math.floor(seconds%3600/60),rest=seconds%60;return hours?hours+' 小时 '+minutes+' 分钟':minutes?minutes+' 分钟 '+rest+' 秒':rest+' 秒'}
|
|
69
|
+
function usageTime(value){return value?new Date(value.replace(' ','T')+'Z').toLocaleTimeString('zh-CN',{timeZone:'Asia/Shanghai',hour:'2-digit',minute:'2-digit',second:'2-digit',hour12:false}):'—'}
|
|
70
|
+
async function loadUsage(){const view=usageView,page=usagePage;el('usageError').textContent='';el('usageCount').textContent='正在读取';try{const result=await request('/admin/api/usage?view='+view+'&page='+page);if(view!==usageView||page!==usagePage||el('usage').hidden)return;usageTotal=result.total;el('usageCount').textContent='今日有记录:'+usageTotal+(view==='user'?' 个用户':' 台工作站')+' · 北京时间';el('usagePageText').textContent='第 '+page+' / '+Math.max(1,Math.ceil(usageTotal/30))+' 页';el('usagePrev').disabled=page<=1;el('usageNext').disabled=page*30>=usageTotal;const rows=el('usageRows');rows.replaceChildren();if(!result.rows.length){rows.append(node('div','empty-note','今日暂无计时记录'));return}for(const item of result.rows){const row=node('div','usage-row');const counts=' · 消息 '+Number(item.message_count||0)+' · 轮次 '+Number(item.turn_count||0);const heading=node('div','usage-row-head');const title=node('span','',view==='user'?item.email:'工作站 …'+item.id.slice(-8));title.title=view==='user'?item.email:item.id;heading.append(title,node('small','hint','最后使用 '+usageTime(item.last_used_at)));row.append(heading,node('strong','',view==='user'?'任务 '+usageDuration(item.today_task_seconds)+counts:'任务 '+usageDuration(item.today_task_seconds)+' · 免费账号 '+usageDuration(item.today_free_task_seconds)+counts));rows.append(row)}}catch(error){if(view===usageView&&page===usagePage){usageTotal=0;el('usageCount').textContent='统计暂不可用';el('usageRows').replaceChildren();el('usagePageText').textContent='';el('usagePrev').disabled=true;el('usageNext').disabled=true;el('usageError').textContent=error.message}}}
|
|
71
|
+
function setUsageView(view){if(usageView===view)return;usageView=view;usagePage=1;el('usageUsers').setAttribute('aria-pressed',String(view==='user'));el('usageComputers').setAttribute('aria-pressed',String(view==='computer'));void loadUsage()}
|
|
72
|
+
el('usageUsers').onclick=()=>setUsageView('user');el('usageComputers').onclick=()=>setUsageView('computer');el('usageRefresh').onclick=()=>loadUsage();el('usagePrev').onclick=()=>{if(usagePage>1){usagePage--;void loadUsage()}};el('usageNext').onclick=()=>{if(usagePage*30<usageTotal){usagePage++;void loadUsage()}};
|
|
73
|
+
function showPage(page){for(const button of document.querySelectorAll('[data-page]'))button.setAttribute('aria-current',button.dataset.page===page?'page':'false');for(const section of document.querySelectorAll('.page'))section.hidden=section.id!==page;history.replaceState(null,'','#'+page);if(page==='usage')void loadUsage();else void load()}
|
|
67
74
|
for(const button of document.querySelectorAll('[data-page]'))button.addEventListener('click',()=>showPage(button.dataset.page));
|
|
68
75
|
el('saveLimit').addEventListener('click',()=>write('PUT','/admin/api/settings',{maxRelaySessions:Number(el('limit').value)},'转发容量已保存'));
|
|
69
76
|
el('newAnnouncement').addEventListener('click',()=>openEditor());el('closeEditor').addEventListener('click',()=>el('announcementEditor').close());el('announcementSearch').addEventListener('input',()=>{if(snapshot)renderAnnouncements(snapshot)});el('announcementFilter').addEventListener('change',()=>{if(snapshot)renderAnnouncements(snapshot)});
|
|
70
77
|
async function saveAnnouncement(publish){const title=el('title').value.trim(),message=el('message').value.trim();if(!title||!message){el('editorError').textContent='请填写标题和正文';el('editorError').hidden=false;return}if(publish&&snapshot?.announcement&&snapshot.announcement.id!==editorItem?.id&&!confirm('发布后将替换当前公告,确定发布?'))return;const path=editorMode==='draft'?'/admin/api/announcements/'+encodeURIComponent(editorItem.id):'/admin/api/announcements';const target=publish&&editorMode==='draft'?path+'/publish':path;const body={title,message};if(publish&&editorMode==='new')body.publish=true;const ok=await write('PUT',target,body,publish?'公告已发布':'草稿已保存');if(ok)el('announcementEditor').close()}
|
|
71
78
|
el('saveDraft').addEventListener('click',()=>saveAnnouncement(false));el('publish').addEventListener('click',()=>saveAnnouncement(true));
|
|
72
79
|
function showLogin(error=''){document.querySelector('main').classList.remove('authenticated');el('adminLogin').hidden=false;el('loginError').textContent=error}
|
|
73
|
-
async function authorize(){try{await request('/admin/api/session');document.querySelector('main').classList.add('authenticated');el('adminLogin').hidden=true;showPage(['overview','announcements','settings'].includes(location.hash.slice(1))?location.hash.slice(1):'overview')}catch(error){
|
|
80
|
+
async function authorize(){try{await request('/admin/api/session');document.querySelector('main').classList.add('authenticated');el('adminLogin').hidden=true;showPage(['overview','announcements','settings','usage'].includes(location.hash.slice(1))?location.hash.slice(1):'overview')}catch(error){showLogin(localStorage.getItem('codeRelaxAdminToken')?'登录验证暂时失败,请刷新页面重试:'+error.message:error.message)}}
|
|
74
81
|
el('sendLoginCode').onclick=async()=>{const email=el('loginEmail').value.trim().toLowerCase();if(!email){showLogin('请输入邮箱');return}el('loginError').textContent='';try{const config=await request('/account/config');if(!window.TencentCaptcha){await new Promise((resolve,reject)=>{const script=document.createElement('script');script.src='https://turing.captcha.qcloud.com/TJCaptcha.js';script.onload=resolve;script.onerror=reject;document.head.append(script)})}new TencentCaptcha(String(config.captchaAppId),async result=>{if(result.ret!==0)return;try{await request('/account/email/request',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email,ticket:result.ticket,randstr:result.randstr})});el('loginError').textContent='验证码已发送'}catch(error){el('loginError').textContent=error.message}}).show()}catch(error){el('loginError').textContent='人机验证不可用:'+error.message}};
|
|
75
|
-
el('verifyLogin').onclick=async()=>{const email=el('loginEmail').value.trim().toLowerCase(),code=el('loginCode').value.trim();if(
|
|
76
|
-
el('adminLogout').onclick=()=>{
|
|
77
|
-
if(
|
|
82
|
+
el('verifyLogin').onclick=async()=>{const email=el('loginEmail').value.trim().toLowerCase(),code=el('loginCode').value.trim();if(!/^[0-9]{6}$/.test(code)){el('loginError').textContent='请输入 6 位验证码';return}try{const result=await request('/account/email/verify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email,code})});localStorage.setItem('codeRelaxAdminToken',result.token);await authorize()}catch(error){el('loginError').textContent=error.message}};
|
|
83
|
+
el('adminLogout').onclick=()=>{localStorage.removeItem('codeRelaxAdminToken');showLogin()};
|
|
84
|
+
if(localStorage.getItem('codeRelaxAdminToken'))void authorize();else showLogin();setInterval(()=>{if(!document.hidden&&!el('overview').hidden&&document.querySelector('main').classList.contains('authenticated'))load()},5000);
|
|
78
85
|
</script></body></html>`;
|
|
79
86
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
2
|
import { adminPage } from './admin-page.mjs';
|
|
3
|
-
import { adminUsagePage } from './admin-usage-page.mjs';
|
|
4
3
|
import { startAdminCloud } from './admin-cloud.mjs';
|
|
5
4
|
|
|
6
5
|
function reply(response, status, body, type = 'application/json; charset=utf-8') {
|
|
@@ -23,7 +22,7 @@ export async function startAdminServer({ port = 45851, relayUrl = 'http://127.0.
|
|
|
23
22
|
reply(response, 200, adminPage(), 'text/html; charset=utf-8'); return;
|
|
24
23
|
}
|
|
25
24
|
if (request.method === 'GET' && target.pathname === '/admin/usage') {
|
|
26
|
-
|
|
25
|
+
response.writeHead(302, { Location: '/admin#usage', 'Cache-Control': 'no-store' }); response.end(); return;
|
|
27
26
|
}
|
|
28
27
|
if (request.method === 'GET' && ['/admin/api/status', '/admin/api/session', '/admin/api/usage', '/admin/api/usage-diagnostics'].includes(target.pathname)) {
|
|
29
28
|
try {
|
|
@@ -42,7 +42,7 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
|
|
|
42
42
|
} catch (error) { events.emit('diagnostic', `Usage task cache: ${error.message}`); }
|
|
43
43
|
}
|
|
44
44
|
let fileRemote, fileRetryTimer, turnLocal, turnRetryTimer, turnPulseTimer, turnObserverReady = false,
|
|
45
|
-
queueTurnResync = () => {}, heartbeat, operation, lastClose, closing = false;
|
|
45
|
+
queueTurnResync = () => {}, verifyActiveTurns = () => {}, heartbeat, operation, lastClose, closing = false;
|
|
46
46
|
const alive = () => { clearTimeout(heartbeat); heartbeat = setTimeout(() => remote.terminate(), 75000); };
|
|
47
47
|
remote.on('open', alive); remote.on('ping', alive); remote.on('message', alive);
|
|
48
48
|
function dropLocal(session) {
|
|
@@ -63,7 +63,7 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
|
|
|
63
63
|
for (const socket of fileLocals.values()) socket.terminate();
|
|
64
64
|
fileLocals.clear(); clearTimeout(fileRetryTimer); fileRemote?.terminate(); fileRemote = undefined;
|
|
65
65
|
clearTimeout(turnRetryTimer); clearInterval(turnPulseTimer); turnLocal?.terminate(); turnLocal = undefined;
|
|
66
|
-
turnObserverReady = false; activeTurns.clear(); completedTurns.clear();
|
|
66
|
+
turnObserverReady = false; activeTurns.clear(); completedTurns.clear(); verifyActiveTurns = () => {};
|
|
67
67
|
}
|
|
68
68
|
function startTurnReceiver() {
|
|
69
69
|
if (closing || remote.readyState !== WebSocket.OPEN) return;
|
|
@@ -72,6 +72,7 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
|
|
|
72
72
|
catch { turnRetryTimer = setTimeout(startTurnReceiver, 3000); return; }
|
|
73
73
|
const socket = turnLocal = new WebSocket(connection.url, { maxPayload: 64 * 1024, perMessageDeflate: false });
|
|
74
74
|
const resyncPending = new Map();
|
|
75
|
+
const pulsePending = new Map();
|
|
75
76
|
let resyncId = 1, resyncQueue = [];
|
|
76
77
|
function resyncNext() {
|
|
77
78
|
if (!turnObserverReady || turnLocal !== socket) return;
|
|
@@ -84,6 +85,16 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
|
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
87
|
queueTurnResync = (threadId, turnId) => { resyncQueue.push([threadId, turnId]); resyncNext(); };
|
|
88
|
+
verifyActiveTurns = () => {
|
|
89
|
+
if (!turnObserverReady || turnLocal !== socket) return;
|
|
90
|
+
for (const [threadId, turnId] of activeTurns) {
|
|
91
|
+
if (pulsePending.size >= 8) break;
|
|
92
|
+
if ([...pulsePending.values()].some(pending => pending.threadId === threadId)) continue;
|
|
93
|
+
const id = ++resyncId;
|
|
94
|
+
pulsePending.set(id, { threadId, turnId });
|
|
95
|
+
send(socket, { type: 'request', id, method: 'GET', path: `/api/threads/${encodeURIComponent(threadId)}/active-turn` });
|
|
96
|
+
}
|
|
97
|
+
};
|
|
87
98
|
socket.on('open', () => send(socket, { type: 'auth', token: connection.token }));
|
|
88
99
|
socket.on('error', error => events.emit('diagnostic', `Local turn observer: ${error.message}`));
|
|
89
100
|
socket.on('close', () => {
|
|
@@ -92,6 +103,7 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
|
|
|
92
103
|
if (turnObserverReady) send(remote, { type: 'usage-observer-lost' });
|
|
93
104
|
turnObserverReady = false;
|
|
94
105
|
queueTurnResync = () => {};
|
|
106
|
+
verifyActiveTurns = () => {};
|
|
95
107
|
activeTurns.clear(); completedTurns.clear();
|
|
96
108
|
if (!closing && remote.readyState === WebSocket.OPEN) turnRetryTimer = setTimeout(startTurnReceiver, 3000);
|
|
97
109
|
});
|
|
@@ -103,6 +115,19 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
|
|
|
103
115
|
if (message.type === 'subscribed' && message.id === 1) {
|
|
104
116
|
turnObserverReady = true; resyncQueue = [...trackedTurns]; resyncNext(); return;
|
|
105
117
|
}
|
|
118
|
+
if (message.type === 'response' && pulsePending.has(message.id)) {
|
|
119
|
+
const { threadId, turnId } = pulsePending.get(message.id);
|
|
120
|
+
pulsePending.delete(message.id);
|
|
121
|
+
if (activeTurns.get(threadId) !== turnId) return;
|
|
122
|
+
if (message.status === 200 && message.body?.turnId === turnId) {
|
|
123
|
+
send(remote, { type: 'usage-turn', method: 'heartbeat', threadId, turnId });
|
|
124
|
+
} else if (message.status === 200 || message.status === 404) {
|
|
125
|
+
activeTurns.delete(threadId);
|
|
126
|
+
if (trackedTurns.get(threadId) === turnId) { trackedTurns.delete(threadId); saveTrackedTurns(); }
|
|
127
|
+
send(remote, { type: 'usage-turn', method: 'inactive', threadId, turnId });
|
|
128
|
+
} else socket.close();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
106
131
|
if (message.type === 'response' && resyncPending.has(message.id)) {
|
|
107
132
|
const { threadId, turnId } = resyncPending.get(message.id);
|
|
108
133
|
resyncPending.delete(message.id);
|
|
@@ -193,7 +218,7 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
|
|
|
193
218
|
if (message.type === 'registered' && !registered) {
|
|
194
219
|
registered = true; clearTimeout(timer); startFileReceiver(); startTurnReceiver();
|
|
195
220
|
turnPulseTimer = setInterval(() => {
|
|
196
|
-
|
|
221
|
+
verifyActiveTurns();
|
|
197
222
|
}, 10000);
|
|
198
223
|
resolve(message); return;
|
|
199
224
|
}
|
|
@@ -226,7 +251,7 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
|
|
|
226
251
|
current.forwardToBridge = payload => {
|
|
227
252
|
if (locals.get(message.session) !== current || !current.ready) throw new Error('Local desktop service connection not ready');
|
|
228
253
|
const request = decode(payload, false);
|
|
229
|
-
if (!['request', 'subscribe', 'unsubscribe'].includes(request.type)) throw new Error('Unsupported LAN message');
|
|
254
|
+
if (!['request', 'subscribe', 'unsubscribe', 'response-ack', 'cancel'].includes(request.type)) throw new Error('Unsupported LAN message');
|
|
230
255
|
if (request.type === 'request' && request.method === 'POST' && Number.isSafeInteger(request.id)) {
|
|
231
256
|
const path = (request.path || '').split('?')[0];
|
|
232
257
|
const existing = /^\/api\/threads\/([^/?]+)\/(?:messages|queue)$/.exec(path);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const MAX_BATCH = 200;
|
|
2
|
+
|
|
3
|
+
export function relayDiagnostics(pool, onError = () => {}) {
|
|
4
|
+
let tail = Promise.resolve();
|
|
5
|
+
function validSource(source) {
|
|
6
|
+
if (!/^[a-z-]{1,32}$/.test(source)) throw new Error('Invalid diagnostic source');
|
|
7
|
+
return source;
|
|
8
|
+
}
|
|
9
|
+
function serialize(action) {
|
|
10
|
+
const result = tail.then(action);
|
|
11
|
+
tail = result.catch(error => { onError(error); });
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
function record(source, event, detail) {
|
|
15
|
+
validSource(source);
|
|
16
|
+
if (!/^[a-z-]{1,32}$/.test(event) || !/^[a-z-]{1,64}$/.test(detail)) throw new Error('Invalid diagnostic event');
|
|
17
|
+
void serialize(async () => {
|
|
18
|
+
await pool.execute('INSERT INTO relay_diagnostics (created_at,source,event,detail) VALUES (UTC_TIMESTAMP(3),?,?,?)', [source, event, detail]);
|
|
19
|
+
await pool.execute('DELETE FROM relay_diagnostics WHERE created_at < UTC_TIMESTAMP(3) - INTERVAL 1 DAY');
|
|
20
|
+
}).catch(() => {});
|
|
21
|
+
}
|
|
22
|
+
async function read(source) {
|
|
23
|
+
validSource(source);
|
|
24
|
+
return serialize(async () => {
|
|
25
|
+
const [rows] = await pool.execute(`SELECT id,created_at,source,event,detail FROM relay_diagnostics
|
|
26
|
+
WHERE source=? ORDER BY id LIMIT ${MAX_BATCH + 1}`, [source]);
|
|
27
|
+
return { rows: rows.slice(0, MAX_BATCH), hasMore: rows.length > MAX_BATCH };
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
async function clear(source) {
|
|
31
|
+
validSource(source);
|
|
32
|
+
return serialize(async () => {
|
|
33
|
+
const [result] = await pool.execute('DELETE FROM relay_diagnostics WHERE source=?', [source]);
|
|
34
|
+
return { cleared: result.affectedRows };
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
async function drain(source) {
|
|
38
|
+
validSource(source);
|
|
39
|
+
return serialize(async () => {
|
|
40
|
+
const db = await pool.getConnection();
|
|
41
|
+
try {
|
|
42
|
+
await db.beginTransaction();
|
|
43
|
+
const [rows] = await db.execute(`SELECT id,created_at,source,event,detail FROM relay_diagnostics
|
|
44
|
+
WHERE source=? ORDER BY id LIMIT ${MAX_BATCH + 1} FOR UPDATE`, [source]);
|
|
45
|
+
const selected = rows.slice(0, MAX_BATCH);
|
|
46
|
+
if (selected.length) await db.execute('DELETE FROM relay_diagnostics WHERE source=? AND id <= ?', [source, selected.at(-1).id]);
|
|
47
|
+
await db.commit();
|
|
48
|
+
return { rows: selected, hasMore: rows.length > MAX_BATCH };
|
|
49
|
+
} catch (error) { await db.rollback(); throw error; }
|
|
50
|
+
finally { db.release(); }
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return { record, read, clear, drain };
|
|
54
|
+
}
|