@walkhi/code-relax 0.1.0-beta.21 → 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.
@@ -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 = ['001_relay_core', '002_email_accounts', '003_relay_usage', '004_relay_usage_actions', '005_relay_usage_diagnostics'];
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 === '005_relay_usage_diagnostics')) names.push('relay_usage_diagnostics');
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-link{display:inline-block;padding:12px 17px;color:#286e43;text-decoration:none}.usage-link:hover{text-decoration:underline}</style></head><body><main>
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><a class="usage-link" href="/admin/usage">使用统计</a><button id="adminLogout" type="button" class="secondary">退出后台</button></nav></header>
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><p id="result" class="status" role="status" aria-live="polite"></p>
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)throw new Error(body.error||('HTTP '+response.status));return body};
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
- 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);load()}
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
- reply(response, 200, adminUsagePage(), 'text/html; charset=utf-8'); return;
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
- for (const [threadId, turnId] of activeTurns) send(remote, { type: 'usage-turn', method: 'heartbeat', threadId, turnId });
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 = ['001_relay_core', '002_email_accounts', '003_relay_usage', '004_relay_usage_actions', '005_relay_usage_diagnostics'];
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 { usageDiagnostics } from './usage-diagnostics.mjs';
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('005_relay_usage_diagnostics')
36
- ? usageDiagnostics(database.pool, error => log({ event: 'usage-diagnostic-error', message: error.message })) : null;
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 user = target.searchParams.get('user') || '';
184
- if (!Number.isSafeInteger(page) || page < 1 || page > 100000 || (user && !/^[0-9a-f-]{36}$/.test(user))) {
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, user)); return;
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,30 +489,26 @@ 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-counted', 'confirmed');
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);
@@ -523,10 +519,10 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
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 elapsedMs = Math.max(0, epoch(now) - epoch(row.confirmed_until));
56
- const seconds = Math.min(elapsedMs, 20000) / 1000;
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, userId = '') {
89
- if (userId) {
90
- const [users] = await pool.execute(`SELECT u.id,i.normalized_value AS email,
91
- COALESCE(t.task_seconds,0) AS task_seconds,
92
- COALESCE(t.message_count,0) AS message_count,COALESCE(t.turn_count,0) AS turn_count
93
- FROM users u JOIN user_login_identities i ON i.user_id=u.id AND i.kind='email'
94
- LEFT JOIN relay_usage_totals t ON t.user_id=u.id WHERE u.id=?`, [userId]);
95
- if (!users.length) return null;
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(charged_seconds) AS task_seconds,
104
- MAX(started_at) AS last_used_at FROM relay_usage_sessions WHERE user_id=? GROUP BY computer_id) s ON s.computer_id=c.id
105
- LEFT JOIN (SELECT computer_id,
106
- SUM(kind='message') AS message_count,SUM(kind='turn') AS turn_count,
107
- MAX(occurred_at) AS last_used_at
108
- FROM relay_usage_actions WHERE user_id=? GROUP BY computer_id) a ON a.computer_id=c.id
109
- WHERE s.computer_id IS NOT NULL OR a.computer_id IS NOT NULL
110
- ORDER BY last_used_at DESC`, [userId, userId]);
111
- return { user: users[0], computers };
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 user_login_identities WHERE kind='email'`);
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
- COALESCE(t.task_seconds,0) AS task_seconds,
117
- COALESCE(t.message_count,0) AS message_count,COALESCE(t.turn_count,0) AS turn_count,
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 users u JOIN user_login_identities i ON i.user_id=u.id AND i.kind='email'
120
- LEFT JOIN relay_usage_totals t ON t.user_id=u.id
121
- LEFT JOIN (SELECT user_id,MAX(started_at) AS last_used_at FROM relay_usage_sessions GROUP BY user_id) s ON s.user_id=u.id
122
- LEFT JOIN (SELECT user_id,MAX(occurred_at) AS last_used_at FROM relay_usage_actions GROUP BY user_id) a ON a.user_id=u.id
123
- ORDER BY task_seconds DESC,last_used_at DESC,u.id LIMIT ? OFFSET ?`, [30, offset]);
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@walkhi/code-relax",
3
- "version": "0.1.0-beta.21",
3
+ "version": "0.1.0-beta.22",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -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
- }