@walkhi/code-relax 0.1.0-beta.65 → 0.1.0-beta.68

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.
Files changed (121) hide show
  1. package/dist/bin/codex-remote.mjs +4 -2
  2. package/dist/bin/manage-relay-admin.mjs +20 -20
  3. package/dist/bin/migrate-relay-db.mjs +109 -109
  4. package/dist/bin/publish-harmony-release.mjs +46 -46
  5. package/dist/bin/self-relay-admin.mjs +8 -8
  6. package/dist/bin/self-relay-demo.mjs +33 -33
  7. package/dist/bin/self-relay-server.mjs +66 -66
  8. package/dist/bin/set-relay-admin-password.mjs +11 -11
  9. package/dist/build-platform.json +1 -1
  10. package/dist/get-lan-networks.ps1 +40 -40
  11. package/dist/shared/app-server-events.cjs +511 -511
  12. package/dist/shared/p2p-data-channel.cjs +72 -72
  13. package/dist/shared/p2p-diagnostics.cjs +28 -28
  14. package/dist/shared/p2p-ice-policy.cjs +22 -22
  15. package/dist/shared/plan-pricing.cjs +32 -32
  16. package/dist/shared/question-attention.cjs +69 -69
  17. package/dist/shared/release-version.cjs +31 -31
  18. package/dist/src/archived-threads.mjs +22 -22
  19. package/dist/src/background-process.mjs +18 -18
  20. package/dist/src/code-browser.mjs +100 -100
  21. package/dist/src/desktop-launcher.mjs +62 -62
  22. package/dist/src/dots-task-files.mjs +52 -52
  23. package/dist/src/execution-health.mjs +26 -26
  24. package/dist/src/message-images.mjs +9 -9
  25. package/dist/src/plan-pricing.mjs +47 -47
  26. package/dist/src/platform/codex-runtime.mjs +3 -3
  27. package/dist/src/platform/credential-store.mjs +19 -19
  28. package/dist/src/platform/desktop-host.mjs +3 -3
  29. package/dist/src/platform/desktop-permission-mode.mjs +17 -17
  30. package/dist/src/platform/desktop-project-registration.mjs +3 -3
  31. package/dist/src/platform/file-opener.mjs +9 -9
  32. package/dist/src/platform/linux/README.md +3 -3
  33. package/dist/src/platform/linux/codex-runtime.mjs +56 -56
  34. package/dist/src/platform/linux/desktop-installation.mjs +36 -36
  35. package/dist/src/platform/linux/desktop-project-registration.mjs +11 -11
  36. package/dist/src/platform/linux/network-info.mjs +11 -11
  37. package/dist/src/platform/linux/project-locations.mjs +17 -17
  38. package/dist/src/platform/linux/resident-startup.mjs +52 -52
  39. package/dist/src/platform/linux/retained-runtime.mjs +76 -76
  40. package/dist/src/platform/linux/service-host.mjs +18 -18
  41. package/dist/src/platform/macos/README.md +3 -3
  42. package/dist/src/platform/macos/codex-runtime.mjs +0 -4
  43. package/dist/src/platform/macos/desktop-project-registration.mjs +11 -11
  44. package/dist/src/platform/macos/network-info.mjs +8 -8
  45. package/dist/src/platform/macos/project-locations.mjs +8 -8
  46. package/dist/src/platform/macos/resident-startup.mjs +84 -84
  47. package/dist/src/platform/macos/retained-runtime.mjs +127 -127
  48. package/dist/src/platform/macos/service-host.mjs +18 -18
  49. package/dist/src/platform/macos/system.mjs +29 -29
  50. package/dist/src/platform/network-info.mjs +3 -3
  51. package/dist/src/platform/path-identity.mjs +5 -5
  52. package/dist/src/platform/posix/network-candidates.mjs +23 -23
  53. package/dist/src/platform/posix/runtime-paths.mjs +25 -25
  54. package/dist/src/platform/power-inhibitor.mjs +22 -22
  55. package/dist/src/platform/project-locations.mjs +3 -3
  56. package/dist/src/platform/runtime-paths.mjs +8 -8
  57. package/dist/src/platform/select.mjs +5 -5
  58. package/dist/src/platform/service-host.mjs +3 -3
  59. package/dist/src/platform/user-environment.mjs +3 -3
  60. package/dist/src/platform/windows/IsolatedProcess.cs +79 -79
  61. package/dist/src/platform/windows/background-process.mjs +30 -30
  62. package/dist/src/platform/windows/desktop-helper.mjs +179 -179
  63. package/dist/src/platform/windows/desktop-runtime.ps1 +10 -10
  64. package/dist/src/platform/windows/desktop-shortcuts.ps1 +7 -7
  65. package/dist/src/platform/windows/keep-awake.ps1 +20 -20
  66. package/dist/src/platform/windows/network-info.mjs +34 -34
  67. package/dist/src/platform/windows/resident-start.js +20 -20
  68. package/dist/src/platform/windows/resident-start.ps1 +7 -7
  69. package/dist/src/platform/windows/resident-startup.ps1 +83 -83
  70. package/dist/src/platform/windows/resolve-state-root.ps1 +14 -14
  71. package/dist/src/platform/windows/runtime-paths.mjs +20 -20
  72. package/dist/src/platform/windows/rustdesk-video-probe.mjs +167 -167
  73. package/dist/src/platform/windows/start-hidden-console.ps1 +58 -58
  74. package/dist/src/platform/windows/stop-desktop.ps1 +22 -22
  75. package/dist/src/platform/windows/user-environment.ps1 +41 -41
  76. package/dist/src/project-create.mjs +121 -121
  77. package/dist/src/project-management.mjs +1 -1
  78. package/dist/src/project-remove.mjs +55 -55
  79. package/dist/src/project-update.mjs +59 -59
  80. package/dist/src/relay-config-import.mjs +45 -45
  81. package/dist/src/self-relay/account-connections.mjs +52 -52
  82. package/dist/src/self-relay/admin-auth.mjs +59 -59
  83. package/dist/src/self-relay/admin-cloud.mjs +101 -101
  84. package/dist/src/self-relay/admin-login-page.mjs +7 -7
  85. package/dist/src/self-relay/admin-server.mjs +105 -105
  86. package/dist/src/self-relay/admin-state.mjs +127 -127
  87. package/dist/src/self-relay/admin-workstations-page.mjs +95 -95
  88. package/dist/src/self-relay/admin-workstations.mjs +143 -143
  89. package/dist/src/self-relay/android-releases.mjs +61 -61
  90. package/dist/src/self-relay/client.mjs +61 -61
  91. package/dist/src/self-relay/connection-reporting.mjs +38 -38
  92. package/dist/src/self-relay/debug-allowlist.mjs +33 -33
  93. package/dist/src/self-relay/demo.mjs +6 -6
  94. package/dist/src/self-relay/device-info.mjs +27 -27
  95. package/dist/src/self-relay/device-store.mjs +92 -92
  96. package/dist/src/self-relay/lan-authorizations.mjs +71 -71
  97. package/dist/src/self-relay/lifecycle.mjs +278 -278
  98. package/dist/src/self-relay/long-pairing.mjs +40 -40
  99. package/dist/src/self-relay/mysql-state.mjs +118 -118
  100. package/dist/src/self-relay/phone-connections.mjs +55 -55
  101. package/dist/src/self-relay/server.mjs +1066 -1066
  102. package/dist/src/self-relay/usage-daily.mjs +55 -55
  103. package/dist/src/self-relay/windows-route-helper.mjs +75 -75
  104. package/dist/src/self-relay/wire.mjs +36 -36
  105. package/dist/src/server.mjs +1947 -1947
  106. package/dist/src/service-control.mjs +82 -82
  107. package/dist/src/service-health.mjs +33 -33
  108. package/dist/src/service-power.mjs +58 -58
  109. package/dist/src/service-restart.mjs +81 -81
  110. package/dist/src/service-update-worker.mjs +42 -42
  111. package/dist/src/service-update.mjs +80 -80
  112. package/dist/src/shared-recovery.mjs +152 -152
  113. package/dist/src/skill-install.mjs +51 -51
  114. package/dist/src/thread-title-generation.mjs +150 -150
  115. package/dist/src/turn-model-history.mjs +47 -47
  116. package/dist/tools/rtc-route-helper.ps1 +99 -99
  117. package/dist/tools/start-rtc-route-helper.ps1 +15 -15
  118. package/dist/web/vendor/markdown-it.LICENSE.txt +22 -22
  119. package/dist/web/vendor/markdown-it.umd.min.js +13 -13
  120. package/package.json +4 -4
  121. package/tools/postinstall.mjs +1 -1
