dsh-remote 0.8.40 → 0.8.42
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/README.en.md +2 -6
- package/README.md +2 -6
- package/lib/client.js +130 -191
- package/lib/deploy-tasks.js +142 -0
- package/lib/index.js +111 -337
- package/lib/paths.js +10 -2
- package/lib/web-attach.js +12 -0
- package/package.json +2 -2
- package/lib/archive.js +0 -1005
- package/lib/backup.js +0 -711
- package/lib/routes-backup.js +0 -283
package/README.en.md
CHANGED
|
@@ -39,7 +39,7 @@ The native **"Add workspace"** flow — a centered modal with two tabs, opening
|
|
|
39
39
|
|
|
40
40
|
## Features
|
|
41
41
|
|
|
42
|
-

|
|
43
43
|
|
|
44
44
|
The image above is the overview. What follows is only what the image does not make obvious.
|
|
45
45
|
|
|
@@ -56,8 +56,7 @@ The image above is the overview. What follows is only what the image does not ma
|
|
|
56
56
|
|
|
57
57
|
The rest:
|
|
58
58
|
|
|
59
|
-
- **
|
|
60
|
-
- **Workspace backup / restore (two buttons, nothing to configure)** — the settings page offers just **Back up** and **Restore**: backing up archives **every remote workspace in the sidebar into ONE** `.tar.gz`, and restoring brings them all back from the point in time you pick. Dependency/build directories (`node_modules`, `.git`, `dist`, `build`, `target`, `__pycache__`, `.venv` … both top-level and nested) are excluded automatically. The model can do the same through `rw_backup` / `rw_backup_list` / `rw_restore`. **The archive is verified before anything is written — a corrupt or truncated archive is refused and nothing is touched**; each workspace's previous contents are moved aside first and restored automatically if the swap fails.
|
|
59
|
+
- **22 model tools** (listed so they can be copied or searched): `rw_info`, `rw_connect`, `rw_machines`, `rw_pick_workspace`, `rw_list_dir`, `rw_stat`, `rw_read_file`, `rw_write_file`, `rw_edit`, `rw_append`, `rw_mkdir`, `rw_remove`, `rw_move`, `rw_exec`, `rw_search`, `rw_download`, `rw_upload`, `rw_sync`, `rw_push`, `rw_forward`, `rw_disconnect`, `rw_deploy_probe`.
|
|
61
60
|
- **Open a remote machine's DSH Web UI locally** (`0.8.36+`) — nothing has to be exposed on the remote: the plugin SSHes out, starts a `dsh web` there that binds **`127.0.0.1` only**, and tunnels its port back to a loopback port on this machine. The URL carries a one-time login token, consumed once by a redirect.
|
|
62
61
|
- **One-click health check & deploy** (`0.8.36+`) — **a failed connection is usually the remote dsh version or environment, not the plugin.** The read-only check reports platform / node / npm / dsh version / **whether the native module can load** / proxy and npm registry, with a suggested fix; "Deploy & verify" installs into a remote **private directory** (no system directory, no PATH change, no replacing the version you use — deleting it is a complete rollback) and verifies step by step. The button **no longer requires running the check first** (`0.8.40+`) — the deploy re-checks internally. If that still fails, the built-in `dsh-remote-deploy` skill takes over.
|
|
63
62
|
- **Cross-platform remotes** — all file access is SFTP-protocol-level (no POSIX shell), so Linux/macOS/Windows remotes all work.
|
|
@@ -264,9 +263,6 @@ Deploying to a product profile is a separate, explicit action (`./sync.sh`) for
|
|
|
264
263
|
| `webInstallPrefix` | string | `''` | where an automatic deployment installs dsh; empty means `$HOME/.dsh-remote/dsh` (no system directory, no PATH change) |
|
|
265
264
|
| `webInstallVersion` | string | `0.1.5-rc.2` | dsh version an automatic deployment installs; the default is the first that can start the web surface on Linux |
|
|
266
265
|
| `webInstallRegistry` | string | `''` | npm registry for the install; **empty keeps the remote's own config**, so an internal mirror is never overridden |
|
|
267
|
-
| `backupDir` | string | `''` | Where backup archives are written **on the remote**; empty means `$HOME/.dsh-remote/backups` (resolved on the remote, never guessed from this machine's home) |
|
|
268
|
-
| `backupExcludes` | string[] | `[]` | Extra paths excluded **on top of the built-in set** (gitignore-style). No longer surfaced in the UI; the built-in set covers `node_modules`/`.git`/`dist`/`build`/`target`/`__pycache__`/`.venv` and friends, top-level and nested |
|
|
269
|
-
| `maxBackupTransferBytes` | int | `2147483648` | Cap (bytes) on moving an archive between this machine and the remote; **the remote archive is never truncated** — this only bounds the optional cross-machine copy. 0 = unlimited |
|
|
270
266
|
|
|
271
267
|
> The authoritative list is the `Config` schema in `lib/index.js`; this table mirrors it.
|
|
272
268
|
|
package/README.md
CHANGED
|
@@ -39,14 +39,13 @@ DSH 的 Web 界面刻意只监听 `127.0.0.1`(CLI 为安全拒绝 `--host 0.0.
|
|
|
39
39
|
|
|
40
40
|
## 功能
|
|
41
41
|
|
|
42
|
-

|
|
43
43
|
|
|
44
44
|
上图是能力总览;下面只列**上图没说清、但用起来需要知道**的部分。
|
|
45
45
|
|
|
46
46
|
其余要点:
|
|
47
47
|
|
|
48
|
-
- **
|
|
49
|
-
- **工作区备份 / 恢复(一个按钮就够)** —— 设置页里只有「备份」和「恢复」两个按钮:**备份**把侧边栏里的所有远程工作区打包成**一个** `.tar.gz`,**恢复**用选中的那个时间点把全部工作区还原。依赖/构建目录(`node_modules`、`.git`、`dist`、`build`、`target`、`__pycache__`、`.venv`… 顶层与嵌套)自动排除,不需要你配置。模型侧仍可用 `rw_backup` / `rw_backup_list` / `rw_restore` 做同样的事。**恢复前先校验归档可读,归档损坏或截断就拒绝执行、目标一个字节都不动**;每个工作区的旧目录会先移到一旁,换入失败会自动还原。
|
|
48
|
+
- **22 个模型工具**(便于复制/检索):`rw_info`、`rw_connect`、`rw_machines`、`rw_pick_workspace`、`rw_list_dir`、`rw_stat`、`rw_read_file`、`rw_write_file`、`rw_edit`、`rw_append`、`rw_mkdir`、`rw_remove`、`rw_move`、`rw_exec`、`rw_search`、`rw_download`、`rw_upload`、`rw_sync`、`rw_push`、`rw_forward`、`rw_disconnect`、`rw_deploy_probe`。
|
|
50
49
|
- **把远端机器的 DSH 界面挂到本机**(`0.8.36+`)—— 不用在远端开任何端口:插件主动 SSH 连过去,在远端**只监听 `127.0.0.1`** 地起一个 `dsh web`,再把端口经隧道搬回本机的一个 loopback 端口。地址带一次性登录令牌,只在本机这次跳转里用一次。
|
|
51
50
|
- **连不上时一键体检与部署**(`0.8.36+`)—— **连不上大多不是插件的问题,而是远端 dsh 的版本或环境不对**。体检(只读)会报出平台/node/npm/dsh 版本、**原生模块能否启动**、代理与 npm 源,并给出修复建议;「部署并验证」把它装到远端**私有目录**(不写系统目录、不改 PATH、不覆盖你在用的版本,删目录即回滚)并逐步校验。按钮**不再需要先体检**——直接点即可(`0.8.40+`),部署过程自己会重新体检。失败还能交给内置的 `dsh-remote-deploy` 技能排查。
|
|
52
51
|
- **远端跨平台** —— 文件访问走 SFTP 协议层(不依赖 POSIX shell),Linux/macOS/Windows 远端都能列/读/写/搜索/同步。
|
|
@@ -246,9 +245,6 @@ scripts/dev-run.sh --status # 是否在运行
|
|
|
246
245
|
| `webInstallPrefix` | string | `''` | 自动部署装到远端的哪个目录;留空用 `$HOME/.dsh-remote/dsh`(不写系统目录、不改 PATH) |
|
|
247
246
|
| `webInstallVersion` | string | `0.1.5-rc.2` | 自动部署安装的 dsh 版本;默认值是第一个在 Linux 上能启动 web 的版本 |
|
|
248
247
|
| `webInstallRegistry` | string | `''` | 安装用的 npm 源;**留空沿用远端自己的配置**,不会覆盖内网镜像 |
|
|
249
|
-
| `backupDir` | string | `''` | 备份归档在**远端**的存放目录;留空用 `$HOME/.dsh-remote/backups`(在远端解析,不用本机 home 猜) |
|
|
250
|
-
| `backupExcludes` | string[] | `[]` | 在**内置排除集之外**额外排除的路径(gitignore 风格)。设置页不再暴露此项;内置集已覆盖 `node_modules`/`.git`/`dist`/`build`/`target`/`__pycache__`/`.venv` 等(顶层与嵌套) |
|
|
251
|
-
| `maxBackupTransferBytes` | int | `2147483648` | 归档在本机与远端之间搬运的大小上限(字节);**远端的归档从不截断**,只约束可选的对拷。0 = 不限 |
|
|
252
248
|
|
|
253
249
|
> 权威清单是 `lib/index.js` 里的 `Config` schema,本表与之一致。
|
|
254
250
|
|
package/lib/client.js
CHANGED
|
@@ -134,6 +134,14 @@ window.__ModuleLoader__.load({
|
|
|
134
134
|
'settings.deployUseCommand': '将使用的 dsh',
|
|
135
135
|
'settings.deploySteps': '安装步骤',
|
|
136
136
|
'settings.deployDone': '部署完成,已记录该机器使用的 dsh',
|
|
137
|
+
'settings.deployFailed': '部署失败',
|
|
138
|
+
'settings.deployResumed': '已有部署任务在运行,已恢复显示其进度',
|
|
139
|
+
'settings.deployTaskTitle': '部署任务',
|
|
140
|
+
'settings.deployTaskRunning': '进行中',
|
|
141
|
+
'settings.deployTaskDone': '已完成',
|
|
142
|
+
'settings.deployTaskFailed': '失败',
|
|
143
|
+
'settings.deployTaskCancelled': '已取消',
|
|
144
|
+
'settings.deployCancel': '取消',
|
|
137
145
|
'settings.deployInvestigateStarted': '已创建排查会话,请在会话里跟进。',
|
|
138
146
|
'settings.badgePwd': '密码',
|
|
139
147
|
'settings.badgeKeychain': '钥匙串',
|
|
@@ -187,33 +195,6 @@ window.__ModuleLoader__.load({
|
|
|
187
195
|
'settings.modeAuto': '自动',
|
|
188
196
|
'settings.modeOff': '关闭',
|
|
189
197
|
'settings.modeManual': '手动',
|
|
190
|
-
// ── 工作区备份(rw_backup / rw_restore)────────────────────────────
|
|
191
|
-
'settings.backup': '工作区备份',
|
|
192
|
-
'settings.backupHint': '一键把侧边栏里的所有远程工作区打包成一个 .tar.gz;恢复时用同一个归档把全部工作区还原。依赖/构建目录(node_modules、.git、dist、build…)会自动排除。',
|
|
193
|
-
'settings.backupCreate': '备份',
|
|
194
|
-
'settings.backupCreating': '备份中…',
|
|
195
|
-
'settings.backupRestore': '恢复',
|
|
196
|
-
'settings.backupRestoring': '恢复中…',
|
|
197
|
-
'settings.backupPickOne': '— 选择要恢复的备份时间点 —',
|
|
198
|
-
'settings.backupCovers': '本次将备份 {n} 个工作区:',
|
|
199
|
-
'settings.backupDirLabel': '远端备份目录:{dir}',
|
|
200
|
-
'settings.backupNone': '还没有备份。点「备份」创建第一个。',
|
|
201
|
-
'settings.backupCount': '{n} 个备份',
|
|
202
|
-
'settings.backupEntries': '{n} 个条目',
|
|
203
|
-
'settings.backupDownload': '下载到本机',
|
|
204
|
-
'settings.backupDelete': '删除',
|
|
205
|
-
'settings.backupVerify': '校验',
|
|
206
|
-
'settings.backupRestoreConfirm': '要用 {file} 恢复全部工作区吗?归档里没有的文件会被删除(原目录会先移到一旁,换入失败会自动还原)。',
|
|
207
|
-
'settings.backupDeleteConfirm': '删除备份 {file}?此操作不可撤销。',
|
|
208
|
-
'settings.backupVerified': '校验通过:sha256 {sha}',
|
|
209
|
-
'settings.backupVerifyMismatch': '校验失败:哈希与记录不一致(归档可能已损坏)',
|
|
210
|
-
'settings.backupRestored': '已从 {file} 恢复:{target}',
|
|
211
|
-
'settings.backupCreatedN': '已备份 {n} 个工作区 → {file}',
|
|
212
|
-
'settings.backupDownloaded': '已下载到本机:{path}',
|
|
213
|
-
'settings.backupDeleted': '已删除 {file}',
|
|
214
|
-
'settings.backupFailed': '备份操作失败:{msg}',
|
|
215
|
-
'settings.backupMachine': '机器',
|
|
216
|
-
'settings.backupPickMachine': '— 选择机器 —',
|
|
217
198
|
'picker.title': '选择工作目录',
|
|
218
199
|
'picker.close': '关闭 ✕',
|
|
219
200
|
'picker.tabLocal': '本机',
|
|
@@ -419,6 +400,14 @@ window.__ModuleLoader__.load({
|
|
|
419
400
|
'settings.deployUseCommand': 'dsh that will be used',
|
|
420
401
|
'settings.deploySteps': 'Install steps',
|
|
421
402
|
'settings.deployDone': 'Deployed; this machine now records the dsh it uses',
|
|
403
|
+
'settings.deployFailed': 'Deploy failed',
|
|
404
|
+
'settings.deployResumed': 'A deploy is already running — re-attached to its progress',
|
|
405
|
+
'settings.deployTaskTitle': 'Deploy task',
|
|
406
|
+
'settings.deployTaskRunning': 'running',
|
|
407
|
+
'settings.deployTaskDone': 'done',
|
|
408
|
+
'settings.deployTaskFailed': 'failed',
|
|
409
|
+
'settings.deployTaskCancelled': 'cancelled',
|
|
410
|
+
'settings.deployCancel': 'Cancel',
|
|
422
411
|
'settings.deployInvestigateStarted': 'A session was created — follow up there.',
|
|
423
412
|
'settings.badgePwd': 'password',
|
|
424
413
|
'settings.badgeKeychain': 'keychain',
|
|
@@ -472,33 +461,6 @@ window.__ModuleLoader__.load({
|
|
|
472
461
|
'settings.modeAuto': 'Auto',
|
|
473
462
|
'settings.modeOff': 'Off',
|
|
474
463
|
'settings.modeManual': 'Manual',
|
|
475
|
-
// ── workspace backup (rw_backup / rw_restore) ──────────────────────
|
|
476
|
-
'settings.backup': 'Workspace backup',
|
|
477
|
-
'settings.backupHint': 'One click archives every remote workspace in the sidebar into a single .tar.gz; restore brings them all back from that archive. Dependency/build directories (node_modules, .git, dist, build…) are excluded automatically.',
|
|
478
|
-
'settings.backupCreate': 'Back up',
|
|
479
|
-
'settings.backupCreating': 'Backing up…',
|
|
480
|
-
'settings.backupRestore': 'Restore',
|
|
481
|
-
'settings.backupRestoring': 'Restoring…',
|
|
482
|
-
'settings.backupPickOne': '— choose a backup point in time —',
|
|
483
|
-
'settings.backupCovers': 'This will back up {n} workspace(s):',
|
|
484
|
-
'settings.backupDirLabel': 'Remote backup directory: {dir}',
|
|
485
|
-
'settings.backupNone': 'No backups yet. Click "Back up" to create the first one.',
|
|
486
|
-
'settings.backupCount': '{n} backup(s)',
|
|
487
|
-
'settings.backupEntries': '{n} entries',
|
|
488
|
-
'settings.backupDownload': 'Download',
|
|
489
|
-
'settings.backupDelete': 'Delete',
|
|
490
|
-
'settings.backupVerify': 'Verify',
|
|
491
|
-
'settings.backupRestoreConfirm': 'Restore ALL workspaces from {file}? Files not present in the archive will be removed (each target is moved aside first, and restored automatically if the swap fails).',
|
|
492
|
-
'settings.backupDeleteConfirm': 'Delete the backup {file}? This cannot be undone.',
|
|
493
|
-
'settings.backupVerified': 'Verified: sha256 {sha}',
|
|
494
|
-
'settings.backupVerifyMismatch': 'Verification FAILED: the digest does not match the recorded one (the archive may be corrupt)',
|
|
495
|
-
'settings.backupRestored': 'Restored from {file}: {target}',
|
|
496
|
-
'settings.backupCreatedN': 'Backed up {n} workspace(s) → {file}',
|
|
497
|
-
'settings.backupDownloaded': 'Downloaded locally: {path}',
|
|
498
|
-
'settings.backupDeleted': 'Deleted {file}',
|
|
499
|
-
'settings.backupFailed': 'Backup action failed: {msg}',
|
|
500
|
-
'settings.backupMachine': 'Machine',
|
|
501
|
-
'settings.backupPickMachine': '— choose a machine —',
|
|
502
464
|
'picker.title': 'Select working directory',
|
|
503
465
|
'picker.close': 'Close ✕',
|
|
504
466
|
'picker.tabLocal': 'Local',
|
|
@@ -914,19 +876,11 @@ window.__ModuleLoader__.load({
|
|
|
914
876
|
const [deployBusy, setDeployBusy] = React.useState('')
|
|
915
877
|
const [deployErr, setDeployErr] = React.useState('')
|
|
916
878
|
const [deployOpen, setDeployOpen] = React.useState(false)
|
|
917
|
-
//
|
|
918
|
-
//
|
|
879
|
+
// The HOST-SIDE deploy task (resumable across window close/reopen). This
|
|
880
|
+
// is the object the poll keeps fresh; it survives because the work runs
|
|
881
|
+
// on the host, not in this tab.
|
|
882
|
+
const [deployTask, setDeployTask] = React.useState(null)
|
|
919
883
|
// session's machine", which the host resolves from the session binding.
|
|
920
|
-
const [backupOpen, setBackupOpen] = React.useState(false)
|
|
921
|
-
const [backupData, setBackupData] = React.useState(null)
|
|
922
|
-
const [backupBusy, setBackupBusy] = React.useState('')
|
|
923
|
-
const [backupMsg, setBackupMsg] = React.useState('')
|
|
924
|
-
const [backupErr, setBackupErr] = React.useState('')
|
|
925
|
-
// `backupPick` is the archive the Restore/Verify/Delete buttons act on. A
|
|
926
|
-
// timestamp picker replaced the old per-row buttons: restoring is the rare
|
|
927
|
-
// action, and one selector is all it needs.
|
|
928
|
-
const [backupPick, setBackupPick] = React.useState('')
|
|
929
|
-
const [backupMachine, setBackupMachine] = React.useState('')
|
|
930
884
|
React.useEffect(() => { ensureChromeCss() }, [])
|
|
931
885
|
|
|
932
886
|
const testConn = () => {
|
|
@@ -1016,56 +970,34 @@ window.__ModuleLoader__.load({
|
|
|
1016
970
|
const refreshForwards = () => api('GET', '/dsh-remote/forwards').then((r) => setForwards(r.forwards || [])).catch(() => {})
|
|
1017
971
|
React.useEffect(() => { refreshForwards(); api('GET', '/dsh-remote/audit?limit=30').then((r) => setAuditLines(r.lines || [])).catch(() => {}) }, [])
|
|
1018
972
|
|
|
1019
|
-
// ── 工作区备份 ────────────────────────────────────────────────────────
|
|
1020
|
-
// Reads/writes go through /dsh-remote/backup. The list carries BOTH the
|
|
1021
|
-
// remote archives and any pulled locally, so one refresh answers the panel.
|
|
1022
|
-
const backupQuery = (extra) => {
|
|
1023
|
-
const q = backupMachine ? 'machineId=' + encodeURIComponent(backupMachine) : ''
|
|
1024
|
-
return q ? (extra ? q + '&' + extra : q) : (extra || '')
|
|
1025
|
-
}
|
|
1026
|
-
const refreshBackups = () => api('GET', '/dsh-remote/backup' + (backupQuery('') ? '?' + backupQuery('') : ''))
|
|
1027
|
-
.then((r) => { setBackupData(r); setBackupErr('') })
|
|
1028
|
-
.catch((e) => setBackupErr(String((e && e.message) || e)))
|
|
1029
|
-
React.useEffect(() => { refreshBackups() }, [backupMachine])
|
|
1030
|
-
const backupAction = (action, body) => {
|
|
1031
|
-
setBackupBusy(action); setBackupMsg(''); setBackupErr('')
|
|
1032
|
-
return api('POST', '/dsh-remote/backup', Object.assign({ action }, backupMachine ? { machineId: backupMachine } : {}, body || {}))
|
|
1033
|
-
.then((r) => {
|
|
1034
|
-
if (r) {
|
|
1035
|
-
setBackupData(r)
|
|
1036
|
-
// Keep the picker pointing at something real after a list refresh
|
|
1037
|
-
// (a delete/restore changes the set): prefer the current choice,
|
|
1038
|
-
// else the newest archive.
|
|
1039
|
-
const names = (r.entries || []).map((e) => e.name)
|
|
1040
|
-
setBackupPick((cur) => (cur && names.includes(cur) ? cur : (names[0] || '')))
|
|
1041
|
-
}
|
|
1042
|
-
if (action === 'create' && r && r.created) {
|
|
1043
|
-
setBackupMsg(tr('settings.backupCreatedN', { n: (r.created.workspaces || []).length, file: r.created.name }))
|
|
1044
|
-
} else if (action === 'restore' && r && r.restored) {
|
|
1045
|
-
setBackupMsg(tr('settings.backupRestored', { file: r.restored.file, target: (r.restored.targets || []).join('、') })
|
|
1046
|
-
+ (r.warning ? ' — ' + r.warning : ''))
|
|
1047
|
-
} else if (action === 'delete') {
|
|
1048
|
-
setBackupMsg(tr('settings.backupDeleted', { file: body.file }))
|
|
1049
|
-
} else if (action === 'download' && r && (r.localPath || (r.restored && r.restored.file))) {
|
|
1050
|
-
setBackupMsg(tr('settings.backupDownloaded', { path: r.localPath || body.file }))
|
|
1051
|
-
} else if (action === 'verify' && r) {
|
|
1052
|
-
setBackupMsg(r.matches === false || r.verified === false
|
|
1053
|
-
? tr('settings.backupVerifyMismatch')
|
|
1054
|
-
: tr('settings.backupVerified', { sha: String(r.sha256 || '').slice(0, 16) }))
|
|
1055
|
-
}
|
|
1056
|
-
return r
|
|
1057
|
-
})
|
|
1058
|
-
.catch((e) => { setBackupErr(tr('settings.backupFailed', { msg: String((e && e.message) || e) })); return null })
|
|
1059
|
-
.then((r) => { setBackupBusy(''); return r })
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
973
|
// ── 远程 DSH 界面(issue #46)─────────────────────────────────────────
|
|
1063
974
|
// The remote never opens an inbound port; we SSH out and carry the remote
|
|
1064
975
|
// loopback web UI back to a local port. One tunnel per machine.
|
|
976
|
+
//
|
|
977
|
+
// This SAME poll carries the deploy-task state, so a REOPENED window picks
|
|
978
|
+
// up an in-flight install on the first tick (15s worst case — plus the
|
|
979
|
+
// install button itself resumes). When a deploy is running the interval
|
|
980
|
+
// tightens so step progress looks live.
|
|
1065
981
|
const refreshWebAttaches = () => api('GET', '/dsh-remote/web-attach')
|
|
1066
|
-
.then((r) =>
|
|
982
|
+
.then((r) => {
|
|
983
|
+
setWebAttaches(r.attaches || [])
|
|
984
|
+
const t = (r.deploys || []).find((d) => d.machineId === webAttachMachine) || r.deploy || null
|
|
985
|
+
if (t && (t.status === 'running' || t.status === 'queued')) {
|
|
986
|
+
setDeployTask(t)
|
|
987
|
+
setDeployBusy('install')
|
|
988
|
+
} else if (t) {
|
|
989
|
+
setDeployTask(t)
|
|
990
|
+
}
|
|
991
|
+
return t
|
|
992
|
+
})
|
|
1067
993
|
.catch(() => {})
|
|
1068
|
-
React.useEffect(() => {
|
|
994
|
+
React.useEffect(() => {
|
|
995
|
+
refreshWebAttaches()
|
|
996
|
+
const t = setInterval(() => {
|
|
997
|
+
refreshWebAttaches()
|
|
998
|
+
}, 15000)
|
|
999
|
+
return () => clearInterval(t)
|
|
1000
|
+
}, [])
|
|
1069
1001
|
const webAttachAction = (action, machineId, extra) => {
|
|
1070
1002
|
setWebAttachBusy(machineId + ':' + action); setWebAttachErr('')
|
|
1071
1003
|
return api('POST', '/dsh-remote/web-attach', { action, machineId, ...(extra || {}) })
|
|
@@ -1077,23 +1009,60 @@ window.__ModuleLoader__.load({
|
|
|
1077
1009
|
// ── 远程部署:体检 / 部署并验证 / 让 AI 排查 ───────────────────────────
|
|
1078
1010
|
// The probe is read-only, so it is safe to run before the user agrees to
|
|
1079
1011
|
// anything; the install requires an explicit second click (confirm: true).
|
|
1012
|
+
//
|
|
1013
|
+
// Install is a HOST-SIDE BACKGROUND TASK: the call returns immediately
|
|
1014
|
+
// with a task handle, and progress arrives by polling. Closing the window
|
|
1015
|
+
// loses nothing — reopening picks the task back up from the periodic
|
|
1016
|
+
// web-attach poll (which carries the deploy state), and clicking 部署并验证
|
|
1017
|
+
// again while one runs RESUMES it rather than starting a second npm.
|
|
1018
|
+
const deployPoll = () => api('GET', '/dsh-remote/web-attach')
|
|
1019
|
+
.then((r) => {
|
|
1020
|
+
if (!r) return null
|
|
1021
|
+
// Refresh the attach list like before…
|
|
1022
|
+
setWebAttaches(r.attaches || [])
|
|
1023
|
+
// …and adopt any deploy task for the selected machine.
|
|
1024
|
+
const t = r.deploy || (r.deploys || []).find((d) => d.machineId === webAttachMachine) || null
|
|
1025
|
+
if (t) {
|
|
1026
|
+
setDeployTask(t)
|
|
1027
|
+
if (t.status === 'running' || t.status === 'queued') {
|
|
1028
|
+
setDeployBusy('install')
|
|
1029
|
+
} else if (deployBusy === 'install') {
|
|
1030
|
+
// The task settled since the last poll.
|
|
1031
|
+
setDeployBusy('')
|
|
1032
|
+
if (t.status === 'done') setDeployMsg(tr('settings.deployDone'))
|
|
1033
|
+
if (t.status === 'failed') setDeployErr(t.error || tr('settings.deployFailed'))
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
return t
|
|
1037
|
+
})
|
|
1038
|
+
.catch(() => null)
|
|
1039
|
+
|
|
1080
1040
|
const deployAction = (action, machineId, extra) => {
|
|
1081
|
-
setDeployBusy(action); setDeployErr('')
|
|
1041
|
+
setDeployBusy(action); setDeployErr(''); setDeployMsg('')
|
|
1082
1042
|
return api('POST', '/dsh-remote/web-attach', { action, machineId, ...(extra || {}) })
|
|
1083
1043
|
.then((r) => {
|
|
1084
1044
|
if (action === 'probe') setDeploy({ kind: 'probe', ...r, at: Date.now() })
|
|
1085
|
-
if (action === 'install')
|
|
1045
|
+
if (action === 'install') {
|
|
1046
|
+
// The task is running on the host NOW. Adopt it immediately so the
|
|
1047
|
+
// steps appear without waiting for the first poll tick.
|
|
1048
|
+
if (r && r.task) setDeployTask(r.task)
|
|
1049
|
+
if (r && r.resumed) setDeployMsg(tr('settings.deployResumed'))
|
|
1050
|
+
}
|
|
1086
1051
|
if (action === 'investigate') setDeploy({ kind: 'investigate', ...r, at: Date.now() })
|
|
1087
1052
|
return r
|
|
1088
1053
|
})
|
|
1089
1054
|
.catch((e) => {
|
|
1090
1055
|
const msg = String((e && e.message) || e)
|
|
1091
1056
|
setDeployErr(msg)
|
|
1092
|
-
// Keep the failing step list so the operator sees WHERE it stopped.
|
|
1093
1057
|
setDeploy({ kind: action, ok: false, error: msg, at: Date.now() })
|
|
1094
1058
|
return null
|
|
1095
1059
|
})
|
|
1096
|
-
.then((r) => {
|
|
1060
|
+
.then((r) => {
|
|
1061
|
+
if (action !== 'install') setDeployBusy('')
|
|
1062
|
+
// install keeps busy='install' until the task settles (poll clears it).
|
|
1063
|
+
refreshWebAttaches()
|
|
1064
|
+
return r
|
|
1065
|
+
})
|
|
1097
1066
|
}
|
|
1098
1067
|
|
|
1099
1068
|
const setF = (k) => (ev) => setForm({ ...form, [k]: ev.target.value })
|
|
@@ -1382,6 +1351,46 @@ window.__ModuleLoader__.load({
|
|
|
1382
1351
|
disabled: !!deployBusy || !webAttachMachine,
|
|
1383
1352
|
onClick: () => deployAction('probe', webAttachMachine) },
|
|
1384
1353
|
deployBusy === 'probe' ? tr('settings.deployProbing') : tr('settings.deployProbe'))),
|
|
1354
|
+
// ── live deploy task (resumable; survives window close) ─────────
|
|
1355
|
+
deployTask
|
|
1356
|
+
? React.createElement('div', { style: { marginTop: 6, fontSize: 12, lineHeight: 1.6, border: '1px solid ' + T.border, borderRadius: 8, padding: 8 } },
|
|
1357
|
+
React.createElement('div', { style: { fontWeight: 600, marginBottom: 4 } },
|
|
1358
|
+
tr('settings.deployTaskTitle')
|
|
1359
|
+
+ ' — ' + (deployTask.status === 'running' || deployTask.status === 'queued'
|
|
1360
|
+
? tr('settings.deployTaskRunning')
|
|
1361
|
+
: deployTask.status === 'done' ? tr('settings.deployTaskDone')
|
|
1362
|
+
: deployTask.status === 'cancelled' ? tr('settings.deployTaskCancelled')
|
|
1363
|
+
: tr('settings.deployTaskFailed'))),
|
|
1364
|
+
// The in-flight step: the one thing a re-attached user most
|
|
1365
|
+
// wants to see ("现在装到哪了").
|
|
1366
|
+
deployTask.current && (deployTask.status === 'running')
|
|
1367
|
+
? React.createElement('div', { style: { fontSize: 12, color: T.warn, marginBottom: 4 } }, '▶ ' + deployTask.current)
|
|
1368
|
+
: null,
|
|
1369
|
+
(deployTask.steps || []).length
|
|
1370
|
+
? React.createElement('div', null,
|
|
1371
|
+
React.createElement('div', { style: { fontWeight: 600, marginBottom: 2, fontSize: 11, opacity: 0.8 } }, tr('settings.deploySteps')),
|
|
1372
|
+
deployTask.steps.map((s) => React.createElement('div', { key: s.id, style: { fontSize: 11, fontFamily: 'monospace', display: 'flex', gap: 6 } },
|
|
1373
|
+
React.createElement('span', { style: { color: s.ok ? T.ok : T.danger } }, s.ok ? '✓' : '✗'),
|
|
1374
|
+
React.createElement('span', null, s.title),
|
|
1375
|
+
!s.ok && s.output ? React.createElement('span', { style: { opacity: 0.75, whiteSpace: 'pre-wrap', wordBreak: 'break-all' } }, ' — ' + String(s.output).slice(0, 300)) : null)))
|
|
1376
|
+
: null,
|
|
1377
|
+
deployTask.status === 'running' || deployTask.status === 'queued'
|
|
1378
|
+
? React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginTop: 6 } },
|
|
1379
|
+
React.createElement('span', { style: { fontSize: 11, opacity: 0.7 } },
|
|
1380
|
+
(deployTask.progress && deployTask.progress.doneSteps || 0) + '/' + (deployTask.progress && deployTask.progress.totalSteps || '?')),
|
|
1381
|
+
React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px' },
|
|
1382
|
+
onClick: () => deployAction('install-cancel', webAttachMachine) }, tr('settings.deployCancel')))
|
|
1383
|
+
: null,
|
|
1384
|
+
// A finished task: continue the journey — connect & open.
|
|
1385
|
+
deployTask.status === 'done'
|
|
1386
|
+
? React.createElement('div', { style: { marginTop: 6, color: T.ok, fontSize: 12 } },
|
|
1387
|
+
tr('settings.deployDone')
|
|
1388
|
+
+ (deployTask.result && deployTask.result.command ? ' · ' + deployTask.result.command : ''))
|
|
1389
|
+
: null,
|
|
1390
|
+
deployTask.status === 'failed'
|
|
1391
|
+
? React.createElement('div', { style: { marginTop: 6, color: T.danger, fontSize: 12, wordBreak: 'break-all' } }, deployTask.error || tr('settings.deployFailed'))
|
|
1392
|
+
: null)
|
|
1393
|
+
: null,
|
|
1385
1394
|
// Verdict from the last probe/install, rendered as findings with the
|
|
1386
1395
|
// severity the backend assigned — the UI never re-classifies them.
|
|
1387
1396
|
deploy ? React.createElement('div', { style: { fontSize: 12, lineHeight: 1.6 } },
|
|
@@ -1426,9 +1435,13 @@ window.__ModuleLoader__.load({
|
|
|
1426
1435
|
// reason to run. The host action re-probes internally anyway, so it is
|
|
1427
1436
|
// safe to offer directly; clicking it is the explicit consent.
|
|
1428
1437
|
React.createElement('button', { type: 'button', className: 'dsh-rw-btn dsh-rw-primary', style: { ...primaryBtnS, marginTop: 6 },
|
|
1429
|
-
disabled:
|
|
1438
|
+
disabled: !webAttachMachine,
|
|
1430
1439
|
onClick: () => { if (window.confirm(tr('settings.deployConfirm'))) deployAction('install', webAttachMachine, { confirm: true }) } },
|
|
1431
|
-
|
|
1440
|
+
// While a task runs, the button is the RESUME affordance (disabled
|
|
1441
|
+
// — the poll renders live state; a second click must not restart).
|
|
1442
|
+
(deployTask && (deployTask.status === 'running' || deployTask.status === 'queued'))
|
|
1443
|
+
? tr('settings.deployInstalling')
|
|
1444
|
+
: deployBusy === 'install' ? tr('settings.deployInstalling') : tr('settings.deployInstall')),
|
|
1432
1445
|
// The AI escape hatch: offered after a failure, and ONLY on click —
|
|
1433
1446
|
// it costs a model call, so it is never automatic.
|
|
1434
1447
|
(deployErr || (deploy && deploy.ok === false))
|
|
@@ -1444,80 +1457,6 @@ window.__ModuleLoader__.load({
|
|
|
1444
1457
|
deployErr ? React.createElement('div', { style: { color: T.danger, fontSize: 12, marginTop: 6, wordBreak: 'break-all' } }, deployErr) : null,
|
|
1445
1458
|
) : React.createElement('div', { style: { fontSize: 11, opacity: 0.55 } }, deploy && deploy.verdict ? (deploy.verdict.ok ? tr('settings.deployOk') : tr('settings.deployBlocked')) : ''),
|
|
1446
1459
|
),
|
|
1447
|
-
// 工作区备份(rw_backup / rw_backup_list / rw_restore)
|
|
1448
|
-
//
|
|
1449
|
-
// TWO buttons and a timestamp picker on purpose. The first version exposed
|
|
1450
|
-
// excludes / destination / per-archive verify-download-upload-delete, which
|
|
1451
|
-
// turned "keep a copy of my workspaces" into a configuration exercise.
|
|
1452
|
-
// Everything that needed deciding now lives in code (the exclude set) or
|
|
1453
|
-
// is derived (which workspaces = whatever the sidebar shows).
|
|
1454
|
-
React.createElement('div', { style: boxS },
|
|
1455
|
-
sectionToggle(backupOpen, setBackupOpen, tr('settings.backup'),
|
|
1456
|
-
backupData ? React.createElement('span', { style: { marginLeft: 'auto', fontSize: 11, opacity: 0.6 } },
|
|
1457
|
-
tr('settings.backupCount', { n: (backupData.entries || []).length })) : null),
|
|
1458
|
-
backupOpen ? React.createElement(React.Fragment, null,
|
|
1459
|
-
React.createElement('div', { style: { opacity: 0.65, fontSize: 11, lineHeight: 1.5, marginBottom: 6 } }, tr('settings.backupHint')),
|
|
1460
|
-
// Say exactly what will be included, before the click.
|
|
1461
|
-
backupData && (backupData.covers || []).length
|
|
1462
|
-
? React.createElement('div', { style: { fontSize: 11, opacity: 0.8, marginBottom: 6, wordBreak: 'break-all' } },
|
|
1463
|
-
tr('settings.backupCovers', { n: (backupData.covers || []).length }) + ' ' + (backupData.covers || []).join('、'))
|
|
1464
|
-
: null,
|
|
1465
|
-
backupData && backupData.backupDir
|
|
1466
|
-
? React.createElement('div', { style: { fontSize: 11, opacity: 0.6, marginBottom: 8, wordBreak: 'break-all' } },
|
|
1467
|
-
tr('settings.backupDirLabel', { dir: backupData.backupDir }))
|
|
1468
|
-
: null,
|
|
1469
|
-
// Machine selector: empty means "this session's machine". Kept because
|
|
1470
|
-
// the panel is reachable from a local session, where there is no
|
|
1471
|
-
// session binding to resolve.
|
|
1472
|
-
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', marginBottom: 8, flexWrap: 'wrap' } },
|
|
1473
|
-
React.createElement('span', { style: { fontSize: 12, opacity: 0.8 } }, tr('settings.backupMachine') + ':'),
|
|
1474
|
-
React.createElement('select', { className: 'dsh-rw-input', value: backupMachine, onChange: (e) => setBackupMachine(e.target.value), style: { ...inputS, maxWidth: 220 } },
|
|
1475
|
-
React.createElement('option', { value: '' }, tr('settings.backupPickMachine')),
|
|
1476
|
-
machines.map((m) => React.createElement('option', { key: m.id, value: m.id }, `${m.name || m.host} (${m.username}@${m.host})`)))),
|
|
1477
|
-
// ── the two buttons ───────────────────────────────────────────────
|
|
1478
|
-
React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
|
|
1479
|
-
React.createElement('button', {
|
|
1480
|
-
type: 'button', className: 'dsh-rw-btn dsh-rw-primary', style: { ...primaryBtnS },
|
|
1481
|
-
disabled: !!backupBusy,
|
|
1482
|
-
onClick: () => backupAction('create'),
|
|
1483
|
-
}, backupBusy === 'create' ? tr('settings.backupCreating') : tr('settings.backupCreate')),
|
|
1484
|
-
React.createElement('select', {
|
|
1485
|
-
className: 'dsh-rw-input',
|
|
1486
|
-
value: backupPick,
|
|
1487
|
-
onChange: (e) => setBackupPick(e.target.value),
|
|
1488
|
-
style: { ...inputS, flex: '1 1 260px', minWidth: 200, maxWidth: 380 },
|
|
1489
|
-
},
|
|
1490
|
-
React.createElement('option', { value: '' }, tr('settings.backupPickOne')),
|
|
1491
|
-
(backupData && backupData.entries ? backupData.entries : []).map((e, i) => React.createElement('option', { key: e.name, value: e.name },
|
|
1492
|
-
`${e.createdAt ? e.createdAt.replace('T', ' ').slice(0, 19) : e.mtime || ''} · ${humanSize(e.bytes)} · ${tr('settings.backupEntries', { n: e.members == null ? '?' : e.members })} · ${e.name}`))),
|
|
1493
|
-
React.createElement('button', {
|
|
1494
|
-
type: 'button', className: 'dsh-rw-btn',
|
|
1495
|
-
style: { ...buttonS },
|
|
1496
|
-
disabled: !!backupBusy || !backupPick,
|
|
1497
|
-
onClick: () => {
|
|
1498
|
-
if (!window.confirm(tr('settings.backupRestoreConfirm', { file: backupPick }))) return
|
|
1499
|
-
backupAction('restore', { file: backupPick, confirm: true })
|
|
1500
|
-
},
|
|
1501
|
-
}, backupBusy === 'restore' ? tr('settings.backupRestoring') : tr('settings.backupRestore'))),
|
|
1502
|
-
// Housekeeping stays available but out of the way: verify and delete
|
|
1503
|
-
// whatever the picker has selected.
|
|
1504
|
-
backupPick
|
|
1505
|
-
? React.createElement('div', { style: { marginTop: 6, display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
|
|
1506
|
-
React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px' }, disabled: !!backupBusy,
|
|
1507
|
-
onClick: () => backupAction('verify', { file: backupPick }) }, tr('settings.backupVerify')),
|
|
1508
|
-
React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px' }, disabled: !!backupBusy,
|
|
1509
|
-
onClick: () => backupAction('download', { file: backupPick }) }, tr('settings.backupDownload')),
|
|
1510
|
-
React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px', color: T.dangerText }, disabled: !!backupBusy,
|
|
1511
|
-
onClick: () => { if (window.confirm(tr('settings.backupDeleteConfirm', { file: backupPick }))) backupAction('delete', { file: backupPick }) } }, tr('settings.backupDelete')))
|
|
1512
|
-
: null,
|
|
1513
|
-
backupMsg ? React.createElement('div', { style: { marginTop: 8, fontSize: 12, color: T.ok, wordBreak: 'break-all' } }, backupMsg) : null,
|
|
1514
|
-
backupErr ? React.createElement('div', { style: { marginTop: 8, fontSize: 12, color: T.danger, wordBreak: 'break-all' } }, backupErr) : null,
|
|
1515
|
-
!backupData || !(backupData.entries || []).length
|
|
1516
|
-
? React.createElement('div', { style: { marginTop: 8, opacity: 0.6, fontSize: 12 } }, tr('settings.backupNone'))
|
|
1517
|
-
: null,
|
|
1518
|
-
) : backupData ? React.createElement('div', { style: { fontSize: 11, opacity: 0.55 } },
|
|
1519
|
-
tr('settings.backupCount', { n: (backupData.entries || []).length })) : null,
|
|
1520
|
-
),
|
|
1521
1460
|
// 端口转发面板
|
|
1522
1461
|
React.createElement('div', { style: boxS },
|
|
1523
1462
|
sectionToggle(fwdOpen, setFwdOpen, tr('settings.forwards')),
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// dsh-remote — host-side deploy task manager.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS: `install` used to be a single long HTTP request (npm can
|
|
4
|
+
// legitimately take minutes). If the browser tab closed — or the fetch was
|
|
5
|
+
// cancelled — the RESPONSE was lost and, with it, every trace of what the
|
|
6
|
+
// deploy did: the user could not see the progress again, could not tell whether
|
|
7
|
+
// it succeeded, and clicking the button again would start a SECOND concurrent
|
|
8
|
+
// npm install into the same prefix. High availability here means:
|
|
9
|
+
//
|
|
10
|
+
// · the deploy runs on the HOST, not tied to any request lifecycle;
|
|
11
|
+
// · every step's outcome is recorded as it happens;
|
|
12
|
+
// · the UI can re-attach at any time and see the live state;
|
|
13
|
+
// · a second click while one is running RE-ATTACHES instead of restarting;
|
|
14
|
+
// · the result (including the machine's webAttachCommand) is recorded even
|
|
15
|
+
// if no client ever polls for it.
|
|
16
|
+
//
|
|
17
|
+
// One deploy per machine at a time — npm --prefix into the same directory from
|
|
18
|
+
// two processes corrupts the tree. A NEW request for a machine that already has
|
|
19
|
+
// a running or finished deploy returns the EXISTING task (resume semantics);
|
|
20
|
+
// `force: true` starts a fresh one after the previous is finished.
|
|
21
|
+
|
|
22
|
+
/** A deploy task's lifecycle: queued → running → done | failed | cancelled. */
|
|
23
|
+
export class DeployTasks {
|
|
24
|
+
constructor() {
|
|
25
|
+
/** @type {Map<string, object>} machineId → task */
|
|
26
|
+
this.byMachine = new Map()
|
|
27
|
+
/** @type {Map<string, object>} taskId → task (for stable polling) */
|
|
28
|
+
this.byId = new Map()
|
|
29
|
+
this.nextId = 1
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The task for a machine, if one exists in ANY state.
|
|
34
|
+
* @returns {object|null}
|
|
35
|
+
*/
|
|
36
|
+
forMachine(machineId) {
|
|
37
|
+
return this.byMachine.get(String(machineId || '')) || null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get(id) {
|
|
41
|
+
return this.byId.get(String(id || '')) || null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Start a deploy for a machine, unless one is already running.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} machineId
|
|
48
|
+
* @param {Function} fn - async (ctx) => result. ctx = { progress(patch) }.
|
|
49
|
+
* @returns {{task: object, reused: boolean}} the task plus whether it already
|
|
50
|
+
* existed (caller reports "resumed" so the UI does not claim a fresh start).
|
|
51
|
+
*/
|
|
52
|
+
start(machineId, label, fn) {
|
|
53
|
+
const mid = String(machineId || '')
|
|
54
|
+
const existing = this.byMachine.get(mid)
|
|
55
|
+
if (existing && (existing.status === 'queued' || existing.status === 'running')) {
|
|
56
|
+
return { task: existing, reused: true }
|
|
57
|
+
}
|
|
58
|
+
const id = `deploy-${this.nextId++}`
|
|
59
|
+
const task = {
|
|
60
|
+
id,
|
|
61
|
+
machineId: mid,
|
|
62
|
+
kind: 'deploy',
|
|
63
|
+
label: String(label || ''),
|
|
64
|
+
status: 'queued',
|
|
65
|
+
// Steps are appended as they finish; `steps` is what the UI renders.
|
|
66
|
+
steps: [],
|
|
67
|
+
// The current in-flight step, so a re-attached UI can show "正在安装…"
|
|
68
|
+
// rather than a stale list with no hint that more is coming.
|
|
69
|
+
current: '',
|
|
70
|
+
progress: {},
|
|
71
|
+
result: null,
|
|
72
|
+
error: null,
|
|
73
|
+
startedAt: null,
|
|
74
|
+
finishedAt: null,
|
|
75
|
+
cancelled: false,
|
|
76
|
+
}
|
|
77
|
+
this.byMachine.set(mid, task)
|
|
78
|
+
this.byId.set(id, task)
|
|
79
|
+
// Deliberately NOT queued through the shared TaskManager: that one is
|
|
80
|
+
// single-flight across ALL tasks (one sync at a time), while deploys to
|
|
81
|
+
// DIFFERENT machines must run in parallel.
|
|
82
|
+
task.status = 'running'
|
|
83
|
+
task.startedAt = new Date().toISOString()
|
|
84
|
+
const ctx = {
|
|
85
|
+
progress: (p) => { task.progress = { ...task.progress, ...p } },
|
|
86
|
+
get cancelled() { return !!task.cancelled },
|
|
87
|
+
}
|
|
88
|
+
Promise.resolve()
|
|
89
|
+
.then(() => fn(ctx))
|
|
90
|
+
.then((result) => {
|
|
91
|
+
task.result = result || null
|
|
92
|
+
task.status = task.cancelled ? 'cancelled' : 'done'
|
|
93
|
+
})
|
|
94
|
+
.catch((err) => {
|
|
95
|
+
task.error = String((err && err.message) || err)
|
|
96
|
+
// A step-level failure carries the step list (and hints) as structured
|
|
97
|
+
// data so the UI can render exactly what the old single-response flow
|
|
98
|
+
// rendered — re-attaching must lose nothing.
|
|
99
|
+
if (err && err.detail && typeof err.detail === 'object') task.detail = err.detail
|
|
100
|
+
task.status = task.cancelled ? 'cancelled' : 'failed'
|
|
101
|
+
})
|
|
102
|
+
.finally(() => {
|
|
103
|
+
task.current = ''
|
|
104
|
+
task.finishedAt = new Date().toISOString()
|
|
105
|
+
})
|
|
106
|
+
return { task, reused: false }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Cooperative cancel (a running npm step still finishes; later steps stop). */
|
|
110
|
+
cancel(machineId) {
|
|
111
|
+
const t = this.byMachine.get(String(machineId || ''))
|
|
112
|
+
if (!t) return false
|
|
113
|
+
if (t.status === 'queued' || t.status === 'running') {
|
|
114
|
+
t.cancelled = true
|
|
115
|
+
return true
|
|
116
|
+
}
|
|
117
|
+
return false
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Serializable view for the UI. */
|
|
121
|
+
describe(t) {
|
|
122
|
+
if (!t) return null
|
|
123
|
+
return {
|
|
124
|
+
id: t.id,
|
|
125
|
+
machineId: t.machineId,
|
|
126
|
+
kind: t.kind,
|
|
127
|
+
status: t.status, // queued | running | done | failed | cancelled
|
|
128
|
+
steps: t.steps, // [{id,title,ok,code,output}]
|
|
129
|
+
current: t.current, // in-flight step title ('' when none)
|
|
130
|
+
progress: t.progress,
|
|
131
|
+
result: t.result,
|
|
132
|
+
error: t.error,
|
|
133
|
+
detail: t.detail || null,
|
|
134
|
+
startedAt: t.startedAt,
|
|
135
|
+
finishedAt: t.finishedAt,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
list() {
|
|
140
|
+
return [...this.byMachine.values()].map((t) => this.describe(t))
|
|
141
|
+
}
|
|
142
|
+
}
|