dsh-zen-remote 2.3.11 → 2.4.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.
Files changed (249) hide show
  1. package/CHANGELOG.md +45 -78
  2. package/README.md +94 -84
  3. package/README.zh-CN.md +90 -81
  4. package/bin/zen-remote.mjs +5 -0
  5. package/docs/interface.md +6 -6
  6. package/docs/remote-access.en.md +6 -4
  7. package/docs/remote-access.md +6 -4
  8. package/dsh-push.mjs +96 -10
  9. package/lib/adapters/dsh-better-sidebar/index.js +7 -3
  10. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  11. package/lib/admin-routes.js +10 -2
  12. package/lib/admin-routes.js.map +1 -1
  13. package/lib/cli/args.js +251 -0
  14. package/lib/cli/args.js.map +1 -0
  15. package/lib/cli/bootstrap.js +449 -0
  16. package/lib/cli/bootstrap.js.map +1 -0
  17. package/lib/cli/command-setup.js +484 -0
  18. package/lib/cli/command-setup.js.map +1 -0
  19. package/lib/cli/commands-client.js +359 -0
  20. package/lib/cli/commands-client.js.map +1 -0
  21. package/lib/cli/commands-host.js +1108 -0
  22. package/lib/cli/commands-host.js.map +1 -0
  23. package/lib/cli/commands-status.js +279 -0
  24. package/lib/cli/commands-status.js.map +1 -0
  25. package/lib/cli/config-writer.js +334 -0
  26. package/lib/cli/config-writer.js.map +1 -0
  27. package/lib/cli/context.js +10 -0
  28. package/lib/cli/context.js.map +1 -0
  29. package/lib/cli/i18n.js +620 -0
  30. package/lib/cli/i18n.js.map +1 -0
  31. package/lib/cli/ipc-client.js +160 -0
  32. package/lib/cli/ipc-client.js.map +1 -0
  33. package/lib/cli/ipc-server.js +464 -0
  34. package/lib/cli/ipc-server.js.map +1 -0
  35. package/lib/cli/main.js +245 -0
  36. package/lib/cli/main.js.map +1 -0
  37. package/lib/cli/qr-terminal.js +48 -0
  38. package/lib/cli/qr-terminal.js.map +1 -0
  39. package/lib/client-data/qr.js +286 -0
  40. package/lib/client-data/qr.js.map +1 -0
  41. package/lib/client-routes.js +308 -25
  42. package/lib/client-routes.js.map +1 -1
  43. package/lib/client.js +1013 -229
  44. package/lib/config.js +50 -12
  45. package/lib/config.js.map +1 -1
  46. package/lib/debug-report.js +28 -1
  47. package/lib/debug-report.js.map +1 -1
  48. package/lib/fetch-route-intercept.js +334 -26
  49. package/lib/fetch-route-intercept.js.map +1 -1
  50. package/lib/index.js +608 -375
  51. package/lib/index.js.map +1 -1
  52. package/lib/intercept.js +1833 -280
  53. package/lib/intercept.js.map +1 -1
  54. package/lib/lan-gate-server.cjs +36 -10
  55. package/lib/merge-streams.js +103 -11
  56. package/lib/merge-streams.js.map +1 -1
  57. package/lib/plugin-route-intercept.js +93 -63
  58. package/lib/plugin-route-intercept.js.map +1 -1
  59. package/lib/relay-access.js +8 -1
  60. package/lib/relay-access.js.map +1 -1
  61. package/lib/relay-client.js +133 -4
  62. package/lib/relay-client.js.map +1 -1
  63. package/lib/relay-pool.js +275 -0
  64. package/lib/relay-pool.js.map +1 -0
  65. package/lib/relay-server.js +269 -17
  66. package/lib/relay-server.js.map +1 -1
  67. package/lib/restart-fields.js +6 -4
  68. package/lib/restart-fields.js.map +1 -1
  69. package/lib/share-ops.js +68 -0
  70. package/lib/share-ops.js.map +1 -1
  71. package/lib/share-store.js +65 -27
  72. package/lib/share-store.js.map +1 -1
  73. package/lib/state-store.js +400 -0
  74. package/lib/state-store.js.map +1 -0
  75. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +3 -2
  76. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  77. package/lib/types/admin-routes.d.ts.map +1 -1
  78. package/lib/types/cli/args.d.ts +78 -0
  79. package/lib/types/cli/args.d.ts.map +1 -0
  80. package/lib/types/cli/bootstrap.d.ts +170 -0
  81. package/lib/types/cli/bootstrap.d.ts.map +1 -0
  82. package/lib/types/cli/command-setup.d.ts +72 -0
  83. package/lib/types/cli/command-setup.d.ts.map +1 -0
  84. package/lib/types/cli/commands-client.d.ts +36 -0
  85. package/lib/types/cli/commands-client.d.ts.map +1 -0
  86. package/lib/types/cli/commands-host.d.ts +82 -0
  87. package/lib/types/cli/commands-host.d.ts.map +1 -0
  88. package/lib/types/cli/commands-status.d.ts +46 -0
  89. package/lib/types/cli/commands-status.d.ts.map +1 -0
  90. package/lib/types/cli/config-writer.d.ts +92 -0
  91. package/lib/types/cli/config-writer.d.ts.map +1 -0
  92. package/lib/types/cli/context.d.ts +39 -0
  93. package/lib/types/cli/context.d.ts.map +1 -0
  94. package/lib/types/cli/i18n.d.ts +32 -0
  95. package/lib/types/cli/i18n.d.ts.map +1 -0
  96. package/lib/types/cli/ipc-client.d.ts +83 -0
  97. package/lib/types/cli/ipc-client.d.ts.map +1 -0
  98. package/lib/types/cli/ipc-server.d.ts +113 -0
  99. package/lib/types/cli/ipc-server.d.ts.map +1 -0
  100. package/lib/types/cli/main.d.ts +57 -0
  101. package/lib/types/cli/main.d.ts.map +1 -0
  102. package/lib/types/cli/qr-terminal.d.ts +17 -0
  103. package/lib/types/cli/qr-terminal.d.ts.map +1 -0
  104. package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -1
  105. package/lib/types/client/current-session-report.d.ts +4 -0
  106. package/lib/types/client/current-session-report.d.ts.map +1 -1
  107. package/lib/types/client/effects/model-group-side.d.ts.map +1 -1
  108. package/lib/types/client/effects/remote-session.d.ts.map +1 -1
  109. package/lib/types/client/locales.d.ts +47 -3
  110. package/lib/types/client/locales.d.ts.map +1 -1
  111. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  112. package/lib/types/client/settings/settings-css.d.ts +1 -1
  113. package/lib/types/client/settings/settings-css.d.ts.map +1 -1
  114. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  115. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  116. package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
  117. package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -1
  118. package/lib/types/client/wizard/wizard-css.d.ts +1 -1
  119. package/lib/types/client/wizard/wizard-css.d.ts.map +1 -1
  120. package/lib/types/client-data/model-group-side.d.ts +23 -2
  121. package/lib/types/client-data/model-group-side.d.ts.map +1 -1
  122. package/lib/types/client-data/remote-status.d.ts +47 -1
  123. package/lib/types/client-data/remote-status.d.ts.map +1 -1
  124. package/lib/types/client-data/settings-form.d.ts +85 -1
  125. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  126. package/lib/types/client-data/wizard.d.ts +56 -1
  127. package/lib/types/client-data/wizard.d.ts.map +1 -1
  128. package/lib/types/client-routes.d.ts +25 -1
  129. package/lib/types/client-routes.d.ts.map +1 -1
  130. package/lib/types/config.d.ts +40 -14
  131. package/lib/types/config.d.ts.map +1 -1
  132. package/lib/types/debug-report.d.ts.map +1 -1
  133. package/lib/types/fetch-route-intercept.d.ts +66 -8
  134. package/lib/types/fetch-route-intercept.d.ts.map +1 -1
  135. package/lib/types/index.d.ts +9 -2
  136. package/lib/types/index.d.ts.map +1 -1
  137. package/lib/types/intercept.d.ts +72 -10
  138. package/lib/types/intercept.d.ts.map +1 -1
  139. package/lib/types/merge-streams.d.ts +79 -1
  140. package/lib/types/merge-streams.d.ts.map +1 -1
  141. package/lib/types/plugin-route-intercept.d.ts +25 -9
  142. package/lib/types/plugin-route-intercept.d.ts.map +1 -1
  143. package/lib/types/relay-access.d.ts +3 -1
  144. package/lib/types/relay-access.d.ts.map +1 -1
  145. package/lib/types/relay-client.d.ts +23 -2
  146. package/lib/types/relay-client.d.ts.map +1 -1
  147. package/lib/types/relay-pool.d.ts +148 -0
  148. package/lib/types/relay-pool.d.ts.map +1 -0
  149. package/lib/types/relay-server.d.ts +30 -3
  150. package/lib/types/relay-server.d.ts.map +1 -1
  151. package/lib/types/restart-fields.d.ts +5 -5
  152. package/lib/types/restart-fields.d.ts.map +1 -1
  153. package/lib/types/share-ops.d.ts +47 -1
  154. package/lib/types/share-ops.d.ts.map +1 -1
  155. package/lib/types/share-store.d.ts.map +1 -1
  156. package/lib/types/state-store.d.ts +179 -0
  157. package/lib/types/state-store.d.ts.map +1 -0
  158. package/lib/types/upload-limits.d.ts +15 -0
  159. package/lib/types/upload-limits.d.ts.map +1 -1
  160. package/lib/types/virtual-id.d.ts +21 -0
  161. package/lib/types/virtual-id.d.ts.map +1 -1
  162. package/lib/types/wizard/probe.d.ts.map +1 -1
  163. package/lib/types/wizard/proxy-snippets.d.ts +3 -0
  164. package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
  165. package/lib/types/wizard/routes.d.ts +1 -0
  166. package/lib/types/wizard/routes.d.ts.map +1 -1
  167. package/lib/types/wizard/status.d.ts +9 -0
  168. package/lib/types/wizard/status.d.ts.map +1 -1
  169. package/lib/types/wizard/verify.d.ts +3 -0
  170. package/lib/types/wizard/verify.d.ts.map +1 -1
  171. package/lib/upload-limits.js +15 -0
  172. package/lib/upload-limits.js.map +1 -1
  173. package/lib/virtual-id.js +60 -0
  174. package/lib/virtual-id.js.map +1 -1
  175. package/lib/wizard/probe.js +46 -1
  176. package/lib/wizard/probe.js.map +1 -1
  177. package/lib/wizard/proxy-snippets.js +14 -1
  178. package/lib/wizard/proxy-snippets.js.map +1 -1
  179. package/lib/wizard/routes.js +23 -4
  180. package/lib/wizard/routes.js.map +1 -1
  181. package/lib/wizard/status.js +21 -0
  182. package/lib/wizard/status.js.map +1 -1
  183. package/lib/wizard/verify.js +31 -8
  184. package/lib/wizard/verify.js.map +1 -1
  185. package/package.json +8 -4
  186. package/pwa/icons/badge-96.png +0 -0
  187. package/pwa/icons/badge.svg +1 -0
  188. package/pwa/manifest.json +2 -1
  189. package/pwa/sw.js +3 -1
  190. package/scripts/check-debug-report.mjs +41 -1
  191. package/scripts/check-settings-form.mjs +14 -3
  192. package/scripts/check-settings-view.mjs +247 -0
  193. package/scripts/check-wizard-ui.mjs +37 -0
  194. package/src/adapters/dsh-better-sidebar/index.ts +7 -3
  195. package/src/admin-routes.ts +10 -2
  196. package/src/cli/args.ts +289 -0
  197. package/src/cli/bootstrap.ts +569 -0
  198. package/src/cli/command-setup.ts +540 -0
  199. package/src/cli/commands-client.ts +369 -0
  200. package/src/cli/commands-host.ts +1127 -0
  201. package/src/cli/commands-status.ts +296 -0
  202. package/src/cli/config-writer.ts +354 -0
  203. package/src/cli/context.ts +38 -0
  204. package/src/cli/i18n.ts +645 -0
  205. package/src/cli/ipc-client.ts +216 -0
  206. package/src/cli/ipc-server.ts +531 -0
  207. package/src/cli/main.ts +285 -0
  208. package/src/cli/qr-terminal.ts +49 -0
  209. package/src/client/RemoteStatusIcon.tsx +4 -1
  210. package/src/client/current-session-report.ts +28 -4
  211. package/src/client/effects/model-group-side.ts +19 -5
  212. package/src/client/effects/remote-session.ts +26 -6
  213. package/src/client/locales.ts +96 -6
  214. package/src/client/settings/SettingsSection.tsx +287 -92
  215. package/src/client/settings/settings-css.ts +18 -0
  216. package/src/client/styles/remote-session.css.ts +15 -1
  217. package/src/client/wizard/GatewayWizard.tsx +470 -109
  218. package/src/client/wizard/cards/ProxyCard.tsx +2 -1
  219. package/src/client/wizard/wizard-css.ts +23 -0
  220. package/src/client-data/model-group-side.ts +37 -10
  221. package/src/client-data/remote-status.ts +112 -17
  222. package/src/client-data/settings-form.ts +164 -8
  223. package/src/client-data/wizard.ts +112 -2
  224. package/src/client-routes.ts +334 -26
  225. package/src/config.ts +62 -16
  226. package/src/debug-report.ts +27 -1
  227. package/src/fetch-route-intercept.ts +381 -32
  228. package/src/index.ts +608 -380
  229. package/src/intercept.ts +1866 -286
  230. package/src/merge-streams.ts +155 -10
  231. package/src/plugin-route-intercept.ts +120 -71
  232. package/src/relay-access.ts +14 -3
  233. package/src/relay-client.ts +163 -5
  234. package/src/relay-pool.ts +387 -0
  235. package/src/relay-server.ts +312 -16
  236. package/src/restart-fields.ts +6 -4
  237. package/src/share-ops.ts +82 -1
  238. package/src/share-store.ts +59 -26
  239. package/src/state-store.ts +428 -0
  240. package/src/upload-limits.ts +16 -0
  241. package/src/virtual-id.ts +57 -0
  242. package/src/wizard/probe.ts +54 -2
  243. package/src/wizard/proxy-snippets.ts +17 -2
  244. package/src/wizard/routes.ts +24 -5
  245. package/src/wizard/status.ts +28 -0
  246. package/src/wizard/verify.ts +33 -11
  247. package/AGENTS.md +0 -185
  248. package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +0 -15
  249. package/docs/adr/0002-relay-emulates-websocket.md +0 -8
@@ -21,6 +21,7 @@ import type {
21
21
  AdminStatusBody,
22
22
  ClaimRouteBody,
23
23
  ClientConnectionView,
24
+ ClientServerView,
24
25
  ClientStatusBody,
25
26
  SettingsDeviceView,
26
27
  ZenRemoteSettingsForm,
@@ -33,9 +34,10 @@ import {
33
34
  CLIENT_CONFIG_ROUTE,
34
35
  CLIENT_RECONNECT_ROUTE,
35
36
  CLIENT_STATUS_ROUTE,
37
+ CLIENT_UNPAIR_ROUTE,
36
38
  awaitClientStatusReady,
39
+ canSubmitClientPairing,
37
40
  claimDeviceNameOf,
38
- clientStatusLineOf,
39
41
  deriveClientStatusView,
40
42
  deriveSettingsView,
41
43
  normalizePairingCode,
@@ -55,6 +57,8 @@ import {
55
57
  WIZARD_VERIFY_ROUTE,
56
58
  caddyFailureCopy,
57
59
  configureReadiness,
60
+ deriveClientOverviewSummary,
61
+ deriveClientServerLights,
58
62
  deriveClientWizardLights,
59
63
  deriveWizardLights,
60
64
  formSeedOf,
@@ -157,22 +161,6 @@ const PATH_OPTIONS = [
157
161
  ['lan', 'wizard.path.lan', 'wizard.path.lanDesc'],
158
162
  ] as const
159
163
 
160
- function clientStatusCopy(view: ClientConnectionView, now: number, t: GatewayWizardProps['t']): string {
161
- const line = clientStatusLineOf(view, now)
162
- switch (line.kind) {
163
- case 'connectedName': return t('settings.client.statusConnectedName', { serverName: line.serverName })
164
- case 'connected': return t('settings.client.statusConnected', { serverUrl: line.serverUrl })
165
- case 'offlineRetry': return t('settings.client.statusOfflineRetry', { seconds: line.seconds })
166
- case 'offlineRetrySoon': return t('settings.client.statusOfflineRetrySoon')
167
- case 'unreachable': return t('settings.client.statusUnreachable')
168
- case 'revoked': return t('settings.client.statusRevoked')
169
- case 'incompatible': return t('settings.client.statusIncompatible')
170
- case 'unexpected': return t('settings.client.statusUnexpected')
171
- case 'invalidUrl': return t('settings.client.statusInvalidUrl')
172
- default: return t('settings.client.statusUnpaired')
173
- }
174
- }
175
-
176
164
  function claimFailCopy(code: string, retryAfterMs: number | undefined, t: GatewayWizardProps['t']): string {
177
165
  switch (code) {
178
166
  case 'invalid': return t('settings.client.failInvalid')
@@ -184,10 +172,45 @@ function claimFailCopy(code: string, retryAfterMs: number | undefined, t: Gatewa
184
172
  }
185
173
  case 'unreachable': return t('settings.client.failUnreachable')
186
174
  case 'role-mismatch': return t('settings.client.failRoleMismatch')
175
+ case 'limit-reached': return t('settings.client.failLimitReached')
187
176
  default: return t('settings.client.failUnexpected')
188
177
  }
189
178
  }
190
179
 
180
+ /** The verdicts that will not recover on their own (MS-8): a pool row's dot
181
+ * turns red on these, amber on everything else, green only when connected. */
182
+ const SERVER_DEAD_STATES: ReadonlySet<ClientServerView['state']> = new Set(['revoked', 'incompatible', 'invalid-url'])
183
+
184
+ /** One paired server's dot state in the management surfaces (MS-8). */
185
+ function serverDotState(state: ClientServerView['state']): 'pass' | 'pending' | 'fail' {
186
+ if (state === 'connected') return 'pass'
187
+ return SERVER_DEAD_STATES.has(state) ? 'fail' : 'pending'
188
+ }
189
+
190
+ /**
191
+ * One paired server's badge copy + tone (MS-8): the settings page's
192
+ * per-server wording on the same keys, so the wizard's management modal and
193
+ * the settings block cannot drift apart in what a state is CALLED.
194
+ */
195
+ function serverBadgeCopy(server: ClientServerView, now: number, t: GatewayWizardProps['t']): { text: string; tone: 'live' | 'warn' | 'invalid' | undefined } {
196
+ switch (server.state) {
197
+ case 'connected': return { text: t('settings.client.serverStateConnected'), tone: 'live' }
198
+ case 'unreachable':
199
+ case 'unexpected': {
200
+ if (server.nextRetryAt === undefined) return { text: t('settings.client.serverStateOffline'), tone: 'warn' }
201
+ const seconds = Math.max(0, Math.ceil((server.nextRetryAt - now) / 1000))
202
+ return {
203
+ text: seconds > 0 ? t('settings.client.serverStateRetry', { seconds }) : t('settings.client.serverStateRetrySoon'),
204
+ tone: 'warn',
205
+ }
206
+ }
207
+ case 'revoked': return { text: t('settings.client.serverStateRevoked'), tone: 'invalid' }
208
+ case 'incompatible': return { text: t('settings.client.serverStateIncompatible'), tone: 'invalid' }
209
+ case 'invalid-url': return { text: t('settings.client.serverStateInvalidUrl'), tone: 'invalid' }
210
+ default: return { text: t('settings.client.serverStateUnpaired'), tone: undefined }
211
+ }
212
+ }
213
+
191
214
  export function GatewayWizard(props: GatewayWizardProps): ReactNode {
192
215
  const { t, config } = props
193
216
 
@@ -262,8 +285,14 @@ export function GatewayWizard(props: GatewayWizardProps): ReactNode {
262
285
  const [clientClaimFail, setClientClaimFail] = useState<{ code: string; retryAfterMs?: number } | null>(null)
263
286
  const [clientView, setClientView] = useState<ClientConnectionView | undefined>()
264
287
  const [clientLoading, setClientLoading] = useState(false)
265
- const [clientReconnectBusy, setClientReconnectBusy] = useState(false)
266
288
  const [clientUnpairBusy, setClientUnpairBusy] = useState(false)
289
+ // MS-8: the paired-server pool's management surfaces — the 已配对服务端
290
+ // 管理弹窗, the entry the wizard's step 3 is staring at, and the per-entry
291
+ // action busy/failed markers both the modal and step 3 share.
292
+ const [serversModalOpen, setServersModalOpen] = useState(false)
293
+ const [activeClientEntryId, setActiveClientEntryId] = useState<string | undefined>()
294
+ const [serverBusyEntry, setServerBusyEntry] = useState<string | null>(null)
295
+ const [serverActionFailed, setServerActionFailed] = useState(false)
267
296
 
268
297
  // The saved role, live.
269
298
  const roleSnapshot = useSyncExternalStore(
@@ -388,9 +417,9 @@ export function GatewayWizard(props: GatewayWizardProps): ReactNode {
388
417
  }
389
418
  }
390
419
 
391
- // Close the wizard modal or devices modal on Escape when no sub-card is open and not mid-save.
420
+ // Close the wizard modal, devices modal or servers modal on Escape when no sub-card is open and not mid-save.
392
421
  useEffect(() => {
393
- if ((!modalOpen && !devicesModalOpen) || card !== undefined) return
422
+ if ((!modalOpen && !devicesModalOpen && !serversModalOpen) || card !== undefined) return
394
423
  const onKey = (event: KeyboardEvent): void => {
395
424
  if (event.key === 'Escape' && !savingConfig && !clientClaimBusy && !deviceBusy) {
396
425
  if (renamingDevice !== null) {
@@ -401,12 +430,16 @@ export function GatewayWizard(props: GatewayWizardProps): ReactNode {
401
430
  setDevicesModalOpen(false)
402
431
  return
403
432
  }
433
+ if (serversModalOpen) {
434
+ setServersModalOpen(false)
435
+ return
436
+ }
404
437
  setModalOpen(false)
405
438
  }
406
439
  }
407
440
  document.addEventListener('keydown', onKey)
408
441
  return () => document.removeEventListener('keydown', onKey)
409
- }, [modalOpen, devicesModalOpen, card, savingConfig, clientClaimBusy, deviceBusy, renamingDevice])
442
+ }, [modalOpen, devicesModalOpen, serversModalOpen, card, savingConfig, clientClaimBusy, deviceBusy, renamingDevice])
410
443
 
411
444
  // Seed the entry step AND the configure forms from the FIRST ready
412
445
  // wizard status on a host deployment.
@@ -746,12 +779,12 @@ export function GatewayWizard(props: GatewayWizardProps): ReactNode {
746
779
  if (wizardStatus?.lanAddress !== undefined && wizardStatus.lanAddress !== '') {
747
780
  return `http://${wizardStatus.lanAddress}:${port}`
748
781
  }
749
- return `http://<服务端局域网IP>:${port}`
750
- }, [path, serverUrl, readiness, wizardStatus])
782
+ return t('wizard.desktop.addressFallback', { port: String(port) })
783
+ }, [path, serverUrl, readiness, wizardStatus, t])
751
784
 
752
785
  const copyDesktopInfo = async (): Promise<void> => {
753
786
  if (desktopPairing === undefined) return
754
- const text = `服务端地址:${resolvedHostEndpoint}\n桌面客户端配对码:${desktopPairing.code}`
787
+ const text = t('wizard.desktop.copiedText', { address: resolvedHostEndpoint, code: desktopPairing.code })
755
788
  try {
756
789
  await navigator.clipboard?.writeText(text)
757
790
  setDesktopCopied(true)
@@ -779,6 +812,14 @@ export function GatewayWizard(props: GatewayWizardProps): ReactNode {
779
812
  setFormError('bad-client-code')
780
813
  return
781
814
  }
815
+ // MS-8: a NEW address at a full pool is refused locally (the button is
816
+ // gated too — this backs it up); an already-paired address still
817
+ // re-pairs its entry in place, which is how a revoked token at 8/8
818
+ // gets back in.
819
+ if (claimGate.blockedByLimit) {
820
+ setClientClaimFail({ code: 'limit-reached' })
821
+ return
822
+ }
782
823
  setClientClaimBusy(true)
783
824
  try {
784
825
  // If the deployment is currently running as 'host', switch role to 'client'
@@ -799,18 +840,28 @@ export function GatewayWizard(props: GatewayWizardProps): ReactNode {
799
840
  const response = await fetch(CLIENT_CLAIM_ROUTE, {
800
841
  method: 'POST',
801
842
  headers: { 'Content-Type': 'application/json' },
843
+ // MS-5 pool mode: the route reads `name` (the settings page's field).
802
844
  body: JSON.stringify({
803
845
  serverUrl: trimmedUrl,
804
846
  code: normalizedCode,
805
- deviceName,
847
+ name: deviceName,
806
848
  }),
807
849
  })
808
850
  const body = (await response.json()) as ClaimRouteBody
809
- if (body?.ok === true && typeof body.token === 'string' && body.token !== '') {
810
- const urlToWrite = typeof body.serverUrl === 'string' && body.serverUrl !== '' ? body.serverUrl : trimmedUrl
811
- const echoed = typeof body.deviceName === 'string' && body.deviceName.trim() !== '' ? body.deviceName.trim() : deviceName
812
- const landed = await config.writeClientPairing(urlToWrite, body.token, echoed)
813
- if (landed && shouldReloadAfterClientActivation({
851
+ if (body?.ok === true) {
852
+ if (typeof body.entryId === 'string' && body.entryId !== '') {
853
+ // Pool mode: the pairing landed straight in the pool store (the
854
+ // token never rides the answer) — remember WHICH entry the wizard
855
+ // just touched so step 3 opens on it.
856
+ setActiveClientEntryId(body.entryId)
857
+ } else if (typeof body.token === 'string' && body.token !== '') {
858
+ // Legacy single-server route: the token rides the answer and the
859
+ // row is the store.
860
+ const urlToWrite = typeof body.serverUrl === 'string' && body.serverUrl !== '' ? body.serverUrl : trimmedUrl
861
+ const echoed = typeof body.deviceName === 'string' && body.deviceName.trim() !== '' ? body.deviceName.trim() : deviceName
862
+ await config.writeClientPairing(urlToWrite, body.token, echoed)
863
+ }
864
+ if (shouldReloadAfterClientActivation({
814
865
  wasEffectiveClient,
815
866
  nowClientRole: true,
816
867
  savedOrPaired: true,
@@ -839,29 +890,38 @@ export function GatewayWizard(props: GatewayWizardProps): ReactNode {
839
890
  }
840
891
  }
841
892
 
842
- const runClientReconnect = async (): Promise<void> => {
843
- setClientReconnectBusy(true)
893
+ const runClientUnpair = async (): Promise<void> => {
894
+ setClientUnpairBusy(true)
844
895
  try {
845
- await fetch(CLIENT_RECONNECT_ROUTE, { method: 'POST' })
896
+ await config.clearDeviceToken()
846
897
  await loadClientStatus()
898
+ setStep('configure')
847
899
  } catch {
848
900
  // ignore
849
901
  } finally {
850
- setClientReconnectBusy(false)
902
+ setClientUnpairBusy(false)
851
903
  }
852
904
  }
853
905
 
854
- const runClientUnpair = async (): Promise<void> => {
855
- setClientUnpairBusy(true)
906
+ // MS-8: one paired server's management action — the reconnect route's
907
+ // `{ entryId }` shape or the unpair route's. The status refresh runs
908
+ // either way so the surface re-renders on the pool's verdict.
909
+ const runServerAction = async (route: string, entryId: string, payload: Record<string, unknown>): Promise<void> => {
910
+ setServerBusyEntry(entryId)
911
+ setServerActionFailed(false)
856
912
  try {
857
- await config.clearDeviceToken()
858
- await loadClientStatus()
859
- setStep('configure')
913
+ const response = await fetch(route, {
914
+ method: 'POST',
915
+ headers: { 'Content-Type': 'application/json' },
916
+ body: JSON.stringify(payload),
917
+ })
918
+ if (!response.ok) setServerActionFailed(true)
860
919
  } catch {
861
- // ignore
920
+ setServerActionFailed(true)
862
921
  } finally {
863
- setClientUnpairBusy(false)
922
+ setServerBusyEntry(null)
864
923
  }
924
+ await loadClientStatus()
865
925
  }
866
926
 
867
927
  // Managed-Caddy & Tunnel controls (GW-13a / GW-13b): keyed strictly off the
@@ -989,6 +1049,22 @@ export function GatewayWizard(props: GatewayWizardProps): ReactNode {
989
1049
  clientState: clientView?.state,
990
1050
  interceptInstalled: clientView?.intercept?.installed,
991
1051
  })
1052
+ // MS-8: the paired-server pool as the overview grid, wizard steps 2-3 and
1053
+ // the management modal all read it. The step-3 selection falls back to the
1054
+ // first row (pairing order) when nothing is recorded yet or the recorded
1055
+ // entry was removed.
1056
+ const serverRows = clientView?.servers ?? []
1057
+ const poolSummary = deriveClientOverviewSummary(serverRows, clientView?.maxServers)
1058
+ const activeServer = serverRows.find((server) => server.entryId === activeClientEntryId) ?? serverRows[0]
1059
+ const serverLights = deriveClientServerLights(activeServer)
1060
+ // The pairing form's cap gate (MS-8): blockedByLimit is true only for a NEW
1061
+ // address at a full pool — an already-paired address never trips it.
1062
+ const claimGate = canSubmitClientPairing({
1063
+ pairUrl: clientServerUrl,
1064
+ pairCode: clientPairCode,
1065
+ servers: serverRows,
1066
+ ...(clientView !== undefined ? { maxServers: clientView.maxServers } : {}),
1067
+ })
992
1068
  const verified = poll?.result === 'pass'
993
1069
  const qr = startResult?.ok === true ? qrMatrix(startResult.publicUrl) : null
994
1070
 
@@ -1015,10 +1091,17 @@ export function GatewayWizard(props: GatewayWizardProps): ReactNode {
1015
1091
  tokenConfigured: roleSnapshot.deviceToken.configured,
1016
1092
  clientServerUrl,
1017
1093
  clientState: clientView?.state,
1094
+ pairedServerCount: serverRows.length,
1018
1095
  })
1019
1096
 
1020
1097
  const statusSummary = (() => {
1021
1098
  if (effectiveClient) {
1099
+ if (poolSummary.totalCount > 0) {
1100
+ return {
1101
+ state: (poolSummary.onlineCount > 0 ? 'pass' : 'pending') as 'pass' | 'pending',
1102
+ text: `${t('wizard.role.client')} · ${t('wizard.overview.clientOnlineCount', { online: poolSummary.onlineCount, total: poolSummary.totalCount })}`,
1103
+ }
1104
+ }
1022
1105
  if (clientView?.state === 'connected') {
1023
1106
  const label = clientView.serverName !== '' ? clientView.serverName : (clientView.serverUrl || t('wizard.role.client'))
1024
1107
  return { state: 'pass' as const, text: `${t('wizard.role.client')} · ${label}` }
@@ -1053,6 +1136,15 @@ export function GatewayWizard(props: GatewayWizardProps): ReactNode {
1053
1136
  setModalOpen(true)
1054
1137
  }
1055
1138
 
1139
+ // MS-8: 「+ 添加服务端」 — a BLANK pairing form on step 2, so a stale
1140
+ // address or code from an earlier pairing can never ride into the new one.
1141
+ const openAddServerWizard = (): void => {
1142
+ setClientServerUrl('')
1143
+ setClientPairCode('')
1144
+ setClientClaimFail(null)
1145
+ openWizardAt('configure', { role: 'client' })
1146
+ }
1147
+
1056
1148
  return (
1057
1149
  <div data-zen-remote="wizard">
1058
1150
  <div className="zr-wizard-header">
@@ -1079,32 +1171,68 @@ export function GatewayWizard(props: GatewayWizardProps): ReactNode {
1079
1171
  <p className="zr-wizard-overviewSub">{clientDeviceName.trim() !== '' ? clientDeviceName : t('settings.client.deviceNameDefault')}</p>
1080
1172
  </div>
1081
1173
  <div className="zr-wizard-overviewItem">
1082
- <p className="zr-wizard-overviewLabel">{t('wizard.overview.clientCredLabel')}</p>
1174
+ <p className="zr-wizard-overviewLabel">{t('wizard.overview.clientSyncLabel')}</p>
1083
1175
  <p className="zr-wizard-overviewValue">
1084
- <span className="zr-wizard-dot" data-state={clientLights.credential} />
1085
- <span>{roleSnapshot.deviceToken.configured ? t('settings.client.tokenSet') : t('settings.client.statusUnpaired')}</span>
1176
+ <span className="zr-wizard-dot" data-state={clientLights.sync} />
1177
+ <span>
1178
+ {clientLights.sync === 'pass'
1179
+ ? t('wizard.overview.syncOk')
1180
+ : clientLights.sync === 'fail'
1181
+ ? t('wizard.overview.syncBad')
1182
+ : t('wizard.overview.syncPending')}
1183
+ </span>
1086
1184
  </p>
1087
- <p className="zr-wizard-overviewSub">{t(LIGHT_LABEL[clientLights.credential])}</p>
1185
+ <p className="zr-wizard-overviewSub">{t(LIGHT_LABEL[clientLights.sync])}</p>
1088
1186
  </div>
1089
1187
  <div className="zr-wizard-overviewItem">
1090
1188
  <p className="zr-wizard-overviewLabel">{t('wizard.overview.clientConnLabel')}</p>
1091
1189
  <p className="zr-wizard-overviewValue">
1092
- <span className="zr-wizard-dot" data-state={clientLights.connection} />
1093
- <span>
1094
- {clientView === undefined
1095
- ? (clientLoading ? t('settings.client.statusRefreshing') : t('settings.client.statusUnpaired'))
1096
- : clientStatusCopy(clientView, now, t)}
1097
- </span>
1190
+ <span className="zr-wizard-dot" data-state={poolSummary.connectionLight} />
1191
+ <span>{t('wizard.overview.clientOnlineCount', { online: poolSummary.onlineCount, total: poolSummary.totalCount })}</span>
1192
+ </p>
1193
+ <p className="zr-wizard-overviewSub">
1194
+ {poolSummary.totalCount === 0
1195
+ ? t('settings.client.statusUnpaired')
1196
+ : poolSummary.offlineCount === 0 && poolSummary.revokedCount === 0
1197
+ ? t('wizard.overview.clientAllOnline')
1198
+ : [
1199
+ poolSummary.offlineCount > 0 ? t('wizard.overview.clientOfflineCount', { count: poolSummary.offlineCount }) : '',
1200
+ poolSummary.revokedCount > 0 ? t('wizard.overview.clientRevokedCount', { count: poolSummary.revokedCount }) : '',
1201
+ ].filter((line) => line !== '').join(' · ')}
1098
1202
  </p>
1099
- <p className="zr-wizard-overviewSub">{t(LIGHT_LABEL[clientLights.connection])}</p>
1100
1203
  </div>
1101
1204
  <div className="zr-wizard-overviewItem">
1102
- <p className="zr-wizard-overviewLabel">{t('wizard.overview.clientServerLabel')}</p>
1103
- <p className="zr-wizard-overviewValue">
1104
- <span className="zr-wizard-dot" data-state={clientLights.sync} />
1105
- <span>{clientView?.serverUrl || clientServerUrl || '—'}</span>
1205
+ <div className="zr-wizard-overviewHead">
1206
+ <p className="zr-wizard-overviewLabel">{t('wizard.overview.clientServersLabel')}</p>
1207
+ <button
1208
+ type="button"
1209
+ className="zr-wizard-linkBtn"
1210
+ onClick={() => {
1211
+ setServerActionFailed(false)
1212
+ setServersModalOpen(true)
1213
+ }}
1214
+ >
1215
+ {t('wizard.overview.manageServers')}
1216
+ </button>
1217
+ </div>
1218
+ <p className="zr-wizard-overviewValue" data-limit={poolSummary.limitReached ? 'true' : undefined}>
1219
+ <span className="zr-wizard-dot" data-state={poolSummary.serversLight} />
1220
+ <span>
1221
+ {poolSummary.limitReached
1222
+ ? t('wizard.overview.clientPairedFull', { count: poolSummary.totalCount, max: poolSummary.maxServers })
1223
+ : t('settings.client.poolCount', { count: poolSummary.totalCount, max: poolSummary.maxServers })}
1224
+ </span>
1225
+ </p>
1226
+ <p className="zr-wizard-overviewSub" data-limit={poolSummary.limitReached ? 'true' : undefined}>
1227
+ {poolSummary.limitReached
1228
+ ? t('wizard.overview.limitNote', { max: poolSummary.maxServers })
1229
+ : serverRows.length === 0
1230
+ ? t('settings.client.serversEmpty', { max: poolSummary.maxServers })
1231
+ : t('wizard.overview.clientPairedNames', {
1232
+ names: serverRows.map((server) => (server.displayName !== '' ? server.displayName : server.serverUrl)).join('、'),
1233
+ max: poolSummary.maxServers,
1234
+ })}
1106
1235
  </p>
1107
- <p className="zr-wizard-overviewSub">{t('wizard.client.lightSync')}:{t(LIGHT_LABEL[clientLights.sync])}</p>
1108
1236
  </div>
1109
1237
  </div>
1110
1238
 
@@ -1118,18 +1246,6 @@ export function GatewayWizard(props: GatewayWizardProps): ReactNode {
1118
1246
  >
1119
1247
  {clientLoading ? t('settings.client.statusRefreshing') : t('settings.client.statusRefresh')}
1120
1248
  </button>
1121
- {clientView?.nextRetryAt !== undefined ? (
1122
- <button
1123
- type="button"
1124
- className="zr-wizard-btn"
1125
- disabled={clientReconnectBusy}
1126
- onClick={() => { void runClientReconnect() }}
1127
- >
1128
- {clientReconnectBusy ? t('settings.client.reconnectBusy') : t('settings.client.reconnect')}
1129
- </button>
1130
- ) : null}
1131
- </div>
1132
- <div className="zr-wizard-row">
1133
1249
  <button
1134
1250
  type="button"
1135
1251
  className="zr-wizard-btn"
@@ -1137,12 +1253,25 @@ export function GatewayWizard(props: GatewayWizardProps): ReactNode {
1137
1253
  >
1138
1254
  {t('wizard.overview.reconfigure')}
1139
1255
  </button>
1256
+ </div>
1257
+ <div className="zr-wizard-row">
1258
+ <button
1259
+ type="button"
1260
+ className="zr-wizard-btn"
1261
+ onClick={() => {
1262
+ setServerActionFailed(false)
1263
+ setServersModalOpen(true)
1264
+ }}
1265
+ >
1266
+ {t('wizard.overview.manageServers')}
1267
+ </button>
1140
1268
  <button
1141
1269
  type="button"
1142
1270
  className="zr-wizard-btn zr-wizard-btnPrimary"
1143
- onClick={() => openWizardAt(roleSnapshot.deviceToken.configured ? 'verify' : 'configure', { role: 'client' })}
1271
+ disabled={poolSummary.limitReached}
1272
+ onClick={openAddServerWizard}
1144
1273
  >
1145
- {t('wizard.overview.clientDetail')}
1274
+ {t('wizard.overview.addServer')}
1146
1275
  </button>
1147
1276
  </div>
1148
1277
  </div>
@@ -1572,6 +1701,16 @@ export function GatewayWizard(props: GatewayWizardProps): ReactNode {
1572
1701
  </>
1573
1702
  ) : (
1574
1703
  <>
1704
+ {/* MS-8: the pool cap is stated on EVERY entry into a
1705
+ new pairing, and a full pool shouts in red. */}
1706
+ <p className="zr-wizard-notice">
1707
+ {t('wizard.client.poolCapNote', { count: serverRows.length, max: poolSummary.maxServers })}
1708
+ </p>
1709
+ {poolSummary.limitReached ? (
1710
+ <p className="zr-wizard-notice" data-tone="error">
1711
+ {t('wizard.overview.limitNote', { max: poolSummary.maxServers })}
1712
+ </p>
1713
+ ) : null}
1575
1714
  <div className="zr-wizard-formStack">
1576
1715
  <div className="zr-wizard-field">
1577
1716
  <label className="zr-wizard-label" htmlFor="zr-wizard-client-device">{t('wizard.client.deviceName')}</label>
@@ -1659,10 +1798,14 @@ export function GatewayWizard(props: GatewayWizardProps): ReactNode {
1659
1798
  {t('wizard.client.skipToStatus')}
1660
1799
  </button>
1661
1800
  ) : null}
1801
+ {/* MS-8: at a full pool only an already-paired address
1802
+ may pass — the gate lifts the moment the typed URL
1803
+ names one (覆盖重配), so a revoked token at 8/8 can
1804
+ still re-pair its own server. */}
1662
1805
  <button
1663
1806
  type="button"
1664
1807
  className="zr-wizard-btn zr-wizard-btnPrimary"
1665
- disabled={clientClaimBusy}
1808
+ disabled={clientClaimBusy || claimGate.blockedByLimit}
1666
1809
  onClick={() => { void runClientClaim() }}
1667
1810
  >
1668
1811
  {clientClaimBusy ? t('wizard.client.pairingBusy') : t('wizard.client.pairAndConnect')}
@@ -1737,7 +1880,7 @@ export function GatewayWizard(props: GatewayWizardProps): ReactNode {
1737
1880
  {tunnelResult.existing.map((tunnel) => (
1738
1881
  <button key={tunnel.id} type="button" className="zr-wizard-linkBtn" disabled={disabled || tunnelBusy}
1739
1882
  onClick={() => { setAdoptTunnelId(tunnel.id); void setupTunnelNow(tunnel.id) }}>
1740
- {t('wizard.tunnel.useExisting')}:{tunnel.name}({tunnel.status})
1883
+ {t('wizard.tunnel.useExisting')}: {tunnel.name} ({tunnel.status})
1741
1884
  </button>
1742
1885
  ))}
1743
1886
  </div>
@@ -1881,59 +2024,95 @@ export function GatewayWizard(props: GatewayWizardProps): ReactNode {
1881
2024
  </>
1882
2025
  ) : (
1883
2026
  <>
2027
+ {/* MS-8: with more than one paired server the step
2028
+ switches between them; the selection defaults to the
2029
+ entry the last pairing (or the management modal)
2030
+ touched. */}
2031
+ {serverRows.length > 1 ? (
2032
+ <div className="zr-wizard-targetTabs" role="tablist" aria-label={t('wizard.client.serverPicker')}>
2033
+ {serverRows.map((server) => {
2034
+ const selected = activeServer?.entryId === server.entryId
2035
+ return (
2036
+ <button
2037
+ key={server.entryId}
2038
+ type="button"
2039
+ role="tab"
2040
+ aria-selected={selected}
2041
+ className="zr-wizard-targetTab"
2042
+ data-selected={selected}
2043
+ onClick={() => setActiveClientEntryId(server.entryId)}
2044
+ >
2045
+ {server.displayName !== '' ? server.displayName : server.serverUrl}
2046
+ </button>
2047
+ )
2048
+ })}
2049
+ </div>
2050
+ ) : null}
2051
+
2052
+ {activeServer !== undefined ? (
2053
+ <div className="zr-wizard-serviceInfo">
2054
+ <span className="zr-wizard-dot" data-state={serverDotState(activeServer.state)} />
2055
+ <span className="zr-wizard-lightName">{activeServer.displayName !== '' ? activeServer.displayName : activeServer.serverUrl}</span>
2056
+ <span className="zr-wizard-hint">{activeServer.serverUrl}</span>
2057
+ </div>
2058
+ ) : null}
2059
+
1884
2060
  <ul className="zr-wizard-lights">
1885
2061
  {CLIENT_LIGHT_ORDER.map(({ key, light }) => (
1886
- <li key={key} className="zr-wizard-light" data-state={clientLights[light]}>
2062
+ <li key={key} className="zr-wizard-light" data-state={serverLights[light]}>
1887
2063
  <div className="zr-wizard-lightMain">
1888
- <span className="zr-wizard-dot" data-state={clientLights[light]} />
2064
+ <span className="zr-wizard-dot" data-state={serverLights[light]} />
1889
2065
  <span className="zr-wizard-lightName">{t(key)}</span>
1890
2066
  </div>
1891
- <span className="zr-wizard-lightState" data-state={clientLights[light]}>{t(LIGHT_LABEL[clientLights[light]])}</span>
2067
+ <span className="zr-wizard-lightState" data-state={serverLights[light]}>{t(LIGHT_LABEL[serverLights[light]])}</span>
1892
2068
  </li>
1893
2069
  ))}
1894
2070
  </ul>
1895
2071
 
1896
- <div className="zr-wizard-clientBox">
1897
- <p className="zr-wizard-label">{t('settings.client.statusTitle')}</p>
1898
- <p
1899
- className="zr-wizard-notice"
1900
- data-tone={clientView?.state === 'connected' ? 'ok' : clientLights.connection === 'fail' ? 'error' : undefined}
1901
- >
1902
- {clientView === undefined
1903
- ? (clientLoading ? t('settings.client.statusRefreshing') : t('settings.client.statusUnpaired'))
1904
- : clientStatusCopy(clientView, now, t)}
1905
- </p>
2072
+ {activeServer !== undefined ? (
1906
2073
  <div className="zr-wizard-row">
1907
- <button
1908
- type="button"
1909
- className="zr-wizard-btn"
1910
- disabled={clientLoading}
1911
- onClick={() => { void loadClientStatus() }}
1912
- >
1913
- {clientLoading ? t('settings.client.statusRefreshing') : t('settings.client.statusRefresh')}
1914
- </button>
1915
- {clientView?.nextRetryAt !== undefined ? (
2074
+ {activeServer.nextRetryAt !== undefined ? (
1916
2075
  <button
1917
2076
  type="button"
1918
2077
  className="zr-wizard-btn zr-wizard-btnPrimary"
1919
- disabled={clientReconnectBusy}
1920
- onClick={() => { void runClientReconnect() }}
2078
+ disabled={serverBusyEntry !== null}
2079
+ onClick={() => { void runServerAction(CLIENT_RECONNECT_ROUTE, activeServer.entryId, { entryId: activeServer.entryId }) }}
1921
2080
  >
1922
- {clientReconnectBusy ? t('settings.client.reconnectBusy') : t('settings.client.reconnect')}
1923
- </button>
1924
- ) : null}
1925
- {roleSnapshot.deviceToken.configured ? (
1926
- <button
1927
- type="button"
1928
- className="zr-wizard-btn"
1929
- disabled={clientUnpairBusy}
1930
- onClick={() => { void runClientUnpair() }}
1931
- >
1932
- {clientUnpairBusy ? t('settings.client.unpairBusy') : t('settings.client.unpair')}
2081
+ {serverBusyEntry === activeServer.entryId ? t('settings.client.reconnectBusy') : t('settings.client.reconnect')}
1933
2082
  </button>
1934
2083
  ) : null}
2084
+ <button
2085
+ type="button"
2086
+ className="zr-wizard-btn"
2087
+ disabled={serverBusyEntry !== null}
2088
+ onClick={() => {
2089
+ if (!window.confirm(t('settings.client.unpairServerConfirm'))) return
2090
+ const entryId = activeServer.entryId
2091
+ void runServerAction(CLIENT_UNPAIR_ROUTE, entryId, { entryId }).then(() => {
2092
+ setActiveClientEntryId((current) => (current === entryId ? undefined : current))
2093
+ })
2094
+ }}
2095
+ >
2096
+ {t('settings.client.serverRemove')}
2097
+ </button>
1935
2098
  </div>
1936
- </div>
2099
+ ) : null}
2100
+
2101
+ {/* Legacy row-token fallback: a pre-pool pairing kept
2102
+ its token in the row — unpairing it stays possible
2103
+ even with no pool rows on screen. */}
2104
+ {serverRows.length === 0 && roleSnapshot.deviceToken.configured ? (
2105
+ <div className="zr-wizard-row">
2106
+ <button
2107
+ type="button"
2108
+ className="zr-wizard-btn"
2109
+ disabled={clientUnpairBusy}
2110
+ onClick={() => { void runClientUnpair() }}
2111
+ >
2112
+ {clientUnpairBusy ? t('settings.client.unpairBusy') : t('settings.client.unpair')}
2113
+ </button>
2114
+ </div>
2115
+ ) : null}
1937
2116
  </>
1938
2117
  )}
1939
2118
  </div>
@@ -1978,6 +2157,17 @@ export function GatewayWizard(props: GatewayWizardProps): ReactNode {
1978
2157
  {desktopPairing !== undefined && desktopPairing.expiresAt > now ? t('wizard.desktop.regenerate') : t('wizard.desktop.generate')}
1979
2158
  </button>
1980
2159
  ) : null}
2160
+ {/* MS-8: keep adding servers without leaving the wizard —
2161
+ until the pool is full. */}
2162
+ {wizardRole === 'client' && !poolSummary.limitReached ? (
2163
+ <button
2164
+ type="button"
2165
+ className="zr-wizard-btn zr-wizard-btnPrimary"
2166
+ onClick={openAddServerWizard}
2167
+ >
2168
+ {t('wizard.client.continueAdd')}
2169
+ </button>
2170
+ ) : null}
1981
2171
  <button
1982
2172
  type="button"
1983
2173
  className="zr-wizard-btn"
@@ -2194,6 +2384,177 @@ export function GatewayWizard(props: GatewayWizardProps): ReactNode {
2194
2384
  </div>
2195
2385
  ) : null}
2196
2386
 
2387
+ {serversModalOpen ? (
2388
+ <div
2389
+ data-zen-remote="wizard-modal"
2390
+ onClick={() => {
2391
+ if (serverBusyEntry === null) setServersModalOpen(false)
2392
+ }}
2393
+ >
2394
+ <div
2395
+ className="zr-wizard-modalDialog"
2396
+ role="dialog"
2397
+ aria-modal="true"
2398
+ aria-label={t('wizard.serversModal.title')}
2399
+ onClick={(event) => event.stopPropagation()}
2400
+ >
2401
+ <div className="zr-wizard-modalHeader">
2402
+ <div className="zr-wizard-headerText">
2403
+ <p className="zr-wizard-modalTitle">{t('wizard.serversModal.title')}</p>
2404
+ <p className="zr-wizard-subtitle" data-limit={poolSummary.limitReached ? 'true' : undefined}>
2405
+ {t('wizard.serversModal.subtitle', { count: serverRows.length, max: poolSummary.maxServers })}
2406
+ </p>
2407
+ </div>
2408
+ <button
2409
+ type="button"
2410
+ className="zr-wizard-cardCloseIcon"
2411
+ aria-label={t('wizard.card.close')}
2412
+ disabled={serverBusyEntry !== null}
2413
+ onClick={() => setServersModalOpen(false)}
2414
+ >
2415
+ ×
2416
+ </button>
2417
+ </div>
2418
+
2419
+ <div className="zr-wizard-modalBody">
2420
+ {/* MS-8: a full pool shouts here too — this is the surface a
2421
+ user at 8/8 lands on when they try to add one more. */}
2422
+ {poolSummary.limitReached ? (
2423
+ <p className="zr-wizard-notice" data-tone="error">
2424
+ {t('wizard.overview.limitNote', { max: poolSummary.maxServers })}
2425
+ </p>
2426
+ ) : null}
2427
+ {serverActionFailed ? (
2428
+ <p className="zr-wizard-notice" data-tone="error">{t('settings.actionFail')}</p>
2429
+ ) : null}
2430
+
2431
+ {serverRows.length === 0 ? (
2432
+ <p className="zr-wizard-notice">{t('settings.client.serversEmpty', { max: poolSummary.maxServers })}</p>
2433
+ ) : (
2434
+ <ul className="zr-wizard-deviceList">
2435
+ {serverRows.map((server) => {
2436
+ const badge = serverBadgeCopy(server, now, t)
2437
+ const name = server.displayName !== '' ? server.displayName : server.serverUrl
2438
+ return (
2439
+ <li key={server.entryId} className="zr-wizard-deviceItem">
2440
+ <div className="zr-wizard-deviceHead">
2441
+ <div className="zr-wizard-deviceTitleRow">
2442
+ <span className="zr-wizard-dot" data-state={serverDotState(server.state)} />
2443
+ <span className="zr-wizard-lightName">{name}</span>
2444
+ <span className="zr-wizard-deviceBadge" data-tone={badge.tone}>
2445
+ {badge.text}
2446
+ </span>
2447
+ </div>
2448
+ <span className="zr-wizard-hint">{server.serverUrl}</span>
2449
+ </div>
2450
+
2451
+ <div className="zr-wizard-deviceActions">
2452
+ <button
2453
+ type="button"
2454
+ className="zr-wizard-linkBtn"
2455
+ disabled={serverBusyEntry !== null}
2456
+ onClick={() => {
2457
+ setServersModalOpen(false)
2458
+ setActiveClientEntryId(server.entryId)
2459
+ openWizardAt('verify', { role: 'client' })
2460
+ }}
2461
+ >
2462
+ {t('wizard.serversModal.viewStatus')}
2463
+ </button>
2464
+ {server.nextRetryAt !== undefined ? (
2465
+ <button
2466
+ type="button"
2467
+ className="zr-wizard-linkBtn"
2468
+ disabled={serverBusyEntry !== null}
2469
+ onClick={() => { void runServerAction(CLIENT_RECONNECT_ROUTE, server.entryId, { entryId: server.entryId }) }}
2470
+ >
2471
+ {serverBusyEntry === server.entryId ? t('settings.client.reconnectBusy') : t('settings.client.reconnect')}
2472
+ </button>
2473
+ ) : null}
2474
+ <button
2475
+ type="button"
2476
+ className="zr-wizard-linkBtn"
2477
+ disabled={serverBusyEntry !== null}
2478
+ onClick={() => {
2479
+ // 覆盖重配 pre-fills the entry's own address, so
2480
+ // it stays usable at a FULL pool (an already-paired
2481
+ // address never trips the cap).
2482
+ setServersModalOpen(false)
2483
+ setClientServerUrl(server.serverUrl)
2484
+ setClientPairCode('')
2485
+ setClientClaimFail(null)
2486
+ openWizardAt('configure', { role: 'client' })
2487
+ }}
2488
+ >
2489
+ {t('settings.client.repair')}
2490
+ </button>
2491
+ <button
2492
+ type="button"
2493
+ className="zr-wizard-linkBtn"
2494
+ disabled={serverBusyEntry !== null}
2495
+ onClick={() => {
2496
+ if (!window.confirm(t('settings.client.unpairServerConfirm'))) return
2497
+ const entryId = server.entryId
2498
+ void runServerAction(CLIENT_UNPAIR_ROUTE, entryId, { entryId }).then(() => {
2499
+ setActiveClientEntryId((current) => (current === entryId ? undefined : current))
2500
+ })
2501
+ }}
2502
+ >
2503
+ {t('settings.client.serverRemove')}
2504
+ </button>
2505
+ </div>
2506
+ </li>
2507
+ )
2508
+ })}
2509
+ </ul>
2510
+ )}
2511
+ </div>
2512
+
2513
+ <div className="zr-wizard-modalFooter">
2514
+ <div className="zr-wizard-row">
2515
+ {serverRows.length > 0 ? (
2516
+ <button
2517
+ type="button"
2518
+ className="zr-wizard-btn"
2519
+ disabled={serverBusyEntry !== null}
2520
+ onClick={() => {
2521
+ if (window.confirm(t('settings.client.unpairAllServersConfirm'))) {
2522
+ void runServerAction(CLIENT_UNPAIR_ROUTE, 'all', { all: true }).then(() => {
2523
+ setActiveClientEntryId(undefined)
2524
+ })
2525
+ }
2526
+ }}
2527
+ >
2528
+ {t('settings.client.unpairAllServers')}
2529
+ </button>
2530
+ ) : null}
2531
+ </div>
2532
+ <div className="zr-wizard-row">
2533
+ <button
2534
+ type="button"
2535
+ className="zr-wizard-btn zr-wizard-btnPrimary"
2536
+ disabled={poolSummary.limitReached || serverBusyEntry !== null}
2537
+ onClick={() => {
2538
+ setServersModalOpen(false)
2539
+ openAddServerWizard()
2540
+ }}
2541
+ >
2542
+ {t('wizard.overview.addServer')}
2543
+ </button>
2544
+ <button
2545
+ type="button"
2546
+ className="zr-wizard-btn"
2547
+ disabled={serverBusyEntry !== null}
2548
+ onClick={() => setServersModalOpen(false)}
2549
+ >
2550
+ {t('wizard.card.close')}
2551
+ </button>
2552
+ </div>
2553
+ </div>
2554
+ </div>
2555
+ </div>
2556
+ ) : null}
2557
+
2197
2558
  {card === 'proxy' ? (
2198
2559
  <ProxyCard
2199
2560
  t={t}