@walkhi/code-relax 0.1.0-beta.20 → 0.1.0-beta.22
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/src/self-relay/admin-page.mjs +14 -7
- package/dist/src/self-relay/admin-server.mjs +1 -2
- package/dist/src/self-relay/connector.mjs +28 -3
- package/dist/src/self-relay/diagnostics.mjs +54 -0
- package/dist/src/self-relay/mysql-state.mjs +2 -1
- package/dist/src/self-relay/server.mjs +23 -24
- package/dist/src/self-relay/usage-daily.mjs +55 -0
- package/dist/src/self-relay/usage-meter.mjs +42 -31
- 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
|
@@ -3,6 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import mysql from 'mysql2/promise';
|
|
6
|
+
import { relaySchemaVersions } from '../database/versions.mjs';
|
|
6
7
|
import { deviceStore } from '../src/self-relay/device-store.mjs';
|
|
7
8
|
import { relayAdminState } from '../src/self-relay/admin-state.mjs';
|
|
8
9
|
|
|
@@ -13,7 +14,7 @@ if (!['--schema', '--import', '--check'].includes(command) || !process.env.RELAY
|
|
|
13
14
|
const config = JSON.parse(fs.readFileSync(process.env.RELAY_MYSQL_CONFIG_FILE, 'utf8'));
|
|
14
15
|
const db = await mysql.createConnection({ ...config, dateStrings: true, timezone: 'Z', multipleStatements: false });
|
|
15
16
|
const sqlTime = value => value.replace('T', ' ').replace(/Z$/, '');
|
|
16
|
-
const migrations =
|
|
17
|
+
const migrations = relaySchemaVersions;
|
|
17
18
|
try {
|
|
18
19
|
if (command === '--schema') {
|
|
19
20
|
const [existing] = await db.query("SHOW TABLES LIKE 'schema_migrations'");
|
|
@@ -42,7 +43,8 @@ try {
|
|
|
42
43
|
if (versions.some(row => row.version === '002_email_accounts')) names.push('email_login_challenges', 'user_sessions', 'email_send_limits');
|
|
43
44
|
if (versions.some(row => row.version === '003_relay_usage')) names.push('relay_admin_emails', 'relay_usage_settings', 'relay_usage_totals', 'relay_usage_computer_totals', 'relay_usage_entitlements', 'relay_usage_budgets', 'relay_usage_computer_budgets', 'relay_usage_sessions');
|
|
44
45
|
if (versions.some(row => row.version === '004_relay_usage_actions')) names.push('relay_usage_actions');
|
|
45
|
-
if (versions.some(row => row.version === '
|
|
46
|
+
if (versions.some(row => row.version === '006_relay_diagnostics')) names.push('relay_diagnostics');
|
|
47
|
+
if (versions.some(row => row.version === '007_relay_usage_daily')) names.push('relay_usage_user_days', 'relay_usage_computer_days');
|
|
46
48
|
const counts = {};
|
|
47
49
|
for (const name of names) { const [rows] = await db.query(`SELECT COUNT(*) AS count FROM ${name}`); counts[name] = rows[0].count; }
|
|
48
50
|
if (process.env.RELAY_STATE_FILE && process.env.RELAY_ADMIN_STATE_FILE) {
|
|
@@ -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-
|
|
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;grid-template-columns:minmax(0,1fr) minmax(0,2fr) 150px;gap:12px;align-items:center;padding:12px 14px;border:1px solid var(--admin-row-border);border-radius:var(--admin-control-radius);background:var(--admin-row)}.usage-row span{overflow-wrap:anywhere}.usage-row strong{font-variant-numeric:tabular-nums}.usage-row small{text-align:right}.usage-pages{margin-top:16px}@media(max-width:700px){.usage-row{grid-template-columns:minmax(0,1fr)}.usage-row small{text-align:left}}</style></head><body><main>
|
|
25
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><
|
|
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=sessionStorage.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)
|
|
34
|
+
const request=async(path,options={})=>{const token=sessionStorage.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)){sessionStorage.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,16 +62,22 @@ 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').toLocaleString('zh-CN'):'—'}
|
|
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);row.append(node('span','',view==='user'?item.email:item.id),node('strong','',view==='user'?'任务 '+usageDuration(item.today_task_seconds)+counts:'任务 '+usageDuration(item.today_task_seconds)+' · 免费账号 '+usageDuration(item.today_free_task_seconds)+counts),node('small','hint','最后使用 '+usageTime(item.last_used_at)));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){sessionStorage.removeItem('codeRelaxAdminToken');showLogin(error.message)}}
|
|
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){sessionStorage.removeItem('codeRelaxAdminToken');showLogin(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
82
|
el('verifyLogin').onclick=async()=>{const email=el('loginEmail').value.trim().toLowerCase(),code=el('loginCode').value.trim();if(!/^\\d{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})});sessionStorage.setItem('codeRelaxAdminToken',result.token);await authorize()}catch(error){el('loginError').textContent=error.message}};
|
|
76
83
|
el('adminLogout').onclick=()=>{sessionStorage.removeItem('codeRelaxAdminToken');showLogin()};
|
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|
|
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
import mysql from 'mysql2/promise';
|
|
5
5
|
import { deviceStore } from './device-store.mjs';
|
|
6
6
|
import { relayAdminState } from './admin-state.mjs';
|
|
7
|
+
import { relaySchemaVersions } from '../../database/versions.mjs';
|
|
7
8
|
|
|
8
9
|
const sqlTime = value => value.replace('T', ' ').replace(/Z$/, '');
|
|
9
10
|
const isoTime = value => value.replace(' ', 'T') + 'Z';
|
|
@@ -17,7 +18,7 @@ export async function openRelayMysql(configFile, initialMaxRelaySessions) {
|
|
|
17
18
|
namedPlaceholders: false, dateStrings: true, timezone: 'Z', multipleStatements: false });
|
|
18
19
|
try {
|
|
19
20
|
const [versions] = await pool.query('SELECT version, sha256 FROM schema_migrations');
|
|
20
|
-
const knownVersions =
|
|
21
|
+
const knownVersions = relaySchemaVersions;
|
|
21
22
|
if (!versions.some(row => row.version === knownVersions[0]) || versions.length > knownVersions.length) throw new Error('Unexpected relay database schema');
|
|
22
23
|
for (const row of versions) {
|
|
23
24
|
if (!knownVersions.includes(row.version)) throw new Error('Unexpected relay database schema');
|
|
@@ -12,7 +12,7 @@ import { openRelayMysql } from './mysql-state.mjs';
|
|
|
12
12
|
import { adminPage } from './admin-page.mjs';
|
|
13
13
|
import { openEmailAccounts } from './email-accounts.mjs';
|
|
14
14
|
import { usageMeter, usageReport } from './usage-meter.mjs';
|
|
15
|
-
import {
|
|
15
|
+
import { relayDiagnostics } from './diagnostics.mjs';
|
|
16
16
|
|
|
17
17
|
// Always loopback; a TLS reverse proxy supplies the private-trial Internet endpoint.
|
|
18
18
|
export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlMs = 120000,
|
|
@@ -32,8 +32,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
32
32
|
if (accountConfigFile && !database?.schemaVersions.includes('003_relay_usage')) throw new Error('Relay account and usage schema is required');
|
|
33
33
|
const accounts = accountConfigFile ? openEmailAccounts(database.pool, accountConfigFile) : null;
|
|
34
34
|
const meter = accounts ? usageMeter(database.pool) : null;
|
|
35
|
-
const diagnostics = database?.schemaVersions.includes('
|
|
36
|
-
?
|
|
35
|
+
const diagnostics = database?.schemaVersions.includes('006_relay_diagnostics')
|
|
36
|
+
? relayDiagnostics(database.pool, error => log({ event: 'relay-diagnostic-error', message: error.message })) : null;
|
|
37
37
|
const diagnosticKey = usageDiagnosticKeyFile ? fs.readFileSync(usageDiagnosticKeyFile, 'utf8').trim() : '';
|
|
38
38
|
if (diagnosticKey && !/^[A-Za-z0-9_-]{43}$/.test(diagnosticKey)) throw new Error('Invalid usage diagnostic key');
|
|
39
39
|
const store = database?.devices || deviceStore(stateFile);
|
|
@@ -180,20 +180,22 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
180
180
|
}
|
|
181
181
|
if (request.method === 'GET' && target.pathname === '/admin/api/usage' && meter) {
|
|
182
182
|
const page = Number(target.searchParams.get('page') || '1');
|
|
183
|
-
const
|
|
184
|
-
if (!Number.isSafeInteger(page) || page < 1 || page > 100000 ||
|
|
183
|
+
const view = target.searchParams.get('view') || 'user';
|
|
184
|
+
if (!Number.isSafeInteger(page) || page < 1 || page > 100000 ||
|
|
185
|
+
!['user', 'computer'].includes(view) || target.searchParams.has('user') ||
|
|
186
|
+
target.searchParams.has('computer')) {
|
|
185
187
|
adminReply(response, 400, { error: 'Invalid usage query' }); return;
|
|
186
188
|
}
|
|
187
|
-
adminReply(response, 200, await usageReport(database.pool, page,
|
|
189
|
+
adminReply(response, 200, await usageReport(database.pool, page, view)); return;
|
|
188
190
|
}
|
|
189
191
|
if (diagnostics && target.pathname === '/admin/api/usage-diagnostics') {
|
|
190
|
-
if (request.method === 'GET') adminReply(response, 200, await diagnostics.read());
|
|
191
|
-
else if (request.method === 'DELETE') adminReply(response, 200, await diagnostics.clear());
|
|
192
|
+
if (request.method === 'GET') adminReply(response, 200, await diagnostics.read('usage'));
|
|
193
|
+
else if (request.method === 'DELETE') adminReply(response, 200, await diagnostics.clear('usage'));
|
|
192
194
|
else adminReply(response, 405, { error: 'Unsupported diagnostic request' });
|
|
193
195
|
return;
|
|
194
196
|
}
|
|
195
197
|
if (diagnostics && target.pathname === '/admin/api/usage-diagnostics/drain') {
|
|
196
|
-
if (request.method === 'POST') adminReply(response, 200, await diagnostics.drain());
|
|
198
|
+
if (request.method === 'POST') adminReply(response, 200, await diagnostics.drain('usage'));
|
|
197
199
|
else adminReply(response, 405, { error: 'Unsupported diagnostic request' });
|
|
198
200
|
return;
|
|
199
201
|
}
|
|
@@ -457,7 +459,6 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
457
459
|
repeat: room.p2pProbeRepeat, signals: 0, probeStarted: 0, outbound: Promise.resolve(), queuedBytes: 0,
|
|
458
460
|
relayAdmitted: false, relayExempt: false,
|
|
459
461
|
capacityExempt: debugAllowed(debugAllowlist, room.id, room.clients.find(client => client.id === clientId)) });
|
|
460
|
-
diagnostics?.record('client', userId ? 'account-linked' : 'anonymous');
|
|
461
462
|
send(ws, { type: 'paired', protocolVersion: 7, room: room.id, clientId, revision: room.revision, ...(credential ? { credential } : {}) });
|
|
462
463
|
send(ws, { type: 'computer-info', hostName: room.hostName });
|
|
463
464
|
if (room.lanCandidates?.length) send(ws, { type: 'lan-candidates', candidates: room.lanCandidates });
|
|
@@ -478,10 +479,9 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
478
479
|
const armKey = `${message.session}:${message.requestId}`;
|
|
479
480
|
const connection = [...room.connections.values()].find(item => item.session === message.session);
|
|
480
481
|
if (message.type === 'usage-arm') {
|
|
481
|
-
diagnostics?.record('arm', connection?.userId ? 'account-linked' : 'anonymous');
|
|
482
482
|
if (!connection?.userId || !Number.isSafeInteger(message.requestId) || message.requestId < 1 ||
|
|
483
483
|
(message.threadId !== null && (!plainText(message.threadId, 128, false)))) {
|
|
484
|
-
diagnostics?.record('arm-rejected', !connection?.userId ? 'account-missing' : 'invalid-request');
|
|
484
|
+
diagnostics?.record('usage', 'arm-rejected', !connection?.userId ? 'account-missing' : 'invalid-request');
|
|
485
485
|
return;
|
|
486
486
|
}
|
|
487
487
|
for (const [key, arm] of room.usageArms) if (Date.now() - arm.at > 120000) room.usageArms.delete(key);
|
|
@@ -489,44 +489,40 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
489
489
|
room.usageArms.set(armKey, { userId: connection.userId,
|
|
490
490
|
clientId: [...room.connections.entries()].find(([, item]) => item === connection)?.[0],
|
|
491
491
|
session: message.session, at: Date.now() });
|
|
492
|
-
diagnostics?.record('arm-accepted', 'ready');
|
|
493
492
|
return;
|
|
494
493
|
}
|
|
495
494
|
if (message.type === 'usage-cancel') {
|
|
496
|
-
diagnostics?.record('message-cancelled', room.usageArms.has(armKey) ? 'armed' : 'arm-missing');
|
|
497
495
|
room.usageArms.delete(armKey); return;
|
|
498
496
|
}
|
|
499
497
|
if (message.type === 'usage-message') {
|
|
500
498
|
const arm = room.usageArms.get(armKey);
|
|
501
|
-
diagnostics?.record('message', arm ? 'armed' : 'arm-missing');
|
|
502
499
|
room.usageArms.delete(armKey);
|
|
503
500
|
if (!arm || Date.now() - arm.at > 120000 || !plainText(message.messageKey, 128, false)) {
|
|
504
|
-
diagnostics?.record('message-rejected', !arm ? 'arm-missing' : 'expired-or-invalid');
|
|
501
|
+
diagnostics?.record('usage', 'message-rejected', !arm ? 'arm-missing' : 'expired-or-invalid');
|
|
505
502
|
return;
|
|
506
503
|
}
|
|
507
504
|
try {
|
|
508
505
|
await meter.action(arm, room.id, arm.clientId, 'message', message.messageKey);
|
|
509
|
-
diagnostics?.record('message-counted', 'confirmed');
|
|
510
506
|
if (['start', 'steer'].includes(message.turnKind) &&
|
|
511
507
|
plainText(message.threadId, 80, false) && plainText(message.turnId, 80, false)) {
|
|
512
508
|
const key = `${message.threadId}:${message.turnId}`;
|
|
513
509
|
if (key.length <= 128) {
|
|
514
510
|
const counted = await meter.action(arm, room.id, arm.clientId, 'turn', key);
|
|
515
|
-
if (counted) diagnostics?.record('turn-
|
|
511
|
+
if (counted && message.turnKind === 'steer') diagnostics?.record('usage', 'turn-recovered', 'steer');
|
|
516
512
|
if (!room.usageTasks.has(key) && message.observerReady === true && message.completed !== true &&
|
|
517
513
|
(message.turnKind === 'start' || message.active === true)) {
|
|
518
514
|
const task = await meter.turnOrigin(room.id, key);
|
|
519
515
|
if (task) {
|
|
520
|
-
await meter.pulse(task, true, room.id, task.clientId, key);
|
|
516
|
+
await meter.pulse(task, true, room.id, task.clientId, counted ? key : randomUUID());
|
|
521
517
|
room.usageTasks.set(key, task);
|
|
522
518
|
}
|
|
523
519
|
}
|
|
524
520
|
}
|
|
525
521
|
}
|
|
526
|
-
} catch (error) { diagnostics?.record('message-error', 'database'); log({ event: 'usage-error', message: error.message }); }
|
|
522
|
+
} catch (error) { diagnostics?.record('usage', 'message-error', 'database'); log({ event: 'usage-error', message: error.message }); }
|
|
527
523
|
return;
|
|
528
524
|
}
|
|
529
|
-
if (!['turn/started', 'turn/completed', 'heartbeat', 'resume'].includes(message.method) ||
|
|
525
|
+
if (!['turn/started', 'turn/completed', 'heartbeat', 'resume', 'inactive'].includes(message.method) ||
|
|
530
526
|
!plainText(message.threadId, 80, false) || !plainText(message.turnId, 80, false)) return;
|
|
531
527
|
const key = `${message.threadId}:${message.turnId}`;
|
|
532
528
|
if (key.length > 128) return;
|
|
@@ -534,14 +530,17 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
534
530
|
try {
|
|
535
531
|
if (message.method === 'resume' && !task) {
|
|
536
532
|
task = await meter.turnOrigin(room.id, key);
|
|
537
|
-
if (!task) { diagnostics?.record('turn-resume', 'origin-missing'); return; }
|
|
533
|
+
if (!task) { diagnostics?.record('usage', 'turn-resume', 'origin-missing'); return; }
|
|
538
534
|
await meter.pulse(task, true, room.id, task.clientId, `${key}:${randomUUID()}`);
|
|
539
|
-
diagnostics?.record('turn-resume', 'confirmed');
|
|
535
|
+
diagnostics?.record('usage', 'turn-resume', 'confirmed');
|
|
540
536
|
room.usageTasks.set(key, task);
|
|
541
537
|
} else if (task && message.method === 'heartbeat') await meter.pulse(task, true, room.id, task.clientId);
|
|
538
|
+
else if (task && message.method === 'inactive') {
|
|
539
|
+
await meter.lost(task);
|
|
540
|
+
room.usageTasks.delete(key);
|
|
541
|
+
}
|
|
542
542
|
else if (task && message.method === 'turn/completed') {
|
|
543
543
|
await meter.pulse(task, false, room.id, task.clientId);
|
|
544
|
-
diagnostics?.record('turn-completed', 'confirmed');
|
|
545
544
|
room.usageTasks.delete(key);
|
|
546
545
|
}
|
|
547
546
|
} catch (error) { log({ event: 'usage-error', message: error.message }); }
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const dayMs = 86400000;
|
|
2
|
+
const chinaOffsetMs = 8 * 3600000;
|
|
3
|
+
|
|
4
|
+
export function usageDaySlices(start, end) {
|
|
5
|
+
const slices = [];
|
|
6
|
+
for (let cursor = start; cursor < end;) {
|
|
7
|
+
const date = new Date(cursor + chinaOffsetMs).toISOString().slice(0, 10);
|
|
8
|
+
const nextDay = Date.parse(`${date}T00:00:00Z`) + dayMs - chinaOffsetMs;
|
|
9
|
+
const until = Math.min(end, nextDay);
|
|
10
|
+
slices.push({ date, start: cursor, end: until });
|
|
11
|
+
cursor = until;
|
|
12
|
+
}
|
|
13
|
+
return slices;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const readIntervals = value => Array.isArray(value) ? value : JSON.parse(value || '[]');
|
|
17
|
+
|
|
18
|
+
export function mergeUsageIntervals(existing, start, end) {
|
|
19
|
+
const sorted = [...existing, [start, end]].sort((a, b) => a[0] - b[0]);
|
|
20
|
+
const merged = [];
|
|
21
|
+
for (const range of sorted) {
|
|
22
|
+
const last = merged.at(-1);
|
|
23
|
+
if (last && range[0] <= last[1]) last[1] = Math.max(last[1], range[1]);
|
|
24
|
+
else merged.push([...range]);
|
|
25
|
+
}
|
|
26
|
+
return { intervals: merged, seconds: merged.reduce((sum, [from, until]) => sum + until - from, 0) / 1000 };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function recordDailyUsage(db, userId, computerId, free, start, end, now) {
|
|
30
|
+
for (const slice of usageDaySlices(start, end)) {
|
|
31
|
+
await db.execute(`INSERT IGNORE INTO relay_usage_user_days
|
|
32
|
+
(user_id,usage_date,intervals,updated_at) VALUES (?,?,'[]',?)`, [userId, slice.date, now]);
|
|
33
|
+
const [userRows] = await db.execute(`SELECT intervals FROM relay_usage_user_days
|
|
34
|
+
WHERE user_id=? AND usage_date=? FOR UPDATE`, [userId, slice.date]);
|
|
35
|
+
const user = mergeUsageIntervals(readIntervals(userRows[0].intervals), slice.start, slice.end);
|
|
36
|
+
await db.execute(`UPDATE relay_usage_user_days SET intervals=?,task_seconds=?,updated_at=?
|
|
37
|
+
WHERE user_id=? AND usage_date=?`,
|
|
38
|
+
[JSON.stringify(user.intervals), user.seconds, now, userId, slice.date]);
|
|
39
|
+
|
|
40
|
+
await db.execute(`INSERT IGNORE INTO relay_usage_computer_days
|
|
41
|
+
(computer_id,usage_date,intervals,free_intervals,updated_at) VALUES (?,?,'[]','[]',?)`,
|
|
42
|
+
[computerId, slice.date, now]);
|
|
43
|
+
const [computerRows] = await db.execute(`SELECT intervals,free_intervals FROM relay_usage_computer_days
|
|
44
|
+
WHERE computer_id=? AND usage_date=? FOR UPDATE`, [computerId, slice.date]);
|
|
45
|
+
const computer = mergeUsageIntervals(readIntervals(computerRows[0].intervals), slice.start, slice.end);
|
|
46
|
+
const freeIntervals = readIntervals(computerRows[0].free_intervals);
|
|
47
|
+
const freeComputer = free ? mergeUsageIntervals(freeIntervals, slice.start, slice.end) : null;
|
|
48
|
+
await db.execute(`UPDATE relay_usage_computer_days SET intervals=?,task_seconds=?,
|
|
49
|
+
free_intervals=?,free_task_seconds=COALESCE(?,free_task_seconds),updated_at=?
|
|
50
|
+
WHERE computer_id=? AND usage_date=?`,
|
|
51
|
+
[JSON.stringify(computer.intervals), computer.seconds,
|
|
52
|
+
JSON.stringify(freeComputer?.intervals || freeIntervals), freeComputer?.seconds ?? null,
|
|
53
|
+
now, computerId, slice.date]);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { recordDailyUsage } from './usage-daily.mjs';
|
|
2
3
|
|
|
3
4
|
const epoch = value => new Date(`${value.replace(' ', 'T')}Z`).getTime();
|
|
4
5
|
|
|
@@ -52,14 +53,17 @@ export function usageMeter(pool) {
|
|
|
52
53
|
if (!row || row.ended_at) throw new Error('Invalid relay usage session');
|
|
53
54
|
const [clock] = await db.query('SELECT UTC_TIMESTAMP(3) AS now');
|
|
54
55
|
const now = clock[0].now;
|
|
55
|
-
const
|
|
56
|
-
const
|
|
56
|
+
const nowMs = epoch(now);
|
|
57
|
+
const chargeStart = Math.max(epoch(row.confirmed_until), nowMs - 20000);
|
|
58
|
+
const seconds = Math.max(0, nowMs - chargeStart) / 1000;
|
|
57
59
|
const [plans] = await db.execute('SELECT tier,valid_until FROM relay_usage_entitlements WHERE user_id=?', [row.user_id]);
|
|
58
60
|
const subscribed = plans[0]?.tier === 'subscriber' && plans[0].valid_until && epoch(plans[0].valid_until) > epoch(now);
|
|
59
61
|
const freeSeconds = subscribed ? 0 : seconds;
|
|
60
62
|
await db.execute(`UPDATE relay_usage_sessions SET confirmed_until=?,ended_at=?,charged_seconds=charged_seconds+?,
|
|
61
63
|
free_charged_seconds=free_charged_seconds+? WHERE id=?`, [now, active ? null : now, seconds, freeSeconds, id]);
|
|
62
64
|
if (seconds > 0) {
|
|
65
|
+
await recordDailyUsage(db, row.user_id, row.computer_id, !subscribed,
|
|
66
|
+
chargeStart, nowMs, now);
|
|
63
67
|
await db.execute(`INSERT INTO relay_usage_totals (user_id,task_seconds,updated_at)
|
|
64
68
|
VALUES (?,?,?) ON DUPLICATE KEY UPDATE task_seconds=task_seconds+VALUES(task_seconds),
|
|
65
69
|
updated_at=VALUES(updated_at)`, [row.user_id, seconds, now]);
|
|
@@ -85,41 +89,48 @@ export function usageMeter(pool) {
|
|
|
85
89
|
return { action, turnOrigin, pulse, lost };
|
|
86
90
|
}
|
|
87
91
|
|
|
88
|
-
export async function usageReport(pool, page,
|
|
89
|
-
if (
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const [computers] = await pool.execute(`SELECT c.id AS computer_id,
|
|
97
|
-
COALESCE(s.task_seconds,0) AS task_seconds,
|
|
92
|
+
export async function usageReport(pool, page, view = 'user') {
|
|
93
|
+
if (view === 'computer') {
|
|
94
|
+
const offset = (page - 1) * 30;
|
|
95
|
+
const [counts] = await pool.query(`SELECT COUNT(*) AS total FROM relay_usage_computer_days
|
|
96
|
+
WHERE usage_date=DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) AND task_seconds>0`);
|
|
97
|
+
const [rows] = await pool.execute(`SELECT c.id,
|
|
98
|
+
d.task_seconds AS today_task_seconds,
|
|
99
|
+
d.free_task_seconds AS today_free_task_seconds,
|
|
98
100
|
COALESCE(a.message_count,0) AS message_count,
|
|
99
101
|
COALESCE(a.turn_count,0) AS turn_count,
|
|
100
102
|
COALESCE(GREATEST(s.last_used_at,a.last_used_at),s.last_used_at,a.last_used_at) AS last_used_at
|
|
101
|
-
FROM computers c
|
|
102
|
-
LEFT JOIN (SELECT computer_id,
|
|
103
|
-
SUM(
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
FROM
|
|
109
|
-
WHERE
|
|
110
|
-
ORDER BY
|
|
111
|
-
return {
|
|
103
|
+
FROM relay_usage_computer_days d JOIN computers c ON c.id=d.computer_id
|
|
104
|
+
LEFT JOIN (SELECT computer_id,SUM(kind='message') AS message_count,
|
|
105
|
+
SUM(kind='turn') AS turn_count,MAX(occurred_at) AS last_used_at FROM relay_usage_actions
|
|
106
|
+
WHERE occurred_at >= DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) - INTERVAL 8 HOUR
|
|
107
|
+
AND occurred_at < DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) + INTERVAL 16 HOUR
|
|
108
|
+
GROUP BY computer_id) a ON a.computer_id=c.id
|
|
109
|
+
LEFT JOIN (SELECT computer_id,MAX(confirmed_until) AS last_used_at
|
|
110
|
+
FROM relay_usage_sessions GROUP BY computer_id) s ON s.computer_id=c.id
|
|
111
|
+
WHERE d.usage_date=DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) AND d.task_seconds>0
|
|
112
|
+
ORDER BY d.task_seconds DESC,c.id LIMIT ? OFFSET ?`, [30, offset]);
|
|
113
|
+
return { page, pageSize: 30, total: counts[0].total, rows };
|
|
112
114
|
}
|
|
113
115
|
const offset = (page - 1) * 30;
|
|
114
|
-
const [counts] = await pool.query(`SELECT COUNT(*) AS total FROM
|
|
116
|
+
const [counts] = await pool.query(`SELECT COUNT(*) AS total FROM relay_usage_user_days d
|
|
117
|
+
JOIN user_login_identities i ON i.user_id=d.user_id AND i.kind='email'
|
|
118
|
+
WHERE d.usage_date=DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) AND d.task_seconds>0`);
|
|
115
119
|
const [rows] = await pool.execute(`SELECT u.id,i.normalized_value AS email,
|
|
116
|
-
|
|
117
|
-
COALESCE(
|
|
120
|
+
d.task_seconds AS today_task_seconds,
|
|
121
|
+
COALESCE(a.message_count,0) AS message_count,
|
|
122
|
+
COALESCE(a.turn_count,0) AS turn_count,
|
|
118
123
|
COALESCE(GREATEST(s.last_used_at,a.last_used_at),s.last_used_at,a.last_used_at) AS last_used_at
|
|
119
|
-
FROM
|
|
120
|
-
|
|
121
|
-
LEFT JOIN (SELECT user_id,
|
|
122
|
-
|
|
123
|
-
|
|
124
|
+
FROM relay_usage_user_days d JOIN users u ON u.id=d.user_id
|
|
125
|
+
JOIN user_login_identities i ON i.user_id=u.id AND i.kind='email'
|
|
126
|
+
LEFT JOIN (SELECT user_id,SUM(kind='message') AS message_count,
|
|
127
|
+
SUM(kind='turn') AS turn_count,MAX(occurred_at) AS last_used_at FROM relay_usage_actions
|
|
128
|
+
WHERE occurred_at >= DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) - INTERVAL 8 HOUR
|
|
129
|
+
AND occurred_at < DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) + INTERVAL 16 HOUR
|
|
130
|
+
GROUP BY user_id) a ON a.user_id=u.id
|
|
131
|
+
LEFT JOIN (SELECT user_id,MAX(confirmed_until) AS last_used_at
|
|
132
|
+
FROM relay_usage_sessions GROUP BY user_id) s ON s.user_id=u.id
|
|
133
|
+
WHERE d.usage_date=DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) AND d.task_seconds>0
|
|
134
|
+
ORDER BY d.task_seconds DESC,u.id LIMIT ? OFFSET ?`, [30, offset]);
|
|
124
135
|
return { page, pageSize: 30, total: counts[0].total, rows };
|
|
125
136
|
}
|
package/package.json
CHANGED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { adminUiTokens } from './admin-style.mjs';
|
|
2
|
-
|
|
3
|
-
export function adminUsagePage() {
|
|
4
|
-
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="only light"><title>使用统计 · Code Relax 管理</title><style>${adminUiTokens}</style><style>
|
|
5
|
-
:root{font:14px/1.5 system-ui,"Microsoft YaHei",sans-serif;color:var(--admin-text);background:var(--admin-page)}
|
|
6
|
-
*{box-sizing:border-box}body{margin:0}main{max-width:1080px;margin:auto;padding:28px 20px 60px}
|
|
7
|
-
header{display:flex;align-items:center;gap:20px;flex-wrap:wrap;margin-bottom:22px}
|
|
8
|
-
h1{font-size:27px;margin:0}h2{font-size:18px;margin:0 0 5px}
|
|
9
|
-
a,.row button{color:var(--admin-accent);text-decoration:none}a:hover{text-decoration:underline}
|
|
10
|
-
.card{background:var(--admin-surface);border:1px solid var(--admin-border);border-radius:var(--admin-card-radius);padding:var(--admin-card-padding);box-shadow:0 10px 24px #263a2d0a}
|
|
11
|
-
.subtle,.row small{color:var(--admin-muted)}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:16px}
|
|
12
|
-
.rows{display:grid;gap:8px}.row{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,2fr) 160px;align-items:center;gap:12px;padding:12px 14px;border:1px solid var(--admin-row-border);border-radius:var(--admin-control-radius);background:var(--admin-row)}
|
|
13
|
-
.row button{border:0;background:none;text-align:left;padding:0;cursor:pointer;overflow-wrap:anywhere;font:inherit}.row strong{font-variant-numeric:tabular-nums}
|
|
14
|
-
button.pager{font:inherit;background:var(--admin-accent-surface);color:var(--admin-accent);border:1px solid var(--admin-border);border-radius:var(--admin-control-radius);padding:7px 12px;cursor:pointer}
|
|
15
|
-
button:disabled{opacity:.45;cursor:default}.pages{display:flex;align-items:center;gap:10px;margin-top:16px}.error{color:var(--admin-error)}.detail{margin-top:18px}.detail[hidden]{display:none}
|
|
16
|
-
@media(max-width:650px){main{padding:16px 12px}.card{padding:15px}.row{grid-template-columns:minmax(0,1fr)}.row small{grid-column:1/-1}}
|
|
17
|
-
</style></head><body><main><header><a href="/admin">← 中继管理</a><h1>使用统计</h1><span class="subtle">后台试运行统计,不限制使用</span></header><section class="card"><div class="toolbar"><div><h2>账号使用记录</h2><p id="count" class="subtle">正在读取…</p></div></div><div id="rows" class="rows"></div><div class="pages"><button id="prev" class="pager" type="button">上一页</button><span id="pageText"></span><button id="next" class="pager" type="button">下一页</button></div><p id="error" class="error" role="alert"></p></section><section id="detail" class="card detail" hidden><div class="toolbar"><div><h2 id="detailTitle"></h2><p class="subtle">按工作站查看此账号的统计</p></div><button id="closeDetail" class="pager" type="button">关闭</button></div><div id="detailRows" class="rows"></div></section></main><script>
|
|
18
|
-
const el=id=>document.getElementById(id);let page=1,total=0;
|
|
19
|
-
const duration=value=>{const seconds=Math.floor(Number(value)||0),h=Math.floor(seconds/3600),m=Math.floor(seconds%3600/60),s=seconds%60;return h?h+' 小时 '+m+' 分钟':m?m+' 分钟 '+s+' 秒':s+' 秒'};
|
|
20
|
-
const date=value=>value?new Date(value.replace(' ','T')+'Z').toLocaleString('zh-CN'):'—';
|
|
21
|
-
async function request(path){const token=sessionStorage.getItem('codeRelaxAdminToken');if(!token){location.href='/admin';return null}const response=await fetch(path,{headers:{Authorization:'Bearer '+token}});const body=await response.json();if(response.status===401||response.status===403){sessionStorage.removeItem('codeRelaxAdminToken');location.href='/admin';return null}if(!response.ok)throw new Error(body.error||'读取失败');return body}
|
|
22
|
-
const node=(tag,text,className='')=>{const item=document.createElement(tag);item.textContent=text;if(className)item.className=className;return item};
|
|
23
|
-
const metrics=row=>'消息 '+Number(row.message_count||0)+' · 轮次 '+Number(row.turn_count||0)+' · 任务 '+duration(row.task_seconds);
|
|
24
|
-
async function load(){el('error').textContent='';try{const result=await request('/admin/api/usage?page='+page);if(!result)return;total=result.total;el('count').textContent='共 '+total+' 个账号 · 每页 30 个';el('pageText').textContent='第 '+page+' / '+Math.max(1,Math.ceil(total/30))+' 页';el('prev').disabled=page<=1;el('next').disabled=page*30>=total;el('rows').replaceChildren();if(!result.rows.length){el('rows').append(node('p','暂无使用记录','subtle'));return}for(const row of result.rows){const item=node('div','','row'),button=node('button',row.email),time=node('strong',metrics(row)),last=node('small','最近使用 '+date(row.last_used_at));button.type='button';button.onclick=()=>detail(row.id);item.append(button,time,last);el('rows').append(item)}}catch(error){el('error').textContent=error.message}}
|
|
25
|
-
async function detail(id){try{const result=await request('/admin/api/usage?user='+encodeURIComponent(id));if(!result)return;el('detailTitle').textContent=result.user.email;el('detailRows').replaceChildren();if(!result.computers.length)el('detailRows').append(node('p','暂无使用记录','subtle'));for(const row of result.computers){const item=node('div','','row');item.append(node('span',row.computer_id),node('strong',metrics(row)),node('small','最近使用 '+date(row.last_used_at)));el('detailRows').append(item)}el('detail').hidden=false;el('detail').scrollIntoView({behavior:'smooth',block:'start'})}catch(error){el('error').textContent=error.message}}
|
|
26
|
-
el('prev').onclick=()=>{if(page>1){page--;void load()}};el('next').onclick=()=>{if(page*30<total){page++;void load()}};el('closeDetail').onclick=()=>{el('detail').hidden=true};void load();
|
|
27
|
-
</script></body></html>`;
|
|
28
|
-
}
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
const MAX_BATCH = 200;
|
|
2
|
-
|
|
3
|
-
export function usageDiagnostics(pool, onError = () => {}) {
|
|
4
|
-
let tail = Promise.resolve();
|
|
5
|
-
function serialize(action) {
|
|
6
|
-
const result = tail.then(action);
|
|
7
|
-
tail = result.catch(error => { onError(error); });
|
|
8
|
-
return result;
|
|
9
|
-
}
|
|
10
|
-
function record(event, detail) {
|
|
11
|
-
if (!/^[a-z-]{1,32}$/.test(event) || !/^[a-z-]{1,64}$/.test(detail)) throw new Error('Invalid usage diagnostic');
|
|
12
|
-
void serialize(async () => {
|
|
13
|
-
await pool.execute('INSERT INTO relay_usage_diagnostics (created_at,event,detail) VALUES (UTC_TIMESTAMP(3),?,?)', [event, detail]);
|
|
14
|
-
await pool.execute('DELETE FROM relay_usage_diagnostics WHERE created_at < UTC_TIMESTAMP(3) - INTERVAL 1 DAY');
|
|
15
|
-
}).catch(() => {});
|
|
16
|
-
}
|
|
17
|
-
async function read() {
|
|
18
|
-
return serialize(async () => {
|
|
19
|
-
const [rows] = await pool.query(`SELECT id,created_at,event,detail FROM relay_usage_diagnostics
|
|
20
|
-
ORDER BY id LIMIT ${MAX_BATCH + 1}`);
|
|
21
|
-
return { rows: rows.slice(0, MAX_BATCH), hasMore: rows.length > MAX_BATCH };
|
|
22
|
-
});
|
|
23
|
-
}
|
|
24
|
-
async function clear() {
|
|
25
|
-
return serialize(async () => {
|
|
26
|
-
const [result] = await pool.query('DELETE FROM relay_usage_diagnostics');
|
|
27
|
-
return { cleared: result.affectedRows };
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
async function drain() {
|
|
31
|
-
return serialize(async () => {
|
|
32
|
-
const db = await pool.getConnection();
|
|
33
|
-
try {
|
|
34
|
-
await db.beginTransaction();
|
|
35
|
-
const [rows] = await db.query(`SELECT id,created_at,event,detail FROM relay_usage_diagnostics
|
|
36
|
-
ORDER BY id LIMIT ${MAX_BATCH + 1} FOR UPDATE`);
|
|
37
|
-
const selected = rows.slice(0, MAX_BATCH);
|
|
38
|
-
if (selected.length) await db.execute('DELETE FROM relay_usage_diagnostics WHERE id <= ?', [selected.at(-1).id]);
|
|
39
|
-
await db.commit();
|
|
40
|
-
return { rows: selected, hasMore: rows.length > MAX_BATCH };
|
|
41
|
-
} catch (error) { await db.rollback(); throw error; }
|
|
42
|
-
finally { db.release(); }
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
return { record, read, clear, drain };
|
|
46
|
-
}
|