dsh-plugin-remote-connect-beta 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +115 -0
- package/CONTRIBUTING.md +50 -0
- package/LICENSE +21 -0
- package/README.md +279 -0
- package/README.zh.md +298 -0
- package/SECURITY.md +41 -0
- package/bin/dsh-remote.js +768 -0
- package/docs/client-preview.png +0 -0
- package/docs/market-submission.md +82 -0
- package/docs/multi-tenant.md +136 -0
- package/docs/multi-tenant.zh.md +127 -0
- package/docs/self-host.md +398 -0
- package/docs/self-host.zh.md +369 -0
- package/docs/tenants-preview.png +0 -0
- package/lib/client.js +1120 -0
- package/lib/core/assets/server-setup.sh.tpl +344 -0
- package/lib/core/credential.js +123 -0
- package/lib/core/instance.js +360 -0
- package/lib/core/messages.js +549 -0
- package/lib/core/paths.js +59 -0
- package/lib/core/preflight.js +410 -0
- package/lib/core/proxy.js +924 -0
- package/lib/core/serversetup.js +133 -0
- package/lib/core/snippets.js +193 -0
- package/lib/core/tailscale.js +158 -0
- package/lib/core/tenancy.js +267 -0
- package/lib/core/tenant.js +272 -0
- package/lib/core/tunnel.js +320 -0
- package/lib/index.js +1097 -0
- package/package.json +76 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,1120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH 插件(client 半):侧栏「远程连接」入口 + 控制面板。
|
|
3
|
+
*
|
|
4
|
+
* 这是真正会被 client-modules 装载的 bundle:它必须调用
|
|
5
|
+
* `window.__ModuleLoader__.load({ id, factory })`,并在 factory 里挂
|
|
6
|
+
* `exports.apply` / `exports.inject`。`react` 与
|
|
7
|
+
* `@deepseek-ai/dsh-client-ui-primitives` 属于运行时 seed 模块,可直接 require,
|
|
8
|
+
* 不需要声明 dsh.client.inject。
|
|
9
|
+
*
|
|
10
|
+
* 手写、无构建步骤:改完即可用(这也是本项目刻意不引入打包器的原因)。
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-plugin-remote-connect-beta/client
|
|
13
|
+
*/
|
|
14
|
+
window.__ModuleLoader__.load({
|
|
15
|
+
id: 'dsh-plugin-remote-connect-beta',
|
|
16
|
+
factory: (require) => {
|
|
17
|
+
var module = { exports: {} }
|
|
18
|
+
var exports = module.exports
|
|
19
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
20
|
+
|
|
21
|
+
const React = require('react')
|
|
22
|
+
let primitives = null
|
|
23
|
+
try {
|
|
24
|
+
primitives = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
25
|
+
} catch (error) {
|
|
26
|
+
primitives = null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const NS = 'remote-connect'
|
|
30
|
+
/** 简体中文(键集的事实来源)。 */
|
|
31
|
+
const zh = {
|
|
32
|
+
'entry.label': '远程连接',
|
|
33
|
+
'panel.title': '远程连接',
|
|
34
|
+
'panel.hint': '手机或其它电脑用浏览器打开即可使用这个 Harness,无需安装客户端。',
|
|
35
|
+
'panel.localOnly': '当前不是宿主机窗口,只能查看状态。',
|
|
36
|
+
'action.start': '开启',
|
|
37
|
+
'action.stop': '停止',
|
|
38
|
+
'action.check': '检查服务器',
|
|
39
|
+
'action.snippets': '服务器配置',
|
|
40
|
+
'card.lan': '本地网络连接',
|
|
41
|
+
'card.lan.desc': '同一 Wi-Fi 下的手机/电脑直接打开;窄屏已做手机适配(侧栏浮层 + 点击遮罩关闭)。',
|
|
42
|
+
'card.public': '公网连接',
|
|
43
|
+
'card.public.desc': '经你的服务器提供 HTTPS 入口(边缘口令 + 访问密钥两道门)。',
|
|
44
|
+
'card.public.unconfigured': '在插件配置里填 public.domain 与 public.ssh 后可用。',
|
|
45
|
+
'state.running': '运行中',
|
|
46
|
+
'state.stopped': '已停止',
|
|
47
|
+
'state.busy': '处理中…',
|
|
48
|
+
'state.connected': '已连接',
|
|
49
|
+
'state.listening': '监听中',
|
|
50
|
+
'state.unconfigured': '未配置',
|
|
51
|
+
'state.tunnel': '隧道:',
|
|
52
|
+
'phase.idle': '未启动',
|
|
53
|
+
'phase.connecting': '连接中',
|
|
54
|
+
'phase.up': '已连接',
|
|
55
|
+
'phase.stopped': '已停止',
|
|
56
|
+
'phase.reconnecting': '重连中',
|
|
57
|
+
'phase.restarting': '重启中',
|
|
58
|
+
'phase.error': '需要处理',
|
|
59
|
+
'card.public.tailscale.desc': '用 tailscale funnel 发布到你的 tailnet(服务器侧零配置),地址形如 https://<节点>.<tailnet>.ts.net/。',
|
|
60
|
+
'card.public.tailscale.warn': '⚠️ Funnel 没有内建身份门:唯一防线就是上面的「访问口令」,别把它发给不该给的人。',
|
|
61
|
+
'problems.prefix': '配置待补:',
|
|
62
|
+
'qr.hint': '扫码即可在手机上打开',
|
|
63
|
+
'key.fingerprint': '指纹',
|
|
64
|
+
'key.created': '生成于',
|
|
65
|
+
'key.rotations': '已轮换 {count} 次',
|
|
66
|
+
'key.longLived': '此链接长期有效(换来的浏览器会话 12 小时)',
|
|
67
|
+
'key.copy': '复制链接',
|
|
68
|
+
'key.copied': '已复制',
|
|
69
|
+
'key.current': '当前口令',
|
|
70
|
+
'key.new': '新口令(≥12 位)',
|
|
71
|
+
'key.confirm': '再输一次新口令',
|
|
72
|
+
'key.change': '修改口令',
|
|
73
|
+
'key.changeHint': '修改必须先验证当前口令;验证不过不会写入任何东西。',
|
|
74
|
+
'key.mismatchLocal': '两次输入的新口令不一致(或还有空项)',
|
|
75
|
+
'key.reset': '重置口令(忘记当前口令时)',
|
|
76
|
+
'key.resetConfirm': '这是「重置」不是「修改」:不需要当前口令,但旧链接、旧 Cookie、旧二维码会立即全部失效,并且会记一条本机审计。确定继续?',
|
|
77
|
+
'key.resetDone': '已重置。请在浏览器/密码管理器里删掉旧口令,用新口令重新登录。',
|
|
78
|
+
'key.label': '访问口令',
|
|
79
|
+
'key.reveal': '显示',
|
|
80
|
+
'key.hide': '收起',
|
|
81
|
+
'key.rotate': '生成新的',
|
|
82
|
+
'key.rotateConfirm': '生成新的访问口令?旧链接和已发出的登录状态会立刻失效,需要重新扫码。',
|
|
83
|
+
'key.hint': '这是你唯一要记的口令(等于链接里的 ?k=)。改它不用动服务器。',
|
|
84
|
+
'key.copyHint': '已显示:复制后存进密码管理器;收起后不再显示。',
|
|
85
|
+
'card.tenants': '租户(每人一个独立 Harness)',
|
|
86
|
+
'card.tenants.desc': '每个租户跑在自己独立的 Harness 实例里:独立 DSH_HOME、独立端口、独立令牌。会话、凭据、工作区互不可见。',
|
|
87
|
+
'card.tenants.off': '多租户未启用(插件配置里打开 tenants.enabled)。',
|
|
88
|
+
'tenants.count': '{count} 个租户',
|
|
89
|
+
'tenants.addPlaceholder': '给谁用?输入名字,例如:张三',
|
|
90
|
+
'tenants.add': '新增租户',
|
|
91
|
+
'tenants.remove': '删除',
|
|
92
|
+
'tenants.rotate': '换密钥',
|
|
93
|
+
'tenants.qr': '二维码',
|
|
94
|
+
'tenants.hideQr': '收起二维码',
|
|
95
|
+
'tenants.confirmRemove': '删除租户「{name}」?他的 Harness 实例会停止,但他的数据目录保留(不会替你删)。',
|
|
96
|
+
'tenants.empty': '还没有租户。新增一个,把链接发给他就能用。',
|
|
97
|
+
'tenants.entryHint': '这条链接就是他的入口(含访问密钥,等于密码,别贴群里):',
|
|
98
|
+
'tenants.notRunning': '实例没在跑:点「开启」',
|
|
99
|
+
'tenants.phase': '状态',
|
|
100
|
+
'tenants.port': '端口',
|
|
101
|
+
'tenants.restarts': '重启 {count} 次',
|
|
102
|
+
'tenants.encourageCredentials': '他第一次进来需要在自己的实例里配置模型凭据(各自付费、互不可见)。',
|
|
103
|
+
}
|
|
104
|
+
/** English,键集与 zh 一致。 */
|
|
105
|
+
const en = {
|
|
106
|
+
'entry.label': 'Remote access',
|
|
107
|
+
'panel.title': 'Remote access',
|
|
108
|
+
'panel.hint': 'Open this Harness from a phone or another computer in a browser — no client to install.',
|
|
109
|
+
'panel.localOnly': 'This is not the host window; state is read-only here.',
|
|
110
|
+
'action.start': 'Start',
|
|
111
|
+
'action.stop': 'Stop',
|
|
112
|
+
'action.check': 'Check server',
|
|
113
|
+
'action.snippets': 'Server config',
|
|
114
|
+
'card.lan': 'Local network',
|
|
115
|
+
'card.lan.desc': 'Phones and computers on the same Wi-Fi open it directly; narrow screens get a drawer layout.',
|
|
116
|
+
'card.public': 'Public access',
|
|
117
|
+
'card.public.desc': 'HTTPS entry through your own server (edge password + access key).',
|
|
118
|
+
'card.public.unconfigured': 'Set public.domain and public.ssh in the plugin config to enable.',
|
|
119
|
+
'state.running': 'Running',
|
|
120
|
+
'state.stopped': 'Stopped',
|
|
121
|
+
'state.busy': 'Working…',
|
|
122
|
+
'state.connected': 'Connected',
|
|
123
|
+
'state.listening': 'Listening',
|
|
124
|
+
'state.unconfigured': 'Not configured',
|
|
125
|
+
'state.tunnel': 'Tunnel: ',
|
|
126
|
+
'phase.idle': 'Not started',
|
|
127
|
+
'phase.connecting': 'Connecting',
|
|
128
|
+
'phase.up': 'Connected',
|
|
129
|
+
'phase.stopped': 'Stopped',
|
|
130
|
+
'phase.reconnecting': 'Reconnecting',
|
|
131
|
+
'phase.restarting': 'Restarting',
|
|
132
|
+
'phase.error': 'Needs attention',
|
|
133
|
+
'card.public.tailscale.desc': 'Publish through tailscale funnel on your tailnet (zero server-side setup); the address looks like https://<node>.<tailnet>.ts.net/.',
|
|
134
|
+
'card.public.tailscale.warn': '⚠️ Funnel has no built-in identity gate: the access password above is the only protection — do not hand it to anyone you would not trust.',
|
|
135
|
+
'problems.prefix': 'Config needs attention: ',
|
|
136
|
+
'qr.hint': 'Scan to open on your phone',
|
|
137
|
+
'key.fingerprint': 'Fingerprint',
|
|
138
|
+
'key.created': 'Created',
|
|
139
|
+
'key.rotations': 'Rotated {count} time(s)',
|
|
140
|
+
'key.longLived': 'This link does not expire (the browser session it issues lasts 12 hours)',
|
|
141
|
+
'key.copy': 'Copy link',
|
|
142
|
+
'key.copied': 'Copied',
|
|
143
|
+
'key.current': 'Current password',
|
|
144
|
+
'key.new': 'New password (12+ chars)',
|
|
145
|
+
'key.confirm': 'Repeat the new password',
|
|
146
|
+
'key.change': 'Change password',
|
|
147
|
+
'key.changeHint': 'Changing verifies the current password first; nothing is written if verification fails.',
|
|
148
|
+
'key.mismatchLocal': 'The two new passwords do not match (or a field is empty)',
|
|
149
|
+
'key.reset': 'Reset password (forgot the current one)',
|
|
150
|
+
'key.resetConfirm': 'This is a RESET, not a change: it needs no current password, but every old link, cookie and QR code stops working immediately, and a local audit line is written. Continue?',
|
|
151
|
+
'key.resetDone': 'Reset done. Delete the old password from your browser/password manager and sign in with the new one.',
|
|
152
|
+
'key.label': 'Access password',
|
|
153
|
+
'key.reveal': 'Show',
|
|
154
|
+
'key.hide': 'Hide',
|
|
155
|
+
'key.rotate': 'Generate new',
|
|
156
|
+
'key.rotateConfirm': 'Generate a new access password? Old links and issued sessions stop working immediately; you will need to scan again.',
|
|
157
|
+
'key.hint': 'This is the only password you have to remember (it is the ?k= in the link). Changing it does not touch the server.',
|
|
158
|
+
'key.copyHint': 'Shown once: copy it into your password manager. Hiding it will not show it again without another click.',
|
|
159
|
+
'card.tenants': 'Tenants (one Harness each)',
|
|
160
|
+
'card.tenants.desc': 'Every tenant runs in its own Harness instance: own DSH_HOME, own port, own launch token. Sessions, credentials and workspaces are not shared.',
|
|
161
|
+
'card.tenants.off': 'Multi-tenancy is off (set tenants.enabled in the plugin config).',
|
|
162
|
+
'tenants.count': '{count} tenants',
|
|
163
|
+
'tenants.addPlaceholder': 'Who is it for? Type a name, e.g. Alice',
|
|
164
|
+
'tenants.add': 'Add tenant',
|
|
165
|
+
'tenants.remove': 'Remove',
|
|
166
|
+
'tenants.rotate': 'New key',
|
|
167
|
+
'tenants.qr': 'QR code',
|
|
168
|
+
'tenants.hideQr': 'Hide QR code',
|
|
169
|
+
'tenants.confirmRemove': 'Remove tenant "{name}"? Their Harness stops; their data directory is left untouched.',
|
|
170
|
+
'tenants.empty': 'No tenants yet. Add one and send them the link.',
|
|
171
|
+
'tenants.entryHint': 'This link is their entry point (it carries the access key — treat it like a password):',
|
|
172
|
+
'tenants.notRunning': 'Instance is not running: press Start',
|
|
173
|
+
'tenants.phase': 'State',
|
|
174
|
+
'tenants.port': 'Port',
|
|
175
|
+
'tenants.restarts': '{count} restarts',
|
|
176
|
+
'tenants.encourageCredentials': 'They configure their own model credentials inside their instance on first use (separate billing, nothing shared).',
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** apply 时记下的 Cordis 上下文:只用于惰性读取 locale 服务。 */
|
|
180
|
+
let ctxRef = null
|
|
181
|
+
|
|
182
|
+
const API = '/remote-connect/api'
|
|
183
|
+
const PLUGIN_ID = 'dsh-plugin-remote-connect-beta'
|
|
184
|
+
const POLL_MS = 5000
|
|
185
|
+
|
|
186
|
+
const CSS = [
|
|
187
|
+
'.dshRcRow{display:flex;align-items:center;gap:8px;width:calc(100% + 4px);margin:4px -2px}',
|
|
188
|
+
'.dshRcRow.isRail{width:36px;margin:8px 0 10px}',
|
|
189
|
+
'.dshRcTrigger{box-sizing:border-box;cursor:pointer;min-width:0;height:42px;color:var(--dsw-alias-label-primary);background:0 0;border:none;border-radius:12px;flex:1;display:flex;align-items:center;gap:8px;margin:0;padding:0 10px 0 8px;font-family:inherit;font-size:14px;line-height:22px;overflow:hidden}',
|
|
190
|
+
'.dshRcTrigger:hover{background:var(--dsw-alias-interactive-bg-hover)}',
|
|
191
|
+
'.dshRcTrigger.isRail{corner-shape:round;border-radius:50%;flex:none;justify-content:center;gap:0;width:36px;height:36px;margin:0;padding:0}',
|
|
192
|
+
'.dshRcTriggerLabel{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
|
|
193
|
+
'.dshRcDot{width:7px;height:7px;border-radius:50%;background:var(--dsw-alias-state-success-primary);flex:none;margin-left:auto}',
|
|
194
|
+
'.dshRcLayer{position:absolute;inset:0;z-index:60;pointer-events:auto}',
|
|
195
|
+
'.dshRcBackdrop{position:absolute;inset:0;background:transparent}',
|
|
196
|
+
'.dshRcPanel{position:absolute;left:12px;bottom:104px;width:344px;max-width:calc(100vw - 24px);max-height:calc(100vh - 140px);overflow:auto;box-sizing:border-box;padding:14px;border-radius:14px;background:var(--dsw-alias-bg-overlay,#fff);border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.08));box-shadow:0 16px 40px rgba(0,0,0,.18);color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5}',
|
|
197
|
+
'.dshRcTitle{font-size:14px;font-weight:600;margin-bottom:2px}',
|
|
198
|
+
'.dshRcHint{color:var(--dsw-alias-label-secondary);margin-bottom:10px}',
|
|
199
|
+
'.dshRcCard{border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.08));border-radius:10px;padding:10px;margin-bottom:10px;background:var(--dsw-alias-bg-layer-1,transparent)}',
|
|
200
|
+
'.dshRcCard.isOn{border-color:var(--dsw-alias-brand-primary)}',
|
|
201
|
+
'.dshRcHead{display:flex;align-items:center;justify-content:space-between;gap:8px;font-weight:600}',
|
|
202
|
+
'.dshRcDesc{color:var(--dsw-alias-label-secondary);margin-top:4px}',
|
|
203
|
+
'.dshRcBadge{font-size:11px;padding:1px 6px;border-radius:999px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);flex:none;font-weight:500}',
|
|
204
|
+
'.dshRcBadge.isOn{background:var(--dsw-alias-state-success-primary);color:#fff}',
|
|
205
|
+
'.dshRcBadge.isWarn{background:var(--dsw-alias-state-warn-primary);color:#fff}',
|
|
206
|
+
'.dshRcUrl{width:100%;box-sizing:border-box;margin-top:8px;font-family:var(--ds-font-family-code,monospace);font-size:12px;padding:6px 8px;border-radius:8px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary)}',
|
|
207
|
+
'.dshRcQr{margin-top:10px;display:flex;justify-content:center;background:#fff;border-radius:10px;padding:8px}',
|
|
208
|
+
'.dshRcQrHint{margin-top:6px;text-align:center;color:var(--dsw-alias-label-secondary);font-size:12px}',
|
|
209
|
+
'.dshRcActions{margin-top:10px;display:flex;gap:8px;flex-wrap:wrap}',
|
|
210
|
+
'.dshRcButton{cursor:pointer;border:none;border-radius:8px;padding:6px 14px;font:inherit;font-weight:500;background:var(--dsw-alias-brand-primary,#2f6fed);color:#fff}',
|
|
211
|
+
'.dshRcButton.isGhost{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l1)}',
|
|
212
|
+
'.dshRcButton:disabled{opacity:.55;cursor:default}',
|
|
213
|
+
'.dshRcWarn{margin-top:8px;color:var(--dsw-alias-state-warn-primary)}',
|
|
214
|
+
'.dshRcError{margin-top:8px;color:var(--dsw-alias-state-error-primary);word-break:break-word}',
|
|
215
|
+
'.dshRcCheck{margin-top:8px;font-size:12px;display:flex;flex-direction:column;gap:6px}',
|
|
216
|
+
'.dshRcCheckRow{color:var(--dsw-alias-label-secondary)}',
|
|
217
|
+
'.dshRcCheckHead{display:flex;align-items:baseline;gap:6px}',
|
|
218
|
+
'.dshRcCheckHead b{font-weight:600;color:var(--dsw-alias-label-primary)}',
|
|
219
|
+
'.dshRcCheckMark{flex:none}',
|
|
220
|
+
'.dshRcCheckDetail{word-break:break-word}',
|
|
221
|
+
'.dshRcCheckHint{color:var(--dsw-alias-state-warn-primary,rgba(180,83,9,1))}',
|
|
222
|
+
'.dshRcTenant{border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.08));border-radius:10px;padding:8px;margin-top:8px}',
|
|
223
|
+
'.dshRcInput{cursor:text;flex:1 1 140px;width:auto;min-width:0}',
|
|
224
|
+
'.dshRcKeyValue{margin-left:6px;font-family:var(--ds-font-family-code,monospace)}',
|
|
225
|
+
'.dshRcSnippet{margin-top:8px;max-height:180px;overflow:auto;white-space:pre;font-family:var(--ds-font-family-code,monospace);font-size:11px;background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:8px}',
|
|
226
|
+
].join('\n')
|
|
227
|
+
|
|
228
|
+
if (
|
|
229
|
+
typeof document !== 'undefined' &&
|
|
230
|
+
document.querySelector('style[data-plugin-css=' + JSON.stringify(PLUGIN_ID + '/client.css') + ']') === null
|
|
231
|
+
) {
|
|
232
|
+
const tag = document.createElement('style')
|
|
233
|
+
tag.dataset.plugin = PLUGIN_ID
|
|
234
|
+
tag.dataset.pluginCss = PLUGIN_ID + '/client.css'
|
|
235
|
+
tag.textContent = CSS
|
|
236
|
+
document.head.appendChild(tag)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---- 极简 store:组件订阅,异步动作直接 setState ----
|
|
240
|
+
const store = {
|
|
241
|
+
open: false,
|
|
242
|
+
busy: false,
|
|
243
|
+
error: null,
|
|
244
|
+
data: null,
|
|
245
|
+
checkResults: null,
|
|
246
|
+
snippet: null,
|
|
247
|
+
revealedKey: null,
|
|
248
|
+
keyBusy: false,
|
|
249
|
+
keyCurrent: '',
|
|
250
|
+
keyNext: '',
|
|
251
|
+
keyConfirm: '',
|
|
252
|
+
keyCopied: false,
|
|
253
|
+
tenantQr: null,
|
|
254
|
+
tenantQrId: null,
|
|
255
|
+
tenantDraft: '',
|
|
256
|
+
}
|
|
257
|
+
const listeners = new Set()
|
|
258
|
+
|
|
259
|
+
function setState(patch) {
|
|
260
|
+
Object.assign(store, patch)
|
|
261
|
+
for (const listener of [...listeners]) listener()
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function useStore() {
|
|
265
|
+
const pair = React.useState(0)
|
|
266
|
+
const force = pair[1]
|
|
267
|
+
React.useEffect(() => {
|
|
268
|
+
const listener = () => force((value) => value + 1)
|
|
269
|
+
listeners.add(listener)
|
|
270
|
+
return () => listeners.delete(listener)
|
|
271
|
+
}, [])
|
|
272
|
+
return store
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* 面板当前语言:优先用 harness 的 locale 服务,取不到时退回浏览器语言。
|
|
277
|
+
* 宿主半用它渲染检查结果与隧道状态,所以每次请求都带上。
|
|
278
|
+
*/
|
|
279
|
+
function currentLocale() {
|
|
280
|
+
const locale = ctxRef === null ? undefined : ctxRef.get('locale')
|
|
281
|
+
const active = locale === undefined ? undefined : locale.getLocale?.().active
|
|
282
|
+
if (typeof active === 'string' && active !== '') return active
|
|
283
|
+
if (typeof navigator !== 'undefined' && typeof navigator.language === 'string') return navigator.language
|
|
284
|
+
return undefined
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* 组件外的文案:面板动作(changeKey/resetKey)不在 React 里,拿不到 props.t,
|
|
289
|
+
* 所以按当前语言直接从本插件的两份字典里取。
|
|
290
|
+
*/
|
|
291
|
+
function tt(key) {
|
|
292
|
+
const lang = String(currentLocale() ?? 'en').toLowerCase().startsWith('zh') ? 'zh' : 'en'
|
|
293
|
+
const table = lang === 'zh' ? zh : en
|
|
294
|
+
return table[key] ?? en[key] ?? key
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function api(path, options) {
|
|
298
|
+
try {
|
|
299
|
+
const locale = currentLocale()
|
|
300
|
+
const url = API + path + (locale === undefined ? '' : '?locale=' + encodeURIComponent(locale))
|
|
301
|
+
const response = await fetch(url, {
|
|
302
|
+
headers: { 'content-type': 'application/json' },
|
|
303
|
+
...options,
|
|
304
|
+
})
|
|
305
|
+
const text = await response.text()
|
|
306
|
+
if (text === '') return { ok: false, error: 'HTTP ' + String(response.status) }
|
|
307
|
+
try {
|
|
308
|
+
return JSON.parse(text)
|
|
309
|
+
} catch (error) {
|
|
310
|
+
return { ok: false, error: text.slice(0, 300) }
|
|
311
|
+
}
|
|
312
|
+
} catch (error) {
|
|
313
|
+
return { ok: false, error: String((error && error.message) || error) }
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function refresh() {
|
|
318
|
+
const payload = await api('/state')
|
|
319
|
+
setState({
|
|
320
|
+
data: payload.ok === false ? store.data : payload,
|
|
321
|
+
error: payload.ok === false ? payload.error : null,
|
|
322
|
+
})
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function act(path) {
|
|
326
|
+
setState({ busy: true, error: null, snippet: null })
|
|
327
|
+
const payload = await api(path, { method: 'POST' })
|
|
328
|
+
setState({
|
|
329
|
+
busy: false,
|
|
330
|
+
data: payload.ok === false ? store.data : payload,
|
|
331
|
+
error: payload.ok === false ? payload.error : null,
|
|
332
|
+
})
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function runCheck() {
|
|
336
|
+
setState({ busy: true, error: null })
|
|
337
|
+
const payload = await api('/check', { method: 'POST' })
|
|
338
|
+
setState({
|
|
339
|
+
busy: false,
|
|
340
|
+
checkResults: Array.isArray(payload.results) ? payload.results : null,
|
|
341
|
+
error: payload.ok === false ? payload.error : null,
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** 显示访问口令明文(只在宿主窗口可用;收起后要再点一次才会再显示)。 */
|
|
346
|
+
async function revealKey() {
|
|
347
|
+
if (store.revealedKey !== null) {
|
|
348
|
+
setState({ revealedKey: null })
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
setState({ keyBusy: true })
|
|
352
|
+
const payload = await api('/access-key/reveal', { method: 'POST' })
|
|
353
|
+
setState({
|
|
354
|
+
keyBusy: false,
|
|
355
|
+
revealedKey: typeof payload.accessKey === 'string' ? payload.accessKey : null,
|
|
356
|
+
error: payload.ok === false ? payload.error : null,
|
|
357
|
+
})
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** 复制当前入口链接(含新口令),并短暂提示"已复制"。 */
|
|
361
|
+
async function copyEntryLink() {
|
|
362
|
+
const entry = store.data?.public?.entry ?? null
|
|
363
|
+
if (typeof entry !== 'string' || entry === '') return
|
|
364
|
+
try {
|
|
365
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard !== undefined) {
|
|
366
|
+
await navigator.clipboard.writeText(entry)
|
|
367
|
+
}
|
|
368
|
+
} catch {
|
|
369
|
+
/* 复制失败就让用户手动选中输入框 */
|
|
370
|
+
}
|
|
371
|
+
setState({ keyCopied: true })
|
|
372
|
+
setTimeout(() => setState({ keyCopied: false }), 1500)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* 修改访问口令:先把「当前口令」送宿主半校验,通过才写新口令。
|
|
377
|
+
* 校验与写入是两次调用(规范 §4),避免"旧口令错了但新口令已经写进去"。
|
|
378
|
+
*/
|
|
379
|
+
async function changeKey() {
|
|
380
|
+
const current = String(store.keyCurrent ?? '')
|
|
381
|
+
const next = String(store.keyNext ?? '')
|
|
382
|
+
const confirm = String(store.keyConfirm ?? '')
|
|
383
|
+
if (current === '' || next === '' || next !== confirm) {
|
|
384
|
+
setState({ error: tt('key.mismatchLocal') })
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
setState({ keyBusy: true, error: null })
|
|
388
|
+
const verdict = await api('/access-key/verify', {
|
|
389
|
+
method: 'POST',
|
|
390
|
+
headers: { 'content-type': 'application/json' },
|
|
391
|
+
body: JSON.stringify({ password: current }),
|
|
392
|
+
})
|
|
393
|
+
if (verdict.ok === false) {
|
|
394
|
+
setState({ keyBusy: false, error: verdict.error })
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
const payload = await api('/access-key/rotate', {
|
|
398
|
+
method: 'POST',
|
|
399
|
+
headers: { 'content-type': 'application/json' },
|
|
400
|
+
body: JSON.stringify({ current, password: next, confirm }),
|
|
401
|
+
})
|
|
402
|
+
await refresh()
|
|
403
|
+
setState({
|
|
404
|
+
keyBusy: false,
|
|
405
|
+
keyCurrent: payload.ok === false ? store.keyCurrent : '',
|
|
406
|
+
keyNext: payload.ok === false ? store.keyNext : '',
|
|
407
|
+
keyConfirm: payload.ok === false ? store.keyConfirm : '',
|
|
408
|
+
revealedKey: typeof payload.accessKey === 'string' ? payload.accessKey : null,
|
|
409
|
+
error: payload.ok === false ? payload.error : null,
|
|
410
|
+
})
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** 重置(忘记口令):不需要旧口令,但必须显式确认 —— UI 上写明这是降级路径。 */
|
|
414
|
+
async function resetKey() {
|
|
415
|
+
if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
|
|
416
|
+
if (window.confirm(tt('key.resetConfirm')) !== true) return
|
|
417
|
+
}
|
|
418
|
+
setState({ keyBusy: true, error: null })
|
|
419
|
+
const payload = await api('/access-key/rotate', {
|
|
420
|
+
method: 'POST',
|
|
421
|
+
headers: { 'content-type': 'application/json' },
|
|
422
|
+
body: JSON.stringify({ reset: true, acknowledge: true }),
|
|
423
|
+
})
|
|
424
|
+
await refresh()
|
|
425
|
+
setState({
|
|
426
|
+
keyBusy: false,
|
|
427
|
+
revealedKey: typeof payload.accessKey === 'string' ? payload.accessKey : null,
|
|
428
|
+
error: payload.ok === false ? payload.error : tt('key.resetDone'),
|
|
429
|
+
})
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** 租户管理:所有改动都由宿主半校验(只有宿主窗口能调)。 */
|
|
433
|
+
async function tenantAction(action, body) {
|
|
434
|
+
setState({ busy: true, error: null })
|
|
435
|
+
const payload = await api('/tenants/' + action, {
|
|
436
|
+
method: 'POST',
|
|
437
|
+
headers: { 'content-type': 'application/json' },
|
|
438
|
+
body: JSON.stringify(body ?? {}),
|
|
439
|
+
})
|
|
440
|
+
setState({
|
|
441
|
+
busy: false,
|
|
442
|
+
data: payload.ok === false || payload.tenants === undefined ? store.data : { ...store.data, tenants: { ...store.data.tenants, list: payload.tenants } },
|
|
443
|
+
error: payload.ok === false ? payload.error : null,
|
|
444
|
+
})
|
|
445
|
+
return payload
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function tenantAdd() {
|
|
449
|
+
const name = String(store.tenantDraft ?? '').trim()
|
|
450
|
+
if (name === '') return
|
|
451
|
+
const payload = await tenantAction('add', { name })
|
|
452
|
+
if (payload.ok !== false) setState({ tenantDraft: '' })
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function tenantQr(id) {
|
|
456
|
+
if (store.tenantQrId === id) {
|
|
457
|
+
setState({ tenantQr: null, tenantQrId: null })
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
const payload = await api('/tenants/qr?id=' + encodeURIComponent(id))
|
|
461
|
+
setState({
|
|
462
|
+
tenantQr: Array.isArray(payload.rows) ? payload.rows : null,
|
|
463
|
+
tenantQrId: payload.ok === false ? null : id,
|
|
464
|
+
error: payload.ok === false ? payload.error : null,
|
|
465
|
+
})
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function loadSnippet() {
|
|
469
|
+
const payload = await api('/snippets?kind=nginx')
|
|
470
|
+
setState({
|
|
471
|
+
snippet: payload.ok === false ? null : payload,
|
|
472
|
+
error: payload.ok === false ? payload.error : null,
|
|
473
|
+
})
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function GlobeIcon(props) {
|
|
477
|
+
const size = props.size === undefined ? 16 : props.size
|
|
478
|
+
const Icon = primitives === null ? undefined : primitives.IconGlobeOutline14
|
|
479
|
+
if (typeof Icon === 'function') return React.createElement(Icon, { size })
|
|
480
|
+
return React.createElement(
|
|
481
|
+
'svg',
|
|
482
|
+
{ width: size, height: size, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': 'true' },
|
|
483
|
+
React.createElement('circle', { cx: 8, cy: 8, r: 6.1, stroke: 'currentColor', strokeWidth: 1.3 }),
|
|
484
|
+
React.createElement('ellipse', { cx: 8, cy: 8, rx: 2.6, ry: 6.1, stroke: 'currentColor', strokeWidth: 1.3 }),
|
|
485
|
+
React.createElement('path', {
|
|
486
|
+
d: 'M2.3 6.1h11.4M2.3 9.9h11.4',
|
|
487
|
+
stroke: 'currentColor',
|
|
488
|
+
strokeWidth: 1.3,
|
|
489
|
+
strokeLinecap: 'round',
|
|
490
|
+
}),
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function QrCode(props) {
|
|
495
|
+
const t = props.t === undefined ? (key) => key : props.t
|
|
496
|
+
const rows = props.rows
|
|
497
|
+
const size = rows.length
|
|
498
|
+
let path = ''
|
|
499
|
+
for (let y = 0; y < size; y += 1) {
|
|
500
|
+
const row = rows[y]
|
|
501
|
+
for (let x = 0; x < row.length; x += 1) {
|
|
502
|
+
if (row.charAt(x) === '1') path += 'M' + String(x) + ' ' + String(y) + 'h1v1h-1z'
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return React.createElement(
|
|
506
|
+
'div',
|
|
507
|
+
{ className: 'dshRcQr' },
|
|
508
|
+
React.createElement(
|
|
509
|
+
'svg',
|
|
510
|
+
{
|
|
511
|
+
width: 132,
|
|
512
|
+
height: 132,
|
|
513
|
+
viewBox: '0 0 ' + String(size) + ' ' + String(size),
|
|
514
|
+
shapeRendering: 'crispEdges',
|
|
515
|
+
role: 'img',
|
|
516
|
+
'aria-label': t('qr.hint'),
|
|
517
|
+
},
|
|
518
|
+
React.createElement('rect', { x: 0, y: 0, width: size, height: size, fill: '#ffffff' }),
|
|
519
|
+
React.createElement('path', { d: path, fill: '#000000' }),
|
|
520
|
+
),
|
|
521
|
+
)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function Entry(props) {
|
|
525
|
+
const t = props.t === undefined ? (key) => key : props.t
|
|
526
|
+
const state = useStore()
|
|
527
|
+
const wide = props.wide !== false
|
|
528
|
+
const data = state.data
|
|
529
|
+
const active =
|
|
530
|
+
data !== null && data !== undefined && (data.lan.running === true || data.public.running === true)
|
|
531
|
+
React.useEffect(() => {
|
|
532
|
+
void refresh()
|
|
533
|
+
const timer = setInterval(() => {
|
|
534
|
+
void refresh()
|
|
535
|
+
}, 15000)
|
|
536
|
+
return () => clearInterval(timer)
|
|
537
|
+
}, [])
|
|
538
|
+
return React.createElement(
|
|
539
|
+
'div',
|
|
540
|
+
{ className: 'dshRcRow' + (wide ? '' : ' isRail') },
|
|
541
|
+
React.createElement(
|
|
542
|
+
'button',
|
|
543
|
+
{
|
|
544
|
+
type: 'button',
|
|
545
|
+
className: 'dshRcTrigger' + (wide ? '' : ' isRail'),
|
|
546
|
+
'aria-label': t('entry.label'),
|
|
547
|
+
title: t('entry.label'),
|
|
548
|
+
onClick: () => {
|
|
549
|
+
const next = !state.open
|
|
550
|
+
setState({ open: next })
|
|
551
|
+
if (next) void refresh()
|
|
552
|
+
},
|
|
553
|
+
},
|
|
554
|
+
React.createElement(GlobeIcon, { size: wide ? 16 : 18 }),
|
|
555
|
+
wide ? React.createElement('span', { className: 'dshRcTriggerLabel' }, t('entry.label')) : null,
|
|
556
|
+
wide && active ? React.createElement('span', { className: 'dshRcDot' }) : null,
|
|
557
|
+
),
|
|
558
|
+
)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function Card(props) {
|
|
562
|
+
return React.createElement(
|
|
563
|
+
'div',
|
|
564
|
+
{ className: 'dshRcCard' + (props.on ? ' isOn' : '') },
|
|
565
|
+
React.createElement(
|
|
566
|
+
'div',
|
|
567
|
+
{ className: 'dshRcHead' },
|
|
568
|
+
React.createElement('span', null, props.title),
|
|
569
|
+
React.createElement(
|
|
570
|
+
'span',
|
|
571
|
+
{ className: 'dshRcBadge' + (props.on ? ' isOn' : props.warn ? ' isWarn' : '') },
|
|
572
|
+
props.badge,
|
|
573
|
+
),
|
|
574
|
+
),
|
|
575
|
+
props.children,
|
|
576
|
+
)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function Panel(props) {
|
|
580
|
+
const t = props.t === undefined ? (key) => key : props.t
|
|
581
|
+
const state = useStore()
|
|
582
|
+
React.useEffect(() => {
|
|
583
|
+
if (!state.open) return undefined
|
|
584
|
+
void refresh()
|
|
585
|
+
const timer = setInterval(() => {
|
|
586
|
+
void refresh()
|
|
587
|
+
}, POLL_MS)
|
|
588
|
+
return () => clearInterval(timer)
|
|
589
|
+
}, [state.open])
|
|
590
|
+
if (!state.open) return null
|
|
591
|
+
|
|
592
|
+
const data = state.data
|
|
593
|
+
const lan = data === null || data === undefined ? null : data.lan
|
|
594
|
+
const pub = data === null || data === undefined ? null : data.public
|
|
595
|
+
const canControl = data === null || data === undefined || data.canControl !== false
|
|
596
|
+
const qr = data === null || data === undefined ? null : data.qr
|
|
597
|
+
const problems =
|
|
598
|
+
data === null || data === undefined || data.config === undefined ? [] : data.config.problems || []
|
|
599
|
+
const lanOn = lan !== null && lan.running === true
|
|
600
|
+
const pubOn = pub !== null && pub.running === true
|
|
601
|
+
const tunnel = pub === null || pub === undefined ? null : pub.tunnel
|
|
602
|
+
const tunnelMode =
|
|
603
|
+
data === null || data === undefined || data.config === undefined || typeof data.config.tunnel !== 'string'
|
|
604
|
+
? 'ssh'
|
|
605
|
+
: data.config.tunnel
|
|
606
|
+
// 只有自建服务器(ssh)这条路需要自己填域名;tailscale 用节点自带的 ts.net 域名
|
|
607
|
+
const pubConfigured = pub !== null && pub !== undefined && (pub.domain !== '' || tunnelMode === 'tailscale')
|
|
608
|
+
// 访问口令的掩码:state 还没回来(pub 为 null)时也要能渲染,不能崩
|
|
609
|
+
const keyMasked = pub === null || pub === undefined ? '—' : String(pub.accessKeyMasked ?? '—')
|
|
610
|
+
const keyFingerprint = pub === null || pub === undefined ? '—' : String(pub.accessKeyFingerprint ?? '—')
|
|
611
|
+
const keyCreated = pub === null || pub === undefined ? '—' : String(pub.accessKeyCreatedAt ?? '—').slice(0, 19).replace('T', ' ')
|
|
612
|
+
const keyRotations = pub === null || pub === undefined ? 0 : Number(pub.accessKeyRotations ?? 0)
|
|
613
|
+
const canManageKey = pub !== null && pub !== undefined
|
|
614
|
+
const phaseLabel = (phase) => {
|
|
615
|
+
const key = 'phase.' + String(phase)
|
|
616
|
+
return t(key) === key ? String(phase) : t(key)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return React.createElement(
|
|
620
|
+
'div',
|
|
621
|
+
{ className: 'dshRcLayer' },
|
|
622
|
+
React.createElement('div', { className: 'dshRcBackdrop', onClick: () => setState({ open: false }) }),
|
|
623
|
+
React.createElement(
|
|
624
|
+
'div',
|
|
625
|
+
{ className: 'dshRcPanel', role: 'dialog', 'aria-label': t('panel.title') },
|
|
626
|
+
React.createElement('div', { className: 'dshRcTitle' }, t('panel.title')),
|
|
627
|
+
React.createElement(
|
|
628
|
+
'div',
|
|
629
|
+
{ className: 'dshRcHint' },
|
|
630
|
+
t('panel.hint'),
|
|
631
|
+
),
|
|
632
|
+
problems.length > 0
|
|
633
|
+
? React.createElement('div', { className: 'dshRcWarn' }, t('problems.prefix') + problems.join(';'))
|
|
634
|
+
: null,
|
|
635
|
+
!canControl
|
|
636
|
+
? React.createElement('div', { className: 'dshRcHint' }, t('panel.localOnly'))
|
|
637
|
+
: null,
|
|
638
|
+
|
|
639
|
+
React.createElement(
|
|
640
|
+
Card,
|
|
641
|
+
{ title: t('card.lan'), on: lanOn, badge: lanOn ? t('state.running') : t('state.stopped') },
|
|
642
|
+
React.createElement(
|
|
643
|
+
'div',
|
|
644
|
+
{ className: 'dshRcDesc' },
|
|
645
|
+
t('card.lan.desc'),
|
|
646
|
+
),
|
|
647
|
+
lan !== null && lan.url !== null && lan.url !== undefined
|
|
648
|
+
? React.createElement('input', {
|
|
649
|
+
className: 'dshRcUrl',
|
|
650
|
+
readOnly: true,
|
|
651
|
+
value: lan.url,
|
|
652
|
+
onFocus: (event) => event.target.select(),
|
|
653
|
+
onClick: (event) => event.target.select(),
|
|
654
|
+
})
|
|
655
|
+
: null,
|
|
656
|
+
React.createElement(
|
|
657
|
+
'div',
|
|
658
|
+
{ className: 'dshRcActions' },
|
|
659
|
+
React.createElement(
|
|
660
|
+
'button',
|
|
661
|
+
{
|
|
662
|
+
type: 'button',
|
|
663
|
+
className: 'dshRcButton' + (lanOn ? ' isGhost' : ''),
|
|
664
|
+
disabled: state.busy === true || !canControl,
|
|
665
|
+
onClick: () => void act(lanOn ? '/lan/stop' : '/lan/start'),
|
|
666
|
+
},
|
|
667
|
+
lanOn ? t('action.stop') : t('action.start'),
|
|
668
|
+
),
|
|
669
|
+
),
|
|
670
|
+
),
|
|
671
|
+
|
|
672
|
+
React.createElement(
|
|
673
|
+
Card,
|
|
674
|
+
{
|
|
675
|
+
title: t('card.public'),
|
|
676
|
+
on: pubOn,
|
|
677
|
+
warn: pub !== null && pub !== undefined && !pubConfigured,
|
|
678
|
+
badge:
|
|
679
|
+
pub === null || pub === undefined || !pubConfigured
|
|
680
|
+
? t('state.unconfigured')
|
|
681
|
+
: pubOn
|
|
682
|
+
? tunnel !== null && tunnel.phase === 'up'
|
|
683
|
+
? t('state.connected')
|
|
684
|
+
: tunnel === null
|
|
685
|
+
? t('state.listening')
|
|
686
|
+
: phaseLabel(tunnel.phase)
|
|
687
|
+
: t('state.stopped'),
|
|
688
|
+
},
|
|
689
|
+
React.createElement(
|
|
690
|
+
'div',
|
|
691
|
+
{ className: 'dshRcDesc' },
|
|
692
|
+
pubConfigured
|
|
693
|
+
? tunnelMode === 'tailscale'
|
|
694
|
+
? t('card.public.tailscale.desc')
|
|
695
|
+
: t('card.public.desc')
|
|
696
|
+
: t('card.public.unconfigured'),
|
|
697
|
+
),
|
|
698
|
+
// data 为 null(首次请求还没回来或失败)时不能取 config —— 否则面板直接崩
|
|
699
|
+
data !== null &&
|
|
700
|
+
data !== undefined &&
|
|
701
|
+
data.config !== undefined &&
|
|
702
|
+
data.config !== null &&
|
|
703
|
+
data.config.sshUser &&
|
|
704
|
+
tunnelMode === 'ssh'
|
|
705
|
+
? React.createElement(
|
|
706
|
+
'div',
|
|
707
|
+
{ className: 'dshRcDesc' },
|
|
708
|
+
t('state.tunnel') +
|
|
709
|
+
data.config.sshUser +
|
|
710
|
+
'@' +
|
|
711
|
+
(data.config.sshHost || pub.domain) +
|
|
712
|
+
(data.config.sshPort ? ':' + String(data.config.sshPort) : '') +
|
|
713
|
+
' → 127.0.0.1:' +
|
|
714
|
+
String(data.config.remotePort ?? 8788),
|
|
715
|
+
)
|
|
716
|
+
: null,
|
|
717
|
+
pub !== null && pub !== undefined && pub.entry !== null && pub.entry !== undefined
|
|
718
|
+
? React.createElement('input', {
|
|
719
|
+
className: 'dshRcUrl',
|
|
720
|
+
readOnly: true,
|
|
721
|
+
value: pub.entry,
|
|
722
|
+
onFocus: (event) => event.target.select(),
|
|
723
|
+
onClick: (event) => event.target.select(),
|
|
724
|
+
})
|
|
725
|
+
: null,
|
|
726
|
+
// 访问口令:面板里唯一需要用户记住的凭据
|
|
727
|
+
React.createElement(
|
|
728
|
+
'div',
|
|
729
|
+
{ className: 'dshRcDesc' },
|
|
730
|
+
React.createElement('span', null, t('key.label')),
|
|
731
|
+
React.createElement(
|
|
732
|
+
'span',
|
|
733
|
+
{ className: 'dshRcKeyValue' },
|
|
734
|
+
state.revealedKey !== null ? state.revealedKey : keyMasked,
|
|
735
|
+
),
|
|
736
|
+
),
|
|
737
|
+
React.createElement(
|
|
738
|
+
'div',
|
|
739
|
+
{ className: 'dshRcActions' },
|
|
740
|
+
React.createElement(
|
|
741
|
+
'button',
|
|
742
|
+
{
|
|
743
|
+
type: 'button',
|
|
744
|
+
className: 'dshRcButton isGhost',
|
|
745
|
+
disabled: state.busy === true || state.keyBusy === true || !canControl || !canManageKey,
|
|
746
|
+
onClick: () => void revealKey(),
|
|
747
|
+
},
|
|
748
|
+
state.revealedKey !== null ? t('key.hide') : t('key.reveal'),
|
|
749
|
+
),
|
|
750
|
+
React.createElement(
|
|
751
|
+
'button',
|
|
752
|
+
{
|
|
753
|
+
type: 'button',
|
|
754
|
+
className: 'dshRcButton isGhost',
|
|
755
|
+
disabled: state.busy === true || state.keyBusy === true || !canControl || !canManageKey,
|
|
756
|
+
onClick: () => void rotateKey(),
|
|
757
|
+
},
|
|
758
|
+
t('key.rotate'),
|
|
759
|
+
),
|
|
760
|
+
),
|
|
761
|
+
React.createElement(
|
|
762
|
+
'div',
|
|
763
|
+
{ className: 'dshRcDesc' },
|
|
764
|
+
t('key.fingerprint') + ' ' + keyFingerprint + ' · ' + t('key.created') + ' ' + keyCreated +
|
|
765
|
+
' · ' + t('key.rotations', { count: keyRotations }) + ' · ' + t('key.longLived'),
|
|
766
|
+
),
|
|
767
|
+
React.createElement(
|
|
768
|
+
'div',
|
|
769
|
+
{ className: 'dshRcActions' },
|
|
770
|
+
React.createElement(
|
|
771
|
+
'button',
|
|
772
|
+
{
|
|
773
|
+
type: 'button',
|
|
774
|
+
className: 'dshRcButton isGhost',
|
|
775
|
+
disabled: !canManageKey || pub === null || pub === undefined || pub.entry === null || pub.entry === undefined,
|
|
776
|
+
onClick: () => void copyEntryLink(),
|
|
777
|
+
},
|
|
778
|
+
state.keyCopied === true ? t('key.copied') : t('key.copy'),
|
|
779
|
+
),
|
|
780
|
+
),
|
|
781
|
+
React.createElement('input', {
|
|
782
|
+
className: 'dshRcUrl dshRcInput',
|
|
783
|
+
type: 'password',
|
|
784
|
+
placeholder: t('key.current'),
|
|
785
|
+
value: state.keyCurrent ?? '',
|
|
786
|
+
onChange: (event) => setState({ keyCurrent: event.target.value }),
|
|
787
|
+
}),
|
|
788
|
+
React.createElement('input', {
|
|
789
|
+
className: 'dshRcUrl dshRcInput',
|
|
790
|
+
type: 'password',
|
|
791
|
+
placeholder: t('key.new'),
|
|
792
|
+
value: state.keyNext ?? '',
|
|
793
|
+
onChange: (event) => setState({ keyNext: event.target.value }),
|
|
794
|
+
}),
|
|
795
|
+
React.createElement('input', {
|
|
796
|
+
className: 'dshRcUrl dshRcInput',
|
|
797
|
+
type: 'password',
|
|
798
|
+
placeholder: t('key.confirm'),
|
|
799
|
+
value: state.keyConfirm ?? '',
|
|
800
|
+
onChange: (event) => setState({ keyConfirm: event.target.value }),
|
|
801
|
+
}),
|
|
802
|
+
React.createElement(
|
|
803
|
+
'div',
|
|
804
|
+
{ className: 'dshRcActions' },
|
|
805
|
+
React.createElement(
|
|
806
|
+
'button',
|
|
807
|
+
{
|
|
808
|
+
type: 'button',
|
|
809
|
+
className: 'dshRcButton',
|
|
810
|
+
disabled:
|
|
811
|
+
state.busy === true ||
|
|
812
|
+
state.keyBusy === true ||
|
|
813
|
+
!canControl ||
|
|
814
|
+
String(state.keyCurrent ?? '') === '' ||
|
|
815
|
+
String(state.keyNext ?? '') === '',
|
|
816
|
+
onClick: () => void changeKey(),
|
|
817
|
+
},
|
|
818
|
+
t('key.change'),
|
|
819
|
+
),
|
|
820
|
+
React.createElement(
|
|
821
|
+
'button',
|
|
822
|
+
{
|
|
823
|
+
type: 'button',
|
|
824
|
+
className: 'dshRcButton isGhost',
|
|
825
|
+
disabled: state.busy === true || state.keyBusy === true || !canControl,
|
|
826
|
+
onClick: () => void resetKey(),
|
|
827
|
+
},
|
|
828
|
+
t('key.reset'),
|
|
829
|
+
),
|
|
830
|
+
),
|
|
831
|
+
state.revealedKey !== null
|
|
832
|
+
? React.createElement('div', { className: 'dshRcWarn' }, t('key.copyHint'))
|
|
833
|
+
: React.createElement('div', { className: 'dshRcDesc' }, t('key.changeHint')),
|
|
834
|
+
tunnel !== null && tunnel.detail
|
|
835
|
+
? React.createElement(
|
|
836
|
+
'div',
|
|
837
|
+
{ className: 'dshRcDesc' },
|
|
838
|
+
t('state.tunnel') + phaseLabel(tunnel.phase) + ' (' + tunnel.detail + ')',
|
|
839
|
+
)
|
|
840
|
+
: null,
|
|
841
|
+
tunnel !== null && tunnel.hint !== undefined && tunnel.hint !== null
|
|
842
|
+
? React.createElement('div', { className: 'dshRcWarn' }, tunnel.hint)
|
|
843
|
+
: null,
|
|
844
|
+
pub !== null && pub !== undefined && pub.tunnelUrl
|
|
845
|
+
? React.createElement('input', {
|
|
846
|
+
className: 'dshRcUrl',
|
|
847
|
+
readOnly: true,
|
|
848
|
+
value: pub.tunnelUrl,
|
|
849
|
+
onFocus: (event) => event.target.select(),
|
|
850
|
+
onClick: (event) => event.target.select(),
|
|
851
|
+
})
|
|
852
|
+
: null,
|
|
853
|
+
React.createElement(
|
|
854
|
+
'div',
|
|
855
|
+
{ className: 'dshRcActions' },
|
|
856
|
+
React.createElement(
|
|
857
|
+
'button',
|
|
858
|
+
{
|
|
859
|
+
type: 'button',
|
|
860
|
+
className: 'dshRcButton' + (pubOn ? ' isGhost' : ''),
|
|
861
|
+
disabled: state.busy === true || !canControl || !pubConfigured,
|
|
862
|
+
onClick: () => void act(pubOn ? '/public/stop' : '/public/start'),
|
|
863
|
+
},
|
|
864
|
+
pubOn ? t('action.stop') : t('action.start'),
|
|
865
|
+
),
|
|
866
|
+
React.createElement(
|
|
867
|
+
'button',
|
|
868
|
+
{
|
|
869
|
+
type: 'button',
|
|
870
|
+
className: 'dshRcButton isGhost',
|
|
871
|
+
disabled: state.busy === true,
|
|
872
|
+
onClick: () => void runCheck(),
|
|
873
|
+
},
|
|
874
|
+
t('action.check'),
|
|
875
|
+
),
|
|
876
|
+
tunnelMode === 'ssh'
|
|
877
|
+
? React.createElement(
|
|
878
|
+
'button',
|
|
879
|
+
{
|
|
880
|
+
type: 'button',
|
|
881
|
+
className: 'dshRcButton isGhost',
|
|
882
|
+
disabled: state.busy === true,
|
|
883
|
+
onClick: () => void loadSnippet(),
|
|
884
|
+
},
|
|
885
|
+
t('action.snippets'),
|
|
886
|
+
)
|
|
887
|
+
: null,
|
|
888
|
+
),
|
|
889
|
+
Array.isArray(state.checkResults)
|
|
890
|
+
? React.createElement(
|
|
891
|
+
'div',
|
|
892
|
+
{ className: 'dshRcCheck' },
|
|
893
|
+
// 检查结果由宿主按当前语言渲染;这里只排版,不加任何语言相关的标点
|
|
894
|
+
state.checkResults.map((item, index) =>
|
|
895
|
+
React.createElement(
|
|
896
|
+
'div',
|
|
897
|
+
{ key: String(index), className: 'dshRcCheckRow' },
|
|
898
|
+
React.createElement(
|
|
899
|
+
'div',
|
|
900
|
+
{ className: 'dshRcCheckHead' },
|
|
901
|
+
React.createElement('span', { className: 'dshRcCheckMark' }, item.ok ? '✔' : '✖'),
|
|
902
|
+
React.createElement('b', null, item.name),
|
|
903
|
+
),
|
|
904
|
+
React.createElement('div', { className: 'dshRcCheckDetail' }, item.detail),
|
|
905
|
+
item.ok === false && item.hint
|
|
906
|
+
? React.createElement('div', { className: 'dshRcCheckHint' }, item.hint)
|
|
907
|
+
: null,
|
|
908
|
+
),
|
|
909
|
+
),
|
|
910
|
+
)
|
|
911
|
+
: null,
|
|
912
|
+
typeof state.snippet === 'string'
|
|
913
|
+
? React.createElement('div', { className: 'dshRcSnippet' }, state.snippet)
|
|
914
|
+
: null,
|
|
915
|
+
),
|
|
916
|
+
|
|
917
|
+
// ── 多租户:每人一个独立 Harness 实例 ──
|
|
918
|
+
data !== null && data !== undefined && data.tenants !== undefined && data.tenants !== null
|
|
919
|
+
? React.createElement(
|
|
920
|
+
Card,
|
|
921
|
+
{
|
|
922
|
+
title: t('card.tenants'),
|
|
923
|
+
on: data.tenants.enabled === true && (data.tenants.list ?? []).length > 0,
|
|
924
|
+
badge:
|
|
925
|
+
data.tenants.enabled !== true
|
|
926
|
+
? t('state.unconfigured')
|
|
927
|
+
: t('tenants.count', { count: (data.tenants.list ?? []).length }),
|
|
928
|
+
},
|
|
929
|
+
React.createElement(
|
|
930
|
+
'div',
|
|
931
|
+
{ className: 'dshRcDesc' },
|
|
932
|
+
data.tenants.enabled === true ? t('card.tenants.desc') : t('card.tenants.off'),
|
|
933
|
+
),
|
|
934
|
+
// 找不到 harness 入口时直接说清楚,否则每个租户都会起不来
|
|
935
|
+
data.tenants.harness !== null &&
|
|
936
|
+
data.tenants.harness !== undefined &&
|
|
937
|
+
data.tenants.harness.problem !== null
|
|
938
|
+
? React.createElement('div', { className: 'dshRcWarn' }, String(data.tenants.harness.problem))
|
|
939
|
+
: null,
|
|
940
|
+
data.tenants.enabled === true
|
|
941
|
+
? React.createElement(
|
|
942
|
+
'div',
|
|
943
|
+
{ className: 'dshRcActions' },
|
|
944
|
+
React.createElement('input', {
|
|
945
|
+
className: 'dshRcUrl dshRcInput',
|
|
946
|
+
placeholder: t('tenants.addPlaceholder'),
|
|
947
|
+
value: state.tenantDraft ?? '',
|
|
948
|
+
onChange: (event) => setState({ tenantDraft: event.target.value }),
|
|
949
|
+
onKeyDown: (event) => {
|
|
950
|
+
if (event.key === 'Enter') void tenantAdd()
|
|
951
|
+
},
|
|
952
|
+
}),
|
|
953
|
+
React.createElement(
|
|
954
|
+
'button',
|
|
955
|
+
{
|
|
956
|
+
type: 'button',
|
|
957
|
+
className: 'dshRcButton',
|
|
958
|
+
disabled: state.busy === true || !canControl || String(state.tenantDraft ?? '').trim() === '',
|
|
959
|
+
onClick: () => void tenantAdd(),
|
|
960
|
+
},
|
|
961
|
+
t('tenants.add'),
|
|
962
|
+
),
|
|
963
|
+
)
|
|
964
|
+
: null,
|
|
965
|
+
data.tenants.enabled === true && (data.tenants.list ?? []).length === 0
|
|
966
|
+
? React.createElement('div', { className: 'dshRcDesc' }, t('tenants.empty'))
|
|
967
|
+
: null,
|
|
968
|
+
data.tenants.enabled === true
|
|
969
|
+
? (data.tenants.list ?? []).map((tenant) =>
|
|
970
|
+
React.createElement(
|
|
971
|
+
'div',
|
|
972
|
+
{ key: tenant.id, className: 'dshRcTenant' },
|
|
973
|
+
React.createElement(
|
|
974
|
+
'div',
|
|
975
|
+
{ className: 'dshRcHead' },
|
|
976
|
+
React.createElement('span', null, tenant.name + ' · ' + tenant.id),
|
|
977
|
+
React.createElement(
|
|
978
|
+
'span',
|
|
979
|
+
{ className: 'dshRcBadge' + (tenant.running === true ? ' isOn' : '') },
|
|
980
|
+
phaseLabel(tenant.phase),
|
|
981
|
+
),
|
|
982
|
+
),
|
|
983
|
+
React.createElement(
|
|
984
|
+
'div',
|
|
985
|
+
{ className: 'dshRcDesc' },
|
|
986
|
+
tenant.running === true
|
|
987
|
+
? t('tenants.port') + ' ' + String(tenant.port) + (tenant.restarts > 0 ? ' · ' + t('tenants.restarts', { count: tenant.restarts }) : '')
|
|
988
|
+
: t('tenants.notRunning'),
|
|
989
|
+
),
|
|
990
|
+
tenant.detail
|
|
991
|
+
? React.createElement('div', { className: 'dshRcDesc' }, tenant.detail)
|
|
992
|
+
: null,
|
|
993
|
+
tenant.hint ? React.createElement('div', { className: 'dshRcWarn' }, tenant.hint) : null,
|
|
994
|
+
tenant.lanEntry !== null && tenant.lanEntry !== undefined
|
|
995
|
+
? React.createElement('input', {
|
|
996
|
+
className: 'dshRcUrl',
|
|
997
|
+
readOnly: true,
|
|
998
|
+
value: tenant.lanEntry,
|
|
999
|
+
onFocus: (event) => event.target.select(),
|
|
1000
|
+
onClick: (event) => event.target.select(),
|
|
1001
|
+
})
|
|
1002
|
+
: null,
|
|
1003
|
+
React.createElement(
|
|
1004
|
+
'div',
|
|
1005
|
+
{ className: 'dshRcActions' },
|
|
1006
|
+
React.createElement(
|
|
1007
|
+
'button',
|
|
1008
|
+
{
|
|
1009
|
+
type: 'button',
|
|
1010
|
+
className: 'dshRcButton' + (tenant.running === true ? ' isGhost' : ''),
|
|
1011
|
+
disabled: state.busy === true || !canControl,
|
|
1012
|
+
onClick: () => void tenantAction(tenant.running === true ? 'stop' : 'start', { id: tenant.id }),
|
|
1013
|
+
},
|
|
1014
|
+
tenant.running === true ? t('action.stop') : t('action.start'),
|
|
1015
|
+
),
|
|
1016
|
+
React.createElement(
|
|
1017
|
+
'button',
|
|
1018
|
+
{
|
|
1019
|
+
type: 'button',
|
|
1020
|
+
className: 'dshRcButton isGhost',
|
|
1021
|
+
disabled: state.busy === true || !canControl,
|
|
1022
|
+
onClick: () => void tenantQr(tenant.id),
|
|
1023
|
+
},
|
|
1024
|
+
state.tenantQrId === tenant.id ? t('tenants.hideQr') : t('tenants.qr'),
|
|
1025
|
+
),
|
|
1026
|
+
React.createElement(
|
|
1027
|
+
'button',
|
|
1028
|
+
{
|
|
1029
|
+
type: 'button',
|
|
1030
|
+
className: 'dshRcButton isGhost',
|
|
1031
|
+
disabled: state.busy === true || !canControl,
|
|
1032
|
+
title: t('tenants.rotate'),
|
|
1033
|
+
onClick: () => void tenantAction('rotate', { id: tenant.id }),
|
|
1034
|
+
},
|
|
1035
|
+
t('tenants.rotate'),
|
|
1036
|
+
),
|
|
1037
|
+
React.createElement(
|
|
1038
|
+
'button',
|
|
1039
|
+
{
|
|
1040
|
+
type: 'button',
|
|
1041
|
+
className: 'dshRcButton isGhost',
|
|
1042
|
+
disabled: state.busy === true || !canControl,
|
|
1043
|
+
onClick: () => {
|
|
1044
|
+
if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
|
|
1045
|
+
if (window.confirm(t('tenants.confirmRemove', { name: tenant.name })) !== true) return
|
|
1046
|
+
}
|
|
1047
|
+
void tenantAction('remove', { id: tenant.id })
|
|
1048
|
+
},
|
|
1049
|
+
},
|
|
1050
|
+
t('tenants.remove'),
|
|
1051
|
+
),
|
|
1052
|
+
),
|
|
1053
|
+
state.tenantQrId === tenant.id && Array.isArray(state.tenantQr)
|
|
1054
|
+
? React.createElement(QrCode, { rows: state.tenantQr, t: t })
|
|
1055
|
+
: null,
|
|
1056
|
+
),
|
|
1057
|
+
)
|
|
1058
|
+
: null,
|
|
1059
|
+
data.tenants.enabled === true && (data.tenants.list ?? []).length > 0
|
|
1060
|
+
? React.createElement('div', { className: 'dshRcDesc' }, t('tenants.encourageCredentials'))
|
|
1061
|
+
: null,
|
|
1062
|
+
)
|
|
1063
|
+
: null,
|
|
1064
|
+
Array.isArray(qr) && qr.length > 0 ? React.createElement(QrCode, { rows: qr, t: t }) : null,
|
|
1065
|
+
Array.isArray(qr) && qr.length > 0
|
|
1066
|
+
? React.createElement('div', { className: 'dshRcQrHint' }, t('qr.hint'))
|
|
1067
|
+
: null,
|
|
1068
|
+
state.error !== null && state.error !== undefined
|
|
1069
|
+
? React.createElement('div', { className: 'dshRcError' }, state.error)
|
|
1070
|
+
: null,
|
|
1071
|
+
),
|
|
1072
|
+
)
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function apply(ctx) {
|
|
1076
|
+
const slots = ctx.get('slots')
|
|
1077
|
+
if (slots === undefined) return
|
|
1078
|
+
ctxRef = ctx
|
|
1079
|
+
const locale = ctx.get('locale')
|
|
1080
|
+
if (locale !== undefined) {
|
|
1081
|
+
ctx.effect(() => locale.register(NS, { zh, en }), 'remote-connect: dictionaries')
|
|
1082
|
+
}
|
|
1083
|
+
slots.inject('sidebar.footer.action', () =>
|
|
1084
|
+
slots.register(
|
|
1085
|
+
// 侧栏不投影 footer action 的 label(真正显示的是组件内的 t() 文案),这里只作兜底
|
|
1086
|
+
{ name: 'sidebar.footer.action', id: 'remote-connect', order: -100, label: '远程连接', locale: NS },
|
|
1087
|
+
Entry,
|
|
1088
|
+
),
|
|
1089
|
+
)
|
|
1090
|
+
slots.inject('shell.overlay', () =>
|
|
1091
|
+
slots.register({ name: 'shell.overlay', id: 'remote-connect-panel', order: 40, locale: NS }, Panel),
|
|
1092
|
+
)
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// locale 是软依赖:没有它时组件用 key 兜底渲染,插件仍然可用
|
|
1096
|
+
const inject = ['slots', 'locale']
|
|
1097
|
+
|
|
1098
|
+
exports.apply = apply
|
|
1099
|
+
exports.inject = inject
|
|
1100
|
+
/** 供测试/排障使用;装载器只读 apply 与 inject,忽略其它导出。 */
|
|
1101
|
+
exports.internals = {
|
|
1102
|
+
store,
|
|
1103
|
+
setState,
|
|
1104
|
+
api,
|
|
1105
|
+
refresh,
|
|
1106
|
+
act,
|
|
1107
|
+
runCheck,
|
|
1108
|
+
loadSnippet,
|
|
1109
|
+
currentLocale,
|
|
1110
|
+
tenantAction,
|
|
1111
|
+
tenantAdd,
|
|
1112
|
+
tenantQr,
|
|
1113
|
+
revealKey,
|
|
1114
|
+
copyEntryLink,
|
|
1115
|
+
changeKey,
|
|
1116
|
+
resetKey,
|
|
1117
|
+
}
|
|
1118
|
+
return module.exports
|
|
1119
|
+
},
|
|
1120
|
+
})
|