@hope_phenom/dsh-plugin-remote-use 0.0.0-stage → 0.2.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +207 -2
- package/README.zh.md +195 -0
- package/assets/icon.svg +47 -0
- package/cordis.patch.yml +41 -0
- package/lib/bridge/acp-bridge.js +905 -0
- package/lib/bridge/peer-connection.js +730 -0
- package/lib/client.js +2842 -0
- package/lib/core/acp-model-overlay.js +121 -0
- package/lib/core/approval-queue.js +216 -0
- package/lib/core/audit-log.js +322 -0
- package/lib/core/desktop-notify.js +173 -0
- package/lib/core/environment-prompt.js +76 -0
- package/lib/core/relay-supervisor.js +278 -0
- package/lib/core/review-engine.js +679 -0
- package/lib/core/sandbox-diagnosis.js +258 -0
- package/lib/core/sandbox-probe.js +310 -0
- package/lib/core/self-acp-spec.js +117 -0
- package/lib/core/selfcheck-runner.js +150 -0
- package/lib/core/settings-store.js +226 -0
- package/lib/core/shot-gc.js +334 -0
- package/lib/core/task-cwd.js +50 -0
- package/lib/core/task-memory.js +163 -0
- package/lib/core/task-registry.js +238 -0
- package/lib/core/toolcall-cache.js +206 -0
- package/lib/core/transfer-queue.js +58 -0
- package/lib/core/transfer.js +1144 -0
- package/lib/core/worker-arming.js +334 -0
- package/lib/index.js +2207 -0
- package/lib/provider/out-of-process-helpers.js +76 -0
- package/lib/provider/remote-provider.js +261 -0
- package/lib/tools/mediator-shim.mjs +180 -0
- package/lib/tools/prepare-sandbox-mediator.mjs +529 -0
- package/lib/tools/remote-tools.js +680 -0
- package/lib/tools/selfcheck-fix-prompt.md +206 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +72 -4
- package/slave/acp-profile-approval.patch.yml +66 -0
- package/slave/file-transfer.mjs +552 -0
- package/slave/integrity-probe.ps1 +99 -0
- package/slave/make-launch-spec.mjs +184 -0
- package/slave/net-address.mjs +138 -0
- package/slave/node_modules/.package-lock.json +26 -0
- package/slave/node_modules/ws/LICENSE +20 -0
- package/slave/node_modules/ws/README.md +548 -0
- package/slave/node_modules/ws/browser.js +8 -0
- package/slave/node_modules/ws/index.js +22 -0
- package/slave/node_modules/ws/lib/buffer-util.js +131 -0
- package/slave/node_modules/ws/lib/constants.js +19 -0
- package/slave/node_modules/ws/lib/event-target.js +292 -0
- package/slave/node_modules/ws/lib/extension.js +203 -0
- package/slave/node_modules/ws/lib/limiter.js +55 -0
- package/slave/node_modules/ws/lib/permessage-deflate.js +530 -0
- package/slave/node_modules/ws/lib/receiver.js +743 -0
- package/slave/node_modules/ws/lib/sender.js +607 -0
- package/slave/node_modules/ws/lib/stream.js +161 -0
- package/slave/node_modules/ws/lib/subprotocol.js +62 -0
- package/slave/node_modules/ws/lib/validation.js +152 -0
- package/slave/node_modules/ws/lib/websocket-server.js +562 -0
- package/slave/node_modules/ws/lib/websocket.js +1421 -0
- package/slave/node_modules/ws/package.json +74 -0
- package/slave/node_modules/ws/wrapper.mjs +21 -0
- package/slave/pairing.mjs +175 -0
- package/slave/permission-pairing.mjs +240 -0
- package/slave/prune-shots.mjs +170 -0
- package/slave/relay.mjs +1351 -0
- package/slave/repair-workspace-integrity.ps1 +75 -0
- package/slave/screenshot.ps1 +114 -0
- package/slave/selfcheck.ps1 +350 -0
- package/slave/stage-app.ps1 +116 -0
- package/slave/workspace-integrity.mjs +157 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,2842 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half of dsh-plugin-remote-use.
|
|
3
|
+
*
|
|
4
|
+
* One Settings section (`settings.section`) that owns the plugin's operator surface: the
|
|
5
|
+
* availability of every paired worker, a per-worker diagnostics panel (fresh self-check,
|
|
6
|
+
* screenshot, link ping), the task list, and an emergency stop.
|
|
7
|
+
*
|
|
8
|
+
* Written as a plain module-loader factory: no build step, React comes from the browser
|
|
9
|
+
* module table, and nothing from `@deepseek-ai/*` is required beyond the platform seeds.
|
|
10
|
+
* This mirrors the shape of the sibling notification plugin, which is the working reference
|
|
11
|
+
* for a third-party Settings page in this app.
|
|
12
|
+
*
|
|
13
|
+
* All state lives on the Host and is read through `/api/dsh/dsh-plugin-remote-use`, so the
|
|
14
|
+
* page never duplicates plugin logic -- in particular it does not decide readiness itself,
|
|
15
|
+
* it renders the worker's own report.
|
|
16
|
+
*
|
|
17
|
+
* DELIBERATELY ABSENT: a role selector. Switching this machine between master and worker
|
|
18
|
+
* (symmetric pairing) is planned but is a different risk class from managing known workers,
|
|
19
|
+
* so the page only STATES the current role instead of offering a dead control.
|
|
20
|
+
*/
|
|
21
|
+
window.__ModuleLoader__.load({
|
|
22
|
+
// MUST equal the package name. The loader keys its graph rows -- and therefore its "already
|
|
23
|
+
// loaded" bookkeeping -- by the PACKAGE name, while this id is what the bundle registers itself
|
|
24
|
+
// under. When they disagreed (this said the pre-rename `dsh-plugin-remote-use`), the loader did
|
|
25
|
+
// not recognise its own registration, executed the bundle a second time, and dsh-client-modules
|
|
26
|
+
// threw: `duplicate factory registration for "dsh-plugin-remote-use"`, which surfaced as
|
|
27
|
+
// "web boot: 1 entry did not activate" and left the whole Web UI unable to boot.
|
|
28
|
+
id: '@hope_phenom/dsh-plugin-remote-use',
|
|
29
|
+
factory(require) {
|
|
30
|
+
const React = require('react')
|
|
31
|
+
const h = React.createElement
|
|
32
|
+
|
|
33
|
+
/** Locale namespace owned by this page. */
|
|
34
|
+
const NS = 'dsh-plugin-remote-use'
|
|
35
|
+
/** Data route served by the Host half. */
|
|
36
|
+
const ROUTE = '/api/dsh/dsh-plugin-remote-use'
|
|
37
|
+
|
|
38
|
+
const zh = {
|
|
39
|
+
nav: '远程工作节点',
|
|
40
|
+
title: '远程工作节点',
|
|
41
|
+
description: '把涉及 GUI 的自动化任务派给配对的工作节点执行,避免抢占本机的鼠标与键盘;可用状态、诊断与急停都在这里。',
|
|
42
|
+
groupRole: '本机角色',
|
|
43
|
+
roleMaster: '主机',
|
|
44
|
+
// 这条曾经写「切换为工作节点(对称配对)尚未实现」—— 而它下面就是完整的布防面板。
|
|
45
|
+
// 界面上说「没做」而实际做了,与文档里那类过期声明是同一个问题。
|
|
46
|
+
roleNote: '本机作为主机向工作节点派单;也可以布防为工作节点(对称配对),见下方。',
|
|
47
|
+
groupWorkers: '工作节点',
|
|
48
|
+
loading: '正在读取工作节点状态…',
|
|
49
|
+
// Points at the step that is actually missing, which is NOT where a worker is configured.
|
|
50
|
+
//
|
|
51
|
+
// This string used to send the operator to the profile patch for a per-worker tool row, and
|
|
52
|
+
// that was WRONG as of 0.2.0: the plugin registers its own delegation tool through `ctx.tools`,
|
|
53
|
+
// so adding a worker here is the whole job (PITFALLS F11 -- a Web session's tool catalog comes
|
|
54
|
+
// from the agent preset, so a patch row would not have reached the model anyway). Nothing
|
|
55
|
+
// else in the UI was telling operators to edit a patch; this one line was.
|
|
56
|
+
noWorkers: '尚未配置工作节点。在上方添加,或粘贴配对串即可 —— 工具会随之出现,不需要改 profile 补丁,也不需要重启。',
|
|
57
|
+
reportPending: '就绪报告未在限时内取得 —— 工作节点可能不可达。连接状态见下。',
|
|
58
|
+
verdictReady: '就绪',
|
|
59
|
+
// 有警告但**阻断项全过** —— 产品的派单门禁只看 status==='fail',所以这个节点是可用的。
|
|
60
|
+
verdictWarn: '可用(有警告)',
|
|
61
|
+
verdictPartial: '检查不完整',
|
|
62
|
+
verdictBlocked: '不可用',
|
|
63
|
+
verdictUnknown: '未知',
|
|
64
|
+
linkConnected: '已连接',
|
|
65
|
+
linkDown: '未连接',
|
|
66
|
+
labelCwd: '工作目录',
|
|
67
|
+
labelSession: '交互会话',
|
|
68
|
+
labelCounts: '检查结果',
|
|
69
|
+
labelActive: '在途任务',
|
|
70
|
+
labelLink: '连接',
|
|
71
|
+
countsText: '阻断 {b} · 警告 {w} · 未检查 {u}',
|
|
72
|
+
reconnects: '重连 {n} 次',
|
|
73
|
+
approvals: '审批 {n} 次',
|
|
74
|
+
heartbeatFailures: '心跳失败 {n} 次',
|
|
75
|
+
generatedAt: '报告时间',
|
|
76
|
+
cachedAge: '缓存 {n} 毫秒',
|
|
77
|
+
labelSessionMode: '会话档位',
|
|
78
|
+
// 三个值都不翻译成中文:它们是 DSH 的闭集取值,翻成中文就对不上 profile 与日志里写的了。
|
|
79
|
+
sessionModeUnset: '未设置(DSH 默认 workspace-write)',
|
|
80
|
+
sessionModeNeedsFull: 'GUI 自动化需要 danger-full-access',
|
|
81
|
+
sessionModeMismatch: '实际 {eff},配置 {cfg} —— 启动规格与设置不一致;重建规格并重启中继后才会一致',
|
|
82
|
+
labelIntegrity: '进程完整性',
|
|
83
|
+
integrityDegraded: '工作目录里的程序以 {ws} 启动,低于 %TEMP% 的 {tmp};GUI 写 %LOCALAPPDATA% 会失败(见 S20)',
|
|
84
|
+
fieldWorkerSessionModeEffective: '运行中的中继实际档位:{mode}',
|
|
85
|
+
workerSessionReArmRequired: '已保存的档位是 {cfg},但运行中的中继仍以 {eff} 启动。需要重新布防(先停止再布防)才会生效 —— 保存设置不会改写已经启动的中继所用的启动规格。',
|
|
86
|
+
workerSessionNotRunning: '本插件当前没有运行中的中继;下次布防会按已保存的档位生成启动规格。手管中继请重新生成 spec 并重启。',
|
|
87
|
+
labelListenerMode: '中继实际档位',
|
|
88
|
+
btnSelfcheck: '重新自检',
|
|
89
|
+
btnScreenshot: '截图',
|
|
90
|
+
btnPing: '连接测试',
|
|
91
|
+
btnAll: '全部重新自检',
|
|
92
|
+
showChecks: '显示全部检查({n})',
|
|
93
|
+
hideChecks: '收起检查',
|
|
94
|
+
colId: '项',
|
|
95
|
+
colName: '检查',
|
|
96
|
+
colStatus: '结果',
|
|
97
|
+
colActual: '实测',
|
|
98
|
+
colFix: '修复方式',
|
|
99
|
+
statusPass: '通过',
|
|
100
|
+
statusFail: '失败',
|
|
101
|
+
statusWarn: '警告',
|
|
102
|
+
statusUnchecked: '未检查',
|
|
103
|
+
statusSkip: '跳过',
|
|
104
|
+
groupTasks: '任务',
|
|
105
|
+
noTasks: '暂无任务记录。',
|
|
106
|
+
colTask: '任务',
|
|
107
|
+
colWorker: '工作节点',
|
|
108
|
+
colState: '状态',
|
|
109
|
+
colTools: '工具',
|
|
110
|
+
colStarted: '开始',
|
|
111
|
+
waiting: '等待审批',
|
|
112
|
+
groupDanger: '急停',
|
|
113
|
+
dangerNote: '打断所有工作节点上的在途任务。取消是协作式的,任务需到达停顿点后才结算为已中止。',
|
|
114
|
+
btnStopAll: '全部急停',
|
|
115
|
+
stopping: '正在停止…',
|
|
116
|
+
stopped: '已请求取消 {n} 个任务',
|
|
117
|
+
nothingToStop: '没有在途任务',
|
|
118
|
+
loadFailed: '无法连接插件数据接口',
|
|
119
|
+
retry: '重试',
|
|
120
|
+
shotDone: '截图已保存:{p}',
|
|
121
|
+
busy: '处理中…',
|
|
122
|
+
screenshotHint: '截图保存在主机临时目录,可在会话中引用该路径。',
|
|
123
|
+
fieldSessions: '本机会话状态',
|
|
124
|
+
fieldKeepShots: '每个工作节点保留的截图数',
|
|
125
|
+
fieldKeepShotsHint: '保留最新的若干张。全分辨率截图约 9.5 MB,这一项决定了长时间 GUI 验证会占用多少磁盘。填负数表示全部保留。',
|
|
126
|
+
fieldWorkerSessionMode: '本机远程会话的沙箱档位',
|
|
127
|
+
fieldWorkerSessionModeHint: '布防时写进启动规格:之后由本机接的远程会话都从这一档开始。GUI 自动化需要 danger-full-access —— GUI 在受限令牌下的失败是静默的(命令返回成功、只留一个桌面对话框),所以它不能被逐次审批掉。',
|
|
128
|
+
fieldWorkerModel: '本机远程会话的模型',
|
|
129
|
+
fieldWorkerModelHint: '填 provider/model(例如 deepseek-official/deepseek-flash),布防时写进 ACP 启动配置。留空 = 用从机 profile 里配的模型。这个值同时决定"能不能收图":模型不声明 image 输入时,主机会被拒绝发图,从机的 read_image 也会失败 —— 填错是静默降级,布防时留意警告。改了需要重新布防。',
|
|
130
|
+
fieldAuditMaxBytes: '审计文件轮转阈值(字节)',
|
|
131
|
+
fieldAuditMaxBytesHint: '审计是追加式文件,本身没有停止点。达到该大小就改名归档并另起一个,绝不截断正在写的文件。填 0 或负数表示不轮转(无限增长)。',
|
|
132
|
+
fieldAuditKeepFiles: '保留的审计归档数',
|
|
133
|
+
fieldAuditKeepFilesHint: '超出这个数量的最旧归档会被删除 —— 这是本项目里唯一会删掉审计记录的动作。填负数表示全部保留。',
|
|
134
|
+
auditUsage: '当前 {size};单文件上限 {max};保留归档 {keep} 个(现有 {n} 个)',
|
|
135
|
+
auditUnbounded: '不限',
|
|
136
|
+
fieldPolicy: '委派策略文案',
|
|
137
|
+
fieldPolicyHint: '在会话启用远程工作期间注入,用来告诉模型何时该把工作交给工作节点。清空表示不注入;无论哪种情况工具仍然受开关约束。',
|
|
138
|
+
sessionsOn: '当前有 {n} 个会话启用了远程工作',
|
|
139
|
+
sessionsNone: '当前没有会话启用远程工作',
|
|
140
|
+
switchHint: '按会话生效且不持久化:在需要使用工作节点的会话里输入 {cmd}。',
|
|
141
|
+
// --- approvals ---
|
|
142
|
+
groupApprovals: '等待你批准的请求',
|
|
143
|
+
approvalsNone: '当前没有等待批准的请求。',
|
|
144
|
+
approvalsUnavailable: '此构建没有审批队列,引擎无法自行裁定的请求会被直接拒绝。',
|
|
145
|
+
approvalsNote: '审阅引擎无法自行裁定的请求会停在这里等待,而不是直接失败;超过下面的时限仍未处理则按拒绝处理 —— 所以"放行"永远是一个明确的动作。',
|
|
146
|
+
approvalsBudget: '每个请求最多等待 {s} 秒',
|
|
147
|
+
approvalsMissed: '有 {n} 件请求因无人应答被拒',
|
|
148
|
+
approvalsMissedNote: '这些请求等过、但没人来得及处理,于是按拒绝处理了。列在这里是为了让"错过"事后也看得见 —— 要收起这个列表请用下面的清空;文件里的记录不会消失。',
|
|
149
|
+
colSettled: '处理时间',
|
|
150
|
+
// 工作现场里的三处审批面(会话头按钮 / 输入框上方 / 右侧面板)
|
|
151
|
+
approvalsPanel: '审批',
|
|
152
|
+
approvalsDockWaiting: '有 {n} 件远程任务在等你批准',
|
|
153
|
+
approvalsDockHint: '每个请求最多等 {s} 秒,超时按拒绝处理',
|
|
154
|
+
approvalsHeaderPending: '审批 {n}',
|
|
155
|
+
approvalsHeaderMissed: '错过 {n} 件',
|
|
156
|
+
approvalsOpenPanel: '打开审批面板',
|
|
157
|
+
approvalsPanelPending: '等待批准({n})',
|
|
158
|
+
approvalsPanelEmpty: '当前没有等待批准的请求。',
|
|
159
|
+
approvalsPanelMissed: '无人应答而被拒({n})',
|
|
160
|
+
approvalsPanelNoMissed: '没有错过的请求。',
|
|
161
|
+
approvalsPanelAlso: '记录同时写在 {p}',
|
|
162
|
+
btnClearMissed: '清空错过记录',
|
|
163
|
+
btnClearing: '正在清空…',
|
|
164
|
+
clearedMissed: '已清空 {n} 件',
|
|
165
|
+
clearMissedNote: '只清空这里的显示;记录文件是追加写入的,原始条目仍在,并会多出一条「已清空」。',
|
|
166
|
+
approvalsLeft: '剩 {s} 秒',
|
|
167
|
+
approvalsPanelPendingTitle: '等你批准',
|
|
168
|
+
approvalsPanelMissedTitle: '无人应答而被拒',
|
|
169
|
+
btnAllowShort: '允许',
|
|
170
|
+
btnDenyShort: '拒绝',
|
|
171
|
+
colTool: '工具',
|
|
172
|
+
colTier: '所需档位',
|
|
173
|
+
colWaited: '已等待',
|
|
174
|
+
colArgs: '参数',
|
|
175
|
+
btnAllow: '允许一次',
|
|
176
|
+
btnDeny: '拒绝',
|
|
177
|
+
approving: '正在提交…',
|
|
178
|
+
approvalAllowed: '已允许一次',
|
|
179
|
+
approvalDenied: '已拒绝',
|
|
180
|
+
roleArmed: '本机正在接受远程驱动',
|
|
181
|
+
roleDisarmed: '未接受远程驱动',
|
|
182
|
+
roleDisarmedNote: '当前没有监听任何端口。布防会开一个端口,让已配对的主机在本机执行命令。',
|
|
183
|
+
roleUptime: '已运行',
|
|
184
|
+
roleToken: '凭据',
|
|
185
|
+
roleSandbox: '约束状态',
|
|
186
|
+
roleSandboxOk: '沙箱已验证',
|
|
187
|
+
roleSandboxUnknown: '尚未验证',
|
|
188
|
+
roleMediator: '沙箱 mediator',
|
|
189
|
+
roleMediatorReady: '已就绪',
|
|
190
|
+
roleEnv: '环境就绪',
|
|
191
|
+
roleEnvReady: '就绪',
|
|
192
|
+
roleEnvBlocked: '未就绪',
|
|
193
|
+
roleEnvUnavailable: '未检查',
|
|
194
|
+
roleEnvFix: '这台机器还没准备好跑 GUI 任务。复制下面的提示词,粘到本机的 DSH 会话里执行:',
|
|
195
|
+
roleEnvCopy: '复制环境修复提示词',
|
|
196
|
+
roleEnvHint: '提示词会先跑自检、按项修复、再复跑验证;需要管理员或需要人登录/解锁的步骤,它会停下来告诉你,不会假装成功。', roleMediatorMissing: '未准备',
|
|
197
|
+
roleMediatorUnsupported: '本机不适用',
|
|
198
|
+
roleMediatorFix: '这台机器还不能布防:沙箱 mediator 未就绪。把下面这段内容交给本机的 DSH 会话执行,或在终端里直接运行其中的命令。',
|
|
199
|
+
roleMediatorCopy: '复制命令与说明',
|
|
200
|
+
roleMediatorHint: '命令必须在这台机器上运行;DSH 自己的沙箱可能拦住工作区外的写入 —— 被拦时请改用终端,或在全权会话里运行。执行完回到这里点「先检查」。',
|
|
201
|
+
roleListenerExited: '监听已退出(退出码 {code})—— 布防状态还在,但端口没有在监听。常见原因:旧中继(如 DshRelay 计划任务)仍占用该端口。先停掉它,再重新布防。输出:{detail}',
|
|
202
|
+
roleBind: '监听地址',
|
|
203
|
+
roleBindHint: '指定某个局域网接口。不提供"所有接口"的选项;虚拟网卡会列出来,只是为了让你看到它为何不被选中。',
|
|
204
|
+
roleVirtual: '虚拟网卡',
|
|
205
|
+
roleNotOffered: '不提供',
|
|
206
|
+
rolePort: '端口',
|
|
207
|
+
rolePortHint: '主机拨这个端口。7331 与参考从机一致。',
|
|
208
|
+
roleTaskDir: '任务目录',
|
|
209
|
+
roleTaskDirHint: '本机上一个绝对路径,远程任务在这里运行,也是沙箱允许它写入的范围。它会写进配对串,主机就不必再猜。留空的话,主机必须自己填一个。',
|
|
210
|
+
roleCheck: '先检查',
|
|
211
|
+
roleArm: '布防为工作节点',
|
|
212
|
+
roleCheckOk: '沙箱能约束本机,地址可用,可以布防。',
|
|
213
|
+
roleCheckFailed: '暂时不能布防:',
|
|
214
|
+
roleIssuedOnce: '只显示这一次。之后不再展示 —— 重新签发意味着先停止布防再重新布防。',
|
|
215
|
+
rolePairing: '配对串',
|
|
216
|
+
rolePairingHint: '把它粘贴到主机的设置页。它携带凭据,请当作密码对待。',
|
|
217
|
+
roleTokenLabel: '凭据',
|
|
218
|
+
roleTokenHint: '同一个密钥的原始形式,供直接索要凭据的主机使用。',
|
|
219
|
+
roleIssuedDone: '我已保存',
|
|
220
|
+
roleStop: '立即停止',
|
|
221
|
+
roleStopForget: '停止并作废凭据',
|
|
222
|
+
roleStopHint: '立即生效,且不会联系主机。',
|
|
223
|
+
groupAudit: '最近的裁定',
|
|
224
|
+
auditNone: '尚无裁定记录。',
|
|
225
|
+
groupTransfers: '文件传输',
|
|
226
|
+
noTransfers: '尚无文件传输记录。',
|
|
227
|
+
colDirection: '方向',
|
|
228
|
+
colPath: '路径',
|
|
229
|
+
colSize: '大小',
|
|
230
|
+
transferSent: '上传到节点',
|
|
231
|
+
transferFetched: '从节点取回',
|
|
232
|
+
auditNote: '追加写入,位置:{p}',
|
|
233
|
+
auditByEngine: '引擎',
|
|
234
|
+
// This key was MISSING entirely: the audit table has always called `t('auditBy')`, so the column
|
|
235
|
+
// rendered the raw key instead of "by <who>". Neither the dictionary-parity test nor review could
|
|
236
|
+
// see it -- both languages lacked it equally. Found by the call-site guard in settings-page.test.js.
|
|
237
|
+
auditBy: '由 {by}',
|
|
238
|
+
auditByPage: '你在设置页点的',
|
|
239
|
+
auditByApi: '程序调用(脚本/API)',
|
|
240
|
+
auditByHuman: '人(旧记录:来源未区分)',
|
|
241
|
+
auditByTimeout: '超时未处理',
|
|
242
|
+
auditByNoAnswerer: '无人可问',
|
|
243
|
+
auditByDisposed: '插件已卸载',
|
|
244
|
+
// --- pairing ---
|
|
245
|
+
btnAdd: '添加工作节点',
|
|
246
|
+
btnPair: '粘贴配对串',
|
|
247
|
+
btnPairSave: '配对',
|
|
248
|
+
pairDone: '配对完成,工作节点已配置并连接。',
|
|
249
|
+
fieldPairing: '配对串',
|
|
250
|
+
fieldPairingHint: '由工作节点生成(relay 的 --print-pairing,或 relay 旁边的 pairing.txt)。它包含工作节点令牌,请当作密码对待:用掉后本框即清空,且绝不会回传到浏览器。',
|
|
251
|
+
fieldPairingCwd: '任务目录(配对串没带时才需要填)',
|
|
252
|
+
fieldPairingCwdHint: '从机上的绝对路径。较新的从机会把它放进配对串,这里留空即可。留空且配对串也没带,配对会被拒绝 —— 没有任务目录的工作节点无法接单。',
|
|
253
|
+
fieldPairingPlaceholder: 'dshpair1.…',
|
|
254
|
+
btnEdit: '编辑',
|
|
255
|
+
btnDelete: '删除',
|
|
256
|
+
btnSave: '保存',
|
|
257
|
+
btnCancel: '取消',
|
|
258
|
+
btnRecheck: '重新自检',
|
|
259
|
+
confirmDelete: '删除工作节点 {id}?该操作会立即断开连接并写入设置文件。',
|
|
260
|
+
formAddTitle: '添加工作节点',
|
|
261
|
+
formEditTitle: '编辑工作节点 {id}',
|
|
262
|
+
fieldId: '标识(id)',
|
|
263
|
+
fieldIdHint: '插件内部使用,也是 profile 补丁里的键;保存后不建议再改。',
|
|
264
|
+
fieldName: '显示名称',
|
|
265
|
+
fieldUrl: '中继地址',
|
|
266
|
+
// Recommends a HOSTNAME, not an IP.
|
|
267
|
+
//
|
|
268
|
+
// It used to show a hardcoded address -- one particular machine's, from DHCP.
|
|
269
|
+
// An operator following that hint writes an address that rots the next time the worker
|
|
270
|
+
// reboots and gets a different lease, and the symptom is "the worker is unreachable", which
|
|
271
|
+
// looks exactly like the worker being powered off. A name survives that, as long as the
|
|
272
|
+
// master can resolve it (measured: this works end to end over LLMNR).
|
|
273
|
+
//
|
|
274
|
+
// Note the asymmetry with the pairing string, which always carries an IP because the relay
|
|
275
|
+
// advertises its own address -- a pasted pairing string does NOT survive the worker moving.
|
|
276
|
+
fieldUrlHint: '从机中继的 WebSocket 地址。建议用主机名(例如 ws://worker-pc:7331),这样从机换 IP 后仍然可用;用 IP 的话每次换地址都要改。',
|
|
277
|
+
fieldToken: '访问令牌',
|
|
278
|
+
fieldTokenHint: '留空表示保持原令牌不变。令牌只写入主机,不会回传到本页面。',
|
|
279
|
+
fieldTokenNew: '访问令牌(必填)',
|
|
280
|
+
fieldCwd: '工作目录',
|
|
281
|
+
fieldCwdHint: '从机上的绝对路径;同时也是从机沙箱允许写入的范围。',
|
|
282
|
+
fieldEnabled: '启用',
|
|
283
|
+
fieldSerial: '并发任务数',
|
|
284
|
+
fieldSerialHint: 'GUI 是独占资源,通常保持 1。',
|
|
285
|
+
tokenStored: '已保存({hint})',
|
|
286
|
+
tokenMissing: '未设置',
|
|
287
|
+
savedApplied: '已保存并生效:新增 {a} · 移除 {r} · 重建 {c}',
|
|
288
|
+
savedRebuilt: '已保存。审批策略变更,所有工作节点已重建。',
|
|
289
|
+
savedNoChange: '已保存,无需变更。',
|
|
290
|
+
groupAdvanced: '审批与连接',
|
|
291
|
+
fieldTier: '默认档位',
|
|
292
|
+
fieldTierHint: '从机申请宽于该档位时,主机的审阅引擎会拒绝。',
|
|
293
|
+
fieldApprovalTimeout: '审批超时(毫秒)',
|
|
294
|
+
fieldApprovalTimeoutHint: '超时按拒绝处理。',
|
|
295
|
+
fieldHeartbeat: '心跳间隔(毫秒)',
|
|
296
|
+
fieldHeartbeatHint: '0 表示关闭。用于在两次调用之间发现静默断链。',
|
|
297
|
+
fieldReportBudget: '页面探测预算(毫秒)',
|
|
298
|
+
fieldReportBudgetHint: '页面加载时等待就绪报告的上限;超时则只显示连接状态。',
|
|
299
|
+
groupSource: '配置来源',
|
|
300
|
+
sourceText: '工作节点由本页管理并写入 {file};该文件存在时,profile 补丁中的同名配置不再生效。',
|
|
301
|
+
// --- 截图缓存 ---
|
|
302
|
+
groupCache: '截图缓存',
|
|
303
|
+
cacheLimitText: '上限 {size}',
|
|
304
|
+
cacheNoLimit: '未设上限(仅受张数限制)',
|
|
305
|
+
labelCacheHost: '主机缓存',
|
|
306
|
+
labelCacheWorker: '从机缓存',
|
|
307
|
+
cacheNone: '暂无',
|
|
308
|
+
cacheUnsupported: '该从机的 relay 不支持缓存上报',
|
|
309
|
+
cacheUnreachable: '从机离线,无法读取',
|
|
310
|
+
cacheFiles: '{n} 张',
|
|
311
|
+
cacheOver: '超出 {size}',
|
|
312
|
+
fieldShotCache: '截图缓存上限',
|
|
313
|
+
fieldShotCacheHint: '留空或 0 表示不设上限。可写 512MB、1.5GB 或字节数。超限时自动删除最旧的;最新一张始终保留——主机正在读取的那张若被删掉,截图工具就会"报成功但没有文件"。',
|
|
314
|
+
// {scope} is not decoration. Host and worker each get their own pair of buttons, so without it
|
|
315
|
+
// the four render as `应用上限 清空缓存 应用上限 清空缓存` -- which reads as a duplicated row
|
|
316
|
+
// rather than as two scopes. The sibling messages below (`cacheCleared`, `cachePruned`, ...) all
|
|
317
|
+
// pass a scope; these two labels were the only ones that forgot it, and because `fmt()` leaves a
|
|
318
|
+
// string with no placeholder untouched, nothing errored.
|
|
319
|
+
btnCacheApply: '{scope}:应用上限',
|
|
320
|
+
btnCacheClear: '{scope}:清空缓存',
|
|
321
|
+
cacheSaveFailed: '上限无法识别:请填 512MB、1.5GB 这样的值,或 0 表示不设上限。',
|
|
322
|
+
cacheCleared: '已清空 {scope}:删除 {n} 个文件,释放 {size}',
|
|
323
|
+
cacheClearNothing: '{scope} 没有可清理的文件',
|
|
324
|
+
cachePruned: '{scope} 已按上限清理:删除 {n} 个,释放 {size}',
|
|
325
|
+
cacheWithinBudget: '{scope} 在限额内,无需清理',
|
|
326
|
+
cacheFilesCleared: '{n} 个文件',
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const en = {
|
|
330
|
+
nav: 'Remote workers',
|
|
331
|
+
title: 'Remote workers',
|
|
332
|
+
description: 'Send GUI automation to paired workers instead of taking over this machine\u2019s mouse and keyboard. Availability, diagnostics and the emergency stop live here.',
|
|
333
|
+
groupRole: 'This machine\u2019s role',
|
|
334
|
+
roleMaster: 'Master',
|
|
335
|
+
roleNote: 'This machine dispatches to workers. It can also be armed as a worker itself (symmetric pairing) -- see below.',
|
|
336
|
+
groupWorkers: 'Workers',
|
|
337
|
+
loading: 'Reading worker state\u2026',
|
|
338
|
+
noWorkers: 'No workers yet. Add one above, or paste a pairing string \u2014 the model\u2019s tools come with it, with no profile-patch edit and no restart.',
|
|
339
|
+
reportPending: 'The readiness report did not arrive in time \u2014 the worker is probably unreachable. Link state below.',
|
|
340
|
+
verdictReady: 'ready',
|
|
341
|
+
verdictWarn: 'usable (warnings)',
|
|
342
|
+
verdictPartial: 'not fully checked',
|
|
343
|
+
verdictBlocked: 'unavailable',
|
|
344
|
+
verdictUnknown: 'unknown',
|
|
345
|
+
linkConnected: 'connected',
|
|
346
|
+
linkDown: 'disconnected',
|
|
347
|
+
labelCwd: 'Working directory',
|
|
348
|
+
labelSession: 'Interactive session',
|
|
349
|
+
labelCounts: 'Checks',
|
|
350
|
+
labelActive: 'Active tasks',
|
|
351
|
+
labelLink: 'Link',
|
|
352
|
+
countsText: 'blocking {b} \u00b7 warning {w} \u00b7 unchecked {u}',
|
|
353
|
+
reconnects: '{n} reconnects',
|
|
354
|
+
approvals: '{n} approvals',
|
|
355
|
+
heartbeatFailures: '{n} heartbeat failures',
|
|
356
|
+
generatedAt: 'Reported at',
|
|
357
|
+
cachedAge: 'cached {n} ms',
|
|
358
|
+
labelSessionMode: 'Session mode',
|
|
359
|
+
// The values themselves stay untranslated: they are DSH's closed vocabulary, and a translated
|
|
360
|
+
// one would no longer match what the profile and the logs say.
|
|
361
|
+
sessionModeUnset: 'not set (DSH defaults to workspace-write)',
|
|
362
|
+
sessionModeNeedsFull: 'GUI automation needs danger-full-access',
|
|
363
|
+
sessionModeMismatch: 'effective {eff}, configured {cfg} \u2014 the launch spec and the setting disagree; re-generate the spec and restart the relay',
|
|
364
|
+
labelIntegrity: 'Process integrity',
|
|
365
|
+
integrityDegraded: 'Programs launched from the workspace run at {ws}, below %TEMP% at {tmp}; a GUI app cannot write %LOCALAPPDATA% (see S20)',
|
|
366
|
+
fieldWorkerSessionModeEffective: 'The running relay actually uses: {mode}',
|
|
367
|
+
workerSessionReArmRequired: 'Saved mode is {cfg}, but the running relay was started with {eff}. Re-arm (stop, then arm again) for it to take effect \u2014 saving does not rewrite the launch spec of a relay that is already running.',
|
|
368
|
+
workerSessionNotRunning: 'This plugin has no relay running; the next arming will generate its launch spec from the saved mode. For a hand-managed relay, re-generate the spec and restart it.',
|
|
369
|
+
labelListenerMode: 'Relay session mode',
|
|
370
|
+
btnSelfcheck: 'Re-check now',
|
|
371
|
+
btnScreenshot: 'Screenshot',
|
|
372
|
+
btnPing: 'Test link',
|
|
373
|
+
btnAll: 'Re-check all',
|
|
374
|
+
showChecks: 'Show all checks ({n})',
|
|
375
|
+
hideChecks: 'Hide checks',
|
|
376
|
+
colId: 'ID',
|
|
377
|
+
colName: 'Check',
|
|
378
|
+
colStatus: 'Result',
|
|
379
|
+
colActual: 'Observed',
|
|
380
|
+
colFix: 'How to fix',
|
|
381
|
+
statusPass: 'pass',
|
|
382
|
+
statusFail: 'fail',
|
|
383
|
+
statusWarn: 'warn',
|
|
384
|
+
statusUnchecked: 'unchecked',
|
|
385
|
+
statusSkip: 'skipped',
|
|
386
|
+
groupTasks: 'Tasks',
|
|
387
|
+
noTasks: 'No tasks recorded yet.',
|
|
388
|
+
colTask: 'Task',
|
|
389
|
+
colWorker: 'Worker',
|
|
390
|
+
colState: 'State',
|
|
391
|
+
colTools: 'Tools',
|
|
392
|
+
colStarted: 'Started',
|
|
393
|
+
waiting: 'waiting for approval',
|
|
394
|
+
groupDanger: 'Emergency stop',
|
|
395
|
+
dangerNote: 'Stops every in-flight task on every worker. Cancellation is cooperative: a task settles as aborted only once the worker reaches a quiescent point.',
|
|
396
|
+
btnStopAll: 'Stop all',
|
|
397
|
+
stopping: 'Stopping\u2026',
|
|
398
|
+
stopped: 'Cancellation requested for {n} task(s)',
|
|
399
|
+
nothingToStop: 'Nothing to stop',
|
|
400
|
+
loadFailed: 'Cannot reach the plugin data route',
|
|
401
|
+
retry: 'Retry',
|
|
402
|
+
shotDone: 'Screenshot saved: {p}',
|
|
403
|
+
busy: 'Working\u2026',
|
|
404
|
+
screenshotHint: 'Screenshots land in the host temp directory; reference that path from the session.',
|
|
405
|
+
fieldSessions: 'Local sessions',
|
|
406
|
+
fieldKeepShots: 'Screenshots kept per worker',
|
|
407
|
+
fieldKeepShotsHint: 'Newest first. A full-resolution capture is roughly 9.5 MB, so this bounds what a long GUI run costs on disk. Negative keeps everything.',
|
|
408
|
+
fieldWorkerSessionMode: 'Sandbox mode for this machine\'s remote sessions',
|
|
409
|
+
fieldWorkerSessionModeHint: 'Written into the launch spec when arming: remote sessions this machine accepts start at this level. GUI automation needs danger-full-access -- a GUI process failing under a restricted token does so SILENTLY (the command returns success; only a desktop dialog is left), so it cannot be surfaced as a per-call approval.',
|
|
410
|
+
fieldWorkerModel: 'Model for this machine\'s remote sessions',
|
|
411
|
+
fieldWorkerModelHint: 'Fill in provider/model (for example deepseek-official/deepseek-flash); it is written into the ACP launch configuration when arming. Empty means "use whatever the worker profile configures". This value also decides image input: a model that does not declare image support makes the master unable to send pictures and breaks read_image on the worker, and that failure is silent -- watch the warning shown when arming. Changing it needs re-arming.',
|
|
412
|
+
fieldAuditMaxBytes: 'Audit rotation threshold (bytes)',
|
|
413
|
+
fieldAuditMaxBytesHint: 'The trail is append-only, so it has no stopping point of its own. At this size it is renamed to an archive and a fresh file starts -- the live file is never truncated. 0 or negative disables rotation (unbounded growth).',
|
|
414
|
+
fieldAuditKeepFiles: 'Audit archives kept',
|
|
415
|
+
fieldAuditKeepFilesHint: 'The oldest archives beyond this are deleted -- the only action in this project that removes audit records. Negative keeps every archive.',
|
|
416
|
+
auditUsage: 'Now {size}; {max} per file; keeping {keep} archive(s), {n} present',
|
|
417
|
+
auditUnbounded: 'unbounded',
|
|
418
|
+
fieldPolicy: 'Delegation policy',
|
|
419
|
+
fieldPolicyHint: 'Injected while a session has remote work switched on. It is what tells the model when to hand work to a worker. Clear it to inject nothing; the tools stay gated either way.',
|
|
420
|
+
sessionsOn: '{n} session(s) have remote work on',
|
|
421
|
+
sessionsNone: 'No session has remote work on right now',
|
|
422
|
+
switchHint: 'Per session, and not persisted: type {cmd} in the session that should use a worker.',
|
|
423
|
+
// --- approvals ---
|
|
424
|
+
groupApprovals: 'Approvals waiting on you',
|
|
425
|
+
approvalsNone: 'Nothing is waiting for approval.',
|
|
426
|
+
approvalsUnavailable: 'This build has no approval queue, so a request the engine cannot justify is refused outright.',
|
|
427
|
+
approvalsNote: 'The review engine refuses anything it cannot justify on its own. Those requests wait here instead of failing, until the budget expires \u2014 and expiry is a refusal, so allowing one is always an explicit act.',
|
|
428
|
+
approvalsBudget: 'Each waits up to {s}s',
|
|
429
|
+
approvalsMissed: '{n} request(s) were refused because nobody answered',
|
|
430
|
+
approvalsMissedNote: 'These waited and nobody got to them, so they were refused. Listed so a miss stays visible after the fact; Clear below hides the list, and the record in the file stays.',
|
|
431
|
+
colSettled: 'Settled',
|
|
432
|
+
// The three approval surfaces that live where the work happens.
|
|
433
|
+
approvalsPanel: 'Approvals',
|
|
434
|
+
approvalsDockWaiting: '{n} remote task(s) are waiting for your approval',
|
|
435
|
+
approvalsDockHint: 'each waits up to {s}s, then it is refused',
|
|
436
|
+
approvalsHeaderPending: 'Approvals {n}',
|
|
437
|
+
approvalsHeaderMissed: '{n} missed',
|
|
438
|
+
approvalsOpenPanel: 'Open the approvals panel',
|
|
439
|
+
approvalsPanelPending: 'Waiting for you ({n})',
|
|
440
|
+
approvalsPanelEmpty: 'Nothing is waiting for approval.',
|
|
441
|
+
approvalsPanelMissed: 'Refused because nobody answered ({n})',
|
|
442
|
+
approvalsPanelNoMissed: 'Nothing was missed.',
|
|
443
|
+
approvalsPanelAlso: 'The record is also written to {p}',
|
|
444
|
+
btnClearMissed: 'Clear missed',
|
|
445
|
+
btnClearing: 'Clearing\u2026',
|
|
446
|
+
clearedMissed: 'Cleared {n}',
|
|
447
|
+
clearMissedNote: 'Hides this list only. The file is append-only: the rows stay, and a "cleared" entry is added.',
|
|
448
|
+
approvalsLeft: '{s}s left',
|
|
449
|
+
approvalsPanelPendingTitle: 'Waiting for you',
|
|
450
|
+
approvalsPanelMissedTitle: 'Refused unanswered',
|
|
451
|
+
btnAllowShort: 'Allow',
|
|
452
|
+
btnDenyShort: 'Deny',
|
|
453
|
+
colTool: 'Tool',
|
|
454
|
+
colTier: 'Required',
|
|
455
|
+
colWaited: 'Waited',
|
|
456
|
+
colArgs: 'Arguments',
|
|
457
|
+
btnAllow: 'Allow once',
|
|
458
|
+
btnDeny: 'Refuse',
|
|
459
|
+
approving: 'Sending\u2026',
|
|
460
|
+
approvalAllowed: 'Allowed once',
|
|
461
|
+
approvalDenied: 'Refused',
|
|
462
|
+
roleArmed: 'This machine is accepting remote driving',
|
|
463
|
+
roleDisarmed: 'Not accepting remote driving',
|
|
464
|
+
roleDisarmedNote: 'Nothing is listening. Arming opens a port and lets a paired master run commands here.',
|
|
465
|
+
roleUptime: 'up',
|
|
466
|
+
roleToken: 'credential',
|
|
467
|
+
roleSandbox: 'Confinement',
|
|
468
|
+
roleSandboxOk: 'sandbox verified',
|
|
469
|
+
roleSandboxUnknown: 'not verified yet',
|
|
470
|
+
roleMediator: 'Sandbox mediator',
|
|
471
|
+
roleMediatorReady: 'ready',
|
|
472
|
+
roleEnv: 'Environment',
|
|
473
|
+
roleEnvReady: 'ready',
|
|
474
|
+
roleEnvBlocked: 'not ready',
|
|
475
|
+
roleEnvUnavailable: 'not checked',
|
|
476
|
+
roleEnvFix: 'This machine is not ready for GUI tasks yet. Copy the prompt below and hand it to this machine\'s DSH session:',
|
|
477
|
+
roleEnvCopy: 'Copy environment fix prompt',
|
|
478
|
+
roleEnvHint: 'The prompt runs the self-check, fixes each item and re-runs it to prove the result; steps that need an administrator or a logged-in desktop are reported, not faked.',
|
|
479
|
+
roleMediatorMissing: 'not prepared',
|
|
480
|
+
roleMediatorUnsupported: 'not applicable here',
|
|
481
|
+
roleMediatorFix: 'This machine cannot be armed yet: its sandbox mediator is not ready. Hand the text below to this machine\'s DSH session, or run the command in it from a terminal.',
|
|
482
|
+
roleMediatorCopy: 'Copy command and notes',
|
|
483
|
+
roleMediatorHint: 'The command must run on THIS machine. DSH\'s own sandbox may refuse writes outside the workspace -- if it does, run it in a terminal or in a full-access session, then press Check again.',
|
|
484
|
+
roleListenerExited: 'The listener exited (code {code}) \u2014 the role is still marked armed, but nothing is listening. A common cause is an older relay (for example the DshRelay scheduled task) still holding the port. Stop it and arm again. Output: {detail}',
|
|
485
|
+
roleBind: 'Listen on',
|
|
486
|
+
roleBindHint: 'A specific LAN interface. Binding every interface is not offered, and virtual adapters are listed only so you can see why they are not chosen.',
|
|
487
|
+
roleVirtual: 'virtual adapter',
|
|
488
|
+
roleNotOffered: 'not offered',
|
|
489
|
+
rolePort: 'Port',
|
|
490
|
+
rolePortHint: 'The master dials this port. 7331 matches the reference worker.',
|
|
491
|
+
roleTaskDir: 'Task directory',
|
|
492
|
+
roleTaskDirHint: 'An absolute path on THIS machine where remote tasks run, and the scope their sandbox may write to. It goes into the pairing string so the master does not have to guess. Left blank, the master must supply one itself.',
|
|
493
|
+
roleCheck: 'Check first',
|
|
494
|
+
roleArm: 'Arm as a worker',
|
|
495
|
+
roleCheckOk: 'The sandbox contains this machine and the address is usable. Safe to arm.',
|
|
496
|
+
roleCheckFailed: 'Cannot arm yet:',
|
|
497
|
+
roleIssuedOnce: 'Shown ONCE. It will not be displayed again \u2014 re-issuing means stopping the worker role and arming again.',
|
|
498
|
+
rolePairing: 'Pairing string',
|
|
499
|
+
rolePairingHint: 'Paste this into the master\u2019s settings page. It carries the credential, so treat it like a password.',
|
|
500
|
+
roleTokenLabel: 'Credential',
|
|
501
|
+
roleTokenHint: 'The same secret in raw form, for a master that asks for it directly.',
|
|
502
|
+
roleIssuedDone: 'I have saved it',
|
|
503
|
+
roleStop: 'Stop now',
|
|
504
|
+
roleStopForget: 'Stop and discard the credential',
|
|
505
|
+
roleStopHint: 'Take effect immediately and never contact the master.',
|
|
506
|
+
groupAudit: 'Recent decisions',
|
|
507
|
+
auditNone: 'No decisions recorded yet.',
|
|
508
|
+
groupTransfers: 'File transfers',
|
|
509
|
+
noTransfers: 'No file transfers recorded yet.',
|
|
510
|
+
colDirection: 'Direction',
|
|
511
|
+
colPath: 'Path',
|
|
512
|
+
colSize: 'Size',
|
|
513
|
+
transferSent: 'sent to node',
|
|
514
|
+
transferFetched: 'fetched from node',
|
|
515
|
+
auditNote: 'Append-only, at {p}',
|
|
516
|
+
auditByEngine: 'engine',
|
|
517
|
+
auditBy: 'by {by}',
|
|
518
|
+
auditByPage: 'you, on the settings page',
|
|
519
|
+
auditByApi: 'a script/API call',
|
|
520
|
+
auditByHuman: 'a person (legacy row: source not distinguished)',
|
|
521
|
+
auditByTimeout: 'expired unanswered',
|
|
522
|
+
auditByNoAnswerer: 'nobody to ask',
|
|
523
|
+
auditByDisposed: 'plugin unloaded',
|
|
524
|
+
// --- pairing ---
|
|
525
|
+
btnAdd: 'Add worker',
|
|
526
|
+
btnPair: 'Paste a pairing string',
|
|
527
|
+
btnPairSave: 'Pair',
|
|
528
|
+
pairDone: 'Paired. The worker is configured and connected.',
|
|
529
|
+
fieldPairing: 'Pairing string',
|
|
530
|
+
fieldPairingHint: 'Produced by the worker (relay --print-pairing, or pairing.txt beside the relay). It contains the worker token, so treat it like a password: it is cleared from this box once used, and never sent back to the browser.',
|
|
531
|
+
fieldPairingCwd: 'Task directory (only needed if the string does not carry one)',
|
|
532
|
+
fieldPairingCwdHint: 'An absolute path on the worker. A current worker puts it in the pairing string, so this can stay blank; if neither supplies one the pairing is refused, because a worker without a task directory cannot accept a task.',
|
|
533
|
+
fieldPairingPlaceholder: 'dshpair1.\u2026',
|
|
534
|
+
btnEdit: 'Edit',
|
|
535
|
+
btnDelete: 'Delete',
|
|
536
|
+
btnSave: 'Save',
|
|
537
|
+
btnCancel: 'Cancel',
|
|
538
|
+
btnRecheck: 'Re-check',
|
|
539
|
+
confirmDelete: 'Delete worker {id}? This disconnects it immediately and writes the settings file.',
|
|
540
|
+
formAddTitle: 'Add a worker',
|
|
541
|
+
formEditTitle: 'Edit worker {id}',
|
|
542
|
+
fieldId: 'Identifier (id)',
|
|
543
|
+
fieldIdHint: 'Used internally and as the profile-patch key; best left alone once saved.',
|
|
544
|
+
fieldName: 'Display name',
|
|
545
|
+
fieldUrl: 'Relay address',
|
|
546
|
+
fieldUrlHint: 'The worker relay\u2019s WebSocket address. Prefer a HOSTNAME (e.g. ws://worker-pc:7331): it survives the worker getting a new IP from DHCP, whereas an address literal has to be edited every time the lease changes.',
|
|
547
|
+
fieldToken: 'Access token',
|
|
548
|
+
fieldTokenHint: 'Leave blank to keep the stored token. It is written on the host only and never returned to this page.',
|
|
549
|
+
fieldTokenNew: 'Access token (required)',
|
|
550
|
+
fieldCwd: 'Working directory',
|
|
551
|
+
fieldCwdHint: 'Absolute path on the worker; also the scope its sandbox allows writes to.',
|
|
552
|
+
fieldEnabled: 'Enabled',
|
|
553
|
+
fieldSerial: 'Concurrent tasks',
|
|
554
|
+
fieldSerialHint: 'The GUI is an exclusive resource on a worker; usually 1.',
|
|
555
|
+
tokenStored: 'stored ({hint})',
|
|
556
|
+
tokenMissing: 'not set',
|
|
557
|
+
savedApplied: 'Saved and applied: {a} added \u00b7 {r} removed \u00b7 {c} rebuilt',
|
|
558
|
+
savedRebuilt: 'Saved. The approval policy changed, so every worker was rebuilt.',
|
|
559
|
+
savedNoChange: 'Saved; nothing needed to change.',
|
|
560
|
+
groupAdvanced: 'Approval and connection',
|
|
561
|
+
fieldTier: 'Default tier',
|
|
562
|
+
fieldTierHint: 'A worker asking for more than this is refused by the host\u2019s review engine.',
|
|
563
|
+
fieldApprovalTimeout: 'Approval timeout (ms)',
|
|
564
|
+
fieldApprovalTimeoutHint: 'A timeout counts as a refusal.',
|
|
565
|
+
fieldHeartbeat: 'Heartbeat interval (ms)',
|
|
566
|
+
fieldHeartbeatHint: '0 disables it. Detects a silently dead link between calls.',
|
|
567
|
+
fieldReportBudget: 'Page probe budget (ms)',
|
|
568
|
+
fieldReportBudgetHint: 'How long a page load waits for a readiness report before showing link state alone.',
|
|
569
|
+
groupSource: 'Configuration source',
|
|
570
|
+
sourceText: 'Workers are managed here and written to {file}. Once that file exists, same-named keys in the profile patch no longer apply.',
|
|
571
|
+
// --- screenshot cache ---
|
|
572
|
+
groupCache: 'Screenshot cache',
|
|
573
|
+
cacheLimitText: 'limit {size}',
|
|
574
|
+
cacheNoLimit: 'no byte limit (count only)',
|
|
575
|
+
labelCacheHost: 'Host cache',
|
|
576
|
+
labelCacheWorker: 'Worker cache',
|
|
577
|
+
cacheNone: 'nothing yet',
|
|
578
|
+
cacheUnsupported: 'this worker\'s relay does not report its cache',
|
|
579
|
+
cacheUnreachable: 'worker offline, cannot read',
|
|
580
|
+
cacheFiles: '{n} file(s)',
|
|
581
|
+
cacheOver: 'over by {size}',
|
|
582
|
+
fieldShotCache: 'Screenshot cache limit',
|
|
583
|
+
fieldShotCacheHint: 'Blank or 0 means no limit. Accepts 512MB, 1.5GB, or a byte count. Past the limit the oldest are deleted automatically; the newest is always kept \u2014 deleting the capture the host is reading would leave the screenshot tool reporting success with no file to show.',
|
|
584
|
+
btnCacheApply: 'Apply limit to {scope}',
|
|
585
|
+
btnCacheClear: 'Clear {scope}',
|
|
586
|
+
cacheSaveFailed: 'That limit is not readable \u2014 use a value like 512MB, 1.5GB, or 0 for no limit.',
|
|
587
|
+
cacheCleared: 'Cleared {scope}: {n} file(s), {size} freed',
|
|
588
|
+
cacheClearNothing: '{scope} had nothing to clear',
|
|
589
|
+
cachePruned: '{scope} pruned to the limit: {n} file(s), {size} freed',
|
|
590
|
+
cacheWithinBudget: '{scope} is within the limit; nothing to prune',
|
|
591
|
+
cacheFilesCleared: '{n} file(s)',
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const CSS = [
|
|
595
|
+
'.ru-page{display:flex;flex-direction:column;gap:22px;max-width:760px;font-size:13px;line-height:1.5}',
|
|
596
|
+
'.ru-head{display:flex;flex-direction:column;gap:4px}',
|
|
597
|
+
'.ru-head-row{display:flex;align-items:center;gap:9px}',
|
|
598
|
+
// `currentColor` so the mark follows the theme and the section's active state.
|
|
599
|
+
'.ru-mark{color:var(--dsw-alias-brand-primary);flex:none}',
|
|
600
|
+
'.ru-title{margin:0;font-size:16px;font-weight:500;line-height:1.4}',
|
|
601
|
+
'.ru-desc{margin:0;font-size:12px;line-height:1.6;color:var(--dsw-alias-label-secondary)}',
|
|
602
|
+
'.ru-group{display:flex;flex-direction:column;border-top:0.5px solid var(--dsw-alias-border-l2);padding-top:14px}',
|
|
603
|
+
'.ru-group-title{margin:0 0 8px;font-size:12px;font-weight:500;color:var(--dsw-alias-label-secondary)}',
|
|
604
|
+
'.ru-note{margin:0;font-size:12px;color:var(--dsw-alias-label-tertiary)}',
|
|
605
|
+
'.ru-note.warn{color:var(--dsw-alias-state-error-primary)}',
|
|
606
|
+
'.ru-note.dim{font-size:11px}',
|
|
607
|
+
// A session level that cannot run GUI automation is not an error, so it is not red: it is the
|
|
608
|
+
// one value in this row that a reader may need to act on, and colour is how that reads.
|
|
609
|
+
'.ru-mode-warn{color:var(--dsw-alias-state-business-primary)}',
|
|
610
|
+
'.ru-dim{color:var(--dsw-alias-label-tertiary)}',
|
|
611
|
+
// A pending approval is the one thing on this page that is BLOCKING a worker, so it is
|
|
612
|
+
// outlined in the business colour rather than reading as another informational card.
|
|
613
|
+
'.ru-approvals{display:flex;flex-direction:column;gap:10px}',
|
|
614
|
+
'.ru-approval{display:flex;flex-direction:column;gap:7px;padding:10px 12px;border:0.5px solid var(--dsw-alias-state-business-primary);border-radius:var(--dsw-radius-sm)}',
|
|
615
|
+
'.ru-approval-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:12px}',
|
|
616
|
+
'.ru-approval-why{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary)}',
|
|
617
|
+
'.ru-approval-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
|
|
618
|
+
'.ru-missed{display:flex;flex-direction:column;gap:6px;padding:9px 11px;border:0.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-sm)}',
|
|
619
|
+
// --- the three approval surfaces that live where the work happens -------------------
|
|
620
|
+
//
|
|
621
|
+
// DESIGN DIRECTION: these sit INSIDE DSH's own chrome (session header, composer, right pane),
|
|
622
|
+
// so the job is not to invent an identity -- it is to inherit the host's tokens and then spend
|
|
623
|
+
// the ONE loud element on the thing that is actually blocking a worker. Every colour below is
|
|
624
|
+
// a `--dsw-alias-*` token, so a skin swap restyles this with everything else, and a saturated
|
|
625
|
+
// accent appears only while something waits.
|
|
626
|
+
// The strip above the input bar: full width of the composer, so it is a column, and the only
|
|
627
|
+
// outer spacing it needs is a gap before the input box below it (F28).
|
|
628
|
+
'.ru-dock{display:flex;flex-direction:column;gap:8px;margin:0 0 8px}',
|
|
629
|
+
'.ru-dock-row{position:relative;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 14px;padding:10px 12px 10px 15px;border-radius:var(--dsw-radius-md);background:var(--dsw-alias-interactive-bg-hover);animation:ru-in 140ms ease-out}',
|
|
630
|
+
'.ru-dock-row::before{content:"";position:absolute;left:0;top:9px;bottom:9px;width:3px;border-radius:2px;background:var(--dsw-alias-state-business-primary)}',
|
|
631
|
+
'.ru-dock-main{display:flex;align-items:baseline;gap:8px;min-width:0;flex-wrap:wrap}',
|
|
632
|
+
'.ru-dock-who{font-size:11px;font-family:ui-monospace,Consolas,monospace;color:var(--dsw-alias-label-tertiary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:right}',
|
|
633
|
+
'.ru-dock-actions{grid-column:1 / -1;display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:4px}',
|
|
634
|
+
'.ru-tier{display:inline-flex;align-items:center;flex:0 0 auto;padding:1px 7px;border-radius:999px;font-size:11px;font-family:ui-monospace,Consolas,monospace;white-space:nowrap;background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 14%,transparent);color:var(--dsw-alias-state-business-primary)}',
|
|
635
|
+
'.ru-count{font-size:11px;font-variant-numeric:tabular-nums;font-family:ui-monospace,Consolas,monospace;color:var(--dsw-alias-label-secondary)}',
|
|
636
|
+
'.ru-clear{display:flex;align-items:center;gap:9px;flex-wrap:wrap;padding:8px 0 2px}',
|
|
637
|
+
'.ru-tool{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary);font-family:ui-monospace,Consolas,monospace}',
|
|
638
|
+
'.ru-hdrbtn{display:inline-flex;align-items:center;gap:6px;height:26px;padding:0 8px;border:0;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-tertiary);font-size:12px;font-variant-numeric:tabular-nums;cursor:pointer}',
|
|
639
|
+
'.ru-hdrbtn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
|
|
640
|
+
'.ru-hdrbtn:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}',
|
|
641
|
+
'.ru-hdrbtn.pending{color:var(--dsw-alias-state-business-primary);font-weight:600}',
|
|
642
|
+
'.ru-beacon{width:6px;height:6px;border-radius:50%;background:currentColor}',
|
|
643
|
+
'.ru-panel{display:flex;flex-direction:column;gap:4px;padding:14px 16px 20px;height:100%;overflow:auto}',
|
|
644
|
+
'.ru-panel-head{display:flex;align-items:baseline;justify-content:space-between;gap:10px;margin:6px 0 2px}',
|
|
645
|
+
'.ru-panel-title{margin:0;font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary)}',
|
|
646
|
+
'.ru-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 14px;padding:11px 0;border-top:0.5px solid var(--dsw-alias-border-l1)}',
|
|
647
|
+
'.ru-row:first-of-type{border-top:0}',
|
|
648
|
+
'.ru-row-main{display:flex;align-items:baseline;gap:8px;min-width:0;flex-wrap:wrap}',
|
|
649
|
+
'.ru-row-actions{grid-column:1 / -1;display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:4px}',
|
|
650
|
+
'.ru-empty{margin:0;padding:14px 0;font-size:12px;color:var(--dsw-alias-label-tertiary)}',
|
|
651
|
+
'.ru-why{grid-column:1 / -1;margin:0;font-size:11px;line-height:1.55;color:var(--dsw-alias-label-tertiary)}',
|
|
652
|
+
// The facts above sit in two columns; everything below them spans both, or a paragraph lands in
|
|
653
|
+
// the auto-width column and renders one word per line (measured 2026-10-15, on screen).
|
|
654
|
+
'.ru-row .ru-args{grid-column:1 / -1;max-width:100%}',
|
|
655
|
+
'.ru-record{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 14px;padding:7px 0;border-top:0.5px solid var(--dsw-alias-border-l1);font-size:11px;color:var(--dsw-alias-label-secondary)}',
|
|
656
|
+
'.ru-record:first-of-type{border-top:0}',
|
|
657
|
+
'.ru-record-when{font-family:ui-monospace,Consolas,monospace;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary);text-align:right}',
|
|
658
|
+
'@keyframes ru-in{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}}',
|
|
659
|
+
'@media (prefers-reduced-motion: reduce){.ru-dock-row{animation:none}}',
|
|
660
|
+
'.ru-args{margin:0;padding:6px 8px;max-height:96px;overflow:auto;border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-interactive-bg-hover);font-family:ui-monospace,Consolas,monospace;font-size:11px;white-space:pre-wrap;word-break:break-all;color:var(--dsw-alias-label-secondary)}',
|
|
661
|
+
'\.ru-role{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
|
|
662
|
+
'.ru-role-banner{display:flex;align-items:center;gap:9px;flex-wrap:wrap;padding:9px 11px;margin:8px 0;border:0.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-sm);font-size:12px}',
|
|
663
|
+
// An open listener is the one state on this page that has to be visible from across the room.
|
|
664
|
+
'.ru-role-banner.armed{border-color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 8%,transparent)}',
|
|
665
|
+
'.ru-card{border:0.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);padding:12px 14px;display:flex;flex-direction:column;gap:10px}',
|
|
666
|
+
'.ru-card + .ru-card{margin-top:10px}',
|
|
667
|
+
'.ru-card-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
|
|
668
|
+
'.ru-name{font-weight:500;font-size:13px}',
|
|
669
|
+
'.ru-badge{font-size:11px;padding:1px 7px;border-radius:999px;border:0.5px solid transparent;white-space:nowrap}',
|
|
670
|
+
'.ru-badge.ok{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary)}',
|
|
671
|
+
'.ru-badge.bad{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}',
|
|
672
|
+
'.ru-badge.warn{color:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary)}',
|
|
673
|
+
'.ru-badge.mute{color:var(--dsw-alias-label-tertiary);border-color:var(--dsw-alias-border-l2)}',
|
|
674
|
+
'.ru-kv{display:grid;grid-template-columns:auto 1fr;gap:3px 12px;font-size:12px}',
|
|
675
|
+
'.ru-k{color:var(--dsw-alias-label-tertiary);white-space:nowrap}',
|
|
676
|
+
'.ru-v{color:var(--dsw-alias-label-primary);word-break:break-all}',
|
|
677
|
+
'.ru-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center}',
|
|
678
|
+
'.ru-btn{font:inherit;font-size:12px;padding:4px 11px;border-radius:var(--dsw-radius-sm);border:0.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2,transparent);color:var(--dsw-alias-label-primary);cursor:pointer}',
|
|
679
|
+
'.ru-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
|
|
680
|
+
'.ru-btn:disabled{opacity:.5;cursor:default}',
|
|
681
|
+
'.ru-btn.primary{background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-foreground);border-color:transparent}',
|
|
682
|
+
'.ru-btn.danger{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}',
|
|
683
|
+
'.ru-btn:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color);outline-offset:1px}',
|
|
684
|
+
'.ru-msg{font-size:12px;padding:6px 9px;border-radius:var(--dsw-radius-sm)}',
|
|
685
|
+
'.ru-msg.err{color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent)}',
|
|
686
|
+
'.ru-msg.ok{color:var(--dsw-alias-state-success-primary)}',
|
|
687
|
+
'.ru-msg.mute{color:var(--dsw-alias-label-tertiary)}',
|
|
688
|
+
'.ru-table{width:100%;border-collapse:collapse;font-size:12px}',
|
|
689
|
+
'.ru-table th{text-align:left;font-weight:500;color:var(--dsw-alias-label-tertiary);padding:4px 8px 4px 0;border-bottom:0.5px solid var(--dsw-alias-border-l2)}',
|
|
690
|
+
'.ru-table td{padding:5px 8px 5px 0;border-bottom:0.5px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);vertical-align:top}',
|
|
691
|
+
'.ru-table td.mono{font-family:ui-monospace,Consolas,monospace;color:var(--dsw-alias-label-primary)}',
|
|
692
|
+
'.ru-mono{font-family:ui-monospace,Consolas,monospace}',
|
|
693
|
+
'.ru-scroll{overflow-x:auto}',
|
|
694
|
+
'.ru-dot{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:6px;vertical-align:1px}',
|
|
695
|
+
'.ru-dot.pass{background:var(--dsw-alias-state-success-primary)}',
|
|
696
|
+
'.ru-dot.fail{background:var(--dsw-alias-state-error-primary)}',
|
|
697
|
+
'.ru-dot.warning,.ru-dot.warn{background:var(--dsw-alias-state-business-primary)}',
|
|
698
|
+
'.ru-dot.unchecked,.ru-dot.skip,.ru-dot.unknown{background:var(--dsw-alias-label-tertiary)}',
|
|
699
|
+
'.ru-form{gap:2px}',
|
|
700
|
+
'.ru-field{display:flex;flex-direction:column;gap:4px;padding:8px 0}',
|
|
701
|
+
'.ru-field-label{font-size:12px;color:var(--dsw-alias-label-secondary)}',
|
|
702
|
+
'.ru-field-hint{font-size:11px;color:var(--dsw-alias-label-tertiary);line-height:1.5}',
|
|
703
|
+
'.ru-inline{flex-direction:row;align-items:center;gap:7px}',
|
|
704
|
+
'.ru-input{font:inherit;font-size:12px;padding:5px 8px;border-radius:var(--dsw-radius-sm);border:0.5px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);width:100%}',
|
|
705
|
+
'.ru-input:disabled{opacity:.6}',
|
|
706
|
+
'.ru-textarea{font-family:ui-monospace,Consolas,monospace;line-height:1.5;resize:vertical}',
|
|
707
|
+
'.ru-input:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color);outline-offset:1px}',
|
|
708
|
+
'.ru-source{font-size:11px;color:var(--dsw-alias-label-tertiary);line-height:1.6;word-break:break-all}',
|
|
709
|
+
// The screenshot-cache block on a worker card. A separate bordered area rather than more
|
|
710
|
+
// rows in the card's key/value grid: those rows describe the LINK, and mixing "is it
|
|
711
|
+
// reachable" with "how much disk is it using" made the cache numbers read as status.
|
|
712
|
+
'.ru-cache{margin-top:10px;padding:10px;border:1px solid var(--dsw-alias-border-secondary);border-radius:6px}',
|
|
713
|
+
'.ru-cache-head{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;margin-bottom:6px}',
|
|
714
|
+
'.ru-cache-side{display:flex;flex-direction:column;gap:1px}',
|
|
715
|
+
'.ru-cache-side .ru-k{font-size:11px}',
|
|
716
|
+
].join('')
|
|
717
|
+
|
|
718
|
+
/** Fill `{name}` placeholders. Kept local so the page does not depend on a t() option API. */
|
|
719
|
+
function fmt(template, values) {
|
|
720
|
+
return String(template).replace(/\{(\w+)\}/g, (whole, key) => (
|
|
721
|
+
Object.prototype.hasOwnProperty.call(values, key) ? String(values[key]) : whole
|
|
722
|
+
))
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* A byte count a person can read at a glance.
|
|
727
|
+
*
|
|
728
|
+
* Binary units on purpose: the caps are configured in bytes and the comparison the operator is
|
|
729
|
+
* making is against those caps, so 8 MiB must not print as "8.4 MB" and look like it overran.
|
|
730
|
+
*/
|
|
731
|
+
function formatBytes(value) {
|
|
732
|
+
const bytes = Number(value)
|
|
733
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return '0'
|
|
734
|
+
const units = ['B', 'KiB', 'MiB', 'GiB']
|
|
735
|
+
let n = bytes
|
|
736
|
+
let unit = 0
|
|
737
|
+
while (n >= 1024 && unit < units.length - 1) { n /= 1024; unit += 1 }
|
|
738
|
+
// One decimal only where it adds information: "0.4 KiB" is noise, "12.5 MiB" is not.
|
|
739
|
+
return `${unit === 0 || n >= 100 ? Math.round(n) : n.toFixed(1)} ${units[unit]}`
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* An audit row's timestamp as local wall-clock time, seconds precision.
|
|
744
|
+
*
|
|
745
|
+
* The trail is the only place the page shows absolute time, and an ISO string with
|
|
746
|
+
* milliseconds is unreadable when the question is "did I approve that, and when".
|
|
747
|
+
* An unparsable value is shown verbatim rather than as "Invalid Date".
|
|
748
|
+
*/
|
|
749
|
+
function formatTime(value) {
|
|
750
|
+
const date = new Date(String(value ?? ''))
|
|
751
|
+
if (Number.isNaN(date.getTime())) return String(value ?? '')
|
|
752
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
753
|
+
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* The verdict drives every colour and the availability badge on the page, so map it once.
|
|
758
|
+
*
|
|
759
|
+
* WHY `warn` IS NOT "unavailable": the worker self-check produces four verdicts, and only two of
|
|
760
|
+
* them mean the worker cannot be used.
|
|
761
|
+
*
|
|
762
|
+
* ready -- every check passed
|
|
763
|
+
* warn -- every BLOCKING check passed; some advisory notes remain. The product's own
|
|
764
|
+
* dispatch gate (`remote-provider`) blocks on a check whose status is `fail` and
|
|
765
|
+
* ignores warnings entirely, so this worker works.
|
|
766
|
+
* partial -- a check could not be evaluated at all
|
|
767
|
+
* blocked -- a blocking check FAILED. This one is unusable.
|
|
768
|
+
*
|
|
769
|
+
* The page used to render anything that was not `ready`/`unknown` as "unavailable", so a worker
|
|
770
|
+
* with zero blocking failures and one advisory note -- which is exactly what the reference
|
|
771
|
+
* worker reports, and what the screenshot that prompted this showed -- was labelled unusable
|
|
772
|
+
* while its own detail line said `blocking 0`. Two parts of the same card disagreed, and the
|
|
773
|
+
* loud one was wrong. That is the same wrong rule the live harness had, fixed there first.
|
|
774
|
+
*/
|
|
775
|
+
function verdictClass(verdict) {
|
|
776
|
+
if (verdict === 'ready') return 'ok'
|
|
777
|
+
// Advisory only: usable, with something worth reading.
|
|
778
|
+
if (verdict === 'warn' || verdict === 'warning') return 'warn'
|
|
779
|
+
// Not evaluated -- not a fault of the worker, but not a pass either.
|
|
780
|
+
if (verdict === 'partial') return 'mute'
|
|
781
|
+
if (verdict === 'blocked') return 'bad'
|
|
782
|
+
return 'mute'
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** The badge text for a verdict. Kept next to `verdictClass` so the two cannot drift apart. */
|
|
786
|
+
function verdictText(t, verdict) {
|
|
787
|
+
if (verdict === 'ready') return t('verdictReady')
|
|
788
|
+
if (verdict === 'warn' || verdict === 'warning') return t('verdictWarn')
|
|
789
|
+
if (verdict === 'partial') return t('verdictPartial')
|
|
790
|
+
if (verdict === 'blocked') return t('verdictBlocked')
|
|
791
|
+
return t('verdictUnknown')
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** A check row's status as a CSS class; DSH reports `warn`, the stylesheet also allows `warning`. */
|
|
795
|
+
function checkClass(status) {
|
|
796
|
+
if (status === 'pass') return 'pass'
|
|
797
|
+
if (status === 'fail') return 'fail'
|
|
798
|
+
if (status === 'warn' || status === 'warning') return 'warning'
|
|
799
|
+
return 'unchecked'
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Render a byte budget for the input field: whole MB when it lines up, bare bytes otherwise.
|
|
804
|
+
*
|
|
805
|
+
* WHY NOT ALWAYS BYTES: the field is a human's, and `2147483648` is not a number anyone
|
|
806
|
+
* reasons about. WHY NOT ALWAYS MB: a budget of 0 (no ceiling) and a budget of 500 KB (an
|
|
807
|
+
* odd but legal value, e.g. set for a test) must not both render as `0 MB`.
|
|
808
|
+
*/
|
|
809
|
+
function budgetText(bytes) {
|
|
810
|
+
const n = Math.floor(Number(bytes))
|
|
811
|
+
if (!Number.isFinite(n) || n <= 0) return '0'
|
|
812
|
+
return n % (1024 * 1024) === 0 ? `${n / (1024 * 1024)}MB` : String(n)
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Parse the operator's budget text into bytes. Accepts `512MB`, `1.5 GB`, `2048`, `0`.
|
|
817
|
+
* @returns {number | undefined} undefined for anything unusable, so the field can refuse
|
|
818
|
+
* rather than silently applying a guess -- an off-by-a-million typo here either deletes
|
|
819
|
+
* nothing or deletes everything, and both look like the control being broken.
|
|
820
|
+
*/
|
|
821
|
+
function parseBudgetText(text) {
|
|
822
|
+
if (typeof text === 'number') return Number.isFinite(text) ? Math.floor(text) : undefined
|
|
823
|
+
if (typeof text !== 'string') return undefined
|
|
824
|
+
const raw = text.trim()
|
|
825
|
+
if (raw === '') return undefined
|
|
826
|
+
const m = /^(-?\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/i.exec(raw)
|
|
827
|
+
if (m === null) return undefined
|
|
828
|
+
const value = Number(m[1])
|
|
829
|
+
if (!Number.isFinite(value)) return undefined
|
|
830
|
+
const unit = (m[2] ?? '').toLowerCase()
|
|
831
|
+
const scale = unit === '' || unit === 'b' ? 1 : unit === 'kb' ? 1024 : unit === 'mb' ? 1024 * 1024 : 1024 * 1024 * 1024
|
|
832
|
+
return Math.floor(value * scale)
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const TERMINAL_STATES = ['completed', 'error', 'aborted', 'cancelled']
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* One worker's screenshot cache: what it occupies on each side, and the controls.
|
|
839
|
+
*
|
|
840
|
+
* WHY IT LIVES ON THE WORKER CARD rather than in the global settings: the accumulation is
|
|
841
|
+
* PER WORKER on both sides -- the host keeps one directory of `<peerId>-<stamp>.png` per
|
|
842
|
+
* worker, and each worker keeps its own -- so a single global "clear cache" button could not
|
|
843
|
+
* say which worker it would empty. The threshold itself is global (one setting governs every
|
|
844
|
+
* worker), which is why the input writes through to the settings route rather than being a
|
|
845
|
+
* per-worker value; that split is stated in the hint so the control is not a surprise.
|
|
846
|
+
*
|
|
847
|
+
* BOTH SIDES ARE SHOWN because the operator's question is "where did my disk go", and either
|
|
848
|
+
* side could be the answer. Showing only the host's would have made the 218 MB incident look
|
|
849
|
+
* like a worker problem, and showing only the worker's would have hidden the larger half.
|
|
850
|
+
*/
|
|
851
|
+
function CachePanel(props) {
|
|
852
|
+
const { t, row, maxBytes, onCache } = props
|
|
853
|
+
const host = row.hostCache ?? null
|
|
854
|
+
const worker = row.workerCache ?? null
|
|
855
|
+
const [budget, setBudget] = React.useState(budgetText(maxBytes))
|
|
856
|
+
const [busy, setBusy] = React.useState('')
|
|
857
|
+
const [msg, setMsg] = React.useState('')
|
|
858
|
+
|
|
859
|
+
// Follow the saved setting when it changes underneath us -- e.g. another tab saves, or the
|
|
860
|
+
// snapshot lands after the first render. Without this the field would keep showing the
|
|
861
|
+
// value the page first painted, and the next Apply would write that stale one back.
|
|
862
|
+
React.useEffect(() => { setBudget(budgetText(maxBytes)) }, [maxBytes])
|
|
863
|
+
|
|
864
|
+
const run = async (scope, op) => {
|
|
865
|
+
const parsed = parseBudgetText(budget)
|
|
866
|
+
// A blank field means "no limit", which is a real choice and not an error. Anything else
|
|
867
|
+
// that does not parse is refused here, at the field, rather than being sent as a guess.
|
|
868
|
+
const chosen = budget.trim() === '' ? 0 : parsed
|
|
869
|
+
if (chosen === undefined) {
|
|
870
|
+
setMsg(t('cacheSaveFailed'))
|
|
871
|
+
return
|
|
872
|
+
}
|
|
873
|
+
setBusy(`${scope}:${op}`)
|
|
874
|
+
setMsg('')
|
|
875
|
+
try {
|
|
876
|
+
// `maxBytes` doubles as "save this budget": the host writes it to the settings file when
|
|
877
|
+
// it differs, so Apply and Clear are the same call with a different `op`.
|
|
878
|
+
const data = await onCache(scope, op, chosen)
|
|
879
|
+
setMsg(String(data?.message ?? ''))
|
|
880
|
+
} catch (error) {
|
|
881
|
+
setMsg(error instanceof Error ? error.message : String(error))
|
|
882
|
+
} finally {
|
|
883
|
+
setBusy('')
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const side = (labelKey, info, unsupportedKey) => {
|
|
888
|
+
const known = info !== null && info !== undefined
|
|
889
|
+
const bytes = Number(info?.bytes ?? 0)
|
|
890
|
+
const files = Number(info?.files ?? 0)
|
|
891
|
+
const over = Number(info?.overBudgetBytes ?? 0)
|
|
892
|
+
const detail = !known
|
|
893
|
+
? t(unsupportedKey)
|
|
894
|
+
: `${formatBytes(bytes)} \u00b7 ${fmt(t('cacheFiles'), { n: files })}${over > 0 ? ` \u00b7 ${fmt(t('cacheOver'), { size: formatBytes(over) })}` : ''}`
|
|
895
|
+
return h('div', { className: 'ru-cache-side', key: labelKey },
|
|
896
|
+
h('span', { className: 'ru-k' }, t(labelKey)),
|
|
897
|
+
h('span', {
|
|
898
|
+
className: `ru-v${!known || over > 0 ? ' ru-dim' : ''}`,
|
|
899
|
+
// The worker's own directory is shown with its full path in the title: two machines
|
|
900
|
+
// both have a "shots" directory, and the path is what tells them apart.
|
|
901
|
+
}, detail),
|
|
902
|
+
known && info.dir !== undefined && info.dir !== '' ? h('span', { className: 'ru-note dim' }, String(info.dir)) : null)
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
return h('div', { className: 'ru-cache' },
|
|
906
|
+
h('div', { className: 'ru-cache-head' },
|
|
907
|
+
h('span', { className: 'ru-name' }, t('groupCache')),
|
|
908
|
+
h('span', {
|
|
909
|
+
className: 'ru-note',
|
|
910
|
+
}, maxBytes > 0 ? fmt(t('cacheLimitText'), { size: formatBytes(maxBytes) }) : t('cacheNoLimit'))),
|
|
911
|
+
h('div', { className: 'ru-kv' },
|
|
912
|
+
side('labelCacheHost', host, 'cacheNone'),
|
|
913
|
+
side('labelCacheWorker', worker?.supported === true ? worker : null,
|
|
914
|
+
worker !== null && worker !== undefined && worker.error !== '' ? 'cacheUnreachable' : 'cacheUnsupported')),
|
|
915
|
+
h('label', { className: 'ru-field' },
|
|
916
|
+
h('span', { className: 'ru-field-label' }, t('fieldShotCache')),
|
|
917
|
+
h('input', {
|
|
918
|
+
className: 'ru-input',
|
|
919
|
+
type: 'text',
|
|
920
|
+
value: budget,
|
|
921
|
+
placeholder: '512MB',
|
|
922
|
+
onChange: (event) => setBudget(event.target.value),
|
|
923
|
+
}),
|
|
924
|
+
h('span', { className: 'ru-field-hint' }, t('fieldShotCacheHint'))),
|
|
925
|
+
h('div', { className: 'ru-actions' },
|
|
926
|
+
h('button', {
|
|
927
|
+
type: 'button', className: 'ru-btn', disabled: busy !== '',
|
|
928
|
+
onClick: () => void run('host', 'prune'),
|
|
929
|
+
}, busy === 'host:prune' ? t('busy') : fmt(t('btnCacheApply'), { scope: t('labelCacheHost') })),
|
|
930
|
+
h('button', {
|
|
931
|
+
type: 'button', className: 'ru-btn', disabled: busy !== '',
|
|
932
|
+
onClick: () => void run('host', 'clear'),
|
|
933
|
+
}, busy === 'host:clear' ? t('busy') : fmt(t('btnCacheClear'), { scope: t('labelCacheHost') })),
|
|
934
|
+
// The worker buttons are offered even when the relay cannot report: a relay that cannot
|
|
935
|
+
// report also cannot prune, so the buttons would fail -- but they fail with a message
|
|
936
|
+
// that says so, which is more useful than a disabled button with no explanation.
|
|
937
|
+
h('button', {
|
|
938
|
+
type: 'button', className: 'ru-btn', disabled: busy !== '',
|
|
939
|
+
onClick: () => void run('worker', 'prune'),
|
|
940
|
+
}, busy === 'worker:prune' ? t('busy') : fmt(t('btnCacheApply'), { scope: t('labelCacheWorker') })),
|
|
941
|
+
h('button', {
|
|
942
|
+
type: 'button', className: 'ru-btn danger', disabled: busy !== '',
|
|
943
|
+
onClick: () => void run('worker', 'clear'),
|
|
944
|
+
}, busy === 'worker:clear' ? t('busy') : fmt(t('btnCacheClear'), { scope: t('labelCacheWorker') }))),
|
|
945
|
+
msg === '' ? null : h('p', { className: 'ru-note' }, msg))
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* How often the settings page re-reads the snapshot while it is visible.
|
|
950
|
+
*
|
|
951
|
+
* Sized against the approval budget, not against taste: a request may only be answerable for
|
|
952
|
+
* the configured window (180s by default), so a row that takes much longer than a couple of
|
|
953
|
+
* seconds to appear eats into the time a person has to react. `refresh()` touches only the
|
|
954
|
+
* snapshot, so this cannot disturb a half-filled form or an in-flight action.
|
|
955
|
+
*/
|
|
956
|
+
const APPROVAL_POLL_MS = 2500
|
|
957
|
+
|
|
958
|
+
function WorkerCard(props) {
|
|
959
|
+
const { t, row, busy, onAction, onEdit, onDelete, onCache, cacheMaxBytes } = props
|
|
960
|
+
const [showChecks, setShowChecks] = React.useState(false)
|
|
961
|
+
const report = row.report
|
|
962
|
+
const conn = row.connection
|
|
963
|
+
const verdict = report === null || report === undefined ? 'unknown' : String(report.verdict ?? 'unknown')
|
|
964
|
+
const checks = Array.isArray(report?.checks) ? report.checks : []
|
|
965
|
+
// Failures are why the operator opened this page, so surface them without a click.
|
|
966
|
+
const interesting = checks.filter((c) => c.status !== 'pass')
|
|
967
|
+
const listed = showChecks ? checks : interesting
|
|
968
|
+
|
|
969
|
+
const linkText = conn?.connected === true ? t('linkConnected') : t('linkDown')
|
|
970
|
+
const linkBits = []
|
|
971
|
+
if (typeof conn?.connects === 'number' && conn.connects > 0) linkBits.push(fmt(t('reconnects'), { n: conn.connects }))
|
|
972
|
+
if (typeof conn?.bridge?.approvals === 'number' && conn.bridge.approvals > 0) linkBits.push(fmt(t('approvals'), { n: conn.bridge.approvals }))
|
|
973
|
+
if (typeof conn?.heartbeatFailures === 'number' && conn.heartbeatFailures > 0) linkBits.push(fmt(t('heartbeatFailures'), { n: conn.heartbeatFailures }))
|
|
974
|
+
|
|
975
|
+
const kv = []
|
|
976
|
+
kv.push(h('span', { key: 'k-cwd', className: 'ru-k' }, t('labelCwd')))
|
|
977
|
+
kv.push(h('span', { key: 'v-cwd', className: 'ru-v ru-mono' }, String(row.cwd ?? '')))
|
|
978
|
+
kv.push(h('span', { key: 'k-session', className: 'ru-k' }, t('labelSession')))
|
|
979
|
+
kv.push(h('span', { key: 'v-session', className: 'ru-v' },
|
|
980
|
+
report === null || report === undefined
|
|
981
|
+
? '\u2014'
|
|
982
|
+
: `${String(report.worker ?? '')} \u00b7 ${String(report.sessionSegment ?? 'unknown')}`))
|
|
983
|
+
kv.push(h('span', { key: 'k-link', className: 'ru-k' }, t('labelLink')))
|
|
984
|
+
kv.push(h('span', { key: 'v-link', className: 'ru-v' },
|
|
985
|
+
linkBits.length === 0 ? linkText : `${linkText} \u00b7 ${linkBits.join(' \u00b7 ')}`))
|
|
986
|
+
kv.push(h('span', { key: 'k-counts', className: 'ru-k' }, t('labelCounts')))
|
|
987
|
+
kv.push(h('span', { key: 'v-counts', className: 'ru-v' },
|
|
988
|
+
report === null || report === undefined
|
|
989
|
+
? '\u2014'
|
|
990
|
+
: fmt(t('countsText'), {
|
|
991
|
+
b: report.blockingFailures ?? 0,
|
|
992
|
+
w: report.warnings ?? 0,
|
|
993
|
+
u: report.unchecked ?? 0,
|
|
994
|
+
})))
|
|
995
|
+
kv.push(h('span', { key: 'k-active', className: 'ru-k' }, t('labelActive')))
|
|
996
|
+
kv.push(h('span', { key: 'v-active', className: 'ru-v' }, String(row.activeTasks ?? 0)))
|
|
997
|
+
// What level this node's remote sessions ACTUALLY run at, as reported by the relay that spawns
|
|
998
|
+
// them. Until this existed, the master could not tell a GUI-capable worker from a confined one:
|
|
999
|
+
// "I set it to full access" and "the session runs workspace-write" were indistinguishable from
|
|
1000
|
+
// here (measured 2026-10-15 -- and they were both true at once, for hours).
|
|
1001
|
+
const mode = typeof report?.sessionMode === 'string' ? report.sessionMode : ''
|
|
1002
|
+
kv.push(h('span', { key: 'k-mode', className: 'ru-k' }, t('labelSessionMode')))
|
|
1003
|
+
kv.push(h('span', {
|
|
1004
|
+
key: 'v-mode',
|
|
1005
|
+
className: `ru-v ru-mono${mode !== '' && mode !== 'danger-full-access' ? ' ru-mode-warn' : ''}`,
|
|
1006
|
+
}, mode === ''
|
|
1007
|
+
? t('sessionModeUnset')
|
|
1008
|
+
: (mode === 'danger-full-access' ? mode : `${mode} \u00b7 ${t('sessionModeNeedsFull')}`)))
|
|
1009
|
+
|
|
1010
|
+
// The mode the relay will actually spawn (above) versus the mode the WORKER's settings page
|
|
1011
|
+
// has saved. A manual relay can run for hours with an old spec while its settings show the
|
|
1012
|
+
// intended value; that disagreement is repeated here, where the operator is looking.
|
|
1013
|
+
const cardWarnings = []
|
|
1014
|
+
const configuredMode = typeof report?.configuredSessionMode === 'string' ? report.configuredSessionMode : ''
|
|
1015
|
+
if (report?.sessionModeMismatch === true && configuredMode !== '') {
|
|
1016
|
+
cardWarnings.push(fmt(t('sessionModeMismatch'), {
|
|
1017
|
+
eff: String(report?.effectiveSessionMode ?? mode),
|
|
1018
|
+
cfg: configuredMode,
|
|
1019
|
+
}))
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// The integrity reading comes from S20: a copy of a signed system binary launched from the
|
|
1023
|
+
// workspace and from %TEMP%. A degraded reading is why a GUI app cannot write %LOCALAPPDATA%
|
|
1024
|
+
// even though the agent shell is High.
|
|
1025
|
+
const integrity = report?.integrity
|
|
1026
|
+
if (integrity !== null && typeof integrity === 'object'
|
|
1027
|
+
&& (integrity.workspace !== null || integrity.temp !== null)) {
|
|
1028
|
+
const levelOf = (entry) => (entry === null || entry === undefined ? '?' : String(entry.level ?? '?'))
|
|
1029
|
+
kv.push(h('span', { key: 'k-integrity', className: 'ru-k' }, t('labelIntegrity')))
|
|
1030
|
+
kv.push(h('span', {
|
|
1031
|
+
key: 'v-integrity',
|
|
1032
|
+
className: `ru-v ru-mono${integrity.degraded === true ? ' ru-mode-warn' : ''}`,
|
|
1033
|
+
}, `workspace=${levelOf(integrity.workspace)} \u00b7 temp=${levelOf(integrity.temp)}`))
|
|
1034
|
+
if (integrity.degraded === true) {
|
|
1035
|
+
cardWarnings.push(fmt(t('integrityDegraded'), {
|
|
1036
|
+
ws: levelOf(integrity.workspace),
|
|
1037
|
+
tmp: levelOf(integrity.temp),
|
|
1038
|
+
}))
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const head = [
|
|
1043
|
+
h('span', { key: 'name', className: 'ru-name' }, String(row.name ?? row.peerId ?? '')),
|
|
1044
|
+
h('span', { key: 'id', className: 'ru-badge mute ru-mono' }, String(row.peerId ?? '')),
|
|
1045
|
+
h('span', { key: 'verdict', className: `ru-badge ${verdictClass(verdict)}` },
|
|
1046
|
+
verdictText(t, verdict)),
|
|
1047
|
+
]
|
|
1048
|
+
if (report?.cachedAgeMs !== undefined && report?.cachedAgeMs !== null) {
|
|
1049
|
+
head.push(h('span', { key: 'age', className: 'ru-note' }, fmt(t('cachedAge'), { n: report.cachedAgeMs })))
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const actions = h('div', { className: 'ru-actions' },
|
|
1053
|
+
h('button', {
|
|
1054
|
+
type: 'button', className: 'ru-btn', disabled: busy !== '',
|
|
1055
|
+
onClick: () => onAction('selfcheck', row.peerId),
|
|
1056
|
+
}, busy === 'selfcheck' ? t('busy') : t('btnSelfcheck')),
|
|
1057
|
+
h('button', {
|
|
1058
|
+
type: 'button', className: 'ru-btn', disabled: busy !== '',
|
|
1059
|
+
onClick: () => onAction('screenshot', row.peerId),
|
|
1060
|
+
}, busy === 'screenshot' ? t('busy') : t('btnScreenshot')),
|
|
1061
|
+
h('button', {
|
|
1062
|
+
type: 'button', className: 'ru-btn', disabled: busy !== '',
|
|
1063
|
+
onClick: () => onAction('ping', row.peerId),
|
|
1064
|
+
}, busy === 'ping' ? t('busy') : t('btnPing')),
|
|
1065
|
+
h('button', {
|
|
1066
|
+
type: 'button', className: 'ru-btn', disabled: busy !== '',
|
|
1067
|
+
onClick: () => onEdit(row),
|
|
1068
|
+
}, t('btnEdit')),
|
|
1069
|
+
h('button', {
|
|
1070
|
+
type: 'button', className: 'ru-btn danger', disabled: busy !== '',
|
|
1071
|
+
onClick: () => onDelete(row),
|
|
1072
|
+
}, t('btnDelete')),
|
|
1073
|
+
h('span', { key: 'hint', className: 'ru-note' }, t('screenshotHint')),
|
|
1074
|
+
)
|
|
1075
|
+
|
|
1076
|
+
return h('div', { className: 'ru-card' },
|
|
1077
|
+
h('div', { className: 'ru-card-head' }, head),
|
|
1078
|
+
h('div', { className: 'ru-kv' }, kv),
|
|
1079
|
+
...cardWarnings.map((warning, index) => h('div', { key: `warn-${index}`, className: 'ru-msg err' }, warning)),
|
|
1080
|
+
// The cache block sits directly under the facts about the worker and above the check
|
|
1081
|
+
// table: it is about this worker, and the check table is the long, rarely-needed part.
|
|
1082
|
+
h(CachePanel, { t, row, maxBytes: cacheMaxBytes, onCache }),
|
|
1083
|
+
// A missing report is stated as its own fact. The previous revision let it fall
|
|
1084
|
+
// through to a generic empty state, which reads as "nothing here" rather than
|
|
1085
|
+
// "the probe did not complete".
|
|
1086
|
+
report === null || report === undefined
|
|
1087
|
+
? h('div', { className: 'ru-msg mute' }, t('reportPending'))
|
|
1088
|
+
: null,
|
|
1089
|
+
row.error !== undefined && row.error !== '' ? h('div', { className: 'ru-msg err' }, String(row.error)) : null,
|
|
1090
|
+
// Configuration, not connectivity: a worker whose task directory is unusable will be
|
|
1091
|
+
// refused at dispatch no matter how healthy the link is. Shown without a click, because
|
|
1092
|
+
// the whole failure mode was that it never announced itself until a delegation failed.
|
|
1093
|
+
row.configProblem !== undefined && row.configProblem !== '' ? h('div', { className: 'ru-msg err' }, String(row.configProblem)) : null,
|
|
1094
|
+
actions,
|
|
1095
|
+
checks.length === 0 ? null : h('div', {},
|
|
1096
|
+
h('button', {
|
|
1097
|
+
type: 'button', className: 'ru-btn',
|
|
1098
|
+
onClick: () => setShowChecks((v) => !v),
|
|
1099
|
+
}, showChecks ? t('hideChecks') : fmt(t('showChecks'), { n: checks.length })),
|
|
1100
|
+
),
|
|
1101
|
+
listed.length === 0 ? null : h('div', { className: 'ru-scroll' },
|
|
1102
|
+
h('table', { className: 'ru-table' },
|
|
1103
|
+
h('thead', null, h('tr', null,
|
|
1104
|
+
h('th', null, t('colId')),
|
|
1105
|
+
h('th', null, t('colName')),
|
|
1106
|
+
h('th', null, t('colStatus')),
|
|
1107
|
+
h('th', null, t('colActual')),
|
|
1108
|
+
h('th', null, t('colFix')))),
|
|
1109
|
+
h('tbody', null, listed.map((c) => h('tr', { key: String(c.id) },
|
|
1110
|
+
h('td', { className: 'mono' }, String(c.id)),
|
|
1111
|
+
h('td', null, String(c.name ?? '')),
|
|
1112
|
+
h('td', null,
|
|
1113
|
+
h('span', { className: `ru-dot ${checkClass(String(c.status))}` }),
|
|
1114
|
+
String(c.status ?? '')),
|
|
1115
|
+
h('td', { className: 'mono' }, String(c.actual ?? '')),
|
|
1116
|
+
h('td', null, String(c.fix ?? ''))))))),
|
|
1117
|
+
)
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* Add/edit form for one worker.
|
|
1122
|
+
*
|
|
1123
|
+
* The token field starts EMPTY even when one is stored: the host never sends a token to
|
|
1124
|
+
* the browser, so the field can only mean "replace it". Blank therefore reads as "keep
|
|
1125
|
+
* the stored one", which is what makes renaming a worker possible without re-entering a
|
|
1126
|
+
* credential the operator may not have to hand.
|
|
1127
|
+
*/
|
|
1128
|
+
function WorkerForm(props) {
|
|
1129
|
+
const { t, draft, onSave, onCancel, busy, isNew } = props
|
|
1130
|
+
const [form, setForm] = React.useState({
|
|
1131
|
+
id: draft.id ?? '',
|
|
1132
|
+
name: draft.name ?? '',
|
|
1133
|
+
url: draft.url ?? '',
|
|
1134
|
+
token: '',
|
|
1135
|
+
cwd: draft.cwd ?? '',
|
|
1136
|
+
enabled: draft.enabled !== false,
|
|
1137
|
+
serialQueue: draft.serialQueue ?? 1,
|
|
1138
|
+
})
|
|
1139
|
+
const set = (key) => (event) => setForm((f) => ({ ...f, [key]: event.target.value }))
|
|
1140
|
+
|
|
1141
|
+
const idOk = form.id.trim() !== ''
|
|
1142
|
+
const urlOk = form.url.trim() !== ''
|
|
1143
|
+
// A new worker with no token would be created and then fail at dispatch; refuse here.
|
|
1144
|
+
const tokenOk = !isNew || form.token.trim() !== ''
|
|
1145
|
+
const valid = idOk && urlOk && tokenOk
|
|
1146
|
+
|
|
1147
|
+
const field = (key, labelKey, hintKey, opts = {}) => h('label', { className: 'ru-field' },
|
|
1148
|
+
h('span', { className: 'ru-field-label' }, t(labelKey)),
|
|
1149
|
+
h('input', {
|
|
1150
|
+
className: 'ru-input',
|
|
1151
|
+
type: opts.type ?? 'text',
|
|
1152
|
+
value: form[key],
|
|
1153
|
+
// The id is the identity the rest of the plugin keys on, so it is fixed after
|
|
1154
|
+
// creation rather than silently renaming an existing worker.
|
|
1155
|
+
disabled: opts.disabled === true,
|
|
1156
|
+
placeholder: opts.placeholder ?? '',
|
|
1157
|
+
onChange: set(key),
|
|
1158
|
+
}),
|
|
1159
|
+
hintKey === undefined ? null : h('span', { className: 'ru-field-hint' }, t(hintKey)))
|
|
1160
|
+
|
|
1161
|
+
const tokenHint = draft.tokenSet === true
|
|
1162
|
+
? `${t('fieldTokenHint')} \u2014 ${fmt(t('tokenStored'), { hint: draft.tokenHint ?? '' })}`
|
|
1163
|
+
: `${t('fieldTokenHint')} \u2014 ${t('tokenMissing')}`
|
|
1164
|
+
|
|
1165
|
+
return h('div', { className: 'ru-card ru-form' },
|
|
1166
|
+
h('div', { className: 'ru-card-head' },
|
|
1167
|
+
h('span', { className: 'ru-name' }, isNew ? t('formAddTitle') : fmt(t('formEditTitle'), { id: draft.id ?? '' }))),
|
|
1168
|
+
|
|
1169
|
+
field('id', 'fieldId', 'fieldIdHint', { disabled: !isNew }),
|
|
1170
|
+
field('name', 'fieldName'),
|
|
1171
|
+
field('url', 'fieldUrl', 'fieldUrlHint'),
|
|
1172
|
+
h('label', { className: 'ru-field' },
|
|
1173
|
+
h('span', { className: 'ru-field-label' }, isNew ? t('fieldTokenNew') : t('fieldToken')),
|
|
1174
|
+
h('input', { className: 'ru-input', type: 'password', value: form.token, autocomplete: 'new-password', onChange: set('token') }),
|
|
1175
|
+
h('span', { className: 'ru-field-hint' }, tokenHint)),
|
|
1176
|
+
field('cwd', 'fieldCwd', 'fieldCwdHint'),
|
|
1177
|
+
|
|
1178
|
+
h('label', { className: 'ru-field ru-inline' },
|
|
1179
|
+
h('input', { type: 'checkbox', checked: form.enabled, onChange: (e) => setForm((f) => ({ ...f, enabled: e.target.checked })) }),
|
|
1180
|
+
h('span', null, t('fieldEnabled'))),
|
|
1181
|
+
h('label', { className: 'ru-field' },
|
|
1182
|
+
h('span', { className: 'ru-field-label' }, t('fieldSerial')),
|
|
1183
|
+
h('input', { className: 'ru-input', type: 'number', min: '1', value: form.serialQueue, onChange: set('serialQueue') }),
|
|
1184
|
+
h('span', { className: 'ru-field-hint' }, t('fieldSerialHint'))),
|
|
1185
|
+
|
|
1186
|
+
h('div', { className: 'ru-actions' },
|
|
1187
|
+
h('button', {
|
|
1188
|
+
type: 'button', className: 'ru-btn primary', disabled: !valid || busy !== '',
|
|
1189
|
+
onClick: () => onSave({
|
|
1190
|
+
id: form.id.trim(),
|
|
1191
|
+
name: form.name.trim() === '' ? form.id.trim() : form.name.trim(),
|
|
1192
|
+
url: form.url.trim(),
|
|
1193
|
+
cwd: form.cwd.trim(),
|
|
1194
|
+
enabled: form.enabled,
|
|
1195
|
+
serialQueue: Number(form.serialQueue) || 1,
|
|
1196
|
+
// Omit entirely when blank so the host keeps the stored token.
|
|
1197
|
+
...(form.token === '' ? {} : { token: form.token }),
|
|
1198
|
+
}),
|
|
1199
|
+
}, busy === 'save' ? t('busy') : t('btnSave')),
|
|
1200
|
+
h('button', { type: 'button', className: 'ru-btn', disabled: busy !== '', onClick: onCancel }, t('btnCancel'))),
|
|
1201
|
+
)
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* Approval policy and connection tuning: the settings that apply to every worker.
|
|
1206
|
+
*
|
|
1207
|
+
* Changing the tier or the timeout rebuilds every worker on the Host, because a running
|
|
1208
|
+
* approval decider captured the old policy. That is deliberate -- a decision remembered
|
|
1209
|
+
* under one policy must not carry into another.
|
|
1210
|
+
*/
|
|
1211
|
+
function AdvancedSettings(props) {
|
|
1212
|
+
const { t, settings, busy, onSave } = props
|
|
1213
|
+
const [form, setForm] = React.useState({
|
|
1214
|
+
requiredTier: settings.requiredTier ?? 'workspace-write',
|
|
1215
|
+
approvalTimeoutMs: settings.approvalTimeoutMs ?? 180000,
|
|
1216
|
+
heartbeatMs: settings.heartbeatMs ?? 15000,
|
|
1217
|
+
keepShots: settings.keepShots ?? 20,
|
|
1218
|
+
workerSessionMode: settings.workerSessionMode ?? 'danger-full-access',
|
|
1219
|
+
workerModel: settings.workerModel ?? '',
|
|
1220
|
+
auditMaxBytes: settings.auditMaxBytes ?? 8388608,
|
|
1221
|
+
auditKeepFiles: settings.auditKeepFiles ?? 5,
|
|
1222
|
+
delegationPolicy: settings.delegationPolicy ?? '',
|
|
1223
|
+
})
|
|
1224
|
+
const set = (key) => (event) => setForm((f) => ({ ...f, [key]: event.target.value }))
|
|
1225
|
+
const number = (key, labelKey, hintKey, min) => h('label', { className: 'ru-field' },
|
|
1226
|
+
h('span', { className: 'ru-field-label' }, t(labelKey)),
|
|
1227
|
+
h('input', { className: 'ru-input', type: 'number', min: String(min), value: form[key], onChange: set(key) }),
|
|
1228
|
+
h('span', { className: 'ru-field-hint' }, t(hintKey)))
|
|
1229
|
+
|
|
1230
|
+
return h('div', { className: 'ru-group' },
|
|
1231
|
+
h('h3', { className: 'ru-group-title' }, t('groupAdvanced')),
|
|
1232
|
+
h('label', { className: 'ru-field' },
|
|
1233
|
+
h('span', { className: 'ru-field-label' }, t('fieldTier')),
|
|
1234
|
+
h('select', { className: 'ru-input', value: form.requiredTier, onChange: set('requiredTier') },
|
|
1235
|
+
// Only the tiers the review engine actually understands.
|
|
1236
|
+
...[['read-only', 'read-only'], ['workspace-write', 'workspace-write'], ['danger-full-access', 'danger-full-access']]
|
|
1237
|
+
.map(([value, label]) => h('option', { key: value, value }, label))),
|
|
1238
|
+
h('span', { className: 'ru-field-hint' }, t('fieldTierHint'))),
|
|
1239
|
+
number('approvalTimeoutMs', 'fieldApprovalTimeout', 'fieldApprovalTimeoutHint', 1000),
|
|
1240
|
+
number('heartbeatMs', 'fieldHeartbeat', 'fieldHeartbeatHint', 0),
|
|
1241
|
+
// A screenshot is a full-resolution PNG (~9.5 MB measured), so this is the difference
|
|
1242
|
+
// between a session-long GUI run costing hundreds of megabytes and costing a bounded
|
|
1243
|
+
// amount. Negative disables pruning, for an operator who archives them elsewhere.
|
|
1244
|
+
number('keepShots', 'fieldKeepShots', 'fieldKeepShotsHint', -1),
|
|
1245
|
+
// The level this machine's own remote sessions run at, written into the launch spec when the
|
|
1246
|
+
// worker role is armed. It sits next to `requiredTier` on purpose, because they are easy to
|
|
1247
|
+
// confuse and are opposites: `requiredTier` is the ceiling the MASTER's review engine will
|
|
1248
|
+
// accept from a request, while this is the floor the WORKER's own sessions start at.
|
|
1249
|
+
h('label', { className: 'ru-field' },
|
|
1250
|
+
h('span', { className: 'ru-field-label' }, t('fieldWorkerSessionMode')),
|
|
1251
|
+
h('select', { className: 'ru-input', value: form.workerSessionMode, onChange: set('workerSessionMode') },
|
|
1252
|
+
...[['danger-full-access', 'danger-full-access'], ['workspace-write', 'workspace-write'], ['read-only', 'read-only']]
|
|
1253
|
+
.map(([value, label]) => h('option', { key: value, value }, label))),
|
|
1254
|
+
h('span', { className: 'ru-field-hint' }, t('fieldWorkerSessionModeHint')),
|
|
1255
|
+
// Configured and EFFECTIVE are two different facts. The select above is what the next
|
|
1256
|
+
// arming will write; this is what the running listener was started with. Showing only the
|
|
1257
|
+
// first is how "I set it to full access" and "it still runs workspace-write" looked the
|
|
1258
|
+
// same on the page (PITFALLS F38/F41).
|
|
1259
|
+
...(() => {
|
|
1260
|
+
const live = settings.workerSession
|
|
1261
|
+
if (live === null || live === undefined) return []
|
|
1262
|
+
const notes = []
|
|
1263
|
+
if (live.running === true && typeof live.effective === 'string' && live.effective !== '') {
|
|
1264
|
+
notes.push(h('span', { key: 'mode-effective', className: 'ru-field-hint ru-mono' },
|
|
1265
|
+
fmt(t('fieldWorkerSessionModeEffective'), { mode: live.effective })))
|
|
1266
|
+
}
|
|
1267
|
+
if (live.reArmRequired === true) {
|
|
1268
|
+
notes.push(h('span', { key: 'mode-rearm', className: 'ru-note warn' },
|
|
1269
|
+
fmt(t('workerSessionReArmRequired'), {
|
|
1270
|
+
cfg: String(live.configured ?? form.workerSessionMode),
|
|
1271
|
+
eff: String(live.effective ?? '?'),
|
|
1272
|
+
})))
|
|
1273
|
+
} else if (live.running !== true) {
|
|
1274
|
+
notes.push(h('span', { key: 'mode-stopped', className: 'ru-field-hint' }, t('workerSessionNotRunning')))
|
|
1275
|
+
}
|
|
1276
|
+
return notes
|
|
1277
|
+
})()),
|
|
1278
|
+
// The model the worker's ACP sessions run with. It is a deployment value for the same
|
|
1279
|
+
// reason the mode is: `dsh-acp` advertises image capability at startup from the configured
|
|
1280
|
+
// route, so it cannot be requested per call -- and a wrong id silently disables images.
|
|
1281
|
+
h('label', { className: 'ru-field' },
|
|
1282
|
+
h('span', { className: 'ru-field-label' }, t('fieldWorkerModel')),
|
|
1283
|
+
h('input', {
|
|
1284
|
+
className: 'ru-input ru-mono',
|
|
1285
|
+
value: form.workerModel,
|
|
1286
|
+
placeholder: 'provider/model',
|
|
1287
|
+
onChange: set('workerModel'),
|
|
1288
|
+
}),
|
|
1289
|
+
h('span', { className: 'ru-field-hint' }, t('fieldWorkerModelHint'))),
|
|
1290
|
+
// The audit trail is append-only, so it has no natural stopping point: without a cap it
|
|
1291
|
+
// grows for as long as the plugin is installed. These two are what bound it. Negative
|
|
1292
|
+
// disables the bound, for an operator who archives the trail elsewhere.
|
|
1293
|
+
number('auditMaxBytes', 'fieldAuditMaxBytes', 'fieldAuditMaxBytesHint', -1),
|
|
1294
|
+
number('auditKeepFiles', 'fieldAuditKeepFiles', 'fieldAuditKeepFilesHint', -1),
|
|
1295
|
+
h('label', { className: 'ru-field' },
|
|
1296
|
+
h('span', { className: 'ru-field-label' }, t('fieldPolicy')),
|
|
1297
|
+
h('textarea', {
|
|
1298
|
+
className: 'ru-input ru-textarea',
|
|
1299
|
+
rows: '7',
|
|
1300
|
+
value: form.delegationPolicy,
|
|
1301
|
+
onChange: set('delegationPolicy'),
|
|
1302
|
+
}),
|
|
1303
|
+
h('span', { className: 'ru-field-hint' }, t('fieldPolicyHint'))),
|
|
1304
|
+
h('div', { className: 'ru-actions', style: { marginTop: '8px' } },
|
|
1305
|
+
h('button', {
|
|
1306
|
+
type: 'button', className: 'ru-btn', disabled: busy !== '',
|
|
1307
|
+
onClick: () => onSave({
|
|
1308
|
+
requiredTier: form.requiredTier,
|
|
1309
|
+
approvalTimeoutMs: Number(form.approvalTimeoutMs) || 180000,
|
|
1310
|
+
heartbeatMs: Number(form.heartbeatMs) || 0,
|
|
1311
|
+
keepShots: Number.isFinite(Number(form.keepShots)) ? Number(form.keepShots) : 20,
|
|
1312
|
+
workerSessionMode: form.workerSessionMode,
|
|
1313
|
+
workerModel: String(form.workerModel ?? '').trim(),
|
|
1314
|
+
auditMaxBytes: Number.isFinite(Number(form.auditMaxBytes)) ? Number(form.auditMaxBytes) : 8388608,
|
|
1315
|
+
auditKeepFiles: Number.isFinite(Number(form.auditKeepFiles)) ? Number(form.auditKeepFiles) : 5,
|
|
1316
|
+
// An empty string is a real choice -- it switches the injected prompt off while
|
|
1317
|
+
// leaving the tools gated -- so it is sent as-is rather than coerced.
|
|
1318
|
+
delegationPolicy: String(form.delegationPolicy),
|
|
1319
|
+
}),
|
|
1320
|
+
}, busy === 'policy' ? t('busy') : t('btnSave'))),
|
|
1321
|
+
)
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* The worker role: this machine accepting remote execution.
|
|
1326
|
+
*
|
|
1327
|
+
* Decision 6 says the page must show the port, the reachable range, the current confinement
|
|
1328
|
+
* state and how to close it BEFORE anything is opened. So the panel leads with the state and
|
|
1329
|
+
* the gate's verdict, and the arm button stays disabled until a check has actually passed --
|
|
1330
|
+
* rather than offering a button whose failure the operator discovers by clicking it.
|
|
1331
|
+
*
|
|
1332
|
+
* The credential is shown ONCE, immediately after arming, and the panel says so. Every later
|
|
1333
|
+
* render reads `tokenHint` from the status, which is a mask.
|
|
1334
|
+
*/
|
|
1335
|
+
/** Clipboard, best effort: the textarea below stays selectable when the API is unavailable. */
|
|
1336
|
+
async function copyToClipboard(text) {
|
|
1337
|
+
try {
|
|
1338
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText !== undefined) {
|
|
1339
|
+
await navigator.clipboard.writeText(text)
|
|
1340
|
+
return true
|
|
1341
|
+
}
|
|
1342
|
+
} catch { /* the operator can select the text instead */ }
|
|
1343
|
+
return false
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/**
|
|
1347
|
+
* The sandbox mediator: state, plus the exact prescription when it is not usable.
|
|
1348
|
+
*
|
|
1349
|
+
* WHY THIS EXISTS AT ALL: the mediator is deployed by hand and can disappear (a cleanup, a disk
|
|
1350
|
+
* migration, an uninstall) while the role is simply disarmed. Measured 2026-11-04 on this very
|
|
1351
|
+
* machine: both mediator directories gone, and the page said nothing, because the probe only
|
|
1352
|
+
* runs when somebody clicks Check. "Not verified yet" and "the mediator is not there" are
|
|
1353
|
+
* different statements, and only the second one tells an operator what to do.
|
|
1354
|
+
*
|
|
1355
|
+
* It renders nothing when the host does not send a report (an older build): a page must not
|
|
1356
|
+
* invent a state it was not told.
|
|
1357
|
+
*/
|
|
1358
|
+
function MediatorBlock(props) {
|
|
1359
|
+
const { t, sandbox, probe } = props
|
|
1360
|
+
if (sandbox === null || sandbox === undefined) return null
|
|
1361
|
+
const ready = sandbox.state === 'ready'
|
|
1362
|
+
// The prescription applies when the artifacts are missing or the profile is unwired, AND when
|
|
1363
|
+
// the probe failed even though they look present (a stale mediator, confinement that no longer
|
|
1364
|
+
// holds). NOT when the host cannot have one: printing a Windows command there is advice that
|
|
1365
|
+
// cannot work.
|
|
1366
|
+
const showFix = sandbox.state === 'missing' || sandbox.state === 'unwired'
|
|
1367
|
+
|| (ready && probe?.ok === false)
|
|
1368
|
+
const prescription = String(sandbox.prescription?.text ?? '')
|
|
1369
|
+
const copy = () => { void copyToClipboard(prescription) }
|
|
1370
|
+
|
|
1371
|
+
return h('div', null,
|
|
1372
|
+
h('div', { className: 'ru-kv' },
|
|
1373
|
+
h('span', { className: 'ru-k' }, t('roleMediator')),
|
|
1374
|
+
h('span', { className: 'ru-v' },
|
|
1375
|
+
ready
|
|
1376
|
+
? h('span', { className: 'ru-badge ok' }, t('roleMediatorReady'))
|
|
1377
|
+
: h('span', { className: 'ru-badge warn' },
|
|
1378
|
+
sandbox.state === 'unsupported' ? t('roleMediatorUnsupported') : t('roleMediatorMissing')))),
|
|
1379
|
+
ready ? null : h('p', { className: 'ru-note dim' }, String(sandbox.reason ?? '')),
|
|
1380
|
+
showFix
|
|
1381
|
+
? h('div', { className: 'ru-card' },
|
|
1382
|
+
h('p', { className: 'ru-note warn' }, t('roleMediatorFix')),
|
|
1383
|
+
h('textarea', {
|
|
1384
|
+
className: 'ru-input ru-textarea',
|
|
1385
|
+
rows: '9',
|
|
1386
|
+
readOnly: true,
|
|
1387
|
+
// The exact text an agent or a person needs: this machine's paths, the one command,
|
|
1388
|
+
// and what "done" looks like. A placeholder path here is the documentation that failed.
|
|
1389
|
+
value: prescription,
|
|
1390
|
+
}),
|
|
1391
|
+
h('div', { className: 'ru-actions' },
|
|
1392
|
+
h('button', { type: 'button', className: 'ru-btn', onClick: copy }, t('roleMediatorCopy'))),
|
|
1393
|
+
h('p', { className: 'ru-note dim' }, t('roleMediatorHint')))
|
|
1394
|
+
: null)
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* The worker's own environment readiness (`slave/selfcheck.ps1`), plus the machine-specific
|
|
1399
|
+
* fix prompt.
|
|
1400
|
+
*
|
|
1401
|
+
* WHY THIS IS ADVISORY: the session checks depend on somebody being logged in and unlocked,
|
|
1402
|
+
* which is an OPERATIONAL state, and S8 moves the mouse. It therefore runs only on an explicit
|
|
1403
|
+
* "Check" and never blocks arming; the master's dispatch gate remains the hard gate.
|
|
1404
|
+
*/
|
|
1405
|
+
function EnvironmentBlock(props) {
|
|
1406
|
+
const { t, readiness } = props
|
|
1407
|
+
if (readiness === null || readiness === undefined) return null
|
|
1408
|
+
const ran = readiness.ok === true
|
|
1409
|
+
const blocked = ran && String(readiness.verdict ?? '') === 'blocked'
|
|
1410
|
+
const failing = ran && Array.isArray(readiness.checks)
|
|
1411
|
+
? readiness.checks.filter((c) => String(c.status ?? '') !== 'pass').slice(0, 6)
|
|
1412
|
+
: []
|
|
1413
|
+
const prompt = String(readiness.prompt?.text ?? '')
|
|
1414
|
+
const copy = () => { void copyToClipboard(prompt) }
|
|
1415
|
+
return h('div', null,
|
|
1416
|
+
h('div', { className: 'ru-kv' },
|
|
1417
|
+
h('span', { className: 'ru-k' }, t('roleEnv')),
|
|
1418
|
+
h('span', { className: 'ru-v' },
|
|
1419
|
+
ran
|
|
1420
|
+
? (blocked
|
|
1421
|
+
? h('span', { className: 'ru-badge warn' }, t('roleEnvBlocked'))
|
|
1422
|
+
: h('span', { className: 'ru-badge ok' }, t('roleEnvReady')))
|
|
1423
|
+
: h('span', { className: 'ru-badge warn' }, t('roleEnvUnavailable')))),
|
|
1424
|
+
ran ? null : h('p', { className: 'ru-note dim' }, String(readiness.reason ?? '')),
|
|
1425
|
+
blocked
|
|
1426
|
+
? h('div', { className: 'ru-card' },
|
|
1427
|
+
h('p', { className: 'ru-note warn' }, t('roleEnvFix')),
|
|
1428
|
+
...failing.map((check, index) => h('p', {
|
|
1429
|
+
key: `${String(check.id ?? '?')}-${String(index)}`,
|
|
1430
|
+
className: 'ru-note dim ru-mono',
|
|
1431
|
+
}, `${String(check.id ?? '?')} ${String(check.status ?? '?')}${String(check.actual ?? '') === '' ? '' : `: ${String(check.actual).slice(0, 160)}`}`)),
|
|
1432
|
+
prompt === ''
|
|
1433
|
+
? null
|
|
1434
|
+
: h('div', { className: 'ru-actions' },
|
|
1435
|
+
h('button', { type: 'button', className: 'ru-btn', onClick: copy }, t('roleEnvCopy'))),
|
|
1436
|
+
h('p', { className: 'ru-note dim' }, t('roleEnvHint')))
|
|
1437
|
+
: null)
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
function WorkerRolePanel(props) {
|
|
1441
|
+
const { t, role, sandbox, readiness, busy, onPreflight, onArm, onDisarm } = props
|
|
1442
|
+
const [bindAddress, setBindAddress] = React.useState('')
|
|
1443
|
+
const [port, setPort] = React.useState(7331)
|
|
1444
|
+
const [taskDir, setTaskDir] = React.useState(String(role?.taskCwd ?? ''))
|
|
1445
|
+
const [check, setCheck] = React.useState(null)
|
|
1446
|
+
const [issued, setIssued] = React.useState(null)
|
|
1447
|
+
|
|
1448
|
+
const interfaces = Array.isArray(role?.interfaces) ? role.interfaces : []
|
|
1449
|
+
const best = interfaces.find((i) => i.score > 0)
|
|
1450
|
+
const chosen = bindAddress === '' ? (best?.address ?? '') : bindAddress
|
|
1451
|
+
const armed = role?.armed === true
|
|
1452
|
+
const checked = check !== null
|
|
1453
|
+
const blocked = checked && check.ok !== true
|
|
1454
|
+
// A fresh Check carries a fresh diagnosis. Prefer it over the snapshot's copy, which was read
|
|
1455
|
+
// when the page loaded -- the operator may have run the prescription in between.
|
|
1456
|
+
const sandboxNow = check?.sandbox ?? sandbox
|
|
1457
|
+
// The environment check runs only when the operator asks for it, so its result arrives with
|
|
1458
|
+
// the check response; the snapshot carries the LAST result so a reload keeps showing it.
|
|
1459
|
+
const readinessNow = check?.readiness ?? readiness
|
|
1460
|
+
const listener = role?.listener
|
|
1461
|
+
// `armed` is the decision; `running` is the fact. A relay that exited (the port was taken by
|
|
1462
|
+
// the old scheduled task, for instance) leaves the first true and the second false. Saying
|
|
1463
|
+
// "armed" alone there is the same class of lie this project keeps removing.
|
|
1464
|
+
const listenerDead = armed && listener !== null && listener !== undefined
|
|
1465
|
+
&& listener.running !== true && listener.exited !== null && listener.exited !== undefined
|
|
1466
|
+
|
|
1467
|
+
const doCheck = async () => {
|
|
1468
|
+
// `checkEnvironment` is what makes the host run the worker's OWN selfcheck: it costs a
|
|
1469
|
+
// PowerShell process and S8 moves the mouse, so it must not ride on a page load.
|
|
1470
|
+
const result = await onPreflight({ bindAddress: chosen, port: Number(port), taskCwd: taskDir, checkEnvironment: true })
|
|
1471
|
+
setCheck(result)
|
|
1472
|
+
}
|
|
1473
|
+
const doArm = async () => {
|
|
1474
|
+
const result = await onArm({ bindAddress: chosen, port: Number(port), taskCwd: taskDir })
|
|
1475
|
+
if (result?.ok === true) setIssued({ token: result.token, pairing: result.pairing })
|
|
1476
|
+
else setCheck({
|
|
1477
|
+
ok: false,
|
|
1478
|
+
blockers: result?.blockers ?? [String(result?.error ?? '')],
|
|
1479
|
+
// The refusal carries a fresh diagnosis; keep it so the prescription shown beside the
|
|
1480
|
+
// blockers is the one that matches this attempt, not the page-load snapshot.
|
|
1481
|
+
sandbox: result?.sandbox,
|
|
1482
|
+
})
|
|
1483
|
+
}
|
|
1484
|
+
const doDisarm = async (forget) => {
|
|
1485
|
+
await onDisarm({ forget })
|
|
1486
|
+
setIssued(null)
|
|
1487
|
+
setCheck(null)
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
const uptime = (ms) => {
|
|
1491
|
+
const s = Math.max(0, Math.round(Number(ms ?? 0) / 1000))
|
|
1492
|
+
if (s < 60) return `${s}s`
|
|
1493
|
+
if (s < 3600) return `${Math.floor(s / 60)}m`
|
|
1494
|
+
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
return h('div', null,
|
|
1498
|
+
// The state first, and loudly: an operator who cannot see whether a listener is open has
|
|
1499
|
+
// no way to tell whether they left one open.
|
|
1500
|
+
h('div', { className: armed ? 'ru-role-banner armed' : 'ru-role-banner' },
|
|
1501
|
+
h('span', { className: `ru-badge ${armed ? 'bad' : 'mute'}` }, armed ? t('roleArmed') : t('roleDisarmed')),
|
|
1502
|
+
armed
|
|
1503
|
+
? h('span', { className: 'ru-dim' },
|
|
1504
|
+
`${String(role.bindAddress)}:${String(role.port)} \u00b7 ${t('roleUptime')} ${uptime(role.uptimeMs)} \u00b7 ${t('roleToken')} ${String(role.tokenHint ?? '')}`)
|
|
1505
|
+
: h('span', { className: 'ru-dim' }, t('roleDisarmedNote'))),
|
|
1506
|
+
|
|
1507
|
+
role?.lastError ? h('p', { className: 'ru-note warn' }, String(role.lastError)) : null,
|
|
1508
|
+
|
|
1509
|
+
// "Armed" but nothing listening is a distinct, actionable state -- most often the old
|
|
1510
|
+
// scheduled-task relay still owns the port when migrating to plugin-managed mode.
|
|
1511
|
+
listenerDead ? h('p', { className: 'ru-note warn' }, fmt(t('roleListenerExited'), {
|
|
1512
|
+
code: String(listener.exited?.code ?? (listener.exited?.signal !== undefined ? listener.exited.signal : '?')),
|
|
1513
|
+
detail: String(listener.output ?? listener.lastError ?? '').trim().split(/\r?\n/u).slice(-3).join(' / ').slice(0, 300),
|
|
1514
|
+
})) : null,
|
|
1515
|
+
|
|
1516
|
+
// What confinement remote work would actually have.
|
|
1517
|
+
h('div', { className: 'ru-kv' },
|
|
1518
|
+
h('span', { className: 'ru-k' }, t('roleSandbox')),
|
|
1519
|
+
h('span', { className: 'ru-v' },
|
|
1520
|
+
role?.probe?.ok === true
|
|
1521
|
+
? h('span', { className: 'ru-badge ok' }, t('roleSandboxOk'))
|
|
1522
|
+
: h('span', { className: 'ru-badge warn' }, t('roleSandboxUnknown'))),
|
|
1523
|
+
// The mode the supervised relay was STARTED with. The arming panel's setting is the
|
|
1524
|
+
// next arm's value; this row is the one that answers "what is running now".
|
|
1525
|
+
role?.listener?.running === true
|
|
1526
|
+
? h('span', { key: 'listener-k', className: 'ru-k' }, t('labelListenerMode'))
|
|
1527
|
+
: null,
|
|
1528
|
+
role?.listener?.running === true
|
|
1529
|
+
? h('span', { key: 'listener-v', className: 'ru-v ru-mono' }, String(role.listener.sessionMode ?? '') === ''
|
|
1530
|
+
? t('sessionModeUnset')
|
|
1531
|
+
: String(role.listener.sessionMode))
|
|
1532
|
+
: null),
|
|
1533
|
+
|
|
1534
|
+
// The mediator, whether or not anyone has clicked Check. Rendered on its own because the
|
|
1535
|
+
// report is a newer field: an older host simply sends none, and the panel must look exactly
|
|
1536
|
+
// as it did before.
|
|
1537
|
+
h(MediatorBlock, { t, sandbox: sandboxNow, probe: role?.probe }),
|
|
1538
|
+
h(EnvironmentBlock, { t, readiness: readinessNow }),
|
|
1539
|
+
|
|
1540
|
+
armed ? null : h('div', null,
|
|
1541
|
+
h('label', { className: 'ru-field' },
|
|
1542
|
+
h('span', { className: 'ru-field-label' }, t('roleBind')),
|
|
1543
|
+
h('select', {
|
|
1544
|
+
className: 'ru-input',
|
|
1545
|
+
value: chosen,
|
|
1546
|
+
onChange: (event) => { setBindAddress(event.target.value); setCheck(null) },
|
|
1547
|
+
},
|
|
1548
|
+
// Everything is listed, including the interfaces that must not be chosen, so the page
|
|
1549
|
+
// can explain rather than silently omit and leave the operator guessing.
|
|
1550
|
+
...interfaces.map((iface) => h('option', {
|
|
1551
|
+
key: iface.address,
|
|
1552
|
+
value: iface.address,
|
|
1553
|
+
disabled: iface.score <= 0,
|
|
1554
|
+
}, `${iface.address} \u2014 ${iface.name}${iface.virtual ? ` (${t('roleVirtual')})` : ''}${iface.score <= 0 ? ` (${t('roleNotOffered')})` : ''}`))),
|
|
1555
|
+
h('span', { className: 'ru-field-hint' }, t('roleBindHint'))),
|
|
1556
|
+
|
|
1557
|
+
h('label', { className: 'ru-field' },
|
|
1558
|
+
h('span', { className: 'ru-field-label' }, t('rolePort')),
|
|
1559
|
+
h('input', {
|
|
1560
|
+
className: 'ru-input', type: 'number', min: '1', max: '65535',
|
|
1561
|
+
value: port,
|
|
1562
|
+
onChange: (event) => { setPort(event.target.value); setCheck(null) },
|
|
1563
|
+
}),
|
|
1564
|
+
h('span', { className: 'ru-field-hint' }, t('rolePortHint'))),
|
|
1565
|
+
|
|
1566
|
+
// The directory the master will hand tasks. Asked for HERE, on the machine where the path
|
|
1567
|
+
// exists, and carried in the pairing string -- the master cannot know a path on this box,
|
|
1568
|
+
// and a worker paired without one cannot accept a task at all (PITFALLS F16).
|
|
1569
|
+
h('label', { className: 'ru-field' },
|
|
1570
|
+
h('span', { className: 'ru-field-label' }, t('roleTaskDir')),
|
|
1571
|
+
h('input', {
|
|
1572
|
+
className: 'ru-input ru-mono',
|
|
1573
|
+
value: taskDir,
|
|
1574
|
+
placeholder: 'C:\\dsh-remote\\work',
|
|
1575
|
+
onChange: (event) => { setTaskDir(event.target.value); setCheck(null) },
|
|
1576
|
+
}),
|
|
1577
|
+
h('span', { className: 'ru-field-hint' }, t('roleTaskDirHint'))),
|
|
1578
|
+
|
|
1579
|
+
h('div', { className: 'ru-actions' },
|
|
1580
|
+
h('button', {
|
|
1581
|
+
type: 'button', className: 'ru-btn', disabled: busy !== '' || chosen === '',
|
|
1582
|
+
onClick: () => void doCheck(),
|
|
1583
|
+
}, busy === 'role-check' ? t('busy') : t('roleCheck')),
|
|
1584
|
+
h('button', {
|
|
1585
|
+
type: 'button', className: 'ru-btn danger',
|
|
1586
|
+
// Disabled until a check has passed. Decision 4 refuses arming when the sandbox
|
|
1587
|
+
// does not contain this machine, and offering a button that can only fail teaches
|
|
1588
|
+
// the operator to click through warnings.
|
|
1589
|
+
disabled: busy !== '' || blocked || !checked || chosen === '',
|
|
1590
|
+
onClick: () => void doArm(),
|
|
1591
|
+
}, busy === 'role-arm' ? t('busy') : t('roleArm'))),
|
|
1592
|
+
|
|
1593
|
+
checked
|
|
1594
|
+
? h('div', { className: check.ok === true ? 'ru-msg ok' : 'ru-msg err' },
|
|
1595
|
+
check.ok === true
|
|
1596
|
+
? t('roleCheckOk')
|
|
1597
|
+
: h('div', null,
|
|
1598
|
+
h('div', null, t('roleCheckFailed')),
|
|
1599
|
+
...(check.blockers ?? []).map((b, i) => h('div', { key: String(i) }, `\u00b7 ${String(b)}`))))
|
|
1600
|
+
: null),
|
|
1601
|
+
|
|
1602
|
+
// Shown once, and labelled as such.
|
|
1603
|
+
issued === null ? null : h('div', { className: 'ru-card' },
|
|
1604
|
+
h('p', { className: 'ru-note warn' }, t('roleIssuedOnce')),
|
|
1605
|
+
h('label', { className: 'ru-field' },
|
|
1606
|
+
h('span', { className: 'ru-field-label' }, t('rolePairing')),
|
|
1607
|
+
h('textarea', { className: 'ru-input ru-textarea', rows: '3', readOnly: true, value: String(issued.pairing ?? '') }),
|
|
1608
|
+
h('span', { className: 'ru-field-hint' }, t('rolePairingHint'))),
|
|
1609
|
+
h('label', { className: 'ru-field' },
|
|
1610
|
+
h('span', { className: 'ru-field-label' }, t('roleTokenLabel')),
|
|
1611
|
+
h('input', { className: 'ru-input ru-mono', readOnly: true, value: String(issued.token ?? '') }),
|
|
1612
|
+
h('span', { className: 'ru-field-hint' }, t('roleTokenHint'))),
|
|
1613
|
+
h('div', { className: 'ru-actions' },
|
|
1614
|
+
h('button', { type: 'button', className: 'ru-btn', onClick: () => setIssued(null) }, t('roleIssuedDone')))),
|
|
1615
|
+
|
|
1616
|
+
armed
|
|
1617
|
+
? h('div', { className: 'ru-actions', style: { marginTop: '10px' } },
|
|
1618
|
+
h('button', {
|
|
1619
|
+
type: 'button', className: 'ru-btn danger', disabled: busy !== '',
|
|
1620
|
+
onClick: () => void doDisarm(false),
|
|
1621
|
+
}, t('roleStop')),
|
|
1622
|
+
h('button', {
|
|
1623
|
+
type: 'button', className: 'ru-btn', disabled: busy !== '',
|
|
1624
|
+
onClick: () => void doDisarm(true),
|
|
1625
|
+
}, t('roleStopForget')),
|
|
1626
|
+
h('span', { className: 'ru-dim' }, t('roleStopHint')))
|
|
1627
|
+
: null)
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
/**
|
|
1631
|
+
* The plugin's mark, inline.
|
|
1632
|
+
*
|
|
1633
|
+
* WHY IT IS INLINED RATHER THAN READ FROM `assets/icon.svg`: this page is a browser
|
|
1634
|
+
* module with no build step and no access to the package's files, and the entity has to
|
|
1635
|
+
* be self-contained anyway. When the manifest icon changes, this must change with it --
|
|
1636
|
+
* `test/settings-page.test.js` asserts the two stay in step.
|
|
1637
|
+
*
|
|
1638
|
+
* WHY IT IS HERE AT ALL: the Settings NAV icon is not plugin-supplied. DSH's
|
|
1639
|
+
* `navIcon(id)` in dsh-client-ui-settings-general is a hardcoded id -> built-in icon map
|
|
1640
|
+
* with a gear fallback, and a `settings.section` row carries only {id, order, label}.
|
|
1641
|
+
* So a third-party plugin cannot change its nav glyph; the page header is where its own
|
|
1642
|
+
* mark can actually appear.
|
|
1643
|
+
*/
|
|
1644
|
+
function RemoteUseIcon(props) {
|
|
1645
|
+
const size = props?.size ?? 20
|
|
1646
|
+
const title = props?.title ?? 'Remote workers'
|
|
1647
|
+
return h('svg', {
|
|
1648
|
+
className: 'ru-mark', width: size, height: size, viewBox: '0 0 64 64',
|
|
1649
|
+
role: 'img', 'aria-label': title, focusable: 'false',
|
|
1650
|
+
},
|
|
1651
|
+
h('g', {
|
|
1652
|
+
fill: 'none', stroke: 'currentColor', strokeWidth: 4,
|
|
1653
|
+
strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
1654
|
+
},
|
|
1655
|
+
h('path', { d: 'M42 30H54V10H28V22' }),
|
|
1656
|
+
h('path', { d: 'M48 30v5' }),
|
|
1657
|
+
h('path', { d: 'M43 35h10' }),
|
|
1658
|
+
h('path', { d: 'M10 22h32v24H10Z' }),
|
|
1659
|
+
h('path', { d: 'M26 46v6' }),
|
|
1660
|
+
h('path', { d: 'M18 52h16' })))
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
function RemoteUseSection(props) {
|
|
1664
|
+
const { t } = props
|
|
1665
|
+
const [snapshot, setSnapshot] = React.useState(null)
|
|
1666
|
+
const [loadError, setLoadError] = React.useState('')
|
|
1667
|
+
const [busy, setBusy] = React.useState('')
|
|
1668
|
+
const [status, setStatus] = React.useState({ kind: 'idle', text: '' })
|
|
1669
|
+
/** null = no form; `{ draft, isNew }` = the add/edit form is open. */
|
|
1670
|
+
const [editing, setEditing] = React.useState(null)
|
|
1671
|
+
/** The paste-a-pairing-string panel. Closed by default: it is the shortcut, not the path. */
|
|
1672
|
+
const [pairingOpen, setPairingOpen] = React.useState(false)
|
|
1673
|
+
const [pairingText, setPairingText] = React.useState('')
|
|
1674
|
+
const [pairingCwd, setPairingCwd] = React.useState('')
|
|
1675
|
+
|
|
1676
|
+
const call = React.useCallback(async (action, payload) => {
|
|
1677
|
+
const response = await fetch(ROUTE, {
|
|
1678
|
+
method: action === 'get' ? 'GET' : 'POST',
|
|
1679
|
+
credentials: 'same-origin',
|
|
1680
|
+
headers: action === 'get' ? undefined : { 'content-type': 'application/json' },
|
|
1681
|
+
body: action === 'get' ? undefined : JSON.stringify({ action, ...(payload ?? {}) }),
|
|
1682
|
+
})
|
|
1683
|
+
const data = await response.json().catch(() => null)
|
|
1684
|
+
if (data === null) throw new Error(`HTTP ${response.status}`)
|
|
1685
|
+
if (data.ok !== true) throw new Error(typeof data.error === 'string' && data.error !== '' ? data.error : `HTTP ${response.status}`)
|
|
1686
|
+
return data
|
|
1687
|
+
}, [])
|
|
1688
|
+
|
|
1689
|
+
const refresh = React.useCallback(async () => {
|
|
1690
|
+
try {
|
|
1691
|
+
const data = await call('get')
|
|
1692
|
+
setSnapshot(data)
|
|
1693
|
+
setLoadError('')
|
|
1694
|
+
return data
|
|
1695
|
+
} catch (error) {
|
|
1696
|
+
setLoadError(error instanceof Error ? error.message : String(error))
|
|
1697
|
+
return null
|
|
1698
|
+
}
|
|
1699
|
+
}, [call])
|
|
1700
|
+
|
|
1701
|
+
const clearState = useClear(call, refresh)
|
|
1702
|
+
|
|
1703
|
+
React.useEffect(() => { void refresh() }, [refresh])
|
|
1704
|
+
// THE PAGE NEVER USED TO REFRESH ITSELF. Measured 2026-10-14: six approval requests expired
|
|
1705
|
+
// unanswered and the operator had never seen a prompt -- the snapshot was read on mount and
|
|
1706
|
+
// after their own actions, so a request that arrived while this page sat open stayed
|
|
1707
|
+
// invisible unless they happened to reload inside a ~88 second window.
|
|
1708
|
+
//
|
|
1709
|
+
// Poll only while the page is VISIBLE: a background tab polling forever is a cost with no
|
|
1710
|
+
// reader, and the one thing that must be timely here is what a person is looking at.
|
|
1711
|
+
React.useEffect(() => {
|
|
1712
|
+
if (typeof document === 'undefined' || typeof setInterval !== 'function') return undefined
|
|
1713
|
+
const timer = setInterval(() => {
|
|
1714
|
+
if (document.visibilityState !== 'visible') return
|
|
1715
|
+
void refresh()
|
|
1716
|
+
}, APPROVAL_POLL_MS)
|
|
1717
|
+
return () => clearInterval(timer)
|
|
1718
|
+
}, [refresh])
|
|
1719
|
+
|
|
1720
|
+
/** Describe what a save actually did, so "nothing seemed to happen" is impossible. */
|
|
1721
|
+
const describeApply = React.useCallback((applied, settings) => {
|
|
1722
|
+
const a = Array.isArray(applied?.added) ? applied.added.length : 0
|
|
1723
|
+
const r = Array.isArray(applied?.removed) ? applied.removed.length : 0
|
|
1724
|
+
const c = Array.isArray(applied?.changed) ? applied.changed.length : 0
|
|
1725
|
+
if (applied?.rebuilt === true) return t('savedRebuilt')
|
|
1726
|
+
if (a === 0 && r === 0 && c === 0) return t('savedNoChange')
|
|
1727
|
+
return fmt(t('savedApplied'), { a, r, c })
|
|
1728
|
+
}, [t])
|
|
1729
|
+
|
|
1730
|
+
const saveWorker = React.useCallback(async (draft) => {
|
|
1731
|
+
setBusy('save')
|
|
1732
|
+
setStatus({ kind: 'idle', text: '' })
|
|
1733
|
+
try {
|
|
1734
|
+
const data = await call('worker.save', { worker: draft })
|
|
1735
|
+
setEditing(null)
|
|
1736
|
+
setStatus({ kind: 'ok', text: describeApply(data.applied, data.settings) })
|
|
1737
|
+
await refresh()
|
|
1738
|
+
} catch (error) {
|
|
1739
|
+
setStatus({ kind: 'err', text: error instanceof Error ? error.message : String(error) })
|
|
1740
|
+
} finally {
|
|
1741
|
+
setBusy('')
|
|
1742
|
+
}
|
|
1743
|
+
}, [call, refresh, describeApply])
|
|
1744
|
+
|
|
1745
|
+
const deleteWorker = React.useCallback(async (row) => {
|
|
1746
|
+
// Deleting a worker is a live disconnect, so ask once with the id in the prompt.
|
|
1747
|
+
const ok = typeof window === 'undefined' || typeof window.confirm !== 'function'
|
|
1748
|
+
? true
|
|
1749
|
+
: window.confirm(fmt(t('confirmDelete'), { id: row.peerId }))
|
|
1750
|
+
if (!ok) return
|
|
1751
|
+
setBusy('delete')
|
|
1752
|
+
setStatus({ kind: 'idle', text: '' })
|
|
1753
|
+
try {
|
|
1754
|
+
const data = await call('worker.remove', { peerId: row.peerId })
|
|
1755
|
+
setEditing(null)
|
|
1756
|
+
setStatus({ kind: 'ok', text: describeApply(data.applied, data.settings) })
|
|
1757
|
+
await refresh()
|
|
1758
|
+
} catch (error) {
|
|
1759
|
+
setStatus({ kind: 'err', text: error instanceof Error ? error.message : String(error) })
|
|
1760
|
+
} finally {
|
|
1761
|
+
setBusy('')
|
|
1762
|
+
}
|
|
1763
|
+
}, [call, refresh, describeApply, t])
|
|
1764
|
+
|
|
1765
|
+
const savePolicy = React.useCallback(async (next) => {
|
|
1766
|
+
setBusy('policy')
|
|
1767
|
+
setStatus({ kind: 'idle', text: '' })
|
|
1768
|
+
try {
|
|
1769
|
+
const data = await call('settings.save', { settings: next })
|
|
1770
|
+
setStatus({ kind: 'ok', text: describeApply(data.applied, data.settings) })
|
|
1771
|
+
await refresh()
|
|
1772
|
+
} catch (error) {
|
|
1773
|
+
setStatus({ kind: 'err', text: error instanceof Error ? error.message : String(error) })
|
|
1774
|
+
} finally {
|
|
1775
|
+
setBusy('')
|
|
1776
|
+
}
|
|
1777
|
+
}, [call, refresh, describeApply])
|
|
1778
|
+
|
|
1779
|
+
const onDecide = React.useCallback(async (id, decision) => {
|
|
1780
|
+
setBusy(`approval:${id}`)
|
|
1781
|
+
setStatus({ kind: 'idle', text: '' })
|
|
1782
|
+
try {
|
|
1783
|
+
const data = await call('approvals.decide', { id, decision })
|
|
1784
|
+
setStatus({
|
|
1785
|
+
kind: 'ok',
|
|
1786
|
+
text: data.optionId === 'allow-once' ? t('approvalAllowed') : t('approvalDenied'),
|
|
1787
|
+
})
|
|
1788
|
+
// Re-read: the request may have expired between render and click, and the audit
|
|
1789
|
+
// trail gained a row either way.
|
|
1790
|
+
await refresh()
|
|
1791
|
+
} catch (error) {
|
|
1792
|
+
// A 409 here means the row was stale -- say so rather than looking like a crash.
|
|
1793
|
+
setStatus({ kind: 'err', text: error instanceof Error ? error.message : String(error) })
|
|
1794
|
+
await refresh()
|
|
1795
|
+
} finally {
|
|
1796
|
+
setBusy('')
|
|
1797
|
+
}
|
|
1798
|
+
}, [call, refresh, t])
|
|
1799
|
+
|
|
1800
|
+
/** Turn a `by` value from the audit trail into words. */
|
|
1801
|
+
const describeBy = React.useCallback((by) => {
|
|
1802
|
+
if (by === 'engine') return t('auditByEngine')
|
|
1803
|
+
// `page` and `api` replaced the single `human`, which a harness's own decision also got.
|
|
1804
|
+
// A row written before that split still says `human` and cannot be told apart any more.
|
|
1805
|
+
if (by === 'page') return t('auditByPage')
|
|
1806
|
+
if (by === 'api') return t('auditByApi')
|
|
1807
|
+
if (by === 'human') return t('auditByHuman')
|
|
1808
|
+
if (by === 'timeout') return t('auditByTimeout')
|
|
1809
|
+
if (by === 'no-answerer') return t('auditByNoAnswerer')
|
|
1810
|
+
if (by === 'disposed') return t('auditByDisposed')
|
|
1811
|
+
return String(by ?? '')
|
|
1812
|
+
}, [t])
|
|
1813
|
+
|
|
1814
|
+
const onPair = React.useCallback(async () => {
|
|
1815
|
+
setBusy('pair')
|
|
1816
|
+
setStatus({ kind: 'idle', text: '' })
|
|
1817
|
+
try {
|
|
1818
|
+
// `cwd` is only consulted when the string does not already carry one; a worker that
|
|
1819
|
+
// advertises its task directory makes this field unnecessary. Sent only when filled, so
|
|
1820
|
+
// a blank box never overrides what the worker said about itself.
|
|
1821
|
+
const body = { pairing: pairingText }
|
|
1822
|
+
if (pairingCwd.trim() !== '') body.cwd = pairingCwd.trim()
|
|
1823
|
+
await call('worker.pair', body)
|
|
1824
|
+
// Clear the field on success: the string is a live credential and there is no reason
|
|
1825
|
+
// to leave it sitting in a form.
|
|
1826
|
+
setPairingText('')
|
|
1827
|
+
setPairingCwd('')
|
|
1828
|
+
setPairingOpen(false)
|
|
1829
|
+
setStatus({ kind: 'ok', text: t('pairDone') })
|
|
1830
|
+
await refresh()
|
|
1831
|
+
} catch (error) {
|
|
1832
|
+
setStatus({ kind: 'err', text: error instanceof Error ? error.message : String(error) })
|
|
1833
|
+
} finally {
|
|
1834
|
+
setBusy('')
|
|
1835
|
+
}
|
|
1836
|
+
}, [call, refresh, pairingText, pairingCwd, t])
|
|
1837
|
+
|
|
1838
|
+
/**
|
|
1839
|
+
* Like `call`, but returns the body instead of throwing on a non-ok reply.
|
|
1840
|
+
*
|
|
1841
|
+
* The arming gate answers 409 with a LIST of blockers -- "the sandbox does not contain
|
|
1842
|
+
* this machine" plus "that address is not on this machine" -- and `call`'s throw would
|
|
1843
|
+
* reduce that to a single message, hiding the rest of what the operator has to fix.
|
|
1844
|
+
*/
|
|
1845
|
+
const callRaw = React.useCallback(async (action, payload) => {
|
|
1846
|
+
const response = await fetch(ROUTE, {
|
|
1847
|
+
method: 'POST',
|
|
1848
|
+
credentials: 'same-origin',
|
|
1849
|
+
headers: { 'content-type': 'application/json' },
|
|
1850
|
+
body: JSON.stringify({ action, ...(payload ?? {}) }),
|
|
1851
|
+
})
|
|
1852
|
+
const data = await response.json().catch(() => null)
|
|
1853
|
+
if (data === null) return { ok: false, error: `HTTP ${response.status}` }
|
|
1854
|
+
return data
|
|
1855
|
+
}, [])
|
|
1856
|
+
|
|
1857
|
+
const onRoleCheck = React.useCallback(async (request) => {
|
|
1858
|
+
setBusy('role-check')
|
|
1859
|
+
try {
|
|
1860
|
+
return await callRaw('worker.preflight', request)
|
|
1861
|
+
} finally {
|
|
1862
|
+
setBusy('')
|
|
1863
|
+
}
|
|
1864
|
+
}, [callRaw])
|
|
1865
|
+
|
|
1866
|
+
const onRoleArm = React.useCallback(async (request) => {
|
|
1867
|
+
setBusy('role-arm')
|
|
1868
|
+
try {
|
|
1869
|
+
const result = await callRaw('worker.arm', request)
|
|
1870
|
+
// Re-read either way: a refusal updates the probe verdict, a success the state.
|
|
1871
|
+
await refresh()
|
|
1872
|
+
return result
|
|
1873
|
+
} finally {
|
|
1874
|
+
setBusy('')
|
|
1875
|
+
}
|
|
1876
|
+
}, [callRaw, refresh])
|
|
1877
|
+
|
|
1878
|
+
const onRoleDisarm = React.useCallback(async (opts) => {
|
|
1879
|
+
setBusy('role-disarm')
|
|
1880
|
+
try {
|
|
1881
|
+
await callRaw('worker.disarm', opts ?? {})
|
|
1882
|
+
await refresh()
|
|
1883
|
+
} finally {
|
|
1884
|
+
setBusy('')
|
|
1885
|
+
}
|
|
1886
|
+
}, [callRaw, refresh])
|
|
1887
|
+
|
|
1888
|
+
const onAction = React.useCallback(async (action, peerId) => {
|
|
1889
|
+
setBusy(action)
|
|
1890
|
+
setStatus({ kind: 'idle', text: '' })
|
|
1891
|
+
try {
|
|
1892
|
+
const data = await call(action, peerId === undefined ? {} : { peerId })
|
|
1893
|
+
if (action === 'screenshot') {
|
|
1894
|
+
setStatus({ kind: 'ok', text: fmt(t('shotDone'), { p: String(data.screenshot?.localPath ?? '') }) })
|
|
1895
|
+
} else if (action === 'ping') {
|
|
1896
|
+
setStatus({ kind: data.connected ? 'ok' : 'err', text: `${peerId}: ${data.connected ? t('linkConnected') : t('linkDown')}` })
|
|
1897
|
+
}
|
|
1898
|
+
// Re-read rather than patching locally: the worker is the source of truth for
|
|
1899
|
+
// readiness, and a fresh self-check changes many fields at once.
|
|
1900
|
+
await refresh()
|
|
1901
|
+
} catch (error) {
|
|
1902
|
+
setStatus({ kind: 'err', text: error instanceof Error ? error.message : String(error) })
|
|
1903
|
+
} finally {
|
|
1904
|
+
setBusy('')
|
|
1905
|
+
}
|
|
1906
|
+
}, [call, refresh, t])
|
|
1907
|
+
|
|
1908
|
+
/**
|
|
1909
|
+
* Cache housekeeping for one worker, and the one place the cached byte budget can be set.
|
|
1910
|
+
*
|
|
1911
|
+
* WHY THIS WRITES THE SETTING FROM A PER-WORKER BUTTON: the threshold is global (one value
|
|
1912
|
+
* governs every worker, on both the host and the worker side) while the buttons are per
|
|
1913
|
+
* worker, because that is where the usage is shown. So an Apply from any card writes the
|
|
1914
|
+
* shared setting -- and the page says so in the field hint rather than leaving the operator
|
|
1915
|
+
* to discover that setting a limit on one worker changed all of them.
|
|
1916
|
+
*
|
|
1917
|
+
* The save is sent only when the value actually changed: an Apply that re-sends the same
|
|
1918
|
+
* number would rewrite the settings file and, worse, trip the policy stamp that rebuilds
|
|
1919
|
+
* every worker on every card.
|
|
1920
|
+
*/
|
|
1921
|
+
const onCache = React.useCallback(async (peerId, scope, op, maxBytes) => {
|
|
1922
|
+
const saved = Number(snapshot?.settings?.shotCacheBytes ?? 0)
|
|
1923
|
+
const body = { peerId, scope, op, maxBytes }
|
|
1924
|
+
const data = await call('cache', body)
|
|
1925
|
+
let message = ''
|
|
1926
|
+
const written = Number.isFinite(data?.result?.removed) ? Number(data.result.removed) : 0
|
|
1927
|
+
const freed = formatBytes(Number(data?.result?.freedBytes ?? 0))
|
|
1928
|
+
const sideName = scope === 'worker' ? t('labelCacheWorker') : t('labelCacheHost')
|
|
1929
|
+
if (op === 'clear') {
|
|
1930
|
+
message = written === 0
|
|
1931
|
+
? fmt(t('cacheClearNothing'), { scope: sideName })
|
|
1932
|
+
: fmt(t('cacheCleared'), { scope: sideName, n: written, size: freed })
|
|
1933
|
+
} else if (data?.supported === false) {
|
|
1934
|
+
message = t('cacheUnsupported')
|
|
1935
|
+
} else if (written === 0) {
|
|
1936
|
+
message = fmt(t('cacheWithinBudget'), { scope: sideName })
|
|
1937
|
+
} else {
|
|
1938
|
+
message = fmt(t('cachePruned'), { scope: sideName, n: written, size: freed })
|
|
1939
|
+
}
|
|
1940
|
+
// Persist the threshold when it is new. This reuses the settings route so the value lands
|
|
1941
|
+
// in the same file, with the same precedence, as every other setting.
|
|
1942
|
+
if (maxBytes !== saved) {
|
|
1943
|
+
await call('settings.save', { settings: { shotCacheBytes: maxBytes } })
|
|
1944
|
+
}
|
|
1945
|
+
await refresh()
|
|
1946
|
+
return { ...data, message }
|
|
1947
|
+
}, [call, refresh, snapshot, t])
|
|
1948
|
+
|
|
1949
|
+
const onStopAll = React.useCallback(async () => {
|
|
1950
|
+
setBusy('stop')
|
|
1951
|
+
setStatus({ kind: 'idle', text: '' })
|
|
1952
|
+
try {
|
|
1953
|
+
const data = await call('interrupt', {})
|
|
1954
|
+
const n = Array.isArray(data.cancelled) ? data.cancelled.length : 0
|
|
1955
|
+
const failed = Array.isArray(data.failures) ? data.failures : []
|
|
1956
|
+
if (n === 0 && failed.length === 0) setStatus({ kind: 'mute', text: t('nothingToStop') })
|
|
1957
|
+
else if (failed.length > 0) setStatus({ kind: 'err', text: `${fmt(t('stopped'), { n })}; ${failed.length} failed` })
|
|
1958
|
+
else setStatus({ kind: 'ok', text: fmt(t('stopped'), { n }) })
|
|
1959
|
+
await refresh()
|
|
1960
|
+
} catch (error) {
|
|
1961
|
+
setStatus({ kind: 'err', text: error instanceof Error ? error.message : String(error) })
|
|
1962
|
+
} finally {
|
|
1963
|
+
setBusy('')
|
|
1964
|
+
}
|
|
1965
|
+
}, [call, refresh, t])
|
|
1966
|
+
|
|
1967
|
+
const workers = Array.isArray(snapshot?.workers) ? snapshot.workers : []
|
|
1968
|
+
const tasks = Array.isArray(snapshot?.tasks) ? snapshot.tasks : []
|
|
1969
|
+
const anyBusy = busy !== ''
|
|
1970
|
+
|
|
1971
|
+
// The route returns TWO arrays and they are NOT interchangeable:
|
|
1972
|
+
// workers[] the live snapshot -- peerId, name, cwd, report, connection
|
|
1973
|
+
// settings.workers[] the saved config -- id, name, url, cwd, enabled, serialQueue,
|
|
1974
|
+
// tokenSet, tokenHint
|
|
1975
|
+
// The card renders the snapshot, because only it carries the readiness report. The FORM
|
|
1976
|
+
// must be seeded from the configuration. Seeding it from the snapshot left id, url and
|
|
1977
|
+
// the token state undefined, so Edit opened an EMPTY form for a worker that was
|
|
1978
|
+
// demonstrably connected and ready.
|
|
1979
|
+
const configWorkers = Array.isArray(snapshot?.settings?.workers) ? snapshot.settings.workers : []
|
|
1980
|
+
const configFor = (peerId) => configWorkers.find((w) => w.id === peerId)
|
|
1981
|
+
|
|
1982
|
+
// Approvals waiting on the operator, and the trail of what was decided. `available` is
|
|
1983
|
+
// distinct from an empty list on purpose: "nothing is waiting" and "this build cannot
|
|
1984
|
+
// ask" are different facts and the page must not conflate them.
|
|
1985
|
+
const approvals = snapshot?.approvals ?? { available: false, pending: [], recent: [], missed: [], missedCount: 0, timeoutMs: 0 }
|
|
1986
|
+
const pending = Array.isArray(approvals.pending) ? approvals.pending : []
|
|
1987
|
+
// Refusals nobody answered. Read from the HOST, which derives them from the append-only
|
|
1988
|
+
// trail, so they survive a restart; the queue's in-memory history would not.
|
|
1989
|
+
const missed = Array.isArray(approvals.missed) ? approvals.missed : []
|
|
1990
|
+
const missedCount = Number.isFinite(Number(approvals.missedCount)) ? Number(approvals.missedCount) : missed.length
|
|
1991
|
+
const audit = snapshot?.audit ?? { available: false, path: '', error: '', recent: [] }
|
|
1992
|
+
const recentAudit = Array.isArray(audit.recent) ? audit.recent : []
|
|
1993
|
+
// The trail is ONE append-only ledger of everything that happened, but the panel's table is
|
|
1994
|
+
// shaped for decisions (tier, option, who ruled). A transfer row has none of those fields, so
|
|
1995
|
+
// feeding it to that table renders a row of dashes under a "decisions" heading -- a silent
|
|
1996
|
+
// misreport of the kind this project keeps finding. Two kinds, two tables.
|
|
1997
|
+
const approvalRows = recentAudit.filter((row) => row?.kind !== 'transfer')
|
|
1998
|
+
const transferRows = recentAudit.filter((row) => row?.kind === 'transfer')
|
|
1999
|
+
|
|
2000
|
+
// Refusals that were never seen. Kept in the SAME group as the live requests on purpose: the
|
|
2001
|
+
// operator's question after "why did that task fail?" is "was I asked?", and a list one group
|
|
2002
|
+
// further down with no heading of its own does not answer it.
|
|
2003
|
+
const missedBlock = missed.length === 0 ? null : h('div', { className: 'ru-missed' },
|
|
2004
|
+
h('p', { className: 'ru-note warn' }, fmt(t('approvalsMissed'), { n: missedCount })),
|
|
2005
|
+
h('p', { className: 'ru-note dim' }, t('approvalsMissedNote')),
|
|
2006
|
+
h('div', { className: 'ru-scroll' },
|
|
2007
|
+
h('table', { className: 'ru-table' },
|
|
2008
|
+
h('thead', null, h('tr', null,
|
|
2009
|
+
h('th', null, t('colSettled')),
|
|
2010
|
+
h('th', null, t('colTool')),
|
|
2011
|
+
h('th', null, t('colTier')),
|
|
2012
|
+
h('th', null, t('colWaited')),
|
|
2013
|
+
h('th', null, t('colWorker')))),
|
|
2014
|
+
h('tbody', null, missed.map((row, index) => h('tr', { key: `${String(row.at)}-${index}` },
|
|
2015
|
+
h('td', { className: 'ru-mono ru-dim' }, formatTime(row.at)),
|
|
2016
|
+
h('td', { className: 'ru-mono' }, String(row.toolName ?? '\u2014')),
|
|
2017
|
+
h('td', null, shownTier(row)),
|
|
2018
|
+
h('td', null,
|
|
2019
|
+
`${Math.round(Number(row.waitedMs ?? 0) / 1000)}s `,
|
|
2020
|
+
h('span', { className: 'ru-dim' }, describeBy(row.by))),
|
|
2021
|
+
h('td', null, String(row.peerId ?? ''))))))))
|
|
2022
|
+
|
|
2023
|
+
return h('div', { className: 'ru-page' },
|
|
2024
|
+
h('style', null, CSS),
|
|
2025
|
+
h('div', { className: 'ru-head' },
|
|
2026
|
+
h('div', { className: 'ru-head-row' },
|
|
2027
|
+
h(RemoteUseIcon, { size: 22 }),
|
|
2028
|
+
h('h2', { className: 'ru-title' }, t('title'))),
|
|
2029
|
+
h('p', { className: 'ru-desc' }, t('description'))),
|
|
2030
|
+
|
|
2031
|
+
loadError === '' ? null : h('div', { className: 'ru-msg err' },
|
|
2032
|
+
`${t('loadFailed')}: ${loadError} `,
|
|
2033
|
+
h('button', { type: 'button', className: 'ru-btn', onClick: () => void refresh() }, t('retry'))),
|
|
2034
|
+
|
|
2035
|
+
h('div', { className: 'ru-group' },
|
|
2036
|
+
h('h3', { className: 'ru-group-title' }, t('groupRole')),
|
|
2037
|
+
h('div', { className: 'ru-role' },
|
|
2038
|
+
h('span', { className: 'ru-badge ok' }, t('roleMaster')),
|
|
2039
|
+
h('span', { className: 'ru-note' }, t('roleNote'))),
|
|
2040
|
+
// The switch itself is per session, and this page has no session identity -- so it
|
|
2041
|
+
// reports the state and names the real control rather than offering a toggle it
|
|
2042
|
+
// could not actually drive.
|
|
2043
|
+
snapshot === null
|
|
2044
|
+
? null
|
|
2045
|
+
: h('div', { className: 'ru-kv', style: { marginTop: '6px' } },
|
|
2046
|
+
h('span', { className: 'ru-k' }, t('fieldSessions')),
|
|
2047
|
+
h('span', { className: 'ru-v' },
|
|
2048
|
+
Number(snapshot?.settings?.sessionsEnabled ?? 0) > 0
|
|
2049
|
+
? fmt(t('sessionsOn'), { n: Number(snapshot.settings.sessionsEnabled) })
|
|
2050
|
+
: t('sessionsNone'))),
|
|
2051
|
+
snapshot === null
|
|
2052
|
+
? null
|
|
2053
|
+
: h('p', { className: 'ru-note dim' },
|
|
2054
|
+
fmt(t('switchHint'), { cmd: String(snapshot?.settings?.enableCommand ?? '/remote-use on') })),
|
|
2055
|
+
|
|
2056
|
+
// The worker role. Rendered only once a snapshot exists: showing "not accepting remote
|
|
2057
|
+
// driving" while the state is still unknown would be a claim the page cannot support.
|
|
2058
|
+
snapshot === null
|
|
2059
|
+
? null
|
|
2060
|
+
: h(WorkerRolePanel, {
|
|
2061
|
+
t,
|
|
2062
|
+
role: snapshot?.settings?.workerRole ?? null,
|
|
2063
|
+
// Read-only diagnosis, present on the FIRST load: a missing mediator must not need a
|
|
2064
|
+
// click to become visible.
|
|
2065
|
+
sandbox: snapshot?.settings?.sandbox ?? null,
|
|
2066
|
+
// The last environment check, if one has been run (advisory only).
|
|
2067
|
+
readiness: snapshot?.settings?.readiness ?? null,
|
|
2068
|
+
busy,
|
|
2069
|
+
onPreflight: onRoleCheck,
|
|
2070
|
+
onArm: onRoleArm,
|
|
2071
|
+
onDisarm: onRoleDisarm,
|
|
2072
|
+
})),
|
|
2073
|
+
|
|
2074
|
+
h('div', { className: 'ru-group' },
|
|
2075
|
+
h('h3', { className: 'ru-group-title' }, t('groupWorkers')),
|
|
2076
|
+
// Three distinct states, because conflating them is how a page tells the operator
|
|
2077
|
+
// "nothing is configured" while a slow request is still in flight. The first load
|
|
2078
|
+
// can legitimately take seconds: the Host attempts a readiness probe, and an
|
|
2079
|
+
// unreachable worker costs a full TCP connect timeout.
|
|
2080
|
+
snapshot === null
|
|
2081
|
+
? h('p', { className: 'ru-note' }, t('loading'))
|
|
2082
|
+
: workers.length === 0
|
|
2083
|
+
? h('p', { className: 'ru-note' }, t('noWorkers'))
|
|
2084
|
+
: h('div', {}, workers.map((row) => h(WorkerCard, {
|
|
2085
|
+
key: String(row.peerId),
|
|
2086
|
+
t,
|
|
2087
|
+
row,
|
|
2088
|
+
busy: busy === 'stop' ? 'stop' : (busy !== '' && busy !== 'stop' ? busy : ''),
|
|
2089
|
+
onAction: (action, peerId) => void onAction(action, peerId),
|
|
2090
|
+
onEdit: (r) => setEditing({
|
|
2091
|
+
// Seed from the CONFIGURATION. The snapshot row it was seeded from before
|
|
2092
|
+
// has no `id`, `url` or token state, so the form came up blank.
|
|
2093
|
+
draft: configFor(r.peerId) ?? {
|
|
2094
|
+
id: r.peerId, name: r.name, url: '', cwd: r.cwd, enabled: true, serialQueue: 1,
|
|
2095
|
+
},
|
|
2096
|
+
isNew: false,
|
|
2097
|
+
}),
|
|
2098
|
+
onDelete: (r) => void deleteWorker(r),
|
|
2099
|
+
onCache: (scope, op, maxBytes) => onCache(row.peerId, scope, op, maxBytes),
|
|
2100
|
+
// The saved ceiling, so the field opens on what is actually in force rather than
|
|
2101
|
+
// on the schema default. The snapshot's settings block is the post-merge truth.
|
|
2102
|
+
cacheMaxBytes: Number(snapshot?.settings?.shotCacheBytes ?? 0),
|
|
2103
|
+
}))),
|
|
2104
|
+
|
|
2105
|
+
// The add/edit form sits with the list it edits, so there is no second place to
|
|
2106
|
+
// look and no modal state to lose.
|
|
2107
|
+
editing === null ? null : h(WorkerForm, {
|
|
2108
|
+
t,
|
|
2109
|
+
draft: editing.draft,
|
|
2110
|
+
isNew: editing.isNew,
|
|
2111
|
+
busy,
|
|
2112
|
+
onSave: (draft) => void saveWorker(draft),
|
|
2113
|
+
onCancel: () => setEditing(null),
|
|
2114
|
+
}),
|
|
2115
|
+
|
|
2116
|
+
snapshot === null ? null : h('div', { className: 'ru-actions', style: { marginTop: '10px' } },
|
|
2117
|
+
h('button', {
|
|
2118
|
+
type: 'button', className: 'ru-btn primary', disabled: anyBusy || editing !== null,
|
|
2119
|
+
onClick: () => setEditing({ draft: { enabled: true, serialQueue: 1 }, isNew: true }),
|
|
2120
|
+
}, t('btnAdd')),
|
|
2121
|
+
// The shortcut past the form. It sits beside "add" because that is the same job:
|
|
2122
|
+
// one paste instead of a url and a bearer token typed off another machine's screen.
|
|
2123
|
+
h('button', {
|
|
2124
|
+
type: 'button', className: 'ru-btn', disabled: anyBusy || editing !== null,
|
|
2125
|
+
onClick: () => setPairingOpen((open) => !open),
|
|
2126
|
+
}, t('btnPair')),
|
|
2127
|
+
h('button', {
|
|
2128
|
+
type: 'button', className: 'ru-btn', disabled: anyBusy || workers.length === 0,
|
|
2129
|
+
onClick: async () => {
|
|
2130
|
+
setBusy('all')
|
|
2131
|
+
setStatus({ kind: 'idle', text: '' })
|
|
2132
|
+
try {
|
|
2133
|
+
for (const row of workers) await call('selfcheck', { peerId: row.peerId })
|
|
2134
|
+
await refresh()
|
|
2135
|
+
} catch (error) {
|
|
2136
|
+
setStatus({ kind: 'err', text: error instanceof Error ? error.message : String(error) })
|
|
2137
|
+
} finally {
|
|
2138
|
+
setBusy('')
|
|
2139
|
+
}
|
|
2140
|
+
},
|
|
2141
|
+
}, busy === 'all' ? t('busy') : t('btnAll'))),
|
|
2142
|
+
|
|
2143
|
+
// The shortcut past the form: one paste instead of a url and a bearer token typed by
|
|
2144
|
+
// hand off another machine's screen.
|
|
2145
|
+
pairingOpen ? h('div', { className: 'ru-card', style: { marginTop: '10px' } },
|
|
2146
|
+
h('label', { className: 'ru-field' },
|
|
2147
|
+
h('span', { className: 'ru-field-label' }, t('fieldPairing')),
|
|
2148
|
+
h('textarea', {
|
|
2149
|
+
className: 'ru-input ru-textarea',
|
|
2150
|
+
rows: '3',
|
|
2151
|
+
placeholder: t('fieldPairingPlaceholder'),
|
|
2152
|
+
value: pairingText,
|
|
2153
|
+
onChange: (event) => setPairingText(event.target.value),
|
|
2154
|
+
}),
|
|
2155
|
+
h('span', { className: 'ru-field-hint' }, t('fieldPairingHint'))),
|
|
2156
|
+
// Inert in the normal case: a current worker puts its task directory in the string.
|
|
2157
|
+
// It exists so that a string WITHOUT one is not a dead end -- pairing is refused when
|
|
2158
|
+
// neither supplies a directory, and this is where the operator supplies it.
|
|
2159
|
+
h('label', { className: 'ru-field' },
|
|
2160
|
+
h('span', { className: 'ru-field-label' }, t('fieldPairingCwd')),
|
|
2161
|
+
h('input', {
|
|
2162
|
+
className: 'ru-input ru-mono',
|
|
2163
|
+
placeholder: 'C:\\dsh-remote\\work',
|
|
2164
|
+
value: pairingCwd,
|
|
2165
|
+
onChange: (event) => setPairingCwd(event.target.value),
|
|
2166
|
+
}),
|
|
2167
|
+
h('span', { className: 'ru-field-hint' }, t('fieldPairingCwdHint'))),
|
|
2168
|
+
h('div', { className: 'ru-actions' },
|
|
2169
|
+
h('button', {
|
|
2170
|
+
type: 'button', className: 'ru-btn primary',
|
|
2171
|
+
disabled: anyBusy || pairingText.trim() === '',
|
|
2172
|
+
onClick: () => void onPair(),
|
|
2173
|
+
}, busy === 'pair' ? t('busy') : t('btnPairSave')),
|
|
2174
|
+
h('button', {
|
|
2175
|
+
type: 'button', className: 'ru-btn',
|
|
2176
|
+
disabled: anyBusy,
|
|
2177
|
+
onClick: () => { setPairingOpen(false); setPairingText(''); setPairingCwd('') },
|
|
2178
|
+
}, t('btnCancel')))) : null),
|
|
2179
|
+
|
|
2180
|
+
status.text === '' ? null : h('div', { className: `ru-msg ${status.kind === 'err' ? 'err' : status.kind === 'ok' ? 'ok' : 'mute'}` }, status.text),
|
|
2181
|
+
|
|
2182
|
+
// Approval policy and connection tuning. Separate from the worker list because these
|
|
2183
|
+
// apply to every worker, and changing them is what forces a full rebuild.
|
|
2184
|
+
snapshot === null || snapshot.settings === undefined ? null : h(AdvancedSettings, {
|
|
2185
|
+
t,
|
|
2186
|
+
settings: snapshot.settings,
|
|
2187
|
+
busy,
|
|
2188
|
+
onSave: (next) => void savePolicy(next),
|
|
2189
|
+
}),
|
|
2190
|
+
|
|
2191
|
+
snapshot === null || typeof snapshot.settingsFile !== 'string' || snapshot.settingsFile === ''
|
|
2192
|
+
? null
|
|
2193
|
+
: h('div', { className: 'ru-group' },
|
|
2194
|
+
h('h3', { className: 'ru-group-title' }, t('groupSource')),
|
|
2195
|
+
h('p', { className: 'ru-source' }, fmt(t('sourceText'), { file: snapshot.settingsFile }))),
|
|
2196
|
+
|
|
2197
|
+
h('div', { className: 'ru-group' },
|
|
2198
|
+
h('h3', { className: 'ru-group-title' }, t('groupApprovals')),
|
|
2199
|
+
approvals.available !== true
|
|
2200
|
+
? h('p', { className: 'ru-note warn' }, t('approvalsUnavailable'))
|
|
2201
|
+
: h('div', null,
|
|
2202
|
+
h('p', { className: 'ru-note' }, t('approvalsNote')),
|
|
2203
|
+
h('p', { className: 'ru-note dim' }, fmt(t('approvalsBudget'), {
|
|
2204
|
+
s: Math.round(Number(approvals.timeoutMs ?? 0) / 1000),
|
|
2205
|
+
})),
|
|
2206
|
+
pending.length === 0
|
|
2207
|
+
? h('p', { className: 'ru-note' }, t('approvalsNone'))
|
|
2208
|
+
: h('div', { className: 'ru-approvals' }, pending.map((row) => {
|
|
2209
|
+
const id = String(row.id ?? '')
|
|
2210
|
+
const rowBusy = busy === `approval:${id}`
|
|
2211
|
+
return h('div', { className: 'ru-approval', key: id },
|
|
2212
|
+
h('div', { className: 'ru-approval-head' },
|
|
2213
|
+
h('strong', { className: 'ru-mono' }, String(row.toolName ?? '?')),
|
|
2214
|
+
h('span', { className: 'ru-badge warn' }, String(row.requiredTier ?? '?')),
|
|
2215
|
+
h('span', { className: 'ru-dim' }, `${String(row.peerId ?? '')} \u00b7 ${String(row.taskId ?? '')}`)),
|
|
2216
|
+
h('div', { className: 'ru-approval-why' }, String(row.reason ?? '')),
|
|
2217
|
+
row.rawInput === '' ? null : h('pre', { className: 'ru-args' }, String(row.rawInput)),
|
|
2218
|
+
h('div', { className: 'ru-approval-actions' },
|
|
2219
|
+
h('button', {
|
|
2220
|
+
type: 'button',
|
|
2221
|
+
className: 'ru-btn primary',
|
|
2222
|
+
disabled: anyBusy,
|
|
2223
|
+
onClick: () => void onDecide(id, 'allow'),
|
|
2224
|
+
}, rowBusy ? t('approving') : t('btnAllow')),
|
|
2225
|
+
h('button', {
|
|
2226
|
+
type: 'button',
|
|
2227
|
+
className: 'ru-btn',
|
|
2228
|
+
disabled: anyBusy,
|
|
2229
|
+
onClick: () => void onDecide(id, 'deny'),
|
|
2230
|
+
}, t('btnDeny')),
|
|
2231
|
+
h('span', { className: 'ru-dim' }, `${t('colWaited')} ${Math.round(Number(row.ageMs ?? 0) / 1000)}s`)))
|
|
2232
|
+
})),
|
|
2233
|
+
missedCount === 0 ? null : clearControls(t, clearState),
|
|
2234
|
+
missedBlock)),
|
|
2235
|
+
|
|
2236
|
+
h('div', { className: 'ru-group' },
|
|
2237
|
+
h('h3', { className: 'ru-group-title' }, t('groupAudit')),
|
|
2238
|
+
audit.available !== true
|
|
2239
|
+
? h('p', { className: 'ru-note dim' }, t('auditNote', { p: '\u2014' }))
|
|
2240
|
+
: h('div', null,
|
|
2241
|
+
h('p', { className: 'ru-note dim' }, fmt(t('auditNote'), { p: String(audit.path ?? '') })),
|
|
2242
|
+
// Where the trail stands against its cap. A limit the operator cannot see is
|
|
2243
|
+
// indistinguishable from no limit, which is how this one went unnoticed for so long.
|
|
2244
|
+
h('p', { className: 'ru-note dim' }, fmt(t('auditUsage'), {
|
|
2245
|
+
size: formatBytes(audit.bytes),
|
|
2246
|
+
max: Number(audit.maxBytes) > 0 ? formatBytes(audit.maxBytes) : t('auditUnbounded'),
|
|
2247
|
+
keep: Number(audit.keepFiles) < 0 ? t('auditUnbounded') : String(audit.keepFiles ?? 0),
|
|
2248
|
+
n: String(Array.isArray(audit.archives) ? audit.archives.length : 0),
|
|
2249
|
+
})),
|
|
2250
|
+
audit.error === '' ? null : h('p', { className: 'ru-note warn' }, String(audit.error)),
|
|
2251
|
+
approvalRows.length === 0
|
|
2252
|
+
? h('p', { className: 'ru-note' }, t('auditNone'))
|
|
2253
|
+
: h('div', { className: 'ru-scroll' },
|
|
2254
|
+
h('table', { className: 'ru-table' },
|
|
2255
|
+
h('thead', null, h('tr', null,
|
|
2256
|
+
h('th', null, t('colStarted')),
|
|
2257
|
+
h('th', null, t('colTool')),
|
|
2258
|
+
h('th', null, t('colTier')),
|
|
2259
|
+
h('th', null, t('colState')),
|
|
2260
|
+
h('th', null, t('colWorker')))),
|
|
2261
|
+
h('tbody', null, approvalRows.map((row, index) => h('tr', { key: `${String(row.at)}-${index}` },
|
|
2262
|
+
h('td', { className: 'ru-mono ru-dim' }, formatTime(row.at)),
|
|
2263
|
+
h('td', { className: 'ru-mono' }, String(row.toolName ?? '\u2014')),
|
|
2264
|
+
h('td', null, shownTier(row)),
|
|
2265
|
+
h('td', null,
|
|
2266
|
+
h('span', {
|
|
2267
|
+
className: row.optionId === 'allow-once' ? 'ru-badge ok' : 'ru-badge',
|
|
2268
|
+
}, String(row.optionId ?? '')),
|
|
2269
|
+
' ',
|
|
2270
|
+
h('span', { className: 'ru-dim' }, fmt(t('auditBy'), { by: describeBy(row.by) }))),
|
|
2271
|
+
h('td', null, String(row.peerId ?? ''))))))))),
|
|
2272
|
+
|
|
2273
|
+
// File movement is a durable write on another machine's disk, so it belongs in the ledger --
|
|
2274
|
+
// but it is not a decision, and it gets its own columns rather than being dressed as one.
|
|
2275
|
+
h('div', { className: 'ru-group' },
|
|
2276
|
+
h('h3', { className: 'ru-group-title' }, t('groupTransfers')),
|
|
2277
|
+
transferRows.length === 0
|
|
2278
|
+
? h('p', { className: 'ru-note' }, t('noTransfers'))
|
|
2279
|
+
: h('div', { className: 'ru-scroll' },
|
|
2280
|
+
h('table', { className: 'ru-table' },
|
|
2281
|
+
h('thead', null, h('tr', null,
|
|
2282
|
+
h('th', null, t('colStarted')),
|
|
2283
|
+
h('th', null, t('colDirection')),
|
|
2284
|
+
h('th', null, t('colPath')),
|
|
2285
|
+
h('th', null, t('colSize')),
|
|
2286
|
+
h('th', null, t('colWorker')))),
|
|
2287
|
+
h('tbody', null, transferRows.map((row, index) => h('tr', { key: `${String(row.at)}-t-${index}` },
|
|
2288
|
+
h('td', { className: 'ru-mono ru-dim' }, formatTime(row.at)),
|
|
2289
|
+
h('td', null, row.direction === 'send' ? t('transferSent') : t('transferFetched')),
|
|
2290
|
+
h('td', { className: 'ru-mono' },
|
|
2291
|
+
String(row.direction === 'send' ? (row.remotePath ?? '') : (row.localPath ?? ''))),
|
|
2292
|
+
h('td', { className: 'ru-mono' }, formatBytes(Number(row.bytes ?? 0))),
|
|
2293
|
+
h('td', null, String(row.peer ?? '')))))))),
|
|
2294
|
+
|
|
2295
|
+
h('div', { className: 'ru-group' },
|
|
2296
|
+
h('h3', { className: 'ru-group-title' }, t('groupTasks')),
|
|
2297
|
+
tasks.length === 0
|
|
2298
|
+
? h('p', { className: 'ru-note' }, t('noTasks'))
|
|
2299
|
+
: h('div', { className: 'ru-scroll' },
|
|
2300
|
+
h('table', { className: 'ru-table' },
|
|
2301
|
+
h('thead', null, h('tr', null,
|
|
2302
|
+
h('th', null, t('colTask')),
|
|
2303
|
+
h('th', null, t('colWorker')),
|
|
2304
|
+
h('th', null, t('colState')),
|
|
2305
|
+
h('th', null, t('colTools')),
|
|
2306
|
+
h('th', null, t('colStarted')))),
|
|
2307
|
+
h('tbody', null, tasks.map((task) => {
|
|
2308
|
+
const state = String(task.state ?? '')
|
|
2309
|
+
const active = !TERMINAL_STATES.includes(state)
|
|
2310
|
+
return h('tr', { key: String(task.taskId) },
|
|
2311
|
+
h('td', { className: 'mono' }, String(task.taskId)),
|
|
2312
|
+
h('td', null, String(task.peerId ?? '')),
|
|
2313
|
+
h('td', null,
|
|
2314
|
+
h('span', { className: `ru-dot ${active ? 'warn' : 'pass'}` }),
|
|
2315
|
+
state,
|
|
2316
|
+
task.waitingForApproval === true ? ` (${t('waiting')})` : ''),
|
|
2317
|
+
h('td', null, Array.isArray(task.recentTools) && task.recentTools.length > 0
|
|
2318
|
+
? `${task.toolCallCount ?? 0} (${task.recentTools.join(', ')})`
|
|
2319
|
+
: String(task.toolCallCount ?? 0)),
|
|
2320
|
+
h('td', null, task.startedAt === undefined ? '' : new Date(task.startedAt).toLocaleTimeString()))
|
|
2321
|
+
}))))),
|
|
2322
|
+
|
|
2323
|
+
h('div', { className: 'ru-group' },
|
|
2324
|
+
h('h3', { className: 'ru-group-title' }, t('groupDanger')),
|
|
2325
|
+
h('p', { className: 'ru-note' }, t('dangerNote')),
|
|
2326
|
+
h('div', { className: 'ru-actions', style: { marginTop: '8px' } },
|
|
2327
|
+
h('button', {
|
|
2328
|
+
type: 'button', className: 'ru-btn danger', disabled: anyBusy,
|
|
2329
|
+
onClick: () => void onStopAll(),
|
|
2330
|
+
}, busy === 'stop' ? t('stopping') : t('btnStopAll')))),
|
|
2331
|
+
)
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
// ---- approval surfaces: session header, composer dock, right-pane tab --------------
|
|
2335
|
+
//
|
|
2336
|
+
// WHY NOT THE SETTINGS PAGE ALONE: it is closed by default, and opening it takes the operator
|
|
2337
|
+
// away from their work. Measured 2026-10-14: six approval requests expired unanswered and the
|
|
2338
|
+
// only sign of them was behind a gear icon nobody had a reason to open.
|
|
2339
|
+
//
|
|
2340
|
+
// WHY ONE FEED AND NOT THREE POLLERS: all three surfaces read the same snapshot, so three
|
|
2341
|
+
// timers would triple the traffic for one fact. `createApprovalFeed` polls once and pushes to
|
|
2342
|
+
// whoever subscribed; a surface stays dormant until it has a subscriber.
|
|
2343
|
+
//
|
|
2344
|
+
// SLOT CHOICE IS CONSTRAINED, NOT COSMETIC: `conversation.input.permission` and
|
|
2345
|
+
// `sidebar.toggle.badge` are both `kind: "single"` and already owned by DSH's own plugins (the
|
|
2346
|
+
// permission selector, the desktop-update badge), so registering there would DISPLACE
|
|
2347
|
+
// first-party UI. Every slot used below is `list` or `keyed`, where coexistence is the design.
|
|
2348
|
+
|
|
2349
|
+
/** The shared poller behind every approval surface. @param {{ get: Function }} io */
|
|
2350
|
+
function createApprovalFeed(io, intervalMs = 2500) {
|
|
2351
|
+
let state = { ready: false, available: false, pending: [], missed: [], missedCount: 0, timeoutMs: 0, auditPath: '', error: '' }
|
|
2352
|
+
const listeners = new Set()
|
|
2353
|
+
let timer
|
|
2354
|
+
const publish = () => { for (const listener of [...listeners]) listener(state) }
|
|
2355
|
+
const refresh = async () => {
|
|
2356
|
+
try {
|
|
2357
|
+
const data = await io.get()
|
|
2358
|
+
const approvals = data?.approvals ?? {}
|
|
2359
|
+
state = {
|
|
2360
|
+
ready: true,
|
|
2361
|
+
available: approvals.available === true,
|
|
2362
|
+
pending: Array.isArray(approvals.pending) ? approvals.pending : [],
|
|
2363
|
+
missed: Array.isArray(approvals.missed) ? approvals.missed : [],
|
|
2364
|
+
missedCount: Number(approvals.missedCount ?? 0),
|
|
2365
|
+
timeoutMs: Number(approvals.timeoutMs ?? 0),
|
|
2366
|
+
// Read from the SAME snapshot the counts come from. The pane used to take this as an
|
|
2367
|
+
// owner prop, and the framework never passed one, so "the record is also at <path>"
|
|
2368
|
+
// was dead text on the one surface that shows it (F30).
|
|
2369
|
+
auditPath: typeof data?.audit?.path === 'string' ? data.audit.path : '',
|
|
2370
|
+
error: '',
|
|
2371
|
+
}
|
|
2372
|
+
} catch (error) {
|
|
2373
|
+
state = { ...state, ready: true, error: error instanceof Error ? error.message : String(error) }
|
|
2374
|
+
}
|
|
2375
|
+
publish()
|
|
2376
|
+
}
|
|
2377
|
+
const stop = () => { if (timer !== undefined) { clearInterval(timer); timer = undefined } }
|
|
2378
|
+
return {
|
|
2379
|
+
current: () => state,
|
|
2380
|
+
subscribe(listener) {
|
|
2381
|
+
listeners.add(listener)
|
|
2382
|
+
// LAZY, and this is not an optimisation. Starting the timer in `apply()` left a live
|
|
2383
|
+
// interval behind that nothing owned: the unit tests never exited, and an app with no
|
|
2384
|
+
// surface mounted would poll a route forever for nobody. The first subscriber is exactly
|
|
2385
|
+
// when the data starts to matter.
|
|
2386
|
+
if (listeners.size === 1 && typeof setInterval === 'function') {
|
|
2387
|
+
timer = setInterval(() => void refresh(), intervalMs)
|
|
2388
|
+
void refresh()
|
|
2389
|
+
}
|
|
2390
|
+
return () => {
|
|
2391
|
+
listeners.delete(listener)
|
|
2392
|
+
if (listeners.size === 0) stop()
|
|
2393
|
+
}
|
|
2394
|
+
},
|
|
2395
|
+
refresh,
|
|
2396
|
+
dispose() { listeners.clear(); stop() },
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
/**
|
|
2401
|
+
* Read the feed as component state.
|
|
2402
|
+
*
|
|
2403
|
+
* `enabled = false` releases the subscription instead of reading it, which matters because the
|
|
2404
|
+
* feed's timer is refcounted: a pane body that is mounted but NOT visible (a background Session,
|
|
2405
|
+
* or a collapsed column) would otherwise be the one subscriber holding a poll open for a surface
|
|
2406
|
+
* nobody is looking at. The pane passes `tab.visible` here.
|
|
2407
|
+
*/
|
|
2408
|
+
function useApprovals(feed, enabled = true) {
|
|
2409
|
+
const [state, setState] = React.useState(feed.current())
|
|
2410
|
+
React.useEffect(() => {
|
|
2411
|
+
if (enabled === false) return undefined
|
|
2412
|
+
setState(feed.current())
|
|
2413
|
+
return feed.subscribe(setState)
|
|
2414
|
+
}, [feed, enabled])
|
|
2415
|
+
return state
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
/** Allow/deny one request. Shared, so no two surfaces can disagree about the outcome. */
|
|
2419
|
+
function useDecide(feed, post) {
|
|
2420
|
+
const [busyId, setBusyId] = React.useState('')
|
|
2421
|
+
const [error, setError] = React.useState('')
|
|
2422
|
+
const decide = React.useCallback(async (id, decision) => {
|
|
2423
|
+
setBusyId(id)
|
|
2424
|
+
setError('')
|
|
2425
|
+
try {
|
|
2426
|
+
// `via` is what makes the audit read "a person clicked". It is EVIDENCE, not a boundary
|
|
2427
|
+
// -- any caller can claim it -- but the alternative was measured dead in this app's own
|
|
2428
|
+
// transport: the click arrived without a usable Origin and was recorded as `api` (F22).
|
|
2429
|
+
await post('approvals.decide', { id, decision, via: 'page' })
|
|
2430
|
+
} catch (failure) {
|
|
2431
|
+
setError(failure instanceof Error ? failure.message : String(failure))
|
|
2432
|
+
} finally {
|
|
2433
|
+
setBusyId('')
|
|
2434
|
+
await feed.refresh()
|
|
2435
|
+
}
|
|
2436
|
+
}, [feed, post])
|
|
2437
|
+
return { busyId, error, decide }
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
/**
|
|
2441
|
+
* Clear the operator-facing record of missed requests.
|
|
2442
|
+
*
|
|
2443
|
+
* WHAT THIS DOES NOT DO, deliberately: delete rows from the trail. The file is append-only
|
|
2444
|
+
* evidence, and the one thing an audit trail must never do is quietly lose the record of
|
|
2445
|
+
* something that happened. Clearing appends a watermark row instead -- so the miss list resets
|
|
2446
|
+
* to empty while the file keeps every row AND keeps the fact that the operator cleared the
|
|
2447
|
+
* view, and by which route. Rows are never removed; only the view's starting point moves.
|
|
2448
|
+
*
|
|
2449
|
+
* @param {(action: string, payload?: object) => Promise<object>} post
|
|
2450
|
+
* @param {() => Promise<unknown>} refresh re-read whatever snapshot this surface renders
|
|
2451
|
+
*/
|
|
2452
|
+
function useClear(post, refresh) {
|
|
2453
|
+
const [busy, setBusy] = React.useState(false)
|
|
2454
|
+
const [error, setError] = React.useState('')
|
|
2455
|
+
const [done, setDone] = React.useState('')
|
|
2456
|
+
const clear = React.useCallback(async () => {
|
|
2457
|
+
setBusy(true)
|
|
2458
|
+
setError('')
|
|
2459
|
+
setDone('')
|
|
2460
|
+
try {
|
|
2461
|
+
// Same provenance claim as the decision buttons: this is a person pressing a button.
|
|
2462
|
+
const data = await post('approvals.clear', { via: 'page' })
|
|
2463
|
+
setDone(String(data?.cleared ?? 0))
|
|
2464
|
+
} catch (failure) {
|
|
2465
|
+
setError(failure instanceof Error ? failure.message : String(failure))
|
|
2466
|
+
} finally {
|
|
2467
|
+
setBusy(false)
|
|
2468
|
+
await refresh()
|
|
2469
|
+
}
|
|
2470
|
+
}, [post, refresh])
|
|
2471
|
+
return { busy, error, done, clear }
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
/** The tier being ASKED for: the request first, the inferred minimum only as a fallback. */
|
|
2475
|
+
function shownTier(row) {
|
|
2476
|
+
const requested = String(row?.requestedTier ?? '')
|
|
2477
|
+
return requested !== '' ? requested : String(row?.requiredTier ?? '?')
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
/** The `by` vocabulary in words, for the record list. */
|
|
2481
|
+
function byWords(t, by) {
|
|
2482
|
+
if (by === 'page') return t('auditByPage')
|
|
2483
|
+
if (by === 'api') return t('auditByApi')
|
|
2484
|
+
if (by === 'timeout') return t('auditByTimeout')
|
|
2485
|
+
if (by === 'disposed') return t('auditByDisposed')
|
|
2486
|
+
if (by === 'human') return t('auditByHuman')
|
|
2487
|
+
return String(by ?? '')
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
/**
|
|
2491
|
+
* The tier being ASKED for: the request first, the inferred minimum only as a fallback.
|
|
2492
|
+
*
|
|
2493
|
+
* Stated once, at the top of these surfaces, because this field is what the 0.2.4 fix was
|
|
2494
|
+
* about: a request for the unrestricted tier used to display the minimum that would do.
|
|
2495
|
+
*/
|
|
2496
|
+
function shownTier(row) {
|
|
2497
|
+
const requested = String(row?.requestedTier ?? '')
|
|
2498
|
+
return requested !== '' ? requested : String(row?.requiredTier ?? '?')
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
/** Who is asking, and for which task. */
|
|
2502
|
+
function whoLine(row) {
|
|
2503
|
+
const task = String(row.taskId ?? '')
|
|
2504
|
+
return `${String(row.peerId ?? '')}${task === '' ? '' : ` \u00b7 ${task}`}`
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
/** How long is left, in mono so the digits do not jitter as they tick. */
|
|
2508
|
+
function countdown(t, row, timeoutMs) {
|
|
2509
|
+
const left = Math.max(0, Math.round((Number(timeoutMs) - Number(row.ageMs ?? 0)) / 1000))
|
|
2510
|
+
return h('span', { className: 'ru-count' }, fmt(t('approvalsLeft'), { s: left }))
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
/** Both decisions, in one place so no surface can offer a different pair. */
|
|
2514
|
+
function decideButtons(t, id, busyId, onDecide) {
|
|
2515
|
+
return [
|
|
2516
|
+
h('button', {
|
|
2517
|
+
type: 'button', className: 'ru-btn primary', disabled: busyId !== '',
|
|
2518
|
+
onClick: () => void onDecide(id, 'allow'),
|
|
2519
|
+
}, t('btnAllowShort')),
|
|
2520
|
+
h('button', {
|
|
2521
|
+
type: 'button', className: 'ru-btn', disabled: busyId !== '',
|
|
2522
|
+
onClick: () => void onDecide(id, 'deny'),
|
|
2523
|
+
}, t('btnDenyShort')),
|
|
2524
|
+
]
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
/**
|
|
2528
|
+
* The "clear the missed list" control, shared by the pane and the Settings page.
|
|
2529
|
+
*
|
|
2530
|
+
* The note is not decoration: without it the button reads as "delete the record", and the
|
|
2531
|
+
* operator would reasonably expect the file to shrink. It does not -- see `useClear`.
|
|
2532
|
+
*/
|
|
2533
|
+
function clearControls(t, clearState) {
|
|
2534
|
+
const { busy, error, done, clear } = clearState
|
|
2535
|
+
return h('div', { className: 'ru-clear' },
|
|
2536
|
+
h('button', {
|
|
2537
|
+
type: 'button', className: 'ru-btn', disabled: busy,
|
|
2538
|
+
onClick: () => void clear(),
|
|
2539
|
+
}, busy ? t('btnClearing') : t('btnClearMissed')),
|
|
2540
|
+
h('span', { className: 'ru-note dim' }, t('clearMissedNote')),
|
|
2541
|
+
done === '' ? null : h('span', { className: 'ru-note' }, fmt(t('clearedMissed'), { n: done })),
|
|
2542
|
+
error === '' ? null : h('span', { className: 'ru-note warn' }, error))
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
/**
|
|
2546
|
+
* The composer dock: one quiet strip per request, two lines each.
|
|
2547
|
+
*
|
|
2548
|
+
* WHY IT IS NOT THE PANEL IN MINIATURE: this strip sits directly above the input, so every pixel
|
|
2549
|
+
* it takes is taken from writing. Line one answers "what is this?", line two answers "what do I
|
|
2550
|
+
* do?" -- the reason and the arguments a decision may need stay in the panel, one click away.
|
|
2551
|
+
*/
|
|
2552
|
+
function dockRows(t, pending, busyId, onDecide, timeoutMs) {
|
|
2553
|
+
return pending.map((row) => {
|
|
2554
|
+
const id = String(row.id ?? '')
|
|
2555
|
+
return h('div', { className: 'ru-dock-row', key: id },
|
|
2556
|
+
h('div', { className: 'ru-dock-main' },
|
|
2557
|
+
h('span', { className: 'ru-tool' }, String(row.toolName ?? '?')),
|
|
2558
|
+
h('span', { className: 'ru-tier' }, shownTier(row))),
|
|
2559
|
+
h('span', { className: 'ru-dock-who' }, whoLine(row)),
|
|
2560
|
+
h('div', { className: 'ru-dock-actions' },
|
|
2561
|
+
countdown(t, row, timeoutMs),
|
|
2562
|
+
...decideButtons(t, id, busyId, onDecide)))
|
|
2563
|
+
})
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
/** The panel rows: the same facts, plus the reason and the arguments. */
|
|
2567
|
+
function approvalRows(props) {
|
|
2568
|
+
const { t, pending, busyId, onDecide, timeoutMs } = props
|
|
2569
|
+
return pending.map((row) => {
|
|
2570
|
+
const id = String(row.id ?? '')
|
|
2571
|
+
return h('div', { className: 'ru-row', key: id },
|
|
2572
|
+
h('div', { className: 'ru-row-main' },
|
|
2573
|
+
h('span', { className: 'ru-tool' }, String(row.toolName ?? '?')),
|
|
2574
|
+
h('span', { className: 'ru-tier' }, shownTier(row)),
|
|
2575
|
+
h('span', { className: 'ru-dim' }, whoLine(row))),
|
|
2576
|
+
countdown(t, row, timeoutMs),
|
|
2577
|
+
h('p', { className: 'ru-why' }, String(row.reason ?? '')),
|
|
2578
|
+
row.rawInput === '' ? null : h('pre', { className: 'ru-args' }, String(row.rawInput)),
|
|
2579
|
+
h('div', { className: 'ru-row-actions' }, ...decideButtons(t, id, busyId, onDecide)))
|
|
2580
|
+
})
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
/** Right above the composer: the operator is already looking here. */
|
|
2584
|
+
function ApprovalsDock(props) {
|
|
2585
|
+
const { t, feed, post } = props
|
|
2586
|
+
const state = useApprovals(feed)
|
|
2587
|
+
const { busyId, error, decide } = useDecide(feed, post)
|
|
2588
|
+
if (state.pending.length === 0) return null
|
|
2589
|
+
return h('div', { className: 'ru-dock', role: 'status', 'aria-live': 'polite' },
|
|
2590
|
+
...dockRows(t, state.pending, busyId, decide, state.timeoutMs),
|
|
2591
|
+
error === '' ? null : h('p', { className: 'ru-note warn' }, error))
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
/** Session header: quiet while nothing waits, and the way into the panel. */
|
|
2595
|
+
function ApprovalsHeaderButton(props) {
|
|
2596
|
+
const { t, feed, openPanel } = props
|
|
2597
|
+
const state = useApprovals(feed)
|
|
2598
|
+
const pending = state.pending.length
|
|
2599
|
+
if (pending === 0 && state.missedCount === 0) return null
|
|
2600
|
+
// The beacon appears ONLY while a worker is actually stuck. A record of past misses is worth
|
|
2601
|
+
// keeping reachable, not worth shouting about in every session forever.
|
|
2602
|
+
const children = pending > 0
|
|
2603
|
+
? [h('span', { className: 'ru-beacon' }), h('span', null, fmt(t('approvalsHeaderPending'), { n: pending }))]
|
|
2604
|
+
: [h('span', null, fmt(t('approvalsHeaderMissed'), { n: state.missedCount }))]
|
|
2605
|
+
return h('button', {
|
|
2606
|
+
type: 'button',
|
|
2607
|
+
className: `ru-hdrbtn${pending > 0 ? ' pending' : ''}`,
|
|
2608
|
+
title: t('approvalsOpenPanel'),
|
|
2609
|
+
onClick: () => openPanel(),
|
|
2610
|
+
}, ...children)
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
/**
|
|
2614
|
+
* The right pane: what waits now, and what was missed before.
|
|
2615
|
+
*
|
|
2616
|
+
* The body reads its own occurrence through the framework-injected `useTabInfo()` -- the
|
|
2617
|
+
* documented face for a pane body, which delivers `{ sidebar, panel, tab }`. Only `tab.visible`
|
|
2618
|
+
* is used here, and for a real reason: `tab.visible` is false for a background Session or a
|
|
2619
|
+
* collapsed column, and the feed's poll is refcounted, so an invisible pane must not subscribe.
|
|
2620
|
+
*/
|
|
2621
|
+
function ApprovalsTab(props) {
|
|
2622
|
+
const { t, feed, post, useTabInfo } = props
|
|
2623
|
+
// The documented face for a pane body: it delivers `{ sidebar, panel, tab }`. REQUIRED, and a
|
|
2624
|
+
// missing one is thrown rather than tolerated -- a shell that does not inject it cannot render
|
|
2625
|
+
// this body, and the renderer's own entry boundary turns that into one dead pane instead of a
|
|
2626
|
+
// dead application. Degrading silently here would be worse: the visibility gate below would
|
|
2627
|
+
// stop working with nothing on screen to say so (F25).
|
|
2628
|
+
if (typeof useTabInfo !== 'function') {
|
|
2629
|
+
throw new Error('remote-use: the approvals pane body needs the framework-injected useTabInfo')
|
|
2630
|
+
}
|
|
2631
|
+
const { tab } = useTabInfo()
|
|
2632
|
+
const visible = tab === undefined || tab.visible !== false
|
|
2633
|
+
const state = useApprovals(feed, visible)
|
|
2634
|
+
const { busyId, error, decide } = useDecide(feed, post)
|
|
2635
|
+
const clearState = useClear(post, feed.refresh)
|
|
2636
|
+
const pendingBlock = state.pending.length === 0
|
|
2637
|
+
? h('p', { className: 'ru-empty' }, t('approvalsPanelEmpty'))
|
|
2638
|
+
: h('div', null, ...approvalRows({ t, pending: state.pending, busyId, onDecide: decide, timeoutMs: state.timeoutMs }))
|
|
2639
|
+
const missedRows = state.missed.map((row, index) => h('div', { className: 'ru-record', key: `${String(row.at)}-${index}` },
|
|
2640
|
+
h('span', null, `${String(row.toolName ?? '?')} \u00b7 ${shownTier(row)} \u00b7 ${whoLine(row)} \u00b7 ${byWords(t, row.by)}`),
|
|
2641
|
+
h('span', { className: 'ru-record-when' }, new Date(row.at).toLocaleString())))
|
|
2642
|
+
const missedBlock = state.missed.length === 0
|
|
2643
|
+
? h('p', { className: 'ru-empty' }, t('approvalsPanelNoMissed'))
|
|
2644
|
+
: h('div', null, ...missedRows)
|
|
2645
|
+
const auditNote = state.auditPath === ''
|
|
2646
|
+
? null
|
|
2647
|
+
: h('p', { className: 'ru-why', style: { paddingTop: '12px' } }, fmt(t('approvalsPanelAlso'), { p: String(state.auditPath) }))
|
|
2648
|
+
return h('div', { className: 'ru-panel', 'data-ru-tab': tab?.id === undefined ? '' : String(tab.id) },
|
|
2649
|
+
h('div', { className: 'ru-panel-head' },
|
|
2650
|
+
h('h3', { className: 'ru-panel-title' }, t('approvalsPanelPendingTitle')),
|
|
2651
|
+
h('span', { className: 'ru-count' }, String(state.pending.length))),
|
|
2652
|
+
pendingBlock,
|
|
2653
|
+
error === '' ? null : h('p', { className: 'ru-note warn' }, error),
|
|
2654
|
+
h('div', { className: 'ru-panel-head' },
|
|
2655
|
+
h('h3', { className: 'ru-panel-title' }, t('approvalsPanelMissedTitle')),
|
|
2656
|
+
h('span', { className: 'ru-count' }, String(state.missedCount))),
|
|
2657
|
+
missedBlock,
|
|
2658
|
+
state.missedCount === 0 ? null : clearControls(t, clearState),
|
|
2659
|
+
auditNote)
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
/** The right-pane tab identity: an address pattern, a title, and how to recognise one. */
|
|
2663
|
+
const APPROVALS_TAB_ID = 'remote-use-approvals'
|
|
2664
|
+
const APPROVALS_RESOURCE = 'dsh-resource://remote-use-approvals'
|
|
2665
|
+
|
|
2666
|
+
const inject = ['slots', 'locale']
|
|
2667
|
+
|
|
2668
|
+
function apply(ctx) {
|
|
2669
|
+
const t = ctx.locale.bind(NS)
|
|
2670
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-plugin-remote-use: dictionaries')
|
|
2671
|
+
|
|
2672
|
+
/**
|
|
2673
|
+
* The stylesheet must be GLOBAL, not a `<style>` inside the settings page's tree.
|
|
2674
|
+
*
|
|
2675
|
+
* MEASURED 2026-10-15: the three approval surfaces render in the session header, above the
|
|
2676
|
+
* composer and in the right pane -- all OUTSIDE the settings page -- so the `<style>` node
|
|
2677
|
+
* that lives in that page's tree never mounted for them. What the operator saw was unstyled
|
|
2678
|
+
* HTML that read as a design failure: `pwsh` touching its tier with no gap, no chip, and the
|
|
2679
|
+
* two-line grid collapsing into the host's own status line. The page keeps its own copy
|
|
2680
|
+
* (belt and braces); this one covers the chrome. Id-guarded, so two loads cannot stack sheets.
|
|
2681
|
+
*/
|
|
2682
|
+
const STYLE_ID = 'dsh-plugin-remote-use-style'
|
|
2683
|
+
if (typeof document !== 'undefined' && document.getElementById(STYLE_ID) === null) {
|
|
2684
|
+
const sheet = document.createElement('style')
|
|
2685
|
+
sheet.id = STYLE_ID
|
|
2686
|
+
sheet.textContent = CSS
|
|
2687
|
+
document.head.appendChild(sheet)
|
|
2688
|
+
ctx.effect(() => () => { sheet.remove() }, 'dsh-plugin-remote-use: stylesheet')
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
// ---- the three approval surfaces, where the work happens --------------------------
|
|
2692
|
+
//
|
|
2693
|
+
// One GET and one POST, shared by the feed and every button, so all three surfaces talk to
|
|
2694
|
+
// the route the same way -- and so `via: 'page'` is asserted in exactly one place.
|
|
2695
|
+
const getSnapshot = async () => {
|
|
2696
|
+
const response = await fetch(ROUTE, { cache: 'no-store' })
|
|
2697
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
2698
|
+
return await response.json()
|
|
2699
|
+
}
|
|
2700
|
+
const postRoute = async (action, payload) => {
|
|
2701
|
+
const response = await fetch(ROUTE, {
|
|
2702
|
+
method: 'POST',
|
|
2703
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2704
|
+
body: JSON.stringify(payload === undefined ? { action } : { action, ...payload }),
|
|
2705
|
+
})
|
|
2706
|
+
const data = await response.json().catch(() => ({}))
|
|
2707
|
+
if (!response.ok || data?.ok === false) throw new Error(String(data?.error ?? `HTTP ${response.status}`))
|
|
2708
|
+
return data
|
|
2709
|
+
}
|
|
2710
|
+
const feed = createApprovalFeed({ get: getSnapshot })
|
|
2711
|
+
ctx.effect(() => () => feed.dispose(), 'dsh-plugin-remote-use: approval feed')
|
|
2712
|
+
|
|
2713
|
+
/**
|
|
2714
|
+
* Register into a slot, and never let one registration take the application down.
|
|
2715
|
+
*
|
|
2716
|
+
* WHY THE try/catch IS NOT PARANOIA: `slots.register` THROWS on a contract violation -- a
|
|
2717
|
+
* `list` slot without `id`, a `keyed` slot without `key`, a name no parent declared -- and an
|
|
2718
|
+
* `apply()` that throws means this plugin's client bundle never loads. Measured 2026-10-14:
|
|
2719
|
+
* the DSH window on the worker would not start at all, because two registrations below were
|
|
2720
|
+
* missing `id` and the test stub had been accepting them. A surface that cannot be placed
|
|
2721
|
+
* must cost the operator that surface, not the application.
|
|
2722
|
+
*
|
|
2723
|
+
* @param {string} slot
|
|
2724
|
+
* @param {object} decl
|
|
2725
|
+
* @param {Function} component
|
|
2726
|
+
*/
|
|
2727
|
+
const registerSlot = (slot, decl, component) => {
|
|
2728
|
+
ctx.slots.inject(slot, () => {
|
|
2729
|
+
try {
|
|
2730
|
+
ctx.slots.register(decl, component)
|
|
2731
|
+
} catch (error) {
|
|
2732
|
+
console.warn(`[remote-use] could not register into "${slot}":`, error)
|
|
2733
|
+
}
|
|
2734
|
+
})
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
// THE SEAT, and why it is this one (measured 2026-10-15, not guessed).
|
|
2738
|
+
//
|
|
2739
|
+
// `conversation.input.dock` is a `list` slot rendered as a FULL-WIDTH row directly above the
|
|
2740
|
+
// input bar, and it is the seat DSH's own first-party strips use for exactly this shape:
|
|
2741
|
+
// `todo` (order 0), `goal` (order 10), the queued-message dock `queue` (order 20). The input
|
|
2742
|
+
// bar itself is the next sibling, so `order: 30` puts this strip immediately above it.
|
|
2743
|
+
//
|
|
2744
|
+
// The previous seat was `conversation.composer.dock`, which is NOT a strip position: it
|
|
2745
|
+
// renders inside the composer's STATUS line, the same flex row as the context meter and the
|
|
2746
|
+
// activity readout. A `list` entry there is one more inline item, so its position drifted with
|
|
2747
|
+
// the row's width and it inherited the status line's typography -- the operator saw it sitting
|
|
2748
|
+
// between "cache hit" and "context share", then saw its reason wrapped one word per line (F28).
|
|
2749
|
+
//
|
|
2750
|
+
// `conversation.composer` (the `chain` seat DSH's own approval prompt uses) was the other
|
|
2751
|
+
// candidate and is WRONG here: its owner renders it with `overlay: true`, and an elected chain
|
|
2752
|
+
// entry is drawn with the fallback set to `display: none` -- that fallback IS the composer bar,
|
|
2753
|
+
// so electing there would hide the input box for as long as a remote request waits.
|
|
2754
|
+
registerSlot('conversation.input.dock', {
|
|
2755
|
+
name: 'conversation.input.dock',
|
|
2756
|
+
// A `list` slot is keyed by id; without one the real service throws (PITFALLS F24).
|
|
2757
|
+
id: 'remote-use-approvals',
|
|
2758
|
+
order: 30,
|
|
2759
|
+
locale: NS,
|
|
2760
|
+
}, (props) => h(ApprovalsDock, { ...props, feed, post: postRoute }))
|
|
2761
|
+
|
|
2762
|
+
registerSlot('conversation.session.header.actions', {
|
|
2763
|
+
name: 'conversation.session.header.actions',
|
|
2764
|
+
id: 'remote-use-approvals',
|
|
2765
|
+
locale: NS,
|
|
2766
|
+
}, (props) => h(ApprovalsHeaderButton, { ...props, feed, openPanel: () => openApprovalsPane() }))
|
|
2767
|
+
|
|
2768
|
+
// The panel is optional on purpose. `sidebarRightTabs`/`sidebarRight` are first-party
|
|
2769
|
+
// services; on a shell that lacks them the dock and the header button still work, so a
|
|
2770
|
+
// missing pane must not take the rest of the plugin down with it. Neither is listed in
|
|
2771
|
+
// `inject`: requiring them would stop `apply` from running at all on such a shell.
|
|
2772
|
+
//
|
|
2773
|
+
// `openResource(address)` is the documented navigation controller -- "every way into the
|
|
2774
|
+
// column calls one of them". It replaced `openResourceIn`, which the docs mark as NOT part of
|
|
2775
|
+
// the public face (F27). The temporary probe that reported this click's internals to the audit
|
|
2776
|
+
// trail has been removed now that the public path is confirmed working; the reasoning it
|
|
2777
|
+
// produced is recorded in docs/PITFALLS.md rather than kept in the shipping path.
|
|
2778
|
+
function openApprovalsPane() {
|
|
2779
|
+
const controller = ctx.get('sidebarRight')
|
|
2780
|
+
if (controller === undefined || controller === null) return
|
|
2781
|
+
try {
|
|
2782
|
+
controller.openResource(`${APPROVALS_RESOURCE}/current`)
|
|
2783
|
+
} catch (error) {
|
|
2784
|
+
// Reported, never swallowed: a silent no-op is what made the first failed click
|
|
2785
|
+
// undiagnosable (F25).
|
|
2786
|
+
console.warn('[remote-use] could not open the approvals pane:', error)
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
const sidebarRightTabs = ctx.get('sidebarRightTabs')
|
|
2790
|
+
if (sidebarRightTabs !== undefined) {
|
|
2791
|
+
ctx.effect(() => sidebarRightTabs.register({
|
|
2792
|
+
id: APPROVALS_TAB_ID,
|
|
2793
|
+
kind: 'remote-use-approvals',
|
|
2794
|
+
patterns: [`${APPROVALS_RESOURCE}/**`],
|
|
2795
|
+
// The documented bands are `extension` (a type from outside the product -- the highest,
|
|
2796
|
+
// and the default when none is named), `builtin` and `fallback`. This plugin IS a type
|
|
2797
|
+
// from outside the product, so `builtin` was the wrong seat (PITFALLS F27).
|
|
2798
|
+
priority: 'extension',
|
|
2799
|
+
// Recognises our own addresses, so another plugin's tab can never be hijacked by ours.
|
|
2800
|
+
canOpen: (address) => String(address).startsWith(APPROVALS_RESOURCE),
|
|
2801
|
+
title: () => t('approvalsPanel'),
|
|
2802
|
+
// THE PIECE THAT WAS MISSING, straight from the docs: the pane's DEFAULT PAGE follows the
|
|
2803
|
+
// number of registered guide entries -- one entry opens its page directly, zero or many
|
|
2804
|
+
// open the guide. A type with no entry contributes nothing to that decision, so the
|
|
2805
|
+
// operator had no way in from the pane itself. Picking a capsule calls
|
|
2806
|
+
// `openTab(entry.kind, { replaceTab: true })`, which is why the entry names its kind.
|
|
2807
|
+
guide: [{
|
|
2808
|
+
id: APPROVALS_TAB_ID,
|
|
2809
|
+
kind: 'remote-use-approvals',
|
|
2810
|
+
title: () => t('approvalsPanel'),
|
|
2811
|
+
description: () => t('approvalsOpenPanel'),
|
|
2812
|
+
}],
|
|
2813
|
+
}), 'dsh-plugin-remote-use: approvals tab type')
|
|
2814
|
+
registerSlot('sidebar.right.pane.tab', {
|
|
2815
|
+
name: 'sidebar.right.pane.tab',
|
|
2816
|
+
key: APPROVALS_TAB_ID,
|
|
2817
|
+
locale: NS,
|
|
2818
|
+
}, (props) => h(ApprovalsTab, { ...props, feed, post: postRoute }))
|
|
2819
|
+
registerSlot('sidebar.right.pane.tab.title', {
|
|
2820
|
+
name: 'sidebar.right.pane.tab.title',
|
|
2821
|
+
key: APPROVALS_TAB_ID,
|
|
2822
|
+
locale: NS,
|
|
2823
|
+
}, () => h('span', null, t('approvalsPanel')))
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
registerSlot('settings.section', {
|
|
2827
|
+
name: 'settings.section',
|
|
2828
|
+
// A slot/ledger id, and deliberately SHORT. Only the registration id at the top of this file
|
|
2829
|
+
// must equal the package name; section ids are conventionally short in DSH's own first-party
|
|
2830
|
+
// sections (`agent-presets`, `account`) and in working third-party plugins (`dsh-skins`).
|
|
2831
|
+
// Changing this one buys nothing and would churn the Settings nav identity.
|
|
2832
|
+
id: 'dsh-plugin-remote-use',
|
|
2833
|
+
// After the notification plugin's 40, so operator surfaces stay grouped together.
|
|
2834
|
+
order: 45,
|
|
2835
|
+
label: () => t('nav'),
|
|
2836
|
+
locale: NS,
|
|
2837
|
+
}, RemoteUseSection)
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
return { inject, apply }
|
|
2841
|
+
},
|
|
2842
|
+
})
|