@twinsearth/w2m-dsh-plugin 1.5.0 → 1.5.1
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/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +15 -0
- package/bin/w2m-secondary-link.mjs +32 -2
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/1.5.1/IDENTITY-ISOLATION-RELAY-FOLD.json +22 -0
- package/lib/client.js +53 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/src/plugin/fleet-target.mjs +120 -1
- package/src/plugin/tools.mjs +1 -1
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "The w2m fleet plugins: one account, many machines, any of Windows, macOS or Linux.",
|
|
8
|
-
"version": "1.5.
|
|
8
|
+
"version": "1.5.1"
|
|
9
9
|
},
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "w2m-fleet",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"description": "Read fleet state and dispatch commands across the machines registered to one account. Read-only MCP surface: the five exposed tools cannot change anything.",
|
|
15
|
-
"version": "1.5.
|
|
15
|
+
"version": "1.5.1",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "TwinsEarth"
|
|
18
18
|
},
|
|
@@ -26,5 +26,5 @@
|
|
|
26
26
|
]
|
|
27
27
|
}
|
|
28
28
|
],
|
|
29
|
-
"version": "1.5.
|
|
29
|
+
"version": "1.5.1"
|
|
30
30
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "w2m-fleet",
|
|
3
3
|
"description": "Drive the same project on online Windows, macOS and Linux machines from one account: read fleet state, dispatch commands and read results. The MCP surface is deliberately read-only.",
|
|
4
|
-
"version": "1.5.
|
|
4
|
+
"version": "1.5.1",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TwinsEarth"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,21 @@
|
|
|
7
7
|
|
|
8
8
|
All notable changes to this project are documented here.
|
|
9
9
|
|
|
10
|
+
## [1.5.1] — 2026-10-23
|
|
11
|
+
|
|
12
|
+
**备用链路的身份隔离 + 控制面板的 relay 折叠。**
|
|
13
|
+
|
|
14
|
+
- `bin/w2m-secondary-link.mjs`:默认给子进程独立身份目录(`<stateDir>/dsh-home`),并同时设置
|
|
15
|
+
`DSH_HOME` / `USERPROFILE` / `HOME`,可用 `--identity-home` 指定。修复的是 v1.5.0 现场实测的坑:
|
|
16
|
+
备用链路首次启动覆盖了主链路 `~/.dsh/xclient/device.json`,机器在下一次重启前看不出问题,
|
|
17
|
+
重启后却会以**备用身份**连到**错误的 relay**。这种"重启后才爆"的坑按默认隔离处理。
|
|
18
|
+
- 控制面板 relay 折叠:`GET /api/w2m/target` 快照新增 `relays`(`relayUrls` 数组优先,兼容单
|
|
19
|
+
`rabbitUrl`),探测 `/healthz` 后按 RTT 排序、标注 primary/standby/offline;超过 3 个节点时只显示
|
|
20
|
+
3 个并给出 "+N 折叠" 提示,展开后显示全部(含离线节点,不隐藏故障)。
|
|
21
|
+
- 探针失败保留在列表末尾(`ok:false`),网络异常不会让节点从视图中消失,也不会抛进快照路径。
|
|
22
|
+
- 测试:secondary-link 7/7、fleet-target 49/49(新增 relay pool 四个用例);门禁 GREEN ×2;
|
|
23
|
+
证据 `docs/acceptance/1.5.1/IDENTITY-ISOLATION-RELAY-FOLD.json`(真实 fleet task `01M5ANYMVKS3OQZ`)。
|
|
24
|
+
|
|
10
25
|
## [1.5.0] — 2026-10-23
|
|
11
26
|
|
|
12
27
|
**多 relay 双通道(active-passive)+ relay 健康看门狗。**
|
|
@@ -47,6 +47,7 @@ const HERE = dirname(fileURLToPath(import.meta.url));
|
|
|
47
47
|
const DEFAULTS = Object.freeze({
|
|
48
48
|
project: process.cwd(),
|
|
49
49
|
stateDir: null,
|
|
50
|
+
identityHome: null,
|
|
50
51
|
logFile: null,
|
|
51
52
|
allowedCommands: '[]',
|
|
52
53
|
restartDelayMs: 3000,
|
|
@@ -68,6 +69,7 @@ export function parseSecondaryArgs(argv) {
|
|
|
68
69
|
fleetKey: null,
|
|
69
70
|
name: null,
|
|
70
71
|
stateDir: null,
|
|
72
|
+
identityHome: null,
|
|
71
73
|
logFile: null,
|
|
72
74
|
project: DEFAULTS.project,
|
|
73
75
|
operatorToken: null,
|
|
@@ -86,6 +88,7 @@ export function parseSecondaryArgs(argv) {
|
|
|
86
88
|
case '--fleet-key': out.fleetKey = value(); break;
|
|
87
89
|
case '--name': out.name = value(); break;
|
|
88
90
|
case '--state': out.stateDir = value(); break;
|
|
91
|
+
case '--identity-home': out.identityHome = value(); break;
|
|
89
92
|
case '--log': out.logFile = value(); break;
|
|
90
93
|
case '--project': out.project = value(); break;
|
|
91
94
|
case '--operator-token': out.operatorToken = value(); break;
|
|
@@ -104,6 +107,18 @@ export function parseSecondaryArgs(argv) {
|
|
|
104
107
|
}
|
|
105
108
|
const suffix = out.name ?? 'standby';
|
|
106
109
|
out.stateDir = out.stateDir ?? join(process.cwd(), '.w2m-secondary', suffix);
|
|
110
|
+
/**
|
|
111
|
+
* v1.5.1: **the standby gets its own identity home.**
|
|
112
|
+
*
|
|
113
|
+
* Measured in the field: the first v1.5.0 standby overwrote `~/.dsh/xclient/device.json` -- the
|
|
114
|
+
* primary link's identity -- because `w2m-localside` derives that path from `DSH_HOME`. The
|
|
115
|
+
* machine kept working (the running agent holds its token in memory) until its next restart, at
|
|
116
|
+
* which point it would have come up as the *standby* identity on the wrong relay. A link that is
|
|
117
|
+
* only correct until the next restart is a trap, so isolation is the default rather than an
|
|
118
|
+
* option: the child runs with `DSH_HOME` (and the platforms' home variables) pointed at this
|
|
119
|
+
* directory, and `--identity-home` exists for operators who need a specific path.
|
|
120
|
+
*/
|
|
121
|
+
out.identityHome = out.identityHome ?? join(out.stateDir, 'dsh-home');
|
|
107
122
|
out.logFile = out.logFile ?? join(out.stateDir, 'link.log');
|
|
108
123
|
return out;
|
|
109
124
|
}
|
|
@@ -158,10 +173,25 @@ export async function runSupervisor(options, deps) {
|
|
|
158
173
|
const stopSignal = deps.stopSignal ?? { stopped: false };
|
|
159
174
|
const args = buildLocalsideArgs(options);
|
|
160
175
|
let delay = DEFAULTS.restartDelayMs;
|
|
161
|
-
|
|
176
|
+
// v1.5.1: the child must not see the primary link's identity. Both spellings are set because
|
|
177
|
+
// Windows resolves the home directory from USERPROFILE while POSIX uses HOME, and `DSH_HOME` is
|
|
178
|
+
// what `src/agent/identity.mjs` actually reads for `<DSH_HOME>/xclient/device.json`.
|
|
179
|
+
const childEnv = {
|
|
180
|
+
...process.env,
|
|
181
|
+
DSH_HOME: options.identityHome,
|
|
182
|
+
USERPROFILE: options.identityHome,
|
|
183
|
+
HOME: options.identityHome,
|
|
184
|
+
};
|
|
185
|
+
try {
|
|
186
|
+
mkdirSync(options.identityHome, { recursive: true, mode: 0o700 });
|
|
187
|
+
} catch (error) {
|
|
188
|
+
log(`cannot create identity home ${options.identityHome}: ${error?.message ?? error}`);
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
log(`supervising secondary link relay=${options.relay} state=${options.stateDir} identityHome=${options.identityHome}`);
|
|
162
192
|
while (!stopSignal.stopped) {
|
|
163
193
|
const startedAt = Date.now();
|
|
164
|
-
const child = spawnFn(process.execPath, args, { stdio: 'ignore', windowsHide: true });
|
|
194
|
+
const child = spawnFn(process.execPath, args, { stdio: 'ignore', windowsHide: true, env: childEnv });
|
|
165
195
|
log(`child started pid=${child.pid ?? '?'}`);
|
|
166
196
|
const exit = await new Promise((resolve) => {
|
|
167
197
|
child.once('exit', (code, signal) => resolve({ code, signal }));
|
package/codex/mcp-servers.toml
CHANGED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.5.1",
|
|
3
|
+
"case": "IDENTITY-ISOLATION-RELAY-FOLD",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-23T09:40:01.304Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"requirement": "the standby link isolates its identity home from the primary link (DSH_HOME/USERPROFILE/HOME) and the composer control folds a relay pool of more than three nodes",
|
|
11
|
+
"task_id": "01M5ANYMVKS3OQZ",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"secondary_link_suite": "7/7 pass",
|
|
16
|
+
"fleet_target_suite": "49/49 pass",
|
|
17
|
+
"identity_home_default": "isolated",
|
|
18
|
+
"relay_fold": "shown+hidden counted"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"note": "both suites were executed as one real fleet task on the Tencent relay before packaging"
|
|
22
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -105,6 +105,12 @@ window.__ModuleLoader__.load({
|
|
|
105
105
|
unreachable: '无法连接插件',
|
|
106
106
|
routeMissing: '这个 Harness 没有提供插件目标路由',
|
|
107
107
|
fleetUnreadable: '机器列表读取失败',
|
|
108
|
+
relays: 'Relay 节点',
|
|
109
|
+
relayPrimary: '主 relay',
|
|
110
|
+
relayStandby: '备用',
|
|
111
|
+
relayOffline: '离线',
|
|
112
|
+
relayFold: '另有 {count} 个已折叠,点击展开',
|
|
113
|
+
relayUnfold: '收起折叠的 relay',
|
|
108
114
|
capLabel: 'Node 执行:{state}',
|
|
109
115
|
capDenied: '拒绝',
|
|
110
116
|
capTemporary: '临时 24 小时',
|
|
@@ -129,6 +135,12 @@ window.__ModuleLoader__.load({
|
|
|
129
135
|
unreachable: 'the plugin could not be reached',
|
|
130
136
|
routeMissing: 'this host does not serve the W2M target route',
|
|
131
137
|
fleetUnreadable: 'the fleet could not be read',
|
|
138
|
+
relays: 'Relay nodes',
|
|
139
|
+
relayPrimary: 'primary',
|
|
140
|
+
relayStandby: 'standby',
|
|
141
|
+
relayOffline: 'offline',
|
|
142
|
+
relayFold: '{count} more folded — expand',
|
|
143
|
+
relayUnfold: 'collapse the folded relays',
|
|
132
144
|
capLabel: 'Node exec: {state}',
|
|
133
145
|
capDenied: 'denied',
|
|
134
146
|
capTemporary: 'temporary 24 h',
|
|
@@ -311,6 +323,13 @@ window.__ModuleLoader__.load({
|
|
|
311
323
|
'background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);',
|
|
312
324
|
'font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary)}',
|
|
313
325
|
'.w2mTarget_note{padding:6px 8px;color:var(--dsw-alias-label-tertiary);font-weight:400}',
|
|
326
|
+
'.w2mTarget_relayFold{margin-top:2px;padding:4px 0;border-top:.5px solid var(--dsw-alias-border-l2)}',
|
|
327
|
+
'.w2mTarget_relayToggle{display:block;width:100%;box-sizing:border-box;padding:4px 8px;border:none;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:11px;text-align:left;cursor:pointer}',
|
|
328
|
+
'.w2mTarget_relayRow{display:flex;align-items:center;gap:6px;padding:2px 8px;font-size:11px;color:var(--dsw-alias-label-secondary)}',
|
|
329
|
+
'.w2mTarget_relayRole{flex:none;min-width:44px;color:var(--dsw-alias-label-tertiary)}',
|
|
330
|
+
'.w2mTarget_relayUrl{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
331
|
+
'.w2mTarget_relayRtt{flex:none;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary)}',
|
|
332
|
+
'.w2mTarget_relayHint{padding:2px 8px;font-size:11px;color:var(--dsw-alias-label-tertiary)}',
|
|
314
333
|
'.w2mTarget_option{display:block;width:100%;box-sizing:border-box;padding:6px 8px;border:none;border-radius:var(--dsw-radius-sm);',
|
|
315
334
|
'background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}',
|
|
316
335
|
'.w2mTarget_option:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
|
|
@@ -416,6 +435,9 @@ window.__ModuleLoader__.load({
|
|
|
416
435
|
*/
|
|
417
436
|
function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
418
437
|
const [open, setOpen] = React.useState(false);
|
|
438
|
+
// v1.5.1: relay folding. The snapshot carries the pool; when it holds more than three nodes
|
|
439
|
+
// the menu shows a summary and this toggle reveals the folded ones.
|
|
440
|
+
const [relayOpen, setRelayOpen] = React.useState(false);
|
|
419
441
|
const [reloadSeq, setReloadSeq] = React.useState(0);
|
|
420
442
|
const [state, setState] = React.useState(null);
|
|
421
443
|
const [error, setError] = React.useState(null);
|
|
@@ -493,6 +515,8 @@ window.__ModuleLoader__.load({
|
|
|
493
515
|
|
|
494
516
|
const chosen = state?.selected?.machine_id ?? null;
|
|
495
517
|
const machines = Array.isArray(state?.machines) ? state.machines : [];
|
|
518
|
+
// v1.5.1: the relay pool the host probed, already folded. `null` means a single-relay host.
|
|
519
|
+
const relayFold = state?.relays ?? null;
|
|
496
520
|
const problem = error ?? fleetProblem(state, t);
|
|
497
521
|
const disabled = typeof sessionId !== 'string' || sessionId === '';
|
|
498
522
|
const label = state === null ? (provisionalLabel(state) ?? t('machine')) : labelOf(state, t);
|
|
@@ -576,6 +600,35 @@ window.__ModuleLoader__.load({
|
|
|
576
600
|
? t('local')
|
|
577
601
|
: (machine.online === true ? (machine.os ?? t('online')) : machine.state)),
|
|
578
602
|
]))),
|
|
603
|
+
relayFold === null ? null : h('div', {
|
|
604
|
+
key: '__relays__',
|
|
605
|
+
className: cls('relayFold'),
|
|
606
|
+
role: 'group',
|
|
607
|
+
'aria-label': t('relays'),
|
|
608
|
+
}, [
|
|
609
|
+
h('button', {
|
|
610
|
+
key: 'toggle',
|
|
611
|
+
type: 'button',
|
|
612
|
+
className: cls('relayToggle'),
|
|
613
|
+
'aria-expanded': relayOpen,
|
|
614
|
+
onClick: (event) => { event.stopPropagation(); setRelayOpen((wasOpen) => !wasOpen); },
|
|
615
|
+
title: relayOpen === true ? t('relayUnfold') : t('relayFold', { count: relayFold.hidden_count }),
|
|
616
|
+
}, `${t('relays')}: ${relayFold.online}/${relayFold.total} ${t('online')}`),
|
|
617
|
+
...(relayOpen === true ? [...relayFold.shown, ...relayFold.hidden] : relayFold.shown).map((item, index) => h('div', {
|
|
618
|
+
key: `relay-${index}`,
|
|
619
|
+
className: cls('relayRow'),
|
|
620
|
+
'data-role': item.role,
|
|
621
|
+
}, [
|
|
622
|
+
h('span', { key: 'role', className: cls('relayRole') }, item.role === 'primary'
|
|
623
|
+
? t('relayPrimary')
|
|
624
|
+
: (item.role === 'standby' ? t('relayStandby') : t('relayOffline'))),
|
|
625
|
+
h('span', { key: 'url', className: cls('relayUrl') }, String(item.url ?? '').replace(/^https?:\/\//, '').replace(/\/$/, '')),
|
|
626
|
+
h('span', { key: 'rtt', className: cls('relayRtt') }, item.rtt_ms === null || item.rtt_ms === undefined ? '—' : `${item.rtt_ms} ms`),
|
|
627
|
+
])),
|
|
628
|
+
relayFold.hidden_count > 0 && relayOpen === false
|
|
629
|
+
? h('div', { key: 'hint', className: cls('relayHint') }, t('relayFold', { count: relayFold.hidden_count }))
|
|
630
|
+
: null,
|
|
631
|
+
]),
|
|
579
632
|
])
|
|
580
633
|
: null;
|
|
581
634
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "twinsearth-w2m-dsh",
|
|
4
|
-
"version": "1.5.
|
|
4
|
+
"version": "1.5.1",
|
|
5
5
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
@@ -434,6 +434,85 @@ export async function readFleet(deps) {
|
|
|
434
434
|
};
|
|
435
435
|
}
|
|
436
436
|
|
|
437
|
+
/**
|
|
438
|
+
* v1.5.1: the relay pool the client folds, derived from configuration.
|
|
439
|
+
*
|
|
440
|
+
* `relayUrls` (array) is the multi-relay form; the single `rabbitUrl` stays valid and is returned
|
|
441
|
+
* as a one-element pool, so a host that never configured a pool behaves exactly as it did.
|
|
442
|
+
*
|
|
443
|
+
* @param {{relayUrls?: unknown, rabbitUrl?: string|null}|null|undefined} cfg
|
|
444
|
+
* @returns {string[]} Normalized base URLs, order preserved, duplicates removed.
|
|
445
|
+
*/
|
|
446
|
+
export function relayUrlsFromConfig(cfg) {
|
|
447
|
+
const raw = Array.isArray(cfg?.relayUrls) ? cfg.relayUrls : [];
|
|
448
|
+
const urls = raw
|
|
449
|
+
.filter((entry) => typeof entry === 'string' && entry.trim() !== '')
|
|
450
|
+
.map((entry) => entry.trim().replace(/\/+$/, ''));
|
|
451
|
+
if (urls.length > 0) return [...new Set(urls)];
|
|
452
|
+
if (typeof cfg?.rabbitUrl === 'string' && cfg.rabbitUrl.trim() !== '') {
|
|
453
|
+
return [cfg.rabbitUrl.trim().replace(/\/+$/, '')];
|
|
454
|
+
}
|
|
455
|
+
return [];
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Fold a relay pool for display: at most `limit` nodes are shown, the rest are counted.
|
|
460
|
+
*
|
|
461
|
+
* The client is expected to render `shown` and `hidden` exactly as given: a view that silently
|
|
462
|
+
* shows every node when there are dozens is the failure this helper exists to prevent.
|
|
463
|
+
*
|
|
464
|
+
* @param {object[]} items Probe results, best first.
|
|
465
|
+
* @param {number} [limit]
|
|
466
|
+
* @returns {{shown: object[], hidden: object[], hidden_count: number, total: number, online: number}}
|
|
467
|
+
*/
|
|
468
|
+
export function foldRelayItems(items, limit = 3) {
|
|
469
|
+
const list = Array.isArray(items) ? items : [];
|
|
470
|
+
const shown = list.slice(0, Math.max(0, limit));
|
|
471
|
+
const hidden = list.slice(Math.max(0, limit));
|
|
472
|
+
return {
|
|
473
|
+
shown,
|
|
474
|
+
hidden,
|
|
475
|
+
hidden_count: hidden.length,
|
|
476
|
+
total: list.length,
|
|
477
|
+
online: list.filter((item) => item?.ok === true).length,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Probe each relay's `/healthz` and return the pool sorted by round-trip time (fastest first).
|
|
483
|
+
* A relay that fails is kept and sorted last with `ok:false` — disappearing from the list would
|
|
484
|
+
* hide exactly the outage the operator needs to see.
|
|
485
|
+
*
|
|
486
|
+
* @param {string[]} urls
|
|
487
|
+
* @param {{timeoutMs?: number, fetchImpl?: Function}} [options]
|
|
488
|
+
* @returns {Promise<object[]>}
|
|
489
|
+
*/
|
|
490
|
+
export async function probeRelays(urls, options = {}) {
|
|
491
|
+
const timeoutMs = Number.isInteger(options.timeoutMs) ? options.timeoutMs : 1200;
|
|
492
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
493
|
+
const list = Array.isArray(urls) ? urls : [];
|
|
494
|
+
const results = await Promise.all(list.map(async (url) => {
|
|
495
|
+
const started = Date.now();
|
|
496
|
+
try {
|
|
497
|
+
const response = await fetchImpl(`${url}/healthz`, {
|
|
498
|
+
signal: typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
|
|
499
|
+
? AbortSignal.timeout(timeoutMs)
|
|
500
|
+
: undefined,
|
|
501
|
+
});
|
|
502
|
+
const body = await response.json().catch(() => ({}));
|
|
503
|
+
if (!response.ok || body?.ok !== true) {
|
|
504
|
+
return { url, ok: false, rtt_ms: null, error: `HTTP ${response.status}` };
|
|
505
|
+
}
|
|
506
|
+
return { url, ok: true, rtt_ms: Date.now() - started, relay_id: body.relay_id ?? null };
|
|
507
|
+
} catch (error) {
|
|
508
|
+
return { url, ok: false, rtt_ms: null, error: error instanceof Error ? error.message : String(error) };
|
|
509
|
+
}
|
|
510
|
+
}));
|
|
511
|
+
const reachable = results.filter((item) => item.ok === true).sort((a, b) => (a.rtt_ms ?? 0) - (b.rtt_ms ?? 0));
|
|
512
|
+
const unreachable = results.filter((item) => item.ok !== true);
|
|
513
|
+
return [...reachable, ...unreachable];
|
|
514
|
+
}
|
|
515
|
+
|
|
437
516
|
/**
|
|
438
517
|
* Build the snapshot the browser renders.
|
|
439
518
|
*
|
|
@@ -447,7 +526,7 @@ export async function readFleet(deps) {
|
|
|
447
526
|
* @returns {object}
|
|
448
527
|
*/
|
|
449
528
|
export function buildUiState(deps) {
|
|
450
|
-
const { store, sessionId, fleet, localMachineName = null, detailUnavailable = false } = deps;
|
|
529
|
+
const { store, sessionId, fleet, localMachineName = null, detailUnavailable = false, relays = null } = deps;
|
|
451
530
|
const selection = store.get(sessionId);
|
|
452
531
|
const machines = Array.isArray(fleet?.machines) ? fleet.machines : [];
|
|
453
532
|
const selected = selection === null ? null : machines.find((machine) => machine.machine_id === selection.machine_id) ?? null;
|
|
@@ -479,6 +558,20 @@ export function buildUiState(deps) {
|
|
|
479
558
|
// but cannot reach this half, and the honest answer is to say so rather than to look empty.
|
|
480
559
|
detail_unavailable: detailUnavailable === true,
|
|
481
560
|
},
|
|
561
|
+
// v1.5.1: the relay pool, folded. `null` means the host did not probe (single-relay host or
|
|
562
|
+
// probe disabled); the control then renders no relay section instead of inventing one.
|
|
563
|
+
relays: relays === null
|
|
564
|
+
? null
|
|
565
|
+
: {
|
|
566
|
+
total: relays.total,
|
|
567
|
+
online: relays.online,
|
|
568
|
+
hidden_count: relays.hidden_count,
|
|
569
|
+
shown: relays.shown,
|
|
570
|
+
hidden: relays.hidden,
|
|
571
|
+
// Fastest reachable relay is the one a dispatch would prefer; naming it here keeps the
|
|
572
|
+
// fold honest when it hides nodes that the router would still consider.
|
|
573
|
+
primary_url: relays.shown.find((item) => item.ok === true)?.url ?? null,
|
|
574
|
+
},
|
|
482
575
|
};
|
|
483
576
|
}
|
|
484
577
|
|
|
@@ -680,12 +773,38 @@ function mountTargetRoutes(connection, deps, status, logger) {
|
|
|
680
773
|
return value;
|
|
681
774
|
};
|
|
682
775
|
|
|
776
|
+
/** v1.5.1: relay probing is cached like the fleet: opening the menu must not turn into a burst. */
|
|
777
|
+
let relayCache = { at: 0, value: null };
|
|
778
|
+
const relayPoolFor = async () => {
|
|
779
|
+
const now = Date.now();
|
|
780
|
+
if (relayCache.value !== null && now - relayCache.at < FLEET_CACHE_MS) return relayCache.value;
|
|
781
|
+
const urls = relayUrlsFromConfig(deps.cfg());
|
|
782
|
+
if (urls.length === 0) {
|
|
783
|
+
relayCache = { at: now, value: null };
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
const probed = await probeRelays(urls);
|
|
787
|
+
// The fold's primary is the fastest reachable node; every other reachable node is standby, and
|
|
788
|
+
// an unreachable one keeps its place at the end as `offline` rather than disappearing.
|
|
789
|
+
let reachableSeen = 0;
|
|
790
|
+
const items = probed.map((item) => {
|
|
791
|
+
if (item.ok !== true) return { ...item, role: 'offline' };
|
|
792
|
+
reachableSeen += 1;
|
|
793
|
+
return { ...item, role: reachableSeen === 1 ? 'primary' : 'standby' };
|
|
794
|
+
});
|
|
795
|
+
const folded = foldRelayItems(items, 3);
|
|
796
|
+
relayCache = { at: now, value: folded };
|
|
797
|
+
return folded;
|
|
798
|
+
};
|
|
799
|
+
|
|
683
800
|
const snapshot = async (sessionId, signal) => {
|
|
684
801
|
const cfg = deps.cfg();
|
|
685
802
|
const identity = await deps.localIdentity();
|
|
686
803
|
const fleet = await fleetFor(signal);
|
|
804
|
+
const relays = await relayPoolFor();
|
|
687
805
|
return buildUiState({
|
|
688
806
|
store: deps.store,
|
|
807
|
+
relays,
|
|
689
808
|
sessionId,
|
|
690
809
|
fleet,
|
|
691
810
|
localMachineName: identity.machine_name ?? cfg.machineName ?? null,
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -276,7 +276,7 @@ const MAX_STAGES = 16;
|
|
|
276
276
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
277
277
|
* overrides it at runtime for tests and packagers.
|
|
278
278
|
*/
|
|
279
|
-
const PLUGIN_VERSION = '1.5.
|
|
279
|
+
const PLUGIN_VERSION = '1.5.1';
|
|
280
280
|
|
|
281
281
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
282
282
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|