dsh-remote-plugin 0.4.4

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/index.mjs ADDED
@@ -0,0 +1,392 @@
1
+ /* dsh-remote DSH 插件 · Node half
2
+ * 在 DSH Web 的 httpServer 上挂 /remote 前缀路由:
3
+ * - /remote/... 移动控制台 + 主机管理页静态资源
4
+ * - /remote/admin/api 管理控制台数据: 优先代理本地网关(完整设备监控/更新检查),
5
+ * 网关不可用时回退到插件模式主机状态
6
+ * 浏览器侧入口由 client half 注册在 DSH 原生侧边栏(见 client.js)。
7
+ */
8
+ import { spawn } from 'node:child_process'
9
+ import { createReadStream, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs'
10
+ import { stat } from 'node:fs/promises'
11
+ import { homedir, hostname, networkInterfaces } from 'node:os'
12
+ import { dirname, extname, normalize, resolve } from 'node:path'
13
+ import { fileURLToPath } from 'node:url'
14
+
15
+ export const name = 'dsh-remote'
16
+ export const inject = ['webServer']
17
+
18
+ const MOUNT = '/remote'
19
+ const PUBLIC_DIR = fileURLToPath(new URL('./public/', import.meta.url))
20
+ const INDEX_FILE = 'index.html'
21
+ const GATEWAY_SCRIPT = fileURLToPath(new URL('./gateway.cjs', import.meta.url))
22
+ const gatewayInstalled = existsSync(GATEWAY_SCRIPT)
23
+ // 本地网关管理 API 代理: 让插件抽屉显示与 8787 网关管理页完全一致的数据。
24
+ const GATEWAY_BASE = (process.env.DSH_REMOTE_GATEWAY || 'http://127.0.0.1:8787').replace(/\/+$/, '')
25
+
26
+ function gatewayToken() {
27
+ if (process.env.DSH_REMOTE_TOKEN) return process.env.DSH_REMOTE_TOKEN
28
+ try {
29
+ return readFileSync(`${homedir()}/.dsh-remote/token`, 'utf8').trim() || ''
30
+ } catch {
31
+ return ''
32
+ }
33
+ }
34
+
35
+ const MIME = {
36
+ '.html': 'text/html; charset=utf-8',
37
+ '.js': 'text/javascript; charset=utf-8',
38
+ '.css': 'text/css; charset=utf-8',
39
+ '.json': 'application/json; charset=utf-8',
40
+ '.svg': 'image/svg+xml',
41
+ '.webmanifest': 'application/manifest+json; charset=utf-8',
42
+ '.png': 'image/png',
43
+ '.ico': 'image/x-icon',
44
+ }
45
+
46
+ let version = '0.0.0'
47
+ try {
48
+ const v = JSON.parse(readFileSync(new URL('./public/version.json', import.meta.url), 'utf8'))
49
+ if (v?.version) version = v.version
50
+ } catch {}
51
+
52
+ // DSH 实际监听地址由 apply 时从 webServer 服务读取
53
+ let dshListen = { host: '127.0.0.1', port: 3080 }
54
+
55
+ function lanIPs() {
56
+ const out = []
57
+ for (const list of Object.values(networkInterfaces())) {
58
+ for (const it of list ?? []) {
59
+ if (it.family === 'IPv4' && !it.internal) out.push(it.address)
60
+ }
61
+ }
62
+ return out
63
+ }
64
+
65
+ function targetPath(pathname) {
66
+ const rel = decodeURIComponent(pathname.slice(MOUNT.length)) || '/'
67
+ const file = rel === '/' ? INDEX_FILE : rel.replace(/^\/+/, '')
68
+ const abs = resolve(PUBLIC_DIR, normalize(file))
69
+ if (abs !== PUBLIC_DIR && !abs.startsWith(PUBLIC_DIR)) return null
70
+ return abs
71
+ }
72
+
73
+ function sendJson(res, status, body) {
74
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
75
+ res.end(JSON.stringify(body))
76
+ }
77
+
78
+ function readBody(req, maxBytes) {
79
+ return new Promise((resolvePromise, reject) => {
80
+ let body = ''
81
+ req.on('data', (chunk) => {
82
+ body += chunk
83
+ if (body.length > maxBytes) {
84
+ reject(new Error('body too large'))
85
+ req.destroy()
86
+ }
87
+ })
88
+ req.on('end', () => resolvePromise(body))
89
+ req.on('error', reject)
90
+ })
91
+ }
92
+
93
+ /** 转发到本地网关管理 API; 失败/超时返回 null。 */
94
+ async function proxyGateway(path, method, body) {
95
+ const token = gatewayToken()
96
+ if (!token) return null
97
+ try {
98
+ const res = await fetch(`${GATEWAY_BASE}${path}`, {
99
+ method,
100
+ headers: {
101
+ authorization: `Bearer ${token}`,
102
+ 'content-type': 'application/json',
103
+ 'x-dsh-remote-client': 'admin',
104
+ },
105
+ body: method === 'POST' ? body : undefined,
106
+ signal: AbortSignal.timeout(1500),
107
+ })
108
+ const json = await res.json().catch(() => ({ ok: false, error: `gateway ${res.status}` }))
109
+ return { status: res.status, json }
110
+ } catch {
111
+ return null
112
+ }
113
+ }
114
+
115
+ /* ---------- 本地网关开关(持久化 + 自愈) ---------- */
116
+
117
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
118
+
119
+ function runExit(cmd, args) {
120
+ return new Promise((resolvePromise) => {
121
+ let p
122
+ try { p = spawn(cmd, args, { stdio: 'ignore' }) }
123
+ catch { return resolvePromise(1) }
124
+ p.on('error', () => resolvePromise(1))
125
+ p.on('exit', (code) => resolvePromise(code ?? 1))
126
+ })
127
+ }
128
+
129
+ async function gatewayRunning() {
130
+ try {
131
+ const res = await fetch(`${GATEWAY_BASE}/health`, { signal: AbortSignal.timeout(800) })
132
+ return res.ok
133
+ } catch {
134
+ return false
135
+ }
136
+ }
137
+
138
+ /** 用户意图持久化在 ~/.dsh-remote/gateway.enabled: on=跟随 DSH 自启/自愈, off=手动停止。 */
139
+ function gatewayStateFile() { return `${homedir()}/.dsh-remote/gateway.enabled` }
140
+
141
+ function gatewayAutostart() {
142
+ if (process.env.DSH_REMOTE_AUTOSTART === '0') return false
143
+ try {
144
+ const v = readFileSync(gatewayStateFile(), 'utf8').trim()
145
+ return v !== 'off'
146
+ } catch {
147
+ return true // 全新安装: 默认自动拉起网关, 抽屉打开即有网关模式
148
+ }
149
+ }
150
+
151
+ function setGatewayEnabled(on) {
152
+ try {
153
+ mkdirSync(`${homedir()}/.dsh-remote`, { recursive: true })
154
+ writeFileSync(gatewayStateFile(), on ? 'on\n' : 'off\n')
155
+ } catch {}
156
+ }
157
+
158
+ /** 启动随插件分发的 gateway.cjs; 已运行则直接返回。 */
159
+ async function startGateway() {
160
+ if (await gatewayRunning()) {
161
+ setGatewayEnabled(true)
162
+ return { ok: true, running: true, started: false }
163
+ }
164
+ const script = GATEWAY_SCRIPT
165
+ if (!existsSync(script)) {
166
+ return { ok: false, running: false, error: '插件包缺少 gateway.cjs, 请升级插件' }
167
+ }
168
+ const port = process.env.DSH_REMOTE_GATEWAY_PORT || '8787'
169
+
170
+ // 首选 systemd-run: 网关成为独立 user 单元, DSH 重启/升级不会连带杀掉它
171
+ let sysd = false
172
+ try {
173
+ await runExit('systemctl', ['--user', 'reset-failed', 'dsh-remote-gateway'])
174
+ sysd = (await runExit('systemd-run', [
175
+ '--user', '--unit=dsh-remote-gateway', '--service-type=exec',
176
+ '--setenv=PORT=' + port, '--setenv=HOST=0.0.0.0',
177
+ '--', process.execPath, script,
178
+ ])) === 0
179
+ } catch {}
180
+
181
+ // 无 systemd 的机器回退: detached 子进程
182
+ if (!sysd) {
183
+ let logFd = null
184
+ try {
185
+ logFd = openSync(`${homedir()}/.dsh-remote/plugin-gateway.log`, 'a')
186
+ } catch {}
187
+ const child = spawn(process.execPath, [script], {
188
+ cwd: dirname(script),
189
+ detached: true,
190
+ stdio: ['ignore', logFd ?? 'ignore', logFd ?? 'ignore'],
191
+ env: { ...process.env, PORT: port },
192
+ })
193
+ child.unref()
194
+ }
195
+ // 最多等 4 秒; 超过可能是端口冲突或首次初始化, 前端稍后刷新即可
196
+ for (let i = 0; i < 16; i++) {
197
+ await sleep(250)
198
+ if (await gatewayRunning()) {
199
+ setGatewayEnabled(true)
200
+ return { ok: true, running: true, started: true }
201
+ }
202
+ }
203
+ return { ok: true, running: false, pending: true, hint: '网关启动中, 稍后刷新' }
204
+ }
205
+
206
+ let ensurePromise = null
207
+ /** 自愈入口: 状态轮询/DSH 启动时调用。开关为 on 且网关没起来, 就自动拉起(并发只拉一次)。 */
208
+ function ensureGateway() {
209
+ if (!gatewayAutostart()) return Promise.resolve(false)
210
+ if (ensurePromise) return ensurePromise
211
+ ensurePromise = (async () => {
212
+ try {
213
+ if (await gatewayRunning()) return true
214
+ const out = await startGateway()
215
+ return !!out.running
216
+ } finally {
217
+ setTimeout(() => { ensurePromise = null }, 4000)
218
+ }
219
+ })()
220
+ return ensurePromise
221
+ }
222
+
223
+ /** 通过网关自身的 /admin/api/shutdown 优雅停止(不管它当初是谁拉起的); 并写入 off 防自愈拉起。 */
224
+ async function stopGateway() {
225
+ const token = gatewayToken()
226
+ if (!token) return { ok: false, running: false, error: '找不到 ~/.dsh-remote/token, 无法认证网关' }
227
+ try {
228
+ const res = await fetch(`${GATEWAY_BASE}/admin/api/shutdown`, {
229
+ method: 'POST',
230
+ headers: { authorization: `Bearer ${token}`, 'x-dsh-remote-client': 'admin' },
231
+ signal: AbortSignal.timeout(2000),
232
+ })
233
+ const json = await res.json().catch(() => ({}))
234
+ if (res.ok) setGatewayEnabled(false)
235
+ return { ok: res.ok, running: false, ...json }
236
+ } catch (e) {
237
+ return { ok: false, running: false, error: '网关不可达: ' + (e?.message || e) }
238
+ }
239
+ }
240
+
241
+ async function resolveFile(pathname) {
242
+ let abs = targetPath(pathname)
243
+ if (abs === null) return null
244
+ try {
245
+ let info = await stat(abs)
246
+ if (info.isDirectory()) {
247
+ abs = resolve(abs, INDEX_FILE)
248
+ info = await stat(abs)
249
+ }
250
+ if (!info.isFile() && !extname(abs)) {
251
+ abs = abs + '.html' // /remote/admin -> admin.html
252
+ info = await stat(abs)
253
+ }
254
+ return info.isFile() ? { abs, info } : null
255
+ } catch {
256
+ if (!extname(abs)) {
257
+ // /remote/admin 无此裸文件 -> 再试 admin.html
258
+ try {
259
+ const alt = abs + '.html'
260
+ const info = await stat(alt)
261
+ return info.isFile() ? { abs: alt, info } : null
262
+ } catch {
263
+ return null
264
+ }
265
+ }
266
+ return null
267
+ }
268
+ }
269
+
270
+ async function serveStatic(req, res) {
271
+ const pathname = new URL(req.url ?? '/', 'http://x').pathname
272
+
273
+ // 无尾斜杠的入口重定向到带斜杠版本:
274
+ // 否则相对资源 styles.css/app.js 会按 URL 规则解析到上级路径 /styles.css,
275
+ // 被 DSH 的 SPA fallback 返回 HTML, 表现为白底 + 脚本不运行。
276
+ if (pathname === MOUNT) {
277
+ res.writeHead(302, { location: `${MOUNT}/` })
278
+ res.end()
279
+ return
280
+ }
281
+ if (pathname === `${MOUNT}/admin`) {
282
+ res.writeHead(302, { location: `${MOUNT}/admin/` })
283
+ res.end()
284
+ return
285
+ }
286
+
287
+ // 管理控制台数据: 优先代理本地网关(设备监控/更新检查完整), 网关不可用回退插件状态
288
+ if (pathname === `${MOUNT}/admin/api/state`) {
289
+ void ensureGateway() // 自愈: 开关为 on 而网关没起来时, 后台拉起, 下个轮询即可见网关
290
+ const localToken = gatewayToken()
291
+ const proxied = await proxyGateway('/admin/api/state', 'GET', '')
292
+ if (proxied !== null) {
293
+ // 主机端 DSH 面板本身已登录本机用户, 管理页无需令牌门禁;
294
+ // 把真实网关令牌一并返回, 抽屉里直接显示并允许复制(供手机 App 使用)。
295
+ sendJson(res, proxied.status, { ...proxied.json, token: localToken, mode: 'gateway', via: 'gateway', gatewayInstalled })
296
+ return
297
+ }
298
+ sendJson(res, 200, {
299
+ ok: true,
300
+ mode: 'plugin',
301
+ version,
302
+ token: localToken || '',
303
+ gatewayInstalled,
304
+ hostname: hostname(),
305
+ lanIPs: lanIPs(),
306
+ startedAt: Date.now() - Math.floor(process.uptime() * 1000),
307
+ uptimeSec: Math.floor(process.uptime()),
308
+ host: dshListen.host,
309
+ port: dshListen.port,
310
+ upstream: { url: 'DSH 内嵌(同进程, 无需网关)', reachable: true },
311
+ latest: { version, newer: false },
312
+ onlineCount: 0,
313
+ deviceCount: 0,
314
+ totalRequests: 0,
315
+ authFailures: 0,
316
+ devices: [],
317
+ })
318
+ return
319
+ }
320
+ if (pathname === `${MOUNT}/admin/api/note` || pathname === `${MOUNT}/admin/api/kick`) {
321
+ if (req.method !== 'POST') {
322
+ res.writeHead(405, { allow: 'POST' })
323
+ res.end()
324
+ return
325
+ }
326
+ const body = await readBody(req, 4096)
327
+ const sub = pathname.endsWith('/note') ? '/note' : '/kick'
328
+ const proxied = await proxyGateway(`/admin/api${sub}`, 'POST', body)
329
+ if (proxied !== null) {
330
+ sendJson(res, proxied.status, proxied.json)
331
+ } else {
332
+ sendJson(res, 502, { ok: false, error: '本地网关不可用, 设备管理需在 8787 网关模式操作' })
333
+ }
334
+ return
335
+ }
336
+
337
+ // 本地网关开关(仅插件内嵌页使用): GET 状态 / POST {action:'start'|'stop'}
338
+ if (pathname === `${MOUNT}/admin/api/gateway`) {
339
+ if (req.method === 'GET') {
340
+ sendJson(res, 200, { ok: true, running: await gatewayRunning() })
341
+ return
342
+ }
343
+ if (req.method === 'POST') {
344
+ let action = ''
345
+ try {
346
+ const raw = await readBody(req, 4096)
347
+ action = JSON.parse(raw || '{}').action
348
+ } catch {}
349
+ if (action === 'start') sendJson(res, 200, await startGateway())
350
+ else if (action === 'stop') sendJson(res, 200, await stopGateway())
351
+ else sendJson(res, 400, { ok: false, error: 'action 必须是 start 或 stop' })
352
+ return
353
+ }
354
+ res.writeHead(405, { allow: 'GET, POST' })
355
+ res.end()
356
+ return
357
+ }
358
+
359
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
360
+ res.writeHead(405, { allow: 'GET, HEAD' })
361
+ res.end()
362
+ return
363
+ }
364
+ const found = await resolveFile(pathname)
365
+ if (found === null) {
366
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
367
+ res.end('not found')
368
+ return
369
+ }
370
+ const { abs, info } = found
371
+ res.writeHead(200, {
372
+ 'content-type': MIME[extname(abs)] ?? 'application/octet-stream',
373
+ 'content-length': info.size,
374
+ 'cache-control': 'no-cache',
375
+ })
376
+ if (req.method === 'HEAD') {
377
+ res.end()
378
+ return
379
+ }
380
+ createReadStream(abs).pipe(res)
381
+ }
382
+
383
+ export function apply(ctx) {
384
+ dshListen = { host: ctx.webServer.host, port: ctx.webServer.port }
385
+ ctx.effect(() => ctx.webServer.register({
386
+ kind: 'prefix',
387
+ path: MOUNT,
388
+ handler: serveStatic,
389
+ }), 'dsh-remote: /remote route')
390
+ // DSH 启动/重启后自愈: 用户没关过网关就自动拉起(默认开, DSH_REMOTE_AUTOSTART=0 关闭)
391
+ void ensureGateway()
392
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "dsh-remote-plugin",
3
+ "version": "0.4.4",
4
+ "description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控,配合 Android App 远程操控会话/审批/提问/goal。",
5
+ "type": "module",
6
+ "main": "./index.mjs",
7
+ "exports": {
8
+ ".": "./index.mjs",
9
+ "./client": "./client.js",
10
+ "./cordis.patch.yml": "./cordis.patch.yml",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "index.mjs",
15
+ "client.js",
16
+ "gateway.cjs",
17
+ "public",
18
+ "cordis.patch.yml"
19
+ ],
20
+ "keywords": [
21
+ "dsh-plugin",
22
+ "deepseek-harness",
23
+ "dsh",
24
+ "bundle",
25
+ "remote-control",
26
+ "mobile"
27
+ ],
28
+ "license": "MIT",
29
+ "homepage": "https://github.com/Blank-not-black/dsh-Remote",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/Blank-not-black/dsh-Remote.git",
33
+ "directory": "packages/plugin"
34
+ },
35
+ "bugs": {
36
+ "url": "https://github.com/Blank-not-black/dsh-Remote/issues"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "engines": {
42
+ "node": ">=18"
43
+ },
44
+ "dsh": {
45
+ "bundle": {
46
+ "patch": "./cordis.patch.yml"
47
+ },
48
+ "client": {
49
+ "inject": [
50
+ "@deepseek-ai/dsh-client-runtime",
51
+ "@deepseek-ai/dsh-client-ui-slots"
52
+ ],
53
+ "platform": "web"
54
+ }
55
+ }
56
+ }
@@ -0,0 +1,89 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <meta name="theme-color" content="#0b0e1a">
7
+ <title>DSH Remote · 管理</title>
8
+ <link rel="stylesheet" href="../styles.css">
9
+ <style>
10
+ .admin-wrap { max-width: 860px; margin: 0 auto; padding: calc(env(safe-area-inset-top, 0px) + 14px) 14px 40px; }
11
+ .admin-title { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
12
+ .admin-title h1 { font-size: 20px; margin: 0; }
13
+ .login-card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px; display: flex; gap: 8px; }
14
+ .login-card input { flex: 1; background: var(--bg); color: var(--text); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; font: inherit; outline: none; }
15
+ .stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px; margin-bottom: 14px; }
16
+ .stat-card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 12px 14px; }
17
+ .stat-card .v { font-size: 20px; font-weight: 700; word-break: break-all; }
18
+ .stat-card .k { font-size: 11px; color: var(--muted); margin-top: 2px; }
19
+ .stat-card.ok .v { color: var(--cyan); } .stat-card.warn .v { color: var(--orange); }
20
+ .table-wrap { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); overflow-x: auto; }
21
+ table { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 760px; }
22
+ th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--line); vertical-align: top; }
23
+ th { font-size: 11px; color: var(--muted); letter-spacing: .5px; }
24
+ tr:last-child td { border-bottom: none; }
25
+ .badge { display: inline-block; font-size: 11px; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); }
26
+ .badge.app { color: var(--blue); border-color: rgba(91,140,255,.4); background: rgba(91,140,255,.08); }
27
+ .badge.admin { color: var(--purple); border-color: rgba(180,140,255,.4); background: rgba(180,140,255,.08); }
28
+ .dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--line); margin-right: 5px; }
29
+ .dot.on { background: var(--cyan); box-shadow: 0 0 6px rgba(125,207,255,.9); }
30
+ .dot.off { background: var(--orange); }
31
+ .mono { font-family: ui-monospace, monospace; font-size: 12px; word-break: break-all; }
32
+ .ua { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }
33
+ .token-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 10px 0 14px; }
34
+ .token-row code { flex: 1; background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; font-size: 12px; word-break: break-all; }
35
+ .empty { text-align: center; color: var(--muted); padding: 30px 0; }
36
+ .empty-actions { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; margin-top: 12px; }
37
+ a.mini-btn { text-decoration: none; display: inline-flex; align-items: center; }
38
+ .gh-btn { display: inline-flex; align-items: center; gap: 6px; text-decoration: none; color: var(--text); }
39
+ .gh-btn svg { width: 16px; height: 16px; fill: currentColor; }
40
+ .admin-title .right { display: flex; align-items: center; gap: 10px; }
41
+ </style>
42
+ </head>
43
+ <body>
44
+ <div class="admin-wrap">
45
+ <div class="admin-title">
46
+ <div style="display:flex;align-items:center;gap:10px">
47
+ <a id="btn-console" class="mini-btn" href="../" title="返回控制台">‹ 控制台</a>
48
+ <button id="btn-close-drawer" class="mini-btn hidden" title="收起面板">‹ 收起面板</button>
49
+ <h1>DSH Remote 管理</h1>
50
+ </div>
51
+ <div class="right">
52
+ <a class="mini-btn gh-btn" href="https://github.com/Blank-not-black/dsh-Remote" target="_blank" rel="noopener" title="访问 GitHub 仓库">
53
+ <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8a8.01 8.01 0 0 0 5.47 7.59c.4.08.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg>
54
+ 仓库
55
+ </a>
56
+ <span id="conn-badge" class="conn-badge off">未认证</span>
57
+ </div>
58
+ </div>
59
+
60
+ <div id="login-view" class="login-card hidden">
61
+ <input id="token-input" type="password" placeholder="输入网关访问令牌" autocomplete="off">
62
+ <button id="btn-login" class="mini-btn">进入</button>
63
+ </div>
64
+
65
+ <div id="main-view" class="hidden">
66
+ <div class="token-row">
67
+ <code id="token-full">—</code>
68
+ <button id="btn-gateway" class="mini-btn hidden">启动网关</button>
69
+ <button id="btn-copy" class="mini-btn">复制令牌</button>
70
+ <button id="btn-logout" class="mini-btn">退出</button>
71
+ </div>
72
+
73
+ <div class="stat-grid" id="stats"></div>
74
+
75
+ <div class="section-head"><span>已连接设备</span><span id="device-summary" class="muted"></span></div>
76
+ <div class="table-wrap">
77
+ <table>
78
+ <thead><tr><th>状态</th><th>名称</th><th>类型</th><th>IP</th><th>通道</th><th>请求</th><th>最后活跃</th><th>UA</th><th></th></tr></thead>
79
+ <tbody id="device-rows"></tbody>
80
+ </table>
81
+ <div id="device-empty" class="empty hidden">暂无设备记录</div>
82
+ </div>
83
+ </div>
84
+ </div>
85
+
86
+ <div id="toast" class="toast hidden"></div>
87
+ <script src="../admin.js"></script>
88
+ </body>
89
+ </html>