@@ -1,95 +1,95 @@
1
- export const adminWorkstationsMarkup = `
2
- <section id="workstations" class="page stack" hidden>
3
- <section class="card">
4
- <div class="usage-toolbar"><div><h2 id="workstationHeading">工作站</h2><p id="workstationCount" class="hint">正在读取</p></div>
5
- <div class="actions"><button id="workstationBack" class="secondary" type="button" hidden>返回列表</button><button id="workstationRefresh" class="secondary" type="button">刷新</button></div></div>
6
- <div id="workstationContent" class="workstation-content"></div><p id="workstationError" class="status error" role="alert"></p>
7
- </section>
8
- </section>`;
9
-
10
- export const adminWorkstationsScript = String.raw`
11
- let workstationId=null,workstationController=null,workstationGeneration=0,workstationRendered='';
12
- const workstationDisclosures=new Map();
13
- const workstationRouteNames={management:'管理连接',pending:'连接方式待确认',direct:'中继直连',relay:'中继转发',lan:'局域网','self-relay':'Code Relax 中继'};
14
- function workstationTime(value){return value?new Date(value).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',hour12:false}):'未记录'}
15
- function workstationFields(values){const list=node('dl','workstation-fields');for(const [label,value] of values){const row=node('div');row.append(node('dt','hint',label),node('dd','',value==null||value===''?'未提供':String(value)));list.append(row)}return list}
16
- function workstationBlock(title){const block=node('section','workstation-block');block.append(node('h3','',title));return block}
17
- function workstationAccount(accounts,missing='未上报'){return accounts.length?accounts.map(account=>account.email||account.id).join('、'):missing}
18
- function workstationAccountView(item){return item.accounts.length?{value:workstationAccount(item.accounts),note:'当前认证'}:item.associatedAccounts.length?{value:workstationAccount(item.associatedAccounts),note:'最近确认'}:{value:'未上报',note:''}}
19
- function workstationAssociated(accounts){return accounts.length?accounts.map(account=>account.email+(account.lastVerifiedAt?'('+workstationTime(account.lastVerifiedAt)+' 确认)':'')).join('、'):'未上报'}
20
- function workstationStatus(online){return node('span','workstation-state'+(online?'':' offline'),online?'在线':'离线')}
21
- function workstationLastRoute(item){return workstationRouteNames[item.lastConnection?.mode]||'未记录'}
22
- function workstationConnectionState(item){return {connected:'已连接',disconnected:'已断开',unconfirmed:'未确认'}[item.connectionState]||'未确认'}
23
- function workstationWork(item,online=item.online){return online?(item.activeTasks?'工作中 · '+item.activeTasks+' 项':'未确认'):'—'}
24
- function workstationHighlights(item,online=item.online){
25
- const list=node('dl','workstation-key-grid');const account=workstationAccountView(item);
26
- for(const [key,label,value,note] of [['state','手机连接状态',workstationConnectionState(item),''],['connection','上次连接方式',workstationLastRoute(item),''],['account','手机账号',account.value,account.note],['work','工作状态',workstationWork(item,online),'']]){
27
- const field=node('div','workstation-key workstation-key-'+key);field.append(node('dt','',label));const result=node('dd','',value);
28
- if(note)result.append(node('small','workstation-account-note',note));if(key==='work'&&online&&item.activeTasks)result.classList.add('working');field.append(result);list.append(field)
29
- }return list
30
- }
31
- function workstationDisclosure(item,key,title){const details=node('details','workstation-disclosure');const identity=item.id+':'+key;details.open=workstationDisclosures.get(identity)===true;details.append(node('summary','',title));details.addEventListener('toggle',()=>{if(details.isConnected)workstationDisclosures.set(identity,details.open)});return details}
32
- function openWorkstation(id){history.pushState({workstationDetail:true},'','#workstations/'+encodeURIComponent(id));showPage('workstations/'+encodeURIComponent(id))}
33
- function cancelWorkstations(){workstationGeneration++;workstationController?.abort();workstationController=null;workstationRendered=''}
34
- function workstationCaption(result){if(result.workstation){const item=result.workstation;return (item.online?'在线':'离线')+' · 更新 '+workstationTime(result.sampledAt)}const online=result.workstations.filter(item=>item.online).length;return online+' 台在线 · '+(result.workstations.length-online)+' 台离线 · 按上线时间从新到旧'}
35
- function renderWorkstationList(result){
36
- const list=el('workstationContent');list.replaceChildren();el('workstationCount').textContent=workstationCaption(result);
37
- if(!result.workstations.length){list.append(node('p','empty-note','暂无工作站'));return}
38
- for(const item of result.workstations){
39
- const row=node('a','workstation-row');row.href='#workstations/'+encodeURIComponent(item.id);row.addEventListener('click',event=>{if(event.button!==0||event.ctrlKey||event.metaKey||event.shiftKey||event.altKey)return;event.preventDefault();openWorkstation(item.id)});const heading=node('span','workstation-row-heading');heading.append(node('strong','',item.name),workstationStatus(item.online));
40
- const footer=node('span','workstation-row-footer');footer.append(node('span','hint',(item.online?'上线时间 ':'最近上线 ')+workstationTime(item.onlineAt)),node('span','workstation-more','查看 ›'));
41
- row.append(heading,workstationHighlights(item),footer);list.append(row)
42
- }
43
- }
44
- function renderWorkstationDetail(result){
45
- const item=result.workstation;const content=el('workstationContent');content.replaceChildren();el('workstationHeading').textContent=item.name;el('workstationCount').textContent=workstationCaption(result);
46
- const overview=node('section','workstation-overview');const heading=node('div','workstation-row-heading');heading.append(workstationStatus(item.online),node('span','hint',(item.online?'上线时间 ':'最近上线 ')+workstationTime(item.onlineAt)));
47
- overview.append(heading,workstationHighlights(item),node('p','hint','聊天连接 '+item.chatPhones+' 台手机 · 已配对 '+item.pairedPhones+' 台'));content.append(overview);
48
- const phones=workstationDisclosure(item,'phones','手机与账号 · '+item.pairedPhones+' 台');const phoneList=node('div','workstation-disclosure-content');
49
- if(!item.phones.length)phoneList.append(node('p','hint','尚无配对手机'));
50
- for(const phone of item.phones){
51
- const card=node('article','workstation-phone');const title=node('div','workstation-row-heading');const platform={android:'Android',harmony:'HarmonyOS',ios:'iOS'}[phone.platform]||'';
52
- title.append(node('strong','',phone.deviceName||platform||'手机'),node('span','hint',workstationConnectionState(phone)));
53
- const account=workstationAccountView(phone);card.append(title,workstationFields([['上次连接方式',workstationLastRoute(phone)],['手机账号',account.value+(account.note?' · '+account.note:'')]]));phoneList.append(card)
54
- }phones.append(phoneList);content.append(phones);
55
- const diagnostics=workstationDisclosure(item,'diagnostics','诊断详情');const body=node('div','workstation-disclosure-content');
56
- const station=workstationBlock('工作站记录');station.append(workstationFields([
57
- ['工作站 ID',item.id],['上线时间',workstationTime(item.onlineAt)],['来源 IP',item.connection?.address],['登记时间',workstationTime(item.registeredAt)],['授权更新时间',workstationTime(item.updatedAt)],
58
- ['手机关联账号',workstationAssociated(item.associatedAccounts)],['当前认证账号',workstationAccount(item.accounts,'未确认')],['任务账号',workstationAccount(item.taskAccounts,'未记录')],
59
- ['元数据连接',item.metadataPhones+' 台手机'],['调试白名单',item.debugEnabled?'已启用':'未启用'],['文件接收通道',item.fileReceiverOnline?'在线':'未连接']
60
- ]));body.append(station);
61
- const connectionEvents=workstationBlock('手机连接记录 · 工作站上报');
62
- connectionEvents.append(node('p','hint',item.connectionReportingCurrent?'已收到当前工作站连接状态':'当前上报未确认;不据此推断手机断开'));
63
- for(const event of item.connectionEvents||[]){const phone=item.phones.find(value=>value.id===event.clientId);connectionEvents.append(workstationFields([
64
- ['时间',workstationTime(event.at)],['事件',event.state==='connected'?'手机已连接':'手机已断开'],['手机',phone?.deviceName||event.clientId],['连接方式',workstationRouteNames[event.mode]||'未确认']
65
- ]))}
66
- if(!item.connectionEvents?.length)connectionEvents.append(node('p','hint','暂无连接 / 断开记录'));body.append(connectionEvents);
67
- const networks=workstationBlock('网络与 OpenAI 环境');
68
- for(const network of item.lanCandidates)networks.append(workstationFields([['局域网地址',network.address],['网络',network.networkType==='wifi'?'Wi-Fi':'以太网'],['网络名称',network.networkName],['网卡',network.interfaceName]]));
69
- if(item.officialComputer){const official=item.officialComputer;networks.append(workstationFields([['OpenAI 环境 ID',official.environmentId],['环境名称',official.name],['主机名',official.hostname],['登记时间',workstationTime(official.updatedAt)]]))}
70
- if(!item.lanCandidates.length&&!item.officialComputer)networks.append(node('p','hint','尚未上报网络资料'));body.append(networks);
71
- for(const phone of item.phones){
72
- const card=workstationBlock(phone.deviceName||'手机连接记录');card.append(workstationFields([
73
- ['手机 ID',phone.id],['平台',phone.platform],['关联账号',workstationAssociated(phone.associatedAccounts)],['当前认证账号',workstationAccount(phone.accounts,'未确认')],['任务账号',workstationAccount(phone.taskAccounts,'未记录')],
74
- ['元数据连接',phone.metadata?'已连接':'未连接'],['设备管理连接',phone.managers.length+' 个'],['配对时间',workstationTime(phone.pairedAt)],['最近鉴权',workstationTime(phone.lastConnectedAt)],['最近局域网上报',workstationTime(phone.lastLanConnectedAt)],['上次连接时间',workstationTime(phone.lastConnection?.at)]
75
- ]));
76
- const channels=[...(phone.chat?[['聊天 / 管理',phone.chat]]:[]),...(phone.metadata?[['元数据',phone.metadata]]:[]),...phone.managers.map(channel=>['设备管理',channel]),...phone.transfers.map(channel=>[channel.purpose==='upload'?'文件上传':'文件下载',channel])];
77
- for(const [name,channel] of channels)card.append(workstationFields([['通道',name],['认证账号',channel.account?.email||'未上报'],['连接时间',workstationTime(channel.connectedAt)],['来源 IP',channel.address]]));
78
- if(phone.chat)card.append(workstationFields([['转发容量',phone.chat.relayAdmitted?(phone.chat.capacityExempt?'白名单豁免':'占用名额'):'未占用'],['待发送队列',phone.chat.queuedBytes+' 字节']]));body.append(card)
79
- }
80
- if(item.tasks.length){const tasks=workstationBlock('任务记录');for(const task of item.tasks)tasks.append(workstationFields([['线程 / 轮次',task.key],['手机 ID',task.clientId],['任务账号',task.account?.email||task.userId]]));body.append(tasks)}
81
- const notes=workstationBlock('状态说明');notes.append(node('p','hint','在线表示工作站已连接云端中继,不代表内核已就绪。手机连接状态与上次连接方式独立:方式保留最近一次工作站确认的局域网 / Code Relax 中继,断开、工作站离线或云端重启不清空;未记录不推测。未确认表示云端没有足够的当前状态,不能据此判断手机未登录或工作站空闲。'),node('p','hint','局域网 / Code Relax 中继的连接、断开由新版工作站上报;健康探测、管理和文件通道不计入。工作站上报链路丢失不记作手机断开,OpenAI 中继暂未提供可靠的手机连接事件。'),node('p','hint','手机账号标为“最近确认”时来自既有验证记录,不代表当前登录;具体时间见关联账号记录。时间按北京时间显示,每 5 秒更新。'));
82
- body.append(notes);diagnostics.append(body);content.append(diagnostics);
83
- }
84
- async function loadWorkstations(){
85
- if(el('workstations').hidden||workstationController)return;const generation=workstationGeneration;const controller=workstationController=new AbortController();el('workstationRefresh').disabled=true;
86
- try{
87
- const result=await request('/admin/api/workstations'+(workstationId?'/'+encodeURIComponent(workstationId):''),{signal:controller.signal});if(generation!==workstationGeneration||el('workstations').hidden)return;
88
- el('workstationError').textContent='';const signature=JSON.stringify(result.workstation||result.workstations);
89
- if(signature!==workstationRendered){if(workstationId)renderWorkstationDetail(result);else renderWorkstationList(result);workstationRendered=signature}else el('workstationCount').textContent=workstationCaption(result)
90
- }catch(error){if(generation!==workstationGeneration||controller.signal.aborted)return;workstationRendered='';el('workstationCount').textContent='读取失败,当前状态未确认';el('workstationContent').replaceChildren();el('workstationError').textContent=error.message}
91
- finally{if(workstationController===controller){workstationController=null;el('workstationRefresh').disabled=false}}
92
- }
93
- el('workstationRefresh').addEventListener('click',()=>loadWorkstations());
94
- el('workstationBack').addEventListener('click',()=>{if(history.state?.workstationDetail)history.back();else navigatePage('workstations')});
95
- `;
1
+ export const adminWorkstationsMarkup = `
2
+ <section id="workstations" class="page stack" hidden>
3
+ <section class="card">
4
+ <div class="usage-toolbar"><div><h2 id="workstationHeading">工作站</h2><p id="workstationCount" class="hint">正在读取</p></div>
5
+ <div class="actions"><button id="workstationBack" class="secondary" type="button" hidden>返回列表</button><button id="workstationRefresh" class="secondary" type="button">刷新</button></div></div>
6
+ <div id="workstationContent" class="workstation-content"></div><p id="workstationError" class="status error" role="alert"></p>
7
+ </section>
8
+ </section>`;
9
+
10
+ export const adminWorkstationsScript = String.raw`
11
+ let workstationId=null,workstationController=null,workstationGeneration=0,workstationRendered='';
12
+ const workstationDisclosures=new Map();
13
+ const workstationRouteNames={management:'管理连接',pending:'连接方式待确认',direct:'中继直连',relay:'中继转发',lan:'局域网','self-relay':'Code Relax 中继'};
14
+ function workstationTime(value){return value?new Date(value).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',hour12:false}):'未记录'}
15
+ function workstationFields(values){const list=node('dl','workstation-fields');for(const [label,value] of values){const row=node('div');row.append(node('dt','hint',label),node('dd','',value==null||value===''?'未提供':String(value)));list.append(row)}return list}
16
+ function workstationBlock(title){const block=node('section','workstation-block');block.append(node('h3','',title));return block}
17
+ function workstationAccount(accounts,missing='未上报'){return accounts.length?accounts.map(account=>account.email||account.id).join('、'):missing}
18
+ function workstationAccountView(item){return item.accounts.length?{value:workstationAccount(item.accounts),note:'当前认证'}:item.associatedAccounts.length?{value:workstationAccount(item.associatedAccounts),note:'最近确认'}:{value:'未上报',note:''}}
19
+ function workstationAssociated(accounts){return accounts.length?accounts.map(account=>account.email+(account.lastVerifiedAt?'('+workstationTime(account.lastVerifiedAt)+' 确认)':'')).join('、'):'未上报'}
20
+ function workstationStatus(online){return node('span','workstation-state'+(online?'':' offline'),online?'在线':'离线')}
21
+ function workstationLastRoute(item){return workstationRouteNames[item.lastConnection?.mode]||'未记录'}
22
+ function workstationConnectionState(item){return {connected:'已连接',disconnected:'已断开',unconfirmed:'未确认'}[item.connectionState]||'未确认'}
23
+ function workstationWork(item,online=item.online){return online?(item.activeTasks?'工作中 · '+item.activeTasks+' 项':'未确认'):'—'}
24
+ function workstationHighlights(item,online=item.online){
25
+ const list=node('dl','workstation-key-grid');const account=workstationAccountView(item);
26
+ for(const [key,label,value,note] of [['state','手机连接状态',workstationConnectionState(item),''],['connection','上次连接方式',workstationLastRoute(item),''],['account','手机账号',account.value,account.note],['work','工作状态',workstationWork(item,online),'']]){
27
+ const field=node('div','workstation-key workstation-key-'+key);field.append(node('dt','',label));const result=node('dd','',value);
28
+ if(note)result.append(node('small','workstation-account-note',note));if(key==='work'&&online&&item.activeTasks)result.classList.add('working');field.append(result);list.append(field)
29
+ }return list
30
+ }
31
+ function workstationDisclosure(item,key,title){const details=node('details','workstation-disclosure');const identity=item.id+':'+key;details.open=workstationDisclosures.get(identity)===true;details.append(node('summary','',title));details.addEventListener('toggle',()=>{if(details.isConnected)workstationDisclosures.set(identity,details.open)});return details}
32
+ function openWorkstation(id){history.pushState({workstationDetail:true},'','#workstations/'+encodeURIComponent(id));showPage('workstations/'+encodeURIComponent(id))}
33
+ function cancelWorkstations(){workstationGeneration++;workstationController?.abort();workstationController=null;workstationRendered=''}
34
+ function workstationCaption(result){if(result.workstation){const item=result.workstation;return (item.online?'在线':'离线')+' · 更新 '+workstationTime(result.sampledAt)}const online=result.workstations.filter(item=>item.online).length;return online+' 台在线 · '+(result.workstations.length-online)+' 台离线 · 按上线时间从新到旧'}
35
+ function renderWorkstationList(result){
36
+ const list=el('workstationContent');list.replaceChildren();el('workstationCount').textContent=workstationCaption(result);
37
+ if(!result.workstations.length){list.append(node('p','empty-note','暂无工作站'));return}
38
+ for(const item of result.workstations){
39
+ const row=node('a','workstation-row');row.href='#workstations/'+encodeURIComponent(item.id);row.addEventListener('click',event=>{if(event.button!==0||event.ctrlKey||event.metaKey||event.shiftKey||event.altKey)return;event.preventDefault();openWorkstation(item.id)});const heading=node('span','workstation-row-heading');heading.append(node('strong','',item.name),workstationStatus(item.online));
40
+ const footer=node('span','workstation-row-footer');footer.append(node('span','hint',(item.online?'上线时间 ':'最近上线 ')+workstationTime(item.onlineAt)),node('span','workstation-more','查看 ›'));
41
+ row.append(heading,workstationHighlights(item),footer);list.append(row)
42
+ }
43
+ }
44
+ function renderWorkstationDetail(result){
45
+ const item=result.workstation;const content=el('workstationContent');content.replaceChildren();el('workstationHeading').textContent=item.name;el('workstationCount').textContent=workstationCaption(result);
46
+ const overview=node('section','workstation-overview');const heading=node('div','workstation-row-heading');heading.append(workstationStatus(item.online),node('span','hint',(item.online?'上线时间 ':'最近上线 ')+workstationTime(item.onlineAt)));
47
+ overview.append(heading,workstationHighlights(item),node('p','hint','聊天连接 '+item.chatPhones+' 台手机 · 已配对 '+item.pairedPhones+' 台'));content.append(overview);
48
+ const phones=workstationDisclosure(item,'phones','手机与账号 · '+item.pairedPhones+' 台');const phoneList=node('div','workstation-disclosure-content');
49
+ if(!item.phones.length)phoneList.append(node('p','hint','尚无配对手机'));
50
+ for(const phone of item.phones){
51
+ const card=node('article','workstation-phone');const title=node('div','workstation-row-heading');const platform={android:'Android',harmony:'HarmonyOS',ios:'iOS'}[phone.platform]||'';
52
+ title.append(node('strong','',phone.deviceName||platform||'手机'),node('span','hint',workstationConnectionState(phone)));
53
+ const account=workstationAccountView(phone);card.append(title,workstationFields([['上次连接方式',workstationLastRoute(phone)],['手机账号',account.value+(account.note?' · '+account.note:'')]]));phoneList.append(card)
54
+ }phones.append(phoneList);content.append(phones);
55
+ const diagnostics=workstationDisclosure(item,'diagnostics','诊断详情');const body=node('div','workstation-disclosure-content');
56
+ const station=workstationBlock('工作站记录');station.append(workstationFields([
57
+ ['工作站 ID',item.id],['上线时间',workstationTime(item.onlineAt)],['来源 IP',item.connection?.address],['登记时间',workstationTime(item.registeredAt)],['授权更新时间',workstationTime(item.updatedAt)],
58
+ ['手机关联账号',workstationAssociated(item.associatedAccounts)],['当前认证账号',workstationAccount(item.accounts,'未确认')],['任务账号',workstationAccount(item.taskAccounts,'未记录')],
59
+ ['元数据连接',item.metadataPhones+' 台手机'],['调试白名单',item.debugEnabled?'已启用':'未启用'],['文件接收通道',item.fileReceiverOnline?'在线':'未连接']
60
+ ]));body.append(station);
61
+ const connectionEvents=workstationBlock('手机连接记录 · 工作站上报');
62
+ connectionEvents.append(node('p','hint',item.connectionReportingCurrent?'已收到当前工作站连接状态':'当前上报未确认;不据此推断手机断开'));
63
+ for(const event of item.connectionEvents||[]){const phone=item.phones.find(value=>value.id===event.clientId);connectionEvents.append(workstationFields([
64
+ ['时间',workstationTime(event.at)],['事件',event.state==='connected'?'手机已连接':'手机已断开'],['手机',phone?.deviceName||event.clientId],['连接方式',workstationRouteNames[event.mode]||'未确认']
65
+ ]))}
66
+ if(!item.connectionEvents?.length)connectionEvents.append(node('p','hint','暂无连接 / 断开记录'));body.append(connectionEvents);
67
+ const networks=workstationBlock('网络与 OpenAI 环境');
68
+ for(const network of item.lanCandidates)networks.append(workstationFields([['局域网地址',network.address],['网络',network.networkType==='wifi'?'Wi-Fi':'以太网'],['网络名称',network.networkName],['网卡',network.interfaceName]]));
69
+ if(item.officialComputer){const official=item.officialComputer;networks.append(workstationFields([['OpenAI 环境 ID',official.environmentId],['环境名称',official.name],['主机名',official.hostname],['登记时间',workstationTime(official.updatedAt)]]))}
70
+ if(!item.lanCandidates.length&&!item.officialComputer)networks.append(node('p','hint','尚未上报网络资料'));body.append(networks);
71
+ for(const phone of item.phones){
72
+ const card=workstationBlock(phone.deviceName||'手机连接记录');card.append(workstationFields([
73
+ ['手机 ID',phone.id],['平台',phone.platform],['关联账号',workstationAssociated(phone.associatedAccounts)],['当前认证账号',workstationAccount(phone.accounts,'未确认')],['任务账号',workstationAccount(phone.taskAccounts,'未记录')],
74
+ ['元数据连接',phone.metadata?'已连接':'未连接'],['设备管理连接',phone.managers.length+' 个'],['配对时间',workstationTime(phone.pairedAt)],['最近鉴权',workstationTime(phone.lastConnectedAt)],['最近局域网上报',workstationTime(phone.lastLanConnectedAt)],['上次连接时间',workstationTime(phone.lastConnection?.at)]
75
+ ]));
76
+ const channels=[...(phone.chat?[['聊天 / 管理',phone.chat]]:[]),...(phone.metadata?[['元数据',phone.metadata]]:[]),...phone.managers.map(channel=>['设备管理',channel]),...phone.transfers.map(channel=>[channel.purpose==='upload'?'文件上传':'文件下载',channel])];
77
+ for(const [name,channel] of channels)card.append(workstationFields([['通道',name],['认证账号',channel.account?.email||'未上报'],['连接时间',workstationTime(channel.connectedAt)],['来源 IP',channel.address]]));
78
+ if(phone.chat)card.append(workstationFields([['转发容量',phone.chat.relayAdmitted?(phone.chat.capacityExempt?'白名单豁免':'占用名额'):'未占用'],['待发送队列',phone.chat.queuedBytes+' 字节']]));body.append(card)
79
+ }
80
+ if(item.tasks.length){const tasks=workstationBlock('任务记录');for(const task of item.tasks)tasks.append(workstationFields([['线程 / 轮次',task.key],['手机 ID',task.clientId],['任务账号',task.account?.email||task.userId]]));body.append(tasks)}
81
+ const notes=workstationBlock('状态说明');notes.append(node('p','hint','在线表示工作站已连接云端中继,不代表内核已就绪。手机连接状态与上次连接方式独立:方式保留最近一次工作站确认的局域网 / Code Relax 中继,断开、工作站离线或云端重启不清空;未记录不推测。未确认表示云端没有足够的当前状态,不能据此判断手机未登录或工作站空闲。'),node('p','hint','局域网 / Code Relax 中继的连接、断开由新版工作站上报;健康探测、管理和文件通道不计入。工作站上报链路丢失不记作手机断开,OpenAI 中继暂未提供可靠的手机连接事件。'),node('p','hint','手机账号标为“最近确认”时来自既有验证记录,不代表当前登录;具体时间见关联账号记录。时间按北京时间显示,每 5 秒更新。'));
82
+ body.append(notes);diagnostics.append(body);content.append(diagnostics);
83
+ }
84
+ async function loadWorkstations(){
85
+ if(el('workstations').hidden||workstationController)return;const generation=workstationGeneration;const controller=workstationController=new AbortController();el('workstationRefresh').disabled=true;
86
+ try{
87
+ const result=await request('/admin/api/workstations'+(workstationId?'/'+encodeURIComponent(workstationId):''),{signal:controller.signal});if(generation!==workstationGeneration||el('workstations').hidden)return;
88
+ el('workstationError').textContent='';const signature=JSON.stringify(result.workstation||result.workstations);
89
+ if(signature!==workstationRendered){if(workstationId)renderWorkstationDetail(result);else renderWorkstationList(result);workstationRendered=signature}else el('workstationCount').textContent=workstationCaption(result)
90
+ }catch(error){if(generation!==workstationGeneration||controller.signal.aborted)return;workstationRendered='';el('workstationCount').textContent='读取失败,当前状态未确认';el('workstationContent').replaceChildren();el('workstationError').textContent=error.message}
91
+ finally{if(workstationController===controller){workstationController=null;el('workstationRefresh').disabled=false}}
92
+ }
93
+ el('workstationRefresh').addEventListener('click',()=>loadWorkstations());
94
+ el('workstationBack').addEventListener('click',()=>{if(history.state?.workstationDetail)history.back();else navigatePage('workstations')});
95
+ `;
@@ -1,143 +1,143 @@
1
- // Explicit projections: authorization hashes, credentials, socket objects and payloads
2
- // must never become part of the management API.
3
- const open = entry => entry?.ws?.readyState === 1;
4
- const socket = ws => ({ connectedAt: ws.adminInfo?.connectedAt || null,
5
- address: ws.adminInfo?.address || null, account: ws.adminInfo?.account
6
- ? { id: ws.adminInfo.account.id, email: ws.adminInfo.account.email } : null });
7
-
8
- export function workstationDetail(room, debugEnabled = false) {
9
- const online = room.host?.readyState === 1;
10
- const connectionReportingCurrent = online && room.adminPhoneConnections !== undefined;
11
- const tasks = online ? [...(room.usageTasks?.entries() || [])] : [];
12
- const phones = room.clients.map(client => {
13
- const connection = room.connections.get(client.id);
14
- const chat = online && open(connection) ? connection : null;
15
- const observer = room.observers?.get(client.id);
16
- const metadata = open(observer) ? socket(observer.ws) : null;
17
- const managers = [...(room.deviceManagers?.entries() || [])]
18
- .filter(([ws, id]) => id === client.id && ws.readyState === 1).map(([ws]) => socket(ws));
19
- const transfers = online ? [...(room.uploadClients?.values() || [])]
20
- .filter(entry => entry.clientId === client.id && open(entry))
21
- .map(entry => ({ purpose: entry.purpose, ...socket(entry.ws) })) : [];
22
- const activeTasks = tasks.filter(([, task]) => task.clientId === client.id).length;
23
- const reportedConnections = online ? [...(room.adminPhoneConnections?.values() || [])]
24
- .filter(item => item.clientId === client.id).map(item => ({ connectionId: item.connectionId, mode: item.mode })) : [];
25
- const connected = reportedConnections.length > 0 || Boolean(chat?.userId);
26
- const channels = [chat && socket(chat.ws), metadata, ...managers, ...transfers].filter(Boolean);
27
- const accounts = [...new Map(channels.filter(item => item.account)
28
- .map(item => [item.account.id, item.account])).values()];
29
- const taskAccounts = [...new Set(tasks.filter(([, task]) => task.clientId === client.id)
30
- .map(([, task]) => task.userId).filter(Boolean))].map(id => accounts.find(account => account.id === id) || { id, email: null });
31
- return {
32
- id: client.id, deviceName: client.deviceName || null, platform: client.platform || null,
33
- pairedAt: client.createdAt || null, lastConnectedAt: client.lastConnectedAt || null,
34
- connected, connectionState: connected ? 'connected' : connectionReportingCurrent ? 'disconnected' : 'unconfirmed',
35
- lastConnection: null, reportedConnections,
36
- accounts, taskAccounts, associatedAccounts: [], metadata, managers, transfers, activeTasks,
37
- chat: chat ? { ...socket(chat.ws), route: chat.userId ? chat.adminRoute || 'pending' : 'management',
38
- capacityExempt: Boolean(chat.capacityExempt), relayAdmitted: Boolean(chat.relayAdmitted || chat.relayExempt),
39
- queuedBytes: chat.queuedBytes || 0 } : null,
40
- lastLanConnectedAt: room.adminLanConnections?.get(client.id) || null,
41
- };
42
- }).sort((a, b) => Number(b.connected) - Number(a.connected) ||
43
- (b.lastConnectedAt || '').localeCompare(a.lastConnectedAt || '') || a.id.localeCompare(b.id));
44
- const accounts = [...new Map(phones.flatMap(phone => phone.accounts).map(account => [account.id, account])).values()];
45
- const taskAccounts = [...new Set(tasks.map(([, task]) => task.userId).filter(Boolean))]
46
- .map(id => accounts.find(account => account.id === id) || { id, email: null });
47
- return {
48
- id: room.id, name: room.hostName || room.adminLastHostName || room.officialComputer?.hostname || '工作站 …' + room.id.slice(-8),
49
- online, registeredAt: room.createdAt || null, updatedAt: room.updatedAt || null,
50
- onlineAt: (online ? room.host.adminInfo?.connectedAt : room.adminLastOnlineAt) || null,
51
- connection: online ? socket(room.host) : null,
52
- pairedPhones: phones.length, connectedPhones: phones.filter(phone => phone.connected).length,
53
- chatPhones: phones.filter(phone => phone.reportedConnections.length || phone.chat && phone.chat.route !== 'management').length,
54
- connectionReportingCurrent,
55
- connectionState: phones.some(phone => phone.connected) ? 'connected' : connectionReportingCurrent ? 'disconnected' : 'unconfirmed',
56
- lastConnection: null,
57
- metadataPhones: phones.filter(phone => phone.metadata).length,
58
- activeTasks: tasks.length, accounts, taskAccounts, associatedAccounts: [], debugEnabled,
59
- fileReceiverOnline: online && room.uploadHost?.readyState === 1,
60
- lanCandidates: online ? (room.lanCandidates || []).map(item => ({ address: item.address,
61
- networkType: item.networkType, networkName: item.networkName, interfaceName: item.interfaceName })) : [],
62
- officialComputer: room.officialComputer ? { environmentId: room.officialComputer.environmentId,
63
- name: room.officialComputer.name, hostname: room.officialComputer.hostname,
64
- updatedAt: room.officialComputer.updatedAt } : null,
65
- tasks: tasks.map(([key, task]) => ({ key, clientId: task.clientId, userId: task.userId,
66
- account: taskAccounts.find(account => account.id === task.userId) || null })), phones,
67
- };
68
- }
69
-
70
- // Previous connection mode is durable history; it never establishes current presence.
71
- export async function resolveWorkstationConnections(workstations, events) {
72
- const paired = new Map(workstations.map(item => [item.id, new Map(item.phones.map(phone => [phone.id, phone]))]));
73
- for (const record of await events.latest(workstations.map(item => item.id))) {
74
- const phone = paired.get(record.computerId)?.get(record.clientId);
75
- if (phone) phone.lastConnection = { mode: record.mode, at: record.at };
76
- }
77
- for (const item of workstations) {
78
- const last = item.phones.filter(phone => phone.lastConnection)
79
- .sort((a, b) => b.lastConnection.at.localeCompare(a.lastConnection.at) || a.id.localeCompare(b.id))[0];
80
- item.lastConnection = last ? { ...last.lastConnection, clientId: last.id } : null;
81
- }
82
- return workstations;
83
- }
84
-
85
- // Account association belongs to a workstation/mobile identity pair. A missing
86
- // signaling socket says nothing about whether that phone has a login session.
87
- // Read existing verified usage attribution without rewriting any history.
88
- export async function resolveWorkstationAccounts(workstations, pool) {
89
- const paired = new Map(workstations.map(item => [item.id, new Map(item.phones.map(phone => [phone.id, phone]))]));
90
- if (pool && workstations.length) {
91
- const [rows] = await pool.execute(`SELECT computer_id,client_id,user_id,MAX(occurred_at) AS last_verified_at
92
- FROM relay_usage_actions WHERE computer_id IN (${workstations.map(() => '?').join(',')})
93
- GROUP BY computer_id,client_id,user_id`, workstations.map(item => item.id));
94
- for (const row of rows) {
95
- const phone = paired.get(row.computer_id)?.get(row.client_id);
96
- if (phone) phone.associatedAccounts.push({ id: row.user_id, email: null,
97
- lastVerifiedAt: row.last_verified_at.replace(' ', 'T') + 'Z' });
98
- }
99
- }
100
- const references = workstations.flatMap(item => [...item.taskAccounts,
101
- ...item.phones.flatMap(phone => [...phone.taskAccounts, ...phone.associatedAccounts])]);
102
- const missing = [...new Set(references.filter(account => !account.email).map(account => account.id))];
103
- if (pool && missing.length) {
104
- const [rows] = await pool.execute(`SELECT i.user_id,i.normalized_value AS email FROM user_login_identities i
105
- JOIN users u ON u.id=i.user_id AND u.status='active'
106
- WHERE i.kind='email' AND i.user_id IN (${missing.map(() => '?').join(',')})`, missing);
107
- const emails = new Map(rows.map(row => [row.user_id, row.email]));
108
- for (const account of references) if (!account.email) account.email = emails.get(account.id) || null;
109
- }
110
- for (const item of workstations) {
111
- for (const phone of item.phones) {
112
- const known = new Map(phone.associatedAccounts.filter(account => account.email)
113
- .sort((a, b) => b.lastVerifiedAt.localeCompare(a.lastVerifiedAt)).map(account => [account.id, account]));
114
- for (const account of [...phone.taskAccounts, ...phone.accounts]) if (account.email) {
115
- known.set(account.id, { ...account, lastVerifiedAt: known.get(account.id)?.lastVerifiedAt || null });
116
- }
117
- phone.associatedAccounts = [...known.values()];
118
- }
119
- const known = new Map();
120
- for (const phone of item.phones) for (const account of phone.associatedAccounts) {
121
- if (!known.has(account.id) || (account.lastVerifiedAt || '') > (known.get(account.id).lastVerifiedAt || '')) known.set(account.id, account);
122
- }
123
- item.associatedAccounts = [...known.values()];
124
- }
125
- return workstations;
126
- }
127
-
128
- export function workstationSummary(item) {
129
- const { phones, tasks, lanCandidates, officialComputer, fileReceiverOnline, debugEnabled, ...summary } = item;
130
- return { ...summary, routes: workstationRoutes(phones) };
131
- }
132
-
133
- function workstationRoutes(phones) {
134
- return [...new Set(phones.flatMap(phone => [...(phone.chat ? [phone.chat.route] : []),
135
- ...phone.reportedConnections.filter(item => item.mode === 'lan' || !phone.chat).map(item => item.mode)]))];
136
- }
137
-
138
- export function listWorkstations(rooms, { details = false } = {}) {
139
- const items = [...rooms.values()].filter(room => room.status === 'active').map(room => workstationDetail(room))
140
- .sort((a, b) => Number(b.online) - Number(a.online) || (b.onlineAt || '').localeCompare(a.onlineAt || '') ||
141
- a.name.localeCompare(b.name, 'zh-CN') || a.id.localeCompare(b.id));
142
- return details ? items : items.map(workstationSummary);
143
- }
1
+ // Explicit projections: authorization hashes, credentials, socket objects and payloads
2
+ // must never become part of the management API.
3
+ const open = entry => entry?.ws?.readyState === 1;
4
+ const socket = ws => ({ connectedAt: ws.adminInfo?.connectedAt || null,
5
+ address: ws.adminInfo?.address || null, account: ws.adminInfo?.account
6
+ ? { id: ws.adminInfo.account.id, email: ws.adminInfo.account.email } : null });
7
+
8
+ export function workstationDetail(room, debugEnabled = false) {
9
+ const online = room.host?.readyState === 1;
10
+ const connectionReportingCurrent = online && room.adminPhoneConnections !== undefined;
11
+ const tasks = online ? [...(room.usageTasks?.entries() || [])] : [];
12
+ const phones = room.clients.map(client => {
13
+ const connection = room.connections.get(client.id);
14
+ const chat = online && open(connection) ? connection : null;
15
+ const observer = room.observers?.get(client.id);
16
+ const metadata = open(observer) ? socket(observer.ws) : null;
17
+ const managers = [...(room.deviceManagers?.entries() || [])]
18
+ .filter(([ws, id]) => id === client.id && ws.readyState === 1).map(([ws]) => socket(ws));
19
+ const transfers = online ? [...(room.uploadClients?.values() || [])]
20
+ .filter(entry => entry.clientId === client.id && open(entry))
21
+ .map(entry => ({ purpose: entry.purpose, ...socket(entry.ws) })) : [];
22
+ const activeTasks = tasks.filter(([, task]) => task.clientId === client.id).length;
23
+ const reportedConnections = online ? [...(room.adminPhoneConnections?.values() || [])]
24
+ .filter(item => item.clientId === client.id).map(item => ({ connectionId: item.connectionId, mode: item.mode })) : [];
25
+ const connected = reportedConnections.length > 0 || Boolean(chat?.userId);
26
+ const channels = [chat && socket(chat.ws), metadata, ...managers, ...transfers].filter(Boolean);
27
+ const accounts = [...new Map(channels.filter(item => item.account)
28
+ .map(item => [item.account.id, item.account])).values()];
29
+ const taskAccounts = [...new Set(tasks.filter(([, task]) => task.clientId === client.id)
30
+ .map(([, task]) => task.userId).filter(Boolean))].map(id => accounts.find(account => account.id === id) || { id, email: null });
31
+ return {
32
+ id: client.id, deviceName: client.deviceName || null, platform: client.platform || null,
33
+ pairedAt: client.createdAt || null, lastConnectedAt: client.lastConnectedAt || null,
34
+ connected, connectionState: connected ? 'connected' : connectionReportingCurrent ? 'disconnected' : 'unconfirmed',
35
+ lastConnection: null, reportedConnections,
36
+ accounts, taskAccounts, associatedAccounts: [], metadata, managers, transfers, activeTasks,
37
+ chat: chat ? { ...socket(chat.ws), route: chat.userId ? chat.adminRoute || 'pending' : 'management',
38
+ capacityExempt: Boolean(chat.capacityExempt), relayAdmitted: Boolean(chat.relayAdmitted || chat.relayExempt),
39
+ queuedBytes: chat.queuedBytes || 0 } : null,
40
+ lastLanConnectedAt: room.adminLanConnections?.get(client.id) || null,
41
+ };
42
+ }).sort((a, b) => Number(b.connected) - Number(a.connected) ||
43
+ (b.lastConnectedAt || '').localeCompare(a.lastConnectedAt || '') || a.id.localeCompare(b.id));
44
+ const accounts = [...new Map(phones.flatMap(phone => phone.accounts).map(account => [account.id, account])).values()];
45
+ const taskAccounts = [...new Set(tasks.map(([, task]) => task.userId).filter(Boolean))]
46
+ .map(id => accounts.find(account => account.id === id) || { id, email: null });
47
+ return {
48
+ id: room.id, name: room.hostName || room.adminLastHostName || room.officialComputer?.hostname || '工作站 …' + room.id.slice(-8),
49
+ online, registeredAt: room.createdAt || null, updatedAt: room.updatedAt || null,
50
+ onlineAt: (online ? room.host.adminInfo?.connectedAt : room.adminLastOnlineAt) || null,
51
+ connection: online ? socket(room.host) : null,
52
+ pairedPhones: phones.length, connectedPhones: phones.filter(phone => phone.connected).length,
53
+ chatPhones: phones.filter(phone => phone.reportedConnections.length || phone.chat && phone.chat.route !== 'management').length,
54
+ connectionReportingCurrent,
55
+ connectionState: phones.some(phone => phone.connected) ? 'connected' : connectionReportingCurrent ? 'disconnected' : 'unconfirmed',
56
+ lastConnection: null,
57
+ metadataPhones: phones.filter(phone => phone.metadata).length,
58
+ activeTasks: tasks.length, accounts, taskAccounts, associatedAccounts: [], debugEnabled,
59
+ fileReceiverOnline: online && room.uploadHost?.readyState === 1,
60
+ lanCandidates: online ? (room.lanCandidates || []).map(item => ({ address: item.address,
61
+ networkType: item.networkType, networkName: item.networkName, interfaceName: item.interfaceName })) : [],
62
+ officialComputer: room.officialComputer ? { environmentId: room.officialComputer.environmentId,
63
+ name: room.officialComputer.name, hostname: room.officialComputer.hostname,
64
+ updatedAt: room.officialComputer.updatedAt } : null,
65
+ tasks: tasks.map(([key, task]) => ({ key, clientId: task.clientId, userId: task.userId,
66
+ account: taskAccounts.find(account => account.id === task.userId) || null })), phones,
67
+ };
68
+ }
69
+
70
+ // Previous connection mode is durable history; it never establishes current presence.
71
+ export async function resolveWorkstationConnections(workstations, events) {
72
+ const paired = new Map(workstations.map(item => [item.id, new Map(item.phones.map(phone => [phone.id, phone]))]));
73
+ for (const record of await events.latest(workstations.map(item => item.id))) {
74
+ const phone = paired.get(record.computerId)?.get(record.clientId);
75
+ if (phone) phone.lastConnection = { mode: record.mode, at: record.at };
76
+ }
77
+ for (const item of workstations) {
78
+ const last = item.phones.filter(phone => phone.lastConnection)
79
+ .sort((a, b) => b.lastConnection.at.localeCompare(a.lastConnection.at) || a.id.localeCompare(b.id))[0];
80
+ item.lastConnection = last ? { ...last.lastConnection, clientId: last.id } : null;
81
+ }
82
+ return workstations;
83
+ }
84
+
85
+ // Account association belongs to a workstation/mobile identity pair. A missing
86
+ // signaling socket says nothing about whether that phone has a login session.
87
+ // Read existing verified usage attribution without rewriting any history.
88
+ export async function resolveWorkstationAccounts(workstations, pool) {
89
+ const paired = new Map(workstations.map(item => [item.id, new Map(item.phones.map(phone => [phone.id, phone]))]));
90
+ if (pool && workstations.length) {
91
+ const [rows] = await pool.execute(`SELECT computer_id,client_id,user_id,MAX(occurred_at) AS last_verified_at
92
+ FROM relay_usage_actions WHERE computer_id IN (${workstations.map(() => '?').join(',')})
93
+ GROUP BY computer_id,client_id,user_id`, workstations.map(item => item.id));
94
+ for (const row of rows) {
95
+ const phone = paired.get(row.computer_id)?.get(row.client_id);
96
+ if (phone) phone.associatedAccounts.push({ id: row.user_id, email: null,
97
+ lastVerifiedAt: row.last_verified_at.replace(' ', 'T') + 'Z' });
98
+ }
99
+ }
100
+ const references = workstations.flatMap(item => [...item.taskAccounts,
101
+ ...item.phones.flatMap(phone => [...phone.taskAccounts, ...phone.associatedAccounts])]);
102
+ const missing = [...new Set(references.filter(account => !account.email).map(account => account.id))];
103
+ if (pool && missing.length) {
104
+ const [rows] = await pool.execute(`SELECT i.user_id,i.normalized_value AS email FROM user_login_identities i
105
+ JOIN users u ON u.id=i.user_id AND u.status='active'
106
+ WHERE i.kind='email' AND i.user_id IN (${missing.map(() => '?').join(',')})`, missing);
107
+ const emails = new Map(rows.map(row => [row.user_id, row.email]));
108
+ for (const account of references) if (!account.email) account.email = emails.get(account.id) || null;
109
+ }
110
+ for (const item of workstations) {
111
+ for (const phone of item.phones) {
112
+ const known = new Map(phone.associatedAccounts.filter(account => account.email)
113
+ .sort((a, b) => b.lastVerifiedAt.localeCompare(a.lastVerifiedAt)).map(account => [account.id, account]));
114
+ for (const account of [...phone.taskAccounts, ...phone.accounts]) if (account.email) {
115
+ known.set(account.id, { ...account, lastVerifiedAt: known.get(account.id)?.lastVerifiedAt || null });
116
+ }
117
+ phone.associatedAccounts = [...known.values()];
118
+ }
119
+ const known = new Map();
120
+ for (const phone of item.phones) for (const account of phone.associatedAccounts) {
121
+ if (!known.has(account.id) || (account.lastVerifiedAt || '') > (known.get(account.id).lastVerifiedAt || '')) known.set(account.id, account);
122
+ }
123
+ item.associatedAccounts = [...known.values()];
124
+ }
125
+ return workstations;
126
+ }
127
+
128
+ export function workstationSummary(item) {
129
+ const { phones, tasks, lanCandidates, officialComputer, fileReceiverOnline, debugEnabled, ...summary } = item;
130
+ return { ...summary, routes: workstationRoutes(phones) };
131
+ }
132
+
133
+ function workstationRoutes(phones) {
134
+ return [...new Set(phones.flatMap(phone => [...(phone.chat ? [phone.chat.route] : []),
135
+ ...phone.reportedConnections.filter(item => item.mode === 'lan' || !phone.chat).map(item => item.mode)]))];
136
+ }
137
+
138
+ export function listWorkstations(rooms, { details = false } = {}) {
139
+ const items = [...rooms.values()].filter(room => room.status === 'active').map(room => workstationDetail(room))
140
+ .sort((a, b) => Number(b.online) - Number(a.online) || (b.onlineAt || '').localeCompare(a.onlineAt || '') ||
141
+ a.name.localeCompare(b.name, 'zh-CN') || a.id.localeCompare(b.id));
142
+ return details ? items : items.map(workstationSummary);
143
+ }