dscs-server 0.0.0-stage → 0.1.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/web/admin.html ADDED
@@ -0,0 +1,895 @@
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">
6
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
7
+ <title>dsh-session-cloudsync 管理台</title>
8
+ <style>
9
+ :root {
10
+ color-scheme: dark;
11
+ --bg: #0f1115;
12
+ --bg-soft: #14171d;
13
+ --panel: #1a1e25;
14
+ --panel-2: #20252e;
15
+ --line: #2a303a;
16
+ --line-strong: #3a4350;
17
+ --text: #e9edf3;
18
+ --muted: #9aa5b5;
19
+ --faint: #6f7b8c;
20
+ --accent: #5b88ff;
21
+ --accent-2: #7ba4ff;
22
+ --accent-soft: rgba(91, 136, 255, .14);
23
+ --good: #5ed392;
24
+ --good-soft: rgba(94, 211, 146, .12);
25
+ --bad: #ef7777;
26
+ --bad-soft: rgba(239, 119, 119, .12);
27
+ --warn: #f0b64a;
28
+ --radius: 14px;
29
+ --shadow: 0 20px 60px rgba(0, 0, 0, .32);
30
+ }
31
+ * { box-sizing: border-box; }
32
+ html, body { min-height: 100%; }
33
+ body {
34
+ margin: 0;
35
+ background:
36
+ radial-gradient(900px 480px at 12% -10%, rgba(91, 136, 255, .11), transparent 65%),
37
+ radial-gradient(700px 420px at 100% 0%, rgba(94, 211, 146, .06), transparent 60%),
38
+ var(--bg);
39
+ color: var(--text);
40
+ font: 14px/1.6 "Segoe UI", "Microsoft YaHei", system-ui, sans-serif;
41
+ -webkit-font-smoothing: antialiased;
42
+ }
43
+ button, input, select { font: inherit; }
44
+ button { cursor: pointer; }
45
+ [hidden] { display: none !important; }
46
+ code, .mono { font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; }
47
+ a { color: var(--accent-2); }
48
+
49
+ .btn {
50
+ display: inline-flex; align-items: center; justify-content: center; gap: 7px;
51
+ min-height: 34px; padding: 7px 13px;
52
+ border: 1px solid transparent; border-radius: 9px;
53
+ background: var(--accent); color: #fff;
54
+ font-size: 13px; font-weight: 600; white-space: nowrap;
55
+ transition: background .16s, border-color .16s, opacity .16s, transform .16s;
56
+ }
57
+ .btn:hover { background: #6b95ff; }
58
+ .btn:active { transform: translateY(1px); }
59
+ .btn:disabled { opacity: .45; cursor: default; transform: none; }
60
+ .btn.secondary { background: var(--panel-2); border-color: var(--line); color: var(--text); }
61
+ .btn.secondary:hover { background: #29303a; }
62
+ .btn.ghost { background: transparent; border-color: var(--line); color: var(--muted); }
63
+ .btn.ghost:hover { color: var(--text); border-color: var(--line-strong); background: rgba(255,255,255,.03); }
64
+ .btn.danger { background: transparent; border-color: rgba(239,119,119,.38); color: #ffabab; }
65
+ .btn.danger:hover { background: var(--bad-soft); border-color: rgba(239,119,119,.58); }
66
+ .btn.small { min-height: 27px; padding: 3px 9px; font-size: 11.5px; }
67
+ .btn.wide { width: 100%; }
68
+
69
+ .input, input, select {
70
+ width: 100%; min-height: 36px;
71
+ padding: 7px 11px;
72
+ border: 1px solid var(--line-strong); border-radius: 9px;
73
+ background: var(--bg-soft); color: var(--text);
74
+ outline: none;
75
+ transition: border-color .16s, box-shadow .16s;
76
+ }
77
+ input[type="search"] { appearance: none; -webkit-appearance: none; }
78
+ input[type="search"]::-webkit-search-decoration,
79
+ input[type="search"]::-webkit-search-cancel-button { -webkit-appearance: none; }
80
+ input:hover, select:hover { border-color: #4a5566; }
81
+ input:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
82
+ input::placeholder { color: var(--faint); }
83
+
84
+ /* login */
85
+ .login-page {
86
+ min-height: 100vh;
87
+ display: grid;
88
+ place-items: center;
89
+ padding: 28px 20px;
90
+ }
91
+ .login-inner {
92
+ width: min(420px, 100%);
93
+ display: flex; flex-direction: column; align-items: center;
94
+ animation: card-in .38s cubic-bezier(.16,1,.3,1) both;
95
+ }
96
+ .brand-mark {
97
+ width: 44px; height: 44px; border-radius: 13px;
98
+ display: grid; place-items: center;
99
+ background: linear-gradient(145deg, #24314a, #17202f);
100
+ border: 1px solid #33415a;
101
+ box-shadow: 0 12px 30px rgba(0,0,0,.28);
102
+ }
103
+ .brand-mark img { width: 26px; height: 26px; }
104
+ .auth-card {
105
+ width: 100%; margin-top: 18px; padding: 28px;
106
+ border: 1px solid var(--line); border-radius: 18px;
107
+ background: linear-gradient(180deg, rgba(32,37,46,.92), rgba(26,30,37,.96));
108
+ box-shadow: var(--shadow);
109
+ }
110
+ .eyebrow {
111
+ display: inline-block;
112
+ color: var(--accent-2); font-size: 10.5px; font-weight: 700;
113
+ letter-spacing: .14em; text-transform: uppercase;
114
+ }
115
+ .auth-card h1 { margin: 8px 0 4px; font-size: 24px; line-height: 1.2; letter-spacing: -.01em; }
116
+ .auth-card .muted { margin: 0; }
117
+ .auth-card .field { margin-top: 22px; }
118
+ .auth-foot { margin-top: 16px; color: var(--faint); font-size: 11.5px; text-align: center; }
119
+ .field { display: flex; flex-direction: column; gap: 6px; }
120
+ .field > span, .field-label { color: var(--muted); font-size: 12px; font-weight: 500; }
121
+ .input-wrap { position: relative; }
122
+ .input-wrap input { padding-right: 58px; }
123
+ .input-wrap .reveal {
124
+ position: absolute; right: 5px; top: 50%; transform: translateY(-50%);
125
+ border: 0; background: transparent; color: var(--muted);
126
+ font-size: 11.5px; padding: 5px 7px; border-radius: 6px;
127
+ }
128
+ .input-wrap .reveal:hover { color: var(--text); background: rgba(255,255,255,.05); }
129
+ .error { min-height: 20px; margin-top: 7px; color: #ffaaaa; font-size: 12px; }
130
+ .login-card .error { margin-top: 9px; }
131
+
132
+ /* app shell */
133
+ .app { max-width: 1180px; margin: 0 auto; padding: 24px 24px 52px; }
134
+ .topbar {
135
+ display: flex; align-items: center; justify-content: space-between; gap: 16px;
136
+ padding: 8px 0 22px;
137
+ animation: fade-in .24s ease both;
138
+ }
139
+ .brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
140
+ .brand img { width: 26px; height: 26px; }
141
+ .brand h1 { margin: 0; font-size: 16px; font-weight: 650; letter-spacing: -.01em; white-space: nowrap; }
142
+ .brand .brand-sub { color: var(--faint); font-size: 11.5px; margin-left: 2px; }
143
+ .top-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
144
+ .version-pill {
145
+ display: inline-flex; align-items: center; gap: 6px;
146
+ color: var(--faint); font-size: 11.5px;
147
+ padding: 4px 9px; border: 1px solid var(--line); border-radius: 999px;
148
+ }
149
+ .version-pill i { width: 6px; height: 6px; border-radius: 50%; background: var(--good); box-shadow: 0 0 0 3px var(--good-soft); }
150
+ .hero {
151
+ display: flex; align-items: flex-end; justify-content: space-between; gap: 16px;
152
+ margin: 4px 0 18px;
153
+ animation: rise-in .32s cubic-bezier(.16,1,.3,1) both;
154
+ }
155
+ .hero h2 { margin: 5px 0 4px; font-size: 22px; line-height: 1.2; letter-spacing: -.015em; }
156
+ .hero .muted { margin: 0; }
157
+ .health { display: inline-flex; align-items: center; gap: 7px; color: var(--good); font-size: 12px; white-space: nowrap; }
158
+ .health i { width: 7px; height: 7px; border-radius: 50%; background: var(--good); box-shadow: 0 0 0 4px var(--good-soft); }
159
+
160
+ .metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 18px; }
161
+ .metric {
162
+ padding: 15px 16px; border: 1px solid var(--line); border-radius: var(--radius);
163
+ background: linear-gradient(180deg, rgba(30,35,44,.9), rgba(24,28,35,.94));
164
+ animation: rise-in .34s cubic-bezier(.16,1,.3,1) both;
165
+ }
166
+ .metric:nth-child(2) { animation-delay: .04s; }
167
+ .metric:nth-child(3) { animation-delay: .08s; }
168
+ .metric:nth-child(4) { animation-delay: .12s; }
169
+ .metric .k { color: var(--faint); font-size: 11.5px; }
170
+ .metric .v { margin-top: 4px; font-size: 23px; line-height: 1.15; font-weight: 650; font-variant-numeric: tabular-nums; }
171
+ .metric .hint { margin-top: 3px; color: var(--faint); font-size: 11px; }
172
+
173
+ .layout {
174
+ display: grid;
175
+ grid-template-columns: minmax(0, 1.48fr) minmax(320px, .82fr);
176
+ grid-template-areas: "sessions storage" "devices devices";
177
+ gap: 16px;
178
+ align-items: stretch;
179
+ }
180
+ .sessions-panel { grid-area: sessions; }
181
+ .storage-panel { grid-area: storage; display: flex; flex-direction: column; }
182
+ .storage-panel .panel-body { display: flex; flex-direction: column; flex: 1 1 auto; }
183
+ .storage-panel .quota-form { margin-top: auto; padding-top: 14px; }
184
+ .devices-panel { grid-area: devices; }
185
+ .panel {
186
+ border: 1px solid var(--line); border-radius: var(--radius);
187
+ background: linear-gradient(180deg, rgba(28,33,41,.92), rgba(23,27,34,.96));
188
+ overflow: hidden;
189
+ animation: rise-in .36s cubic-bezier(.16,1,.3,1) both;
190
+ }
191
+ .panel-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; padding: 16px 16px 0; }
192
+ .panel-head h2 { margin: 0; font-size: 14px; font-weight: 650; }
193
+ .panel-head p { margin: 3px 0 0; color: var(--faint); font-size: 11.5px; }
194
+ .panel-body { padding: 14px 16px 16px; }
195
+ .badge {
196
+ display: inline-flex; align-items: center; gap: 5px;
197
+ min-height: 22px; padding: 1px 8px;
198
+ border: 1px solid var(--line); border-radius: 999px;
199
+ color: var(--muted); background: rgba(255,255,255,.025);
200
+ font-size: 11px; white-space: nowrap;
201
+ }
202
+ .badge.good { color: #9ce8bc; border-color: rgba(94,211,146,.28); background: var(--good-soft); }
203
+ .badge.bad { color: #ffb0b0; border-color: rgba(239,119,119,.3); background: var(--bad-soft); }
204
+ .badge.accent { color: #b9ccff; border-color: rgba(91,136,255,.32); background: var(--accent-soft); }
205
+
206
+ /* storage */
207
+ .storage-line { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 8px; }
208
+ .storage-line b { font-size: 17px; font-weight: 650; font-variant-numeric: tabular-nums; }
209
+ .storage-line span { color: var(--faint); font-size: 11.5px; }
210
+ .bar { height: 7px; border-radius: 999px; background: rgba(255,255,255,.07); overflow: hidden; }
211
+ .bar i { display: block; height: 100%; width: 0; border-radius: inherit; background: linear-gradient(90deg, var(--accent), var(--accent-2)); transition: width .35s cubic-bezier(.16,1,.3,1); }
212
+ .quota-form { display: flex; align-items: center; gap: 7px; margin-top: 13px; }
213
+ .quota-form input { width: 84px; min-height: 32px; }
214
+ .quota-form .unit { color: var(--faint); font-size: 11.5px; }
215
+ .quota-form .btn { margin-left: auto; }
216
+ .inline-result { min-height: 17px; margin-top: 6px; color: var(--faint); font-size: 11.5px; }
217
+ .inline-result.good { color: var(--good); }
218
+ .inline-result.bad { color: #ffaaaa; }
219
+
220
+ /* devices */
221
+ .device-form { display: flex; gap: 7px; }
222
+ .device-form input { min-width: 0; }
223
+ .device-form .btn { flex: 0 0 auto; }
224
+ .device-list { margin-top: 12px; display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 8px; }
225
+ .device-form { max-width: 520px; }
226
+ .device-row {
227
+ display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 8px 10px;
228
+ padding: 10px 11px; border: 1px solid var(--line); border-radius: 10px;
229
+ background: rgba(255,255,255,.018);
230
+ }
231
+ .device-main { min-width: 0; }
232
+ .device-label { display: flex; align-items: center; gap: 7px; font-size: 12.5px; font-weight: 600; }
233
+ .device-id { display: flex; align-items: center; gap: 5px; margin-top: 2px; color: var(--faint); font-size: 10.5px; }
234
+ .device-id code { color: var(--muted); overflow: hidden; text-overflow: ellipsis; }
235
+ .icon-btn {
236
+ border: 0; background: transparent; color: var(--faint);
237
+ border-radius: 6px; padding: 2px 5px; font-size: 10.5px;
238
+ }
239
+ .icon-btn:hover { color: var(--text); background: rgba(255,255,255,.06); }
240
+ .device-meta { color: var(--faint); font-size: 10.5px; line-height: 1.5; margin-top: 3px; display: flex; flex-direction: column; gap: 1px; }
241
+ .device-actions { display: flex; align-items: flex-start; }
242
+
243
+ /* sessions */
244
+ .session-tools { display: flex; align-items: center; gap: 7px; margin-bottom: 11px; }
245
+ .session-tools input { min-width: 0; flex: 1 1 auto; max-width: 300px; min-height: 32px; }
246
+ .session-tools select { width: auto; min-width: 112px; min-height: 32px; }
247
+ .table-wrap { overflow: auto; border: 1px solid var(--line); border-radius: 10px; }
248
+ table { width: 100%; border-collapse: collapse; font-size: 11.5px; }
249
+ th, td { text-align: left; padding: 9px 10px; border-bottom: 1px solid rgba(255,255,255,.055); white-space: nowrap; }
250
+ th { color: var(--faint); font-size: 10.5px; font-weight: 600; letter-spacing: .02em; background: rgba(255,255,255,.025); position: sticky; top: 0; }
251
+ tr:last-child td { border-bottom: 0; }
252
+ tbody tr:hover td { background: rgba(255,255,255,.025); }
253
+
254
+ /* session groups */
255
+ .session-group td { background: rgba(255,255,255,.03); padding: 7px 10px; }
256
+ .session-group-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
257
+ .workspace-alias { width: 150px; min-height: 26px; font-size: 11px; }
258
+ .session-group-key { display: flex; align-items: center; gap: 6px; color: var(--faint); font-size: 10.5px; }
259
+ .session-group-key code { color: var(--muted); font-size: 10.5px; }
260
+ .session-group-meta { margin-left: auto; color: var(--faint); font-size: 10.5px; }
261
+ td.session-id { max-width: 250px; overflow: hidden; text-overflow: ellipsis; }
262
+ td.session-id code { color: #b8c7de; }
263
+ .row-copy { border: 0; background: transparent; color: var(--faint); padding: 2px 4px; border-radius: 5px; font-size: 10px; }
264
+ .row-copy:hover { color: var(--text); background: rgba(255,255,255,.07); }
265
+ .empty { padding: 24px 14px; text-align: center; color: var(--faint); }
266
+ .empty b { display: block; margin-bottom: 3px; color: var(--muted); font-size: 12.5px; }
267
+ .empty .btn { margin-top: 10px; }
268
+ .empty-filter { color: var(--faint); font-size: 11.5px; padding: 16px; text-align: center; }
269
+
270
+ .muted { color: var(--muted); }
271
+ .faint { color: var(--faint); }
272
+ .mono { font-variant-ligatures: none; }
273
+
274
+ /* toast + modal */
275
+ .toast {
276
+ position: fixed; left: 50%; bottom: 24px; z-index: 80;
277
+ max-width: min(520px, calc(100vw - 32px));
278
+ padding: 9px 14px; border: 1px solid var(--line-strong); border-radius: 10px;
279
+ background: rgba(30,35,44,.97); color: var(--text);
280
+ box-shadow: 0 16px 40px rgba(0,0,0,.4);
281
+ font-size: 12px; opacity: 0; pointer-events: none;
282
+ transform: translate(-50%, 8px);
283
+ transition: opacity .18s, transform .18s;
284
+ }
285
+ .toast.on { opacity: 1; transform: translate(-50%, 0); }
286
+ .toast.bad { border-color: rgba(239,119,119,.4); color: #ffb0b0; }
287
+ .modal {
288
+ position: fixed; inset: 0; z-index: 70;
289
+ display: grid; place-items: center; padding: 24px;
290
+ background: rgba(6,8,11,.66); backdrop-filter: blur(6px);
291
+ animation: fade-in .18s ease both;
292
+ }
293
+ body.modal-open { overflow: hidden; }
294
+ .modal-card {
295
+ width: min(520px, 100%);
296
+ padding: 24px; border: 1px solid var(--line-strong); border-radius: 17px;
297
+ background: var(--panel); box-shadow: 0 28px 80px rgba(0,0,0,.55);
298
+ animation: card-in .28s cubic-bezier(.16,1,.3,1) both;
299
+ }
300
+ .modal-card h2 { margin: 0 0 4px; font-size: 18px; }
301
+ .modal-card p { margin: 0; color: var(--muted); font-size: 12.5px; }
302
+ .token-box {
303
+ margin: 16px 0 6px; padding: 12px;
304
+ border: 1px solid var(--line); border-radius: 10px;
305
+ background: var(--bg);
306
+ color: #c7dbff; font: 12px/1.6 ui-monospace, SFMono-Regular, Consolas, monospace;
307
+ word-break: break-all;
308
+ }
309
+ .modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
310
+
311
+ @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
312
+ @keyframes card-in { from { opacity: 0; transform: translateY(-10px) scale(.98); } to { opacity: 1; transform: none; } }
313
+ @keyframes rise-in { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: none; } }
314
+ @media (prefers-reduced-motion: reduce) {
315
+ *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
316
+ }
317
+
318
+ @media (max-width: 980px) {
319
+ .layout { grid-template-columns: 1fr; grid-template-areas: "sessions" "storage" "devices"; }
320
+ }
321
+ @media (max-width: 760px) {
322
+ body { font-size: 13px; }
323
+ .app { padding: 16px 14px 36px; }
324
+ .topbar { align-items: flex-start; flex-direction: column; gap: 10px; padding-bottom: 16px; }
325
+ .top-actions { width: 100%; justify-content: space-between; }
326
+ .hero { align-items: flex-start; flex-direction: column; gap: 8px; }
327
+ .metrics { grid-template-columns: repeat(2, minmax(0,1fr)); gap: 9px; }
328
+ .metric { padding: 12px; }
329
+ .metric .v { font-size: 20px; }
330
+ .session-tools { flex-wrap: wrap; }
331
+ .session-tools input { max-width: none; }
332
+ .session-tools select { flex: 1 1 auto; }
333
+ .auth-card { padding: 22px; }
334
+ }
335
+ @media (max-width: 420px) {
336
+ .metrics { grid-template-columns: 1fr; }
337
+ .brand h1 { font-size: 14px; }
338
+ .brand-sub { display: none; }
339
+ }
340
+ </style>
341
+ </head>
342
+ <body>
343
+ <div id="loginView" class="login-page">
344
+ <div class="login-inner">
345
+ <div class="brand-mark"><img src="/logo.svg" alt=""></div>
346
+ <div class="auth-card login-card">
347
+ <span class="eyebrow">Self-hosted console</span>
348
+ <h1>管理台</h1>
349
+ <p class="muted">用初始化时设置的管理密码登录。</p>
350
+ <label class="field">
351
+ <span for="password">管理密码</span>
352
+ <div class="input-wrap">
353
+ <input id="password" type="password" placeholder="管理密码" autocomplete="current-password" autofocus>
354
+ <button id="revealPassword" class="reveal" type="button" aria-label="显示密码">显示</button>
355
+ </div>
356
+ </label>
357
+ <div id="loginError" class="error" role="alert"></div>
358
+ <button id="loginButton" class="btn wide" type="button">登录</button>
359
+ </div>
360
+ <p class="auth-foot">dsh-session-cloudsync · 端到端加密 · 自部署</p>
361
+ </div>
362
+ </div>
363
+
364
+ <div id="appView" class="app" hidden>
365
+ <header class="topbar">
366
+ <div class="brand">
367
+ <img src="/logo.svg" alt="">
368
+ <h1>dsh-session-cloudsync</h1>
369
+ <span class="brand-sub">管理台</span>
370
+ </div>
371
+ <div class="top-actions">
372
+ <span class="version-pill"><i></i><span id="version">连接中…</span></span>
373
+ <button id="refresh" class="btn secondary" type="button">刷新</button>
374
+ <button id="logout" class="btn ghost" type="button">退出</button>
375
+ </div>
376
+ </header>
377
+
378
+ <main>
379
+ <section class="hero">
380
+ <div>
381
+ <span class="eyebrow">Overview</span>
382
+ <h2>同步概览</h2>
383
+ <p class="muted">自部署节点、设备与会话的只读视图。</p>
384
+ </div>
385
+ <div class="health"><i></i><span id="healthText">服务正常</span></div>
386
+ </section>
387
+
388
+ <section id="metrics" class="metrics" aria-label="概览指标"></section>
389
+
390
+ <div class="layout">
391
+ <section class="panel sessions-panel">
392
+ <div class="panel-head">
393
+ <div>
394
+ <h2>会话</h2>
395
+ <p>按服务端更新时间倒序;只展示元数据。</p>
396
+ </div>
397
+ <span id="sessionBadge" class="badge">0 个</span>
398
+ </div>
399
+ <div class="panel-body">
400
+ <div class="session-tools">
401
+ <input id="sessionSearch" type="search" placeholder="搜索 session id…" autocomplete="off">
402
+ <select id="sessionFilter" aria-label="筛选会话">
403
+ <option value="all">全部状态</option>
404
+ <option value="active">正常</option>
405
+ <option value="deleted">已删除</option>
406
+ </select>
407
+ </div>
408
+ <div class="table-wrap">
409
+ <table>
410
+ <thead>
411
+ <tr><th>会话 id</th><th>格式</th><th>批次</th><th>最后序号</th><th>更新时间</th><th>状态</th></tr>
412
+ </thead>
413
+ <tbody id="sessions"></tbody>
414
+ </table>
415
+ </div>
416
+ <div id="sessionsEmpty" class="empty" hidden></div>
417
+ </div>
418
+ </section>
419
+
420
+ <section class="panel storage-panel">
421
+ <div class="panel-head">
422
+ <div>
423
+ <h2>存储</h2>
424
+ <p>按唯一密文字节计算。</p>
425
+ </div>
426
+ <span id="storageBadge" class="badge">0%</span>
427
+ </div>
428
+ <div class="panel-body">
429
+ <div class="storage-line">
430
+ <b id="storageUsed">0 B</b>
431
+ <span id="storageTotal">/ 0 GiB</span>
432
+ </div>
433
+ <div class="bar" role="progressbar" aria-label="存储配额使用率" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
434
+ <i id="storageBar"></i>
435
+ </div>
436
+ <form id="quotaForm" class="quota-form">
437
+ <input id="quotaGb" type="number" min="1" step="1" inputmode="numeric" aria-label="配额 GiB">
438
+ <span class="unit">GiB</span>
439
+ <button id="saveQuota" class="btn small secondary" type="submit">保存</button>
440
+ </form>
441
+ <div id="quotaResult" class="inline-result" role="status"></div>
442
+ </div>
443
+ </section>
444
+
445
+ <section class="panel devices-panel">
446
+ <div class="panel-head">
447
+ <div>
448
+ <h2>设备</h2>
449
+ <p>每台设备一个 token;token 只显示一次。</p>
450
+ </div>
451
+ <span id="deviceBadge" class="badge">0 台</span>
452
+ </div>
453
+ <div class="panel-body">
454
+ <form id="deviceForm" class="device-form">
455
+ <input id="deviceLabel" placeholder="设备标签,例如 PC-1" autocomplete="off" aria-label="设备标签">
456
+ <button id="createDevice" class="btn" type="submit">签发</button>
457
+ </form>
458
+ <div id="deviceError" class="inline-result bad" role="alert"></div>
459
+ <div id="devices" class="device-list"></div>
460
+ </div>
461
+ </section>
462
+ </div>
463
+ </main>
464
+ </div>
465
+
466
+ <div id="toast" class="toast" role="status" aria-live="polite"></div>
467
+
468
+ <div id="tokenDialog" class="modal" hidden>
469
+ <div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="tokenTitle">
470
+ <h2 id="tokenTitle">设备 token</h2>
471
+ <p>只显示这一次。关闭后无法再次查看,只能重新签发。</p>
472
+ <div id="tokenValue" class="token-box" tabindex="0"></div>
473
+ <div class="modal-actions">
474
+ <button id="copyToken" class="btn secondary" type="button">复制 token</button>
475
+ <button id="closeToken" class="btn" type="button">我已保存</button>
476
+ </div>
477
+ </div>
478
+ </div>
479
+
480
+ <script>
481
+ const $ = (id) => document.getElementById(id)
482
+ const state = { overview: null, devices: [], sessions: [] }
483
+ let toastTimer = null
484
+
485
+ function el(tag, className, text) {
486
+ const node = document.createElement(tag)
487
+ if (className) node.className = className
488
+ if (text !== undefined) node.textContent = text
489
+ return node
490
+ }
491
+
492
+ function formatBytes(value) {
493
+ const bytes = Number(value) || 0
494
+ if (bytes < 1024) return bytes + ' B'
495
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
496
+ if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB'
497
+ return (bytes / 1024 / 1024 / 1024).toFixed(2) + ' GiB'
498
+ }
499
+
500
+ function time(value) {
501
+ return value === null || value === undefined ? '—' : new Date(value).toLocaleString()
502
+ }
503
+
504
+ function showToast(text, bad) {
505
+ const node = $('toast')
506
+ node.textContent = text
507
+ node.classList.toggle('bad', bad === true)
508
+ node.classList.add('on')
509
+ clearTimeout(toastTimer)
510
+ toastTimer = setTimeout(() => node.classList.remove('on'), 2800)
511
+ }
512
+
513
+ async function copyText(text) {
514
+ try {
515
+ await navigator.clipboard.writeText(text)
516
+ } catch (error) {
517
+ const area = document.createElement('textarea')
518
+ area.value = text
519
+ area.style.position = 'fixed'
520
+ area.style.opacity = '0'
521
+ document.body.appendChild(area)
522
+ area.select()
523
+ document.execCommand('copy')
524
+ area.remove()
525
+ }
526
+ showToast('已复制')
527
+ }
528
+
529
+ async function api(path, options) {
530
+ const init = options || {}
531
+ const response = await fetch(path, {
532
+ credentials: 'same-origin',
533
+ headers: { 'content-type': 'application/json' },
534
+ ...init,
535
+ })
536
+ if (response.status === 401) {
537
+ showLogin()
538
+ throw new Error('unauthorized')
539
+ }
540
+ const payload = await response.json().catch(() => ({}))
541
+ if (!response.ok) throw new Error((payload && payload.error && payload.error.message) || '请求失败')
542
+ return payload.value
543
+ }
544
+
545
+ function showLogin() {
546
+ $('loginView').hidden = false
547
+ $('appView').hidden = true
548
+ $('loginError').textContent = ''
549
+ setTimeout(() => $('password').focus(), 30)
550
+ }
551
+
552
+ function showApp() {
553
+ $('loginView').hidden = true
554
+ $('appView').hidden = false
555
+ }
556
+
557
+ function renderMetrics(overview) {
558
+ const percent = overview.maxBytes > 0 ? Math.min(100, Math.round(overview.usedBytes / overview.maxBytes * 1000) / 10) : 0
559
+ const items = [
560
+ { key: '设备', value: overview.devices, hint: '已签发 token' },
561
+ { key: '会话', value: overview.sessions, hint: overview.deletedSessions > 0 ? overview.deletedSessions + ' 个墓碑' : '没有墓碑' },
562
+ { key: '已用存储', value: formatBytes(overview.usedBytes), hint: percent + '% · 配额 ' + formatBytes(overview.maxBytes) },
563
+ { key: '同步版本', value: overview.revision, hint: '服务端 revision' },
564
+ ]
565
+ $('metrics').replaceChildren(...items.map((item) => {
566
+ const box = el('div', 'metric')
567
+ box.append(el('div', 'k', item.key), el('div', 'v', String(item.value)), el('div', 'hint', item.hint))
568
+ return box
569
+ }))
570
+ $('version').textContent = 'v' + overview.version + ' · protocol ' + overview.protocol
571
+ $('storageBadge').textContent = percent + '%'
572
+ $('storageUsed').textContent = formatBytes(overview.usedBytes)
573
+ $('storageTotal').textContent = '/ ' + formatBytes(overview.maxBytes)
574
+ $('storageBar').style.width = Math.min(100, percent) + '%'
575
+ const bar = $('storageBar').parentElement
576
+ bar.setAttribute('aria-valuenow', String(percent))
577
+ $('quotaGb').value = String(Math.max(1, Math.round(overview.maxBytes / 1024 / 1024 / 1024)))
578
+ }
579
+
580
+ function renderDevices(devices) {
581
+ $('deviceBadge').textContent = devices.length + ' 台'
582
+ const list = $('devices')
583
+ if (devices.length === 0) {
584
+ const empty = el('div', 'empty')
585
+ empty.append(el('b', null, '还没有设备'), el('span', null, '签发一个 token,把 DSH 插件接到这台服务器。'))
586
+ list.replaceChildren(empty)
587
+ return
588
+ }
589
+ list.replaceChildren(...devices.map((device) => {
590
+ const row = el('div', 'device-row')
591
+ const main = el('div', 'device-main')
592
+ const label = el('div', 'device-label')
593
+ label.append(el('span', null, device.label || 'device'), el('span', 'badge good', '有效'))
594
+ const idLine = el('div', 'device-id')
595
+ idLine.append(el('code', null, device.deviceId))
596
+ const copy = el('button', 'row-copy', '复制')
597
+ copy.type = 'button'
598
+ copy.onclick = () => copyText(device.deviceId)
599
+ idLine.append(copy)
600
+ const meta = el('div', 'device-meta')
601
+ meta.append(
602
+ el('span', null, '创建 ' + time(device.createdAt)),
603
+ el('span', null, '最后在线 ' + (device.lastSeenAt === null || device.lastSeenAt === undefined ? '从未' : time(device.lastSeenAt))),
604
+ )
605
+ main.append(label, idLine, meta)
606
+ const actions = el('div', 'device-actions')
607
+ const revoke = el('button', 'btn small danger', '吊销')
608
+ revoke.type = 'button'
609
+ revoke.onclick = async () => {
610
+ if (!confirm('吊销后该设备 token 立即失效,确定?')) return
611
+ revoke.disabled = true
612
+ revoke.textContent = '吊销中…'
613
+ try {
614
+ await api('/admin/devices/' + encodeURIComponent(device.deviceId), { method: 'DELETE' })
615
+ showToast('设备已吊销')
616
+ await refresh(true)
617
+ } catch (error) {
618
+ showToast(error.message, true)
619
+ revoke.disabled = false
620
+ revoke.textContent = '吊销'
621
+ }
622
+ }
623
+ actions.append(revoke)
624
+ row.append(main, actions)
625
+ return row
626
+ }))
627
+ }
628
+
629
+ function filteredSessions() {
630
+ const query = $('sessionSearch').value.trim().toLowerCase()
631
+ const mode = $('sessionFilter').value
632
+ return state.sessions.filter((session) => {
633
+ if (query && !session.sessionId.toLowerCase().includes(query)) return false
634
+ if (mode === 'active' && session.deletedAt !== null) return false
635
+ if (mode === 'deleted' && session.deletedAt === null) return false
636
+ return true
637
+ })
638
+ }
639
+
640
+ function sessionRow(session) {
641
+ const row = el('tr')
642
+ const idCell = el('td', 'session-id')
643
+ idCell.append(el('code', null, session.sessionId))
644
+ const copy = el('button', 'row-copy', '复制')
645
+ copy.type = 'button'
646
+ copy.onclick = () => copyText(session.sessionId)
647
+ idCell.append(copy)
648
+ row.append(idCell, el('td', null, 'v' + session.formatVersion), el('td', null, String(session.batchCount)), el('td', null, String(session.lastSeq)), el('td', null, time(session.updatedAt)))
649
+ const status = el('td')
650
+ status.append(el('span', 'badge' + (session.deletedAt === null ? ' good' : ' bad'), session.deletedAt === null ? '正常' : '已删除'))
651
+ row.append(status)
652
+ return row
653
+ }
654
+
655
+ function workspaceGroupRow(group) {
656
+ const row = el('tr', 'session-group')
657
+ const cell = el('td')
658
+ cell.colSpan = 6
659
+ const head = el('div', 'session-group-head')
660
+ if (group.key) {
661
+ const input = el('input', 'workspace-alias')
662
+ input.value = workspaceAlias(group.key)
663
+ input.placeholder = '工作区本机别名(仅浏览器)'
664
+ input.setAttribute('aria-label', '工作区别名')
665
+ input.onchange = () => saveWorkspaceAlias(group.key, input.value)
666
+ head.append(input)
667
+ }
668
+ const keyLine = el('div', 'session-group-key')
669
+ keyLine.append(el('code', null, group.key ? group.key.slice(0, 16) + '…' : '未归属(子代理/旧 manifest)'))
670
+ if (group.key) {
671
+ const copy = el('button', 'row-copy', '复制')
672
+ copy.type = 'button'
673
+ copy.onclick = () => copyText(group.key)
674
+ keyLine.append(copy)
675
+ }
676
+ head.append(keyLine)
677
+ head.append(el('div', 'session-group-meta', group.sessions.length + ' 个会话 · 最近 ' + time(group.sessions[0].updatedAt)))
678
+ cell.append(head)
679
+ row.append(cell)
680
+ return row
681
+ }
682
+
683
+ function renderSessions() {
684
+ const all = state.sessions
685
+ const sessions = filteredSessions()
686
+ $('sessionBadge').textContent = (sessions.length === all.length ? all.length : sessions.length + ' / ' + all.length) + ' 个'
687
+ const body = $('sessions')
688
+ // 会话按 manifest 里的 opaque workspaceKey 分组;子代理没有 key,归到“未归属”。
689
+ const groups = new Map()
690
+ for (const session of sessions) {
691
+ const key = session.workspaceKey || null
692
+ const group = groups.get(key) || { key, sessions: [] }
693
+ group.sessions.push(session)
694
+ groups.set(key, group)
695
+ }
696
+ const ordered = [...groups.values()].sort((left, right) => {
697
+ if (left.key === null) return 1
698
+ if (right.key === null) return -1
699
+ return right.sessions[0].updatedAt - left.sessions[0].updatedAt
700
+ })
701
+ const rows = []
702
+ for (const group of ordered) {
703
+ rows.push(workspaceGroupRow(group))
704
+ for (const session of group.sessions) rows.push(sessionRow(session))
705
+ }
706
+ body.replaceChildren(...rows)
707
+ const empty = $('sessionsEmpty')
708
+ const table = body.closest('.table-wrap')
709
+ if (sessions.length === 0) {
710
+ table.hidden = true
711
+ empty.hidden = false
712
+ empty.replaceChildren()
713
+ if (all.length === 0) {
714
+ empty.append(el('b', null, '还没有同步过会话'), el('span', null, '接入 DSH 插件后,会话会出现在这里。'))
715
+ const button = el('button', 'btn secondary small', '签发第一台设备')
716
+ button.type = 'button'
717
+ button.onclick = () => { $('deviceLabel').focus(); $('deviceLabel').scrollIntoView({ behavior: 'smooth', block: 'center' }) }
718
+ empty.append(button)
719
+ } else {
720
+ empty.append(el('b', null, '没有匹配的会话'), el('span', null, '换一个关键词或状态筛选。'))
721
+ const button = el('button', 'btn ghost small', '清除筛选')
722
+ button.type = 'button'
723
+ button.onclick = () => { $('sessionSearch').value = ''; $('sessionFilter').value = 'all'; renderSessions() }
724
+ empty.append(button)
725
+ }
726
+ } else {
727
+ table.hidden = false
728
+ empty.hidden = true
729
+ }
730
+ }
731
+
732
+ const WORKSPACE_ALIAS_PREFIX = 'dsh-sync-workspace-alias:'
733
+
734
+ function workspaceAlias(key) {
735
+ if (!key) return ''
736
+ try { return localStorage.getItem(WORKSPACE_ALIAS_PREFIX + key) || '' } catch { return '' }
737
+ }
738
+
739
+ function saveWorkspaceAlias(key, value) {
740
+ if (!key) return
741
+ try {
742
+ const trimmed = String(value || '').trim()
743
+ if (trimmed === '') localStorage.removeItem(WORKSPACE_ALIAS_PREFIX + key)
744
+ else localStorage.setItem(WORKSPACE_ALIAS_PREFIX + key, trimmed)
745
+ } catch {
746
+ // 浏览器禁用 localStorage 时别名只是不持久化,不影响服务端数据。
747
+ }
748
+ }
749
+
750
+ async function refresh(silent) {
751
+ const button = $('refresh')
752
+ if (!silent) {
753
+ button.disabled = true
754
+ button.textContent = '刷新中…'
755
+ }
756
+ try {
757
+ const [overview, devices, sessions] = await Promise.all([
758
+ api('/admin/overview'),
759
+ api('/admin/devices'),
760
+ api('/admin/sessions'),
761
+ ])
762
+ state.overview = overview
763
+ state.devices = devices
764
+ state.sessions = sessions
765
+ showApp()
766
+ renderMetrics(overview)
767
+ renderDevices(devices)
768
+ // 正在编辑工作区别名时不重绘会话表,避免 10 秒轮询抢走焦点。
769
+ if (!$('sessions').contains(document.activeElement)) renderSessions()
770
+ $('healthText').textContent = '服务正常'
771
+ } catch (error) {
772
+ if (error.message !== 'unauthorized') {
773
+ $('healthText').textContent = '连接异常'
774
+ showToast(error.message, true)
775
+ }
776
+ } finally {
777
+ if (!silent) {
778
+ button.disabled = false
779
+ button.textContent = '刷新'
780
+ }
781
+ }
782
+ }
783
+
784
+ $('loginButton').onclick = async () => {
785
+ const button = $('loginButton')
786
+ $('loginError').textContent = ''
787
+ if ($('password').value === '') {
788
+ $('loginError').textContent = '请输入管理密码'
789
+ $('password').focus()
790
+ return
791
+ }
792
+ button.disabled = true
793
+ button.textContent = '登录中…'
794
+ try {
795
+ const response = await fetch('/admin/login', {
796
+ method: 'POST',
797
+ headers: { 'content-type': 'application/json' },
798
+ body: JSON.stringify({ password: $('password').value }),
799
+ })
800
+ if (!response.ok) {
801
+ const payload = await response.json().catch(() => ({}))
802
+ $('loginError').textContent = (payload && payload.error && payload.error.message) || '登录失败'
803
+ return
804
+ }
805
+ $('password').value = ''
806
+ await refresh(false)
807
+ } catch (error) {
808
+ $('loginError').textContent = error.message || '登录失败'
809
+ } finally {
810
+ button.disabled = false
811
+ button.textContent = '登录'
812
+ }
813
+ }
814
+ $('password').addEventListener('keydown', (event) => { if (event.key === 'Enter') $('loginButton').click() })
815
+ $('revealPassword').onclick = () => {
816
+ const input = $('password')
817
+ const reveal = input.type === 'password'
818
+ input.type = reveal ? 'text' : 'password'
819
+ $('revealPassword').textContent = reveal ? '隐藏' : '显示'
820
+ $('revealPassword').setAttribute('aria-label', reveal ? '隐藏密码' : '显示密码')
821
+ }
822
+ $('logout').onclick = async () => {
823
+ await fetch('/admin/logout', { method: 'POST' }).catch(() => {})
824
+ showLogin()
825
+ }
826
+ $('refresh').onclick = () => refresh(false)
827
+ $('quotaForm').addEventListener('submit', async (event) => {
828
+ event.preventDefault()
829
+ const button = $('saveQuota')
830
+ const gib = Number($('quotaGb').value)
831
+ $('quotaResult').textContent = ''
832
+ $('quotaResult').className = 'inline-result'
833
+ if (!Number.isFinite(gib) || gib < 1) {
834
+ $('quotaResult').textContent = '至少 1 GiB'
835
+ $('quotaResult').className = 'inline-result bad'
836
+ return
837
+ }
838
+ button.disabled = true
839
+ button.textContent = '保存中…'
840
+ try {
841
+ await api('/admin/quota', { method: 'POST', body: JSON.stringify({ maxBytes: Math.round(gib * 1024 * 1024 * 1024) }) })
842
+ $('quotaResult').textContent = '已保存'
843
+ $('quotaResult').className = 'inline-result good'
844
+ await refresh(true)
845
+ } catch (error) {
846
+ $('quotaResult').textContent = error.message
847
+ $('quotaResult').className = 'inline-result bad'
848
+ } finally {
849
+ button.disabled = false
850
+ button.textContent = '保存'
851
+ }
852
+ })
853
+ $('deviceForm').addEventListener('submit', async (event) => {
854
+ event.preventDefault()
855
+ const button = $('createDevice')
856
+ $('deviceError').textContent = ''
857
+ button.disabled = true
858
+ button.textContent = '签发中…'
859
+ try {
860
+ const created = await api('/admin/devices', { method: 'POST', body: JSON.stringify({ label: $('deviceLabel').value.trim() || 'device' }) })
861
+ $('deviceLabel').value = ''
862
+ showToken(created)
863
+ await refresh(true)
864
+ } catch (error) {
865
+ $('deviceError').textContent = error.message
866
+ } finally {
867
+ button.disabled = false
868
+ button.textContent = '签发'
869
+ }
870
+ })
871
+ function showToken(created) {
872
+ $('tokenValue').textContent = created.token
873
+ $('tokenDialog').hidden = false
874
+ document.body.classList.add('modal-open')
875
+ $('copyToken').onclick = () => copyText(created.token)
876
+ setTimeout(() => $('copyToken').focus(), 30)
877
+ }
878
+ function closeToken() {
879
+ $('tokenDialog').hidden = true
880
+ document.body.classList.remove('modal-open')
881
+ $('tokenValue').textContent = ''
882
+ }
883
+ $('closeToken').onclick = closeToken
884
+ $('tokenDialog').addEventListener('click', (event) => { if (event.target === $('tokenDialog')) closeToken() })
885
+ document.addEventListener('keydown', (event) => {
886
+ if (event.key === 'Escape' && !$('tokenDialog').hidden) closeToken()
887
+ })
888
+ $('sessionSearch').addEventListener('input', renderSessions)
889
+ $('sessionFilter').addEventListener('change', renderSessions)
890
+
891
+ refresh(false)
892
+ setInterval(() => { if (!$('appView').hidden) refresh(true) }, 10000)
893
+ </script>
894
+ </body>
895
+ </html>