dsh-zen-remote 2.3.11 → 2.4.0
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/CHANGELOG.md +39 -78
- package/README.md +94 -84
- package/README.zh-CN.md +90 -81
- package/bin/zen-remote.mjs +5 -0
- package/docs/interface.md +6 -6
- package/docs/remote-access.en.md +6 -4
- package/docs/remote-access.md +6 -4
- package/dsh-push.mjs +96 -10
- package/lib/adapters/dsh-better-sidebar/index.js +7 -3
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/admin-routes.js +10 -2
- package/lib/admin-routes.js.map +1 -1
- package/lib/cli/args.js +251 -0
- package/lib/cli/args.js.map +1 -0
- package/lib/cli/bootstrap.js +449 -0
- package/lib/cli/bootstrap.js.map +1 -0
- package/lib/cli/command-setup.js +484 -0
- package/lib/cli/command-setup.js.map +1 -0
- package/lib/cli/commands-client.js +359 -0
- package/lib/cli/commands-client.js.map +1 -0
- package/lib/cli/commands-host.js +1108 -0
- package/lib/cli/commands-host.js.map +1 -0
- package/lib/cli/commands-status.js +279 -0
- package/lib/cli/commands-status.js.map +1 -0
- package/lib/cli/config-writer.js +334 -0
- package/lib/cli/config-writer.js.map +1 -0
- package/lib/cli/context.js +10 -0
- package/lib/cli/context.js.map +1 -0
- package/lib/cli/i18n.js +620 -0
- package/lib/cli/i18n.js.map +1 -0
- package/lib/cli/ipc-client.js +160 -0
- package/lib/cli/ipc-client.js.map +1 -0
- package/lib/cli/ipc-server.js +464 -0
- package/lib/cli/ipc-server.js.map +1 -0
- package/lib/cli/main.js +245 -0
- package/lib/cli/main.js.map +1 -0
- package/lib/cli/qr-terminal.js +48 -0
- package/lib/cli/qr-terminal.js.map +1 -0
- package/lib/client-data/qr.js +286 -0
- package/lib/client-data/qr.js.map +1 -0
- package/lib/client-routes.js +308 -25
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +1013 -229
- package/lib/config.js +50 -12
- package/lib/config.js.map +1 -1
- package/lib/debug-report.js +28 -1
- package/lib/debug-report.js.map +1 -1
- package/lib/fetch-route-intercept.js +334 -26
- package/lib/fetch-route-intercept.js.map +1 -1
- package/lib/index.js +564 -363
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +1833 -280
- package/lib/intercept.js.map +1 -1
- package/lib/lan-gate-server.cjs +36 -10
- package/lib/merge-streams.js +103 -11
- package/lib/merge-streams.js.map +1 -1
- package/lib/plugin-route-intercept.js +93 -63
- package/lib/plugin-route-intercept.js.map +1 -1
- package/lib/relay-access.js +8 -1
- package/lib/relay-access.js.map +1 -1
- package/lib/relay-client.js +133 -4
- package/lib/relay-client.js.map +1 -1
- package/lib/relay-pool.js +275 -0
- package/lib/relay-pool.js.map +1 -0
- package/lib/relay-server.js +269 -17
- package/lib/relay-server.js.map +1 -1
- package/lib/restart-fields.js +6 -4
- package/lib/restart-fields.js.map +1 -1
- package/lib/share-store.js +65 -27
- package/lib/share-store.js.map +1 -1
- package/lib/state-store.js +400 -0
- package/lib/state-store.js.map +1 -0
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +3 -2
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/admin-routes.d.ts.map +1 -1
- package/lib/types/cli/args.d.ts +78 -0
- package/lib/types/cli/args.d.ts.map +1 -0
- package/lib/types/cli/bootstrap.d.ts +170 -0
- package/lib/types/cli/bootstrap.d.ts.map +1 -0
- package/lib/types/cli/command-setup.d.ts +72 -0
- package/lib/types/cli/command-setup.d.ts.map +1 -0
- package/lib/types/cli/commands-client.d.ts +36 -0
- package/lib/types/cli/commands-client.d.ts.map +1 -0
- package/lib/types/cli/commands-host.d.ts +82 -0
- package/lib/types/cli/commands-host.d.ts.map +1 -0
- package/lib/types/cli/commands-status.d.ts +46 -0
- package/lib/types/cli/commands-status.d.ts.map +1 -0
- package/lib/types/cli/config-writer.d.ts +92 -0
- package/lib/types/cli/config-writer.d.ts.map +1 -0
- package/lib/types/cli/context.d.ts +39 -0
- package/lib/types/cli/context.d.ts.map +1 -0
- package/lib/types/cli/i18n.d.ts +32 -0
- package/lib/types/cli/i18n.d.ts.map +1 -0
- package/lib/types/cli/ipc-client.d.ts +83 -0
- package/lib/types/cli/ipc-client.d.ts.map +1 -0
- package/lib/types/cli/ipc-server.d.ts +113 -0
- package/lib/types/cli/ipc-server.d.ts.map +1 -0
- package/lib/types/cli/main.d.ts +57 -0
- package/lib/types/cli/main.d.ts.map +1 -0
- package/lib/types/cli/qr-terminal.d.ts +17 -0
- package/lib/types/cli/qr-terminal.d.ts.map +1 -0
- package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -1
- package/lib/types/client/current-session-report.d.ts +4 -0
- package/lib/types/client/current-session-report.d.ts.map +1 -1
- package/lib/types/client/effects/model-group-side.d.ts.map +1 -1
- package/lib/types/client/effects/remote-session.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +47 -3
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/settings/settings-css.d.ts +1 -1
- package/lib/types/client/settings/settings-css.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
- package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
- package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -1
- package/lib/types/client/wizard/wizard-css.d.ts +1 -1
- package/lib/types/client/wizard/wizard-css.d.ts.map +1 -1
- package/lib/types/client-data/model-group-side.d.ts +23 -2
- package/lib/types/client-data/model-group-side.d.ts.map +1 -1
- package/lib/types/client-data/remote-status.d.ts +47 -1
- package/lib/types/client-data/remote-status.d.ts.map +1 -1
- package/lib/types/client-data/settings-form.d.ts +85 -1
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-data/wizard.d.ts +56 -1
- package/lib/types/client-data/wizard.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +25 -1
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/config.d.ts +40 -14
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/debug-report.d.ts.map +1 -1
- package/lib/types/fetch-route-intercept.d.ts +66 -8
- package/lib/types/fetch-route-intercept.d.ts.map +1 -1
- package/lib/types/index.d.ts +9 -2
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept.d.ts +72 -10
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/merge-streams.d.ts +79 -1
- package/lib/types/merge-streams.d.ts.map +1 -1
- package/lib/types/plugin-route-intercept.d.ts +25 -9
- package/lib/types/plugin-route-intercept.d.ts.map +1 -1
- package/lib/types/relay-access.d.ts +3 -1
- package/lib/types/relay-access.d.ts.map +1 -1
- package/lib/types/relay-client.d.ts +23 -2
- package/lib/types/relay-client.d.ts.map +1 -1
- package/lib/types/relay-pool.d.ts +148 -0
- package/lib/types/relay-pool.d.ts.map +1 -0
- package/lib/types/relay-server.d.ts +30 -3
- package/lib/types/relay-server.d.ts.map +1 -1
- package/lib/types/restart-fields.d.ts +5 -5
- package/lib/types/restart-fields.d.ts.map +1 -1
- package/lib/types/share-store.d.ts.map +1 -1
- package/lib/types/state-store.d.ts +179 -0
- package/lib/types/state-store.d.ts.map +1 -0
- package/lib/types/upload-limits.d.ts +15 -0
- package/lib/types/upload-limits.d.ts.map +1 -1
- package/lib/types/virtual-id.d.ts +21 -0
- package/lib/types/virtual-id.d.ts.map +1 -1
- package/lib/types/wizard/probe.d.ts.map +1 -1
- package/lib/types/wizard/proxy-snippets.d.ts +3 -0
- package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
- package/lib/types/wizard/routes.d.ts +1 -0
- package/lib/types/wizard/routes.d.ts.map +1 -1
- package/lib/types/wizard/status.d.ts +9 -0
- package/lib/types/wizard/status.d.ts.map +1 -1
- package/lib/types/wizard/verify.d.ts +3 -0
- package/lib/types/wizard/verify.d.ts.map +1 -1
- package/lib/upload-limits.js +15 -0
- package/lib/upload-limits.js.map +1 -1
- package/lib/virtual-id.js +60 -0
- package/lib/virtual-id.js.map +1 -1
- package/lib/wizard/probe.js +46 -1
- package/lib/wizard/probe.js.map +1 -1
- package/lib/wizard/proxy-snippets.js +14 -1
- package/lib/wizard/proxy-snippets.js.map +1 -1
- package/lib/wizard/routes.js +23 -4
- package/lib/wizard/routes.js.map +1 -1
- package/lib/wizard/status.js +21 -0
- package/lib/wizard/status.js.map +1 -1
- package/lib/wizard/verify.js +31 -8
- package/lib/wizard/verify.js.map +1 -1
- package/package.json +8 -4
- package/pwa/icons/badge-96.png +0 -0
- package/pwa/icons/badge.svg +1 -0
- package/pwa/manifest.json +2 -1
- package/pwa/sw.js +3 -1
- package/scripts/check-debug-report.mjs +41 -1
- package/scripts/check-settings-form.mjs +14 -3
- package/scripts/check-settings-view.mjs +247 -0
- package/scripts/check-wizard-ui.mjs +37 -0
- package/src/adapters/dsh-better-sidebar/index.ts +7 -3
- package/src/admin-routes.ts +10 -2
- package/src/cli/args.ts +289 -0
- package/src/cli/bootstrap.ts +569 -0
- package/src/cli/command-setup.ts +540 -0
- package/src/cli/commands-client.ts +369 -0
- package/src/cli/commands-host.ts +1127 -0
- package/src/cli/commands-status.ts +296 -0
- package/src/cli/config-writer.ts +354 -0
- package/src/cli/context.ts +38 -0
- package/src/cli/i18n.ts +645 -0
- package/src/cli/ipc-client.ts +216 -0
- package/src/cli/ipc-server.ts +531 -0
- package/src/cli/main.ts +285 -0
- package/src/cli/qr-terminal.ts +49 -0
- package/src/client/RemoteStatusIcon.tsx +4 -1
- package/src/client/current-session-report.ts +28 -4
- package/src/client/effects/model-group-side.ts +19 -5
- package/src/client/effects/remote-session.ts +26 -6
- package/src/client/locales.ts +96 -6
- package/src/client/settings/SettingsSection.tsx +287 -92
- package/src/client/settings/settings-css.ts +18 -0
- package/src/client/styles/remote-session.css.ts +15 -1
- package/src/client/wizard/GatewayWizard.tsx +470 -109
- package/src/client/wizard/cards/ProxyCard.tsx +2 -1
- package/src/client/wizard/wizard-css.ts +23 -0
- package/src/client-data/model-group-side.ts +37 -10
- package/src/client-data/remote-status.ts +112 -17
- package/src/client-data/settings-form.ts +164 -8
- package/src/client-data/wizard.ts +112 -2
- package/src/client-routes.ts +334 -26
- package/src/config.ts +62 -16
- package/src/debug-report.ts +27 -1
- package/src/fetch-route-intercept.ts +381 -32
- package/src/index.ts +566 -367
- package/src/intercept.ts +1866 -286
- package/src/merge-streams.ts +155 -10
- package/src/plugin-route-intercept.ts +120 -71
- package/src/relay-access.ts +14 -3
- package/src/relay-client.ts +163 -5
- package/src/relay-pool.ts +387 -0
- package/src/relay-server.ts +312 -16
- package/src/restart-fields.ts +6 -4
- package/src/share-store.ts +59 -26
- package/src/state-store.ts +428 -0
- package/src/upload-limits.ts +16 -0
- package/src/virtual-id.ts +57 -0
- package/src/wizard/probe.ts +54 -2
- package/src/wizard/proxy-snippets.ts +17 -2
- package/src/wizard/routes.ts +24 -5
- package/src/wizard/status.ts +28 -0
- package/src/wizard/verify.ts +33 -11
- package/AGENTS.md +0 -185
- package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +0 -15
- 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
|
|
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
|
|
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 =
|
|
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
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
|
843
|
-
|
|
893
|
+
const runClientUnpair = async (): Promise<void> => {
|
|
894
|
+
setClientUnpairBusy(true)
|
|
844
895
|
try {
|
|
845
|
-
await
|
|
896
|
+
await config.clearDeviceToken()
|
|
846
897
|
await loadClientStatus()
|
|
898
|
+
setStep('configure')
|
|
847
899
|
} catch {
|
|
848
900
|
// ignore
|
|
849
901
|
} finally {
|
|
850
|
-
|
|
902
|
+
setClientUnpairBusy(false)
|
|
851
903
|
}
|
|
852
904
|
}
|
|
853
905
|
|
|
854
|
-
|
|
855
|
-
|
|
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
|
|
858
|
-
|
|
859
|
-
|
|
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
|
-
|
|
920
|
+
setServerActionFailed(true)
|
|
862
921
|
} finally {
|
|
863
|
-
|
|
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.
|
|
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.
|
|
1085
|
-
<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.
|
|
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={
|
|
1093
|
-
<span>
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
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
|
-
<
|
|
1103
|
-
|
|
1104
|
-
<
|
|
1105
|
-
|
|
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
|
-
|
|
1271
|
+
disabled={poolSummary.limitReached}
|
|
1272
|
+
onClick={openAddServerWizard}
|
|
1144
1273
|
>
|
|
1145
|
-
{t('wizard.overview.
|
|
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')}
|
|
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={
|
|
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={
|
|
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={
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
1920
|
-
onClick={() => { void
|
|
2078
|
+
disabled={serverBusyEntry !== null}
|
|
2079
|
+
onClick={() => { void runServerAction(CLIENT_RECONNECT_ROUTE, activeServer.entryId, { entryId: activeServer.entryId }) }}
|
|
1921
2080
|
>
|
|
1922
|
-
{
|
|
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
|
-
|
|
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}
|