dscs-server 0.1.3 → 0.1.5
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/README.md +59 -17
- package/dist/admin-password.js +27 -0
- package/dist/audit.js +73 -0
- package/dist/db.js +174 -3
- package/dist/http.js +433 -85
- package/dist/index.js +23 -7
- package/dist/session-admin.js +214 -0
- package/dist/session-history.js +134 -0
- package/dist/sessions.js +133 -73
- package/dist/shared/blob-batch.js +4 -2
- package/dist/shared/wire.js +1 -0
- package/package.json +1 -1
- package/web/admin.html +1833 -297
package/web/admin.html
CHANGED
|
@@ -30,6 +30,15 @@
|
|
|
30
30
|
}
|
|
31
31
|
* { box-sizing: border-box; }
|
|
32
32
|
html, body { min-height: 100%; }
|
|
33
|
+
html { scrollbar-width: thin; scrollbar-color: #3a4350 transparent; scrollbar-gutter: stable; }
|
|
34
|
+
@supports not (scrollbar-gutter: stable) { html { overflow-y: scroll; } }
|
|
35
|
+
*::-webkit-scrollbar { width: 8px; height: 8px; }
|
|
36
|
+
*::-webkit-scrollbar-track { background: transparent; }
|
|
37
|
+
*::-webkit-scrollbar-thumb { background: #3a4350; border: 2px solid transparent; border-radius: 999px; background-clip: content-box; }
|
|
38
|
+
*::-webkit-scrollbar-thumb:hover { background: #4a5566; border: 2px solid transparent; background-clip: content-box; }
|
|
39
|
+
*::-webkit-scrollbar-corner { background: transparent; }
|
|
40
|
+
*::-webkit-scrollbar-button { display: none; width: 0; height: 0; }
|
|
41
|
+
.table-wrap { scrollbar-gutter: stable; overflow-x: auto; overflow-y: hidden; }
|
|
33
42
|
body {
|
|
34
43
|
margin: 0;
|
|
35
44
|
background:
|
|
@@ -64,7 +73,12 @@
|
|
|
64
73
|
.btn.danger { background: transparent; border-color: rgba(239,119,119,.38); color: #ffabab; }
|
|
65
74
|
.btn.danger:hover { background: var(--bad-soft); border-color: rgba(239,119,119,.58); }
|
|
66
75
|
.btn.small { min-height: 27px; padding: 3px 9px; font-size: 11.5px; }
|
|
76
|
+
.btn.tiny { min-height: 22px; padding: 1px 7px; font-size: 10.5px; border-radius: 6px; }
|
|
77
|
+
.row-actions { display: flex; gap: 4px; }
|
|
67
78
|
.btn.wide { width: 100%; }
|
|
79
|
+
.btn svg { flex: 0 0 auto; }
|
|
80
|
+
.spinner { animation: spin .9s linear infinite; }
|
|
81
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
68
82
|
|
|
69
83
|
.input, input, select {
|
|
70
84
|
width: 100%; min-height: 36px;
|
|
@@ -82,36 +96,12 @@
|
|
|
82
96
|
input::placeholder { color: var(--faint); }
|
|
83
97
|
|
|
84
98
|
/* login */
|
|
85
|
-
.login-page {
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
}
|
|
99
|
+
.login-page { position: relative; z-index: 1; min-height: 100vh; display: grid; place-items: center; padding: 28px 20px; }
|
|
100
|
+
.login-inner { width: min(420px, 100%); display: flex; flex-direction: column; align-items: center; animation: card-in .38s cubic-bezier(.16,1,.3,1) both; }
|
|
101
|
+
.brand-mark { width: 44px; height: 44px; border-radius: 13px; display: grid; place-items: center; background: linear-gradient(145deg, #24314a, #17202f); border: 1px solid #33415a; box-shadow: 0 12px 30px rgba(0,0,0,.28); }
|
|
103
102
|
.brand-mark img { width: 26px; height: 26px; }
|
|
104
|
-
.auth-card {
|
|
105
|
-
|
|
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
|
-
}
|
|
103
|
+
.auth-card { width: 100%; margin-top: 18px; padding: 28px; border: 1px solid var(--line); border-radius: 18px; background: linear-gradient(180deg, rgba(32,37,46,.92), rgba(26,30,37,.96)); box-shadow: var(--shadow); }
|
|
104
|
+
.eyebrow { display: inline-block; color: var(--accent-2); font-size: 10.5px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; }
|
|
115
105
|
.auth-card h1 { margin: 8px 0 4px; font-size: 24px; line-height: 1.2; letter-spacing: -.01em; }
|
|
116
106
|
.auth-card .muted { margin: 0; }
|
|
117
107
|
.auth-card .field { margin-top: 22px; }
|
|
@@ -120,96 +110,115 @@
|
|
|
120
110
|
.field > span, .field-label { color: var(--muted); font-size: 12px; font-weight: 500; }
|
|
121
111
|
.input-wrap { position: relative; }
|
|
122
112
|
.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
|
-
}
|
|
113
|
+
.input-wrap .reveal { position: absolute; right: 5px; top: 50%; transform: translateY(-50%); border: 0; background: transparent; color: var(--muted); font-size: 11.5px; padding: 5px 7px; border-radius: 6px; }
|
|
128
114
|
.input-wrap .reveal:hover { color: var(--text); background: rgba(255,255,255,.05); }
|
|
129
115
|
.error { min-height: 20px; margin-top: 7px; color: #ffaaaa; font-size: 12px; }
|
|
130
116
|
.login-card .error { margin-top: 9px; }
|
|
131
117
|
|
|
118
|
+
/* 背景:斜向胶囊条带,低频漂移 + 首屏入场 */
|
|
119
|
+
.admin-bg { position: fixed; inset: 0; z-index: 0; overflow: hidden; pointer-events: none; background: radial-gradient(60vw 40vw at 20% 0%, rgba(111,141,255,.08), transparent 70%); }
|
|
120
|
+
.admin-bg-plane { position: absolute; inset: -55% -30%; transform: rotate(37deg); }
|
|
121
|
+
.admin-bg-rows { position: absolute; inset: 0; }
|
|
122
|
+
.admin-bg-row { position: absolute; left: -280px; right: -280px; height: var(--row, 30px); background-repeat: repeat; opacity: .7; animation: admin-drift-right var(--dur, 8s) linear infinite, admin-enter 2.8s cubic-bezier(.2,.8,.2,1) both; }
|
|
123
|
+
.admin-bg-row.odd { animation-name: admin-drift-left, admin-enter; }
|
|
124
|
+
.admin-bg-shade { position: absolute; inset: 0; pointer-events: none; background-image: radial-gradient(125% 94% at 0% 0%, rgba(130,160,255,.10), transparent 62%), radial-gradient(135% 135% at 50% 50%, transparent 20%, rgba(0,0,0,.55) 100%); }
|
|
125
|
+
@keyframes admin-enter { from { translate: var(--enter-from, 0px) 0; } to { translate: var(--enter-to, 0px) 0; } }
|
|
126
|
+
@keyframes admin-drift-right { from { transform: translateX(0); } to { transform: translateX(var(--tile, 58px)); } }
|
|
127
|
+
@keyframes admin-drift-left { from { transform: translateX(0); } to { transform: translateX(calc(var(--tile, 58px) * -1)); } }
|
|
128
|
+
@media (prefers-reduced-motion: reduce) { .admin-bg-row { animation: none; opacity: .45; } }
|
|
129
|
+
|
|
132
130
|
/* app shell */
|
|
133
|
-
.app { max-width:
|
|
134
|
-
.topbar {
|
|
135
|
-
|
|
136
|
-
|
|
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; }
|
|
131
|
+
.app { position: relative; z-index: 1; max-width: 1240px; margin: 0 auto; padding: 22px 24px 60px; }
|
|
132
|
+
.topbar { display: flex; align-items: center; gap: 12px; padding: 8px 0 18px; animation: fade-in .24s ease both; }
|
|
133
|
+
.brand { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 0 0 auto; }
|
|
134
|
+
.brand img { width: 30px; height: 30px; border-radius: 9px; }
|
|
141
135
|
.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
|
-
.
|
|
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
|
-
}
|
|
136
|
+
.brand .brand-sub { color: var(--faint); font-size: 11.5px; margin-left: 2px; white-space: nowrap; }
|
|
137
|
+
.topbar .spacer { flex: 1 1 auto; }
|
|
138
|
+
.version-pill { display: inline-flex; align-items: center; gap: 6px; color: var(--faint); font-size: 11.5px; padding: 4px 9px; border: 1px solid var(--line); border-radius: 999px; white-space: nowrap; }
|
|
149
139
|
.version-pill i { width: 6px; height: 6px; border-radius: 50%; background: var(--good); box-shadow: 0 0 0 3px var(--good-soft); }
|
|
150
|
-
.
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
.hero h2 { margin: 5px 0 4px; font-size: 22px; line-height: 1.2; letter-spacing: -.015em; }
|
|
140
|
+
.topbar .badge { white-space: nowrap; }
|
|
141
|
+
.top-actions { display: flex; align-items: center; gap: 8px; }
|
|
142
|
+
|
|
143
|
+
.hero { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin: 6px 0 18px; }
|
|
144
|
+
.hero h2 { margin: 5px 0 4px; font-size: 26px; line-height: 1.2; letter-spacing: -.02em; }
|
|
156
145
|
.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);
|
|
159
|
-
|
|
160
|
-
.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
.metric:
|
|
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; }
|
|
146
|
+
.health { display: inline-flex; align-items: center; gap: 7px; padding: 6px 11px; border: 1px solid rgba(94,211,146,.28); border-radius: 999px; background: var(--good-soft); color: var(--good); font-size: 12px; white-space: nowrap; }
|
|
147
|
+
.health i { width: 7px; height: 7px; border-radius: 50%; background: var(--good); }
|
|
148
|
+
.health.bad { border-color: rgba(239,119,119,.35); background: var(--bad-soft); color: #ffabab; }
|
|
149
|
+
.health.bad i { background: var(--bad); }
|
|
150
|
+
|
|
151
|
+
.metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px; margin-bottom: 16px; }
|
|
152
|
+
.metric { padding: 14px 15px; border: 1px solid var(--line); border-radius: var(--radius); background: linear-gradient(180deg, rgba(30,35,44,.9), rgba(24,28,35,.94)); animation: rise-in .34s cubic-bezier(.16,1,.3,1) both; }
|
|
153
|
+
.metric .k { display: flex; align-items: center; gap: 7px; color: var(--faint); font-size: 11.5px; white-space: nowrap; }
|
|
154
|
+
.metric .k svg { color: var(--muted); }
|
|
155
|
+
.metric .v { margin-top: 6px; font-size: 24px; line-height: 1.1; font-weight: 650; font-variant-numeric: tabular-nums; }
|
|
171
156
|
.metric .hint { margin-top: 3px; color: var(--faint); font-size: 11px; }
|
|
157
|
+
.metric .bar { height: 4px; margin-top: 8px; border-radius: 2px; background: rgba(255,255,255,.07); overflow: hidden; }
|
|
158
|
+
.metric .bar i { display: block; height: 100%; background: var(--accent); }
|
|
172
159
|
|
|
173
|
-
.
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
}
|
|
160
|
+
.activity-panel { margin-bottom: 12px; }
|
|
161
|
+
.activity-range { position: relative; }
|
|
162
|
+
.range-popover { position: absolute; right: 0; top: calc(100% + 8px); z-index: 30; width: 280px; padding: 14px; border: 1px solid var(--line-strong); border-radius: 12px; background: var(--panel); box-shadow: 0 18px 50px rgba(0,0,0,.45); }
|
|
163
|
+
.range-presets { display: flex; gap: 6px; margin-bottom: 12px; }
|
|
164
|
+
.range-presets button { border: 1px solid var(--line); background: transparent; color: var(--muted); border-radius: 7px; min-height: 26px; padding: 0 9px; font-size: 11px; cursor: pointer; }
|
|
165
|
+
.range-presets button:hover { color: var(--text); border-color: var(--line-strong); }
|
|
166
|
+
.range-fields { display: grid; gap: 9px; }
|
|
167
|
+
.range-fields label { display: flex; align-items: center; justify-content: space-between; gap: 10px; color: var(--muted); font-size: 11.5px; }
|
|
168
|
+
.range-fields input { width: 160px; min-height: 32px; }
|
|
169
|
+
.range-actions { display: flex; justify-content: flex-end; gap: 7px; margin-top: 13px; }
|
|
170
|
+
.activity-legend { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 11px; }
|
|
171
|
+
.activity-device { display: inline-flex; align-items: center; gap: 5px; padding: 3px 8px; border: 1px solid var(--line); border-radius: 999px; background: transparent; color: var(--muted); font-size: 11px; cursor: pointer; white-space: nowrap; }
|
|
172
|
+
.activity-device:hover { color: var(--text); border-color: var(--line-strong); }
|
|
173
|
+
.activity-device.on { color: var(--text); border-color: rgba(91,136,255,.55); background: var(--accent-soft); }
|
|
174
|
+
.activity-device i { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
|
|
175
|
+
.activity-grid-wrap { overflow-x: auto; padding: 2px 0 4px; }
|
|
176
|
+
.activity-grid { display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 15px); grid-auto-columns: 15px; gap: 4px; width: max-content; }
|
|
177
|
+
.heat-cell { width: 15px; height: 15px; border-radius: 3px; background: rgba(255,255,255,.045); cursor: pointer; }
|
|
178
|
+
.heat-cell:hover { outline: 1px solid rgba(255,255,255,.65); }
|
|
179
|
+
.heat-blank { background: transparent; }
|
|
180
|
+
.heat-level-1 { background: rgba(94,211,146,.22); }
|
|
181
|
+
.heat-level-2 { background: rgba(94,211,146,.4); }
|
|
182
|
+
.heat-level-3 { background: rgba(94,211,146,.65); }
|
|
183
|
+
.heat-level-4 { background: rgba(94,211,146,.92); }
|
|
184
|
+
.activity-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 9px; color: var(--faint); font-size: 11px; flex-wrap: wrap; }
|
|
185
|
+
.heat-legend { display: inline-flex; align-items: center; gap: 4px; }
|
|
186
|
+
.heat-legend .heat-cell { width: 10px; height: 10px; }
|
|
187
|
+
.layout { display: grid; grid-template-columns: minmax(0, 360px) minmax(0, 1fr); grid-template-areas: "sessions sessions" "storage devices"; gap: 12px; align-items: stretch; }
|
|
180
188
|
.sessions-panel { grid-area: sessions; }
|
|
189
|
+
.audit-panel { margin-top: 12px; }
|
|
190
|
+
.audit-more { display: flex; justify-content: center; padding-top: 10px; }
|
|
191
|
+
.audit-detail { max-width: 360px; overflow: hidden; text-overflow: ellipsis; color: var(--muted); }
|
|
192
|
+
.audit-target { color: var(--muted); font-size: 11px; }
|
|
193
|
+
.audit-action { font-weight: 600; }
|
|
194
|
+
.audit-action.good { color: #9ce8bc; }
|
|
195
|
+
.audit-action.bad { color: #ffb0b0; }
|
|
196
|
+
.audit-action.warn { color: var(--warn); }
|
|
181
197
|
.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
198
|
.devices-panel { grid-area: devices; }
|
|
185
|
-
.panel {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
}
|
|
191
|
-
.panel-head {
|
|
192
|
-
.panel-
|
|
193
|
-
.
|
|
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
|
-
}
|
|
199
|
+
.panel { border: 1px solid var(--line); border-radius: var(--radius); background: linear-gradient(180deg, rgba(28,33,41,.92), rgba(23,27,34,.96)); overflow: hidden; animation: rise-in .36s cubic-bezier(.16,1,.3,1) both; }
|
|
200
|
+
.panel-head { display: flex; align-items: center; gap: 10px; padding: 14px 16px 10px; }
|
|
201
|
+
.panel-icon { width: 28px; height: 28px; border-radius: 8px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid var(--line); background: var(--bg-soft); color: var(--muted); }
|
|
202
|
+
.panel-title { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
|
203
|
+
.panel-title b { margin: 0; font-size: 14px; font-weight: 650; }
|
|
204
|
+
.panel-title span { margin: 0; color: var(--faint); font-size: 11.5px; }
|
|
205
|
+
.panel-head .spacer { flex: 1 1 auto; }
|
|
206
|
+
.panel-body { padding: 4px 16px 16px; }
|
|
207
|
+
.badge { display: inline-flex; align-items: center; gap: 5px; min-height: 22px; padding: 1px 8px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); background: rgba(255,255,255,.025); font-size: 11px; white-space: nowrap; }
|
|
202
208
|
.badge.good { color: #9ce8bc; border-color: rgba(94,211,146,.28); background: var(--good-soft); }
|
|
203
209
|
.badge.bad { color: #ffb0b0; border-color: rgba(239,119,119,.3); background: var(--bad-soft); }
|
|
204
210
|
.badge.accent { color: #b9ccff; border-color: rgba(91,136,255,.32); background: var(--accent-soft); }
|
|
205
211
|
|
|
206
|
-
|
|
207
|
-
.storage-line {
|
|
208
|
-
.storage-line b { font-size: 17px; font-weight: 650; font-variant-numeric: tabular-nums; }
|
|
212
|
+
.storage-line { display: flex; align-items: baseline; gap: 7px; margin-bottom: 8px; }
|
|
213
|
+
.storage-line b { font-size: 24px; font-weight: 650; font-variant-numeric: tabular-nums; }
|
|
209
214
|
.storage-line span { color: var(--faint); font-size: 11.5px; }
|
|
210
|
-
.bar { height:
|
|
215
|
+
.bar { height: 6px; border-radius: 3px; background: rgba(255,255,255,.07); overflow: hidden; }
|
|
211
216
|
.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
|
-
.
|
|
217
|
+
.presets { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 12px; }
|
|
218
|
+
.preset { height: 26px; padding: 0 9px; border-radius: 7px; border: 1px solid var(--line); background: transparent; color: var(--muted); font-size: 11.5px; }
|
|
219
|
+
.preset:hover { color: var(--text); border-color: var(--line-strong); }
|
|
220
|
+
.preset.on { border-color: rgba(91,136,255,.5); background: var(--accent-soft); color: var(--accent-2); }
|
|
221
|
+
.quota-form { display: flex; align-items: center; gap: 7px; margin-top: 12px; }
|
|
213
222
|
.quota-form input { width: 84px; min-height: 32px; }
|
|
214
223
|
.quota-form .unit { color: var(--faint); font-size: 11.5px; }
|
|
215
224
|
.quota-form .btn { margin-left: auto; }
|
|
@@ -217,129 +226,172 @@
|
|
|
217
226
|
.inline-result.good { color: var(--good); }
|
|
218
227
|
.inline-result.bad { color: #ffaaaa; }
|
|
219
228
|
|
|
220
|
-
|
|
221
|
-
.device-form { display: flex; gap: 7px; }
|
|
229
|
+
.device-form { display: flex; gap: 7px; max-width: 520px; }
|
|
222
230
|
.device-form input { min-width: 0; }
|
|
223
231
|
.device-form .btn { flex: 0 0 auto; }
|
|
224
|
-
.device-list { margin-top:
|
|
225
|
-
.device-
|
|
226
|
-
.device-row {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}
|
|
231
|
-
.device-
|
|
232
|
-
.device-label {
|
|
233
|
-
.device-id { display: flex; align-items: center; gap:
|
|
234
|
-
.device-id code { color: var(--muted);
|
|
235
|
-
.
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
232
|
+
.device-list { margin-top: 10px; display: flex; flex-direction: column; }
|
|
233
|
+
.device-row { display: flex; align-items: center; gap: 12px; padding: 11px 4px; border-top: 1px solid var(--line); }
|
|
234
|
+
.device-row:first-child { border-top: 0; }
|
|
235
|
+
.device-main { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1 1 auto; }
|
|
236
|
+
.device-label { display: flex; align-items: center; gap: 7px; font-size: 12.5px; font-weight: 600; white-space: nowrap; }
|
|
237
|
+
.device-label .device-color { width: 7px; height: 7px; border-radius: 2px; background: var(--faint); flex: 0 0 auto; }
|
|
238
|
+
.device-label .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--faint); flex: 0 0 auto; }
|
|
239
|
+
.device-label .dot.good { background: var(--good); box-shadow: 0 0 0 3px var(--good-soft); }
|
|
240
|
+
.device-label .dot.warn { background: var(--warn); box-shadow: 0 0 0 3px rgba(240,182,74,.12); }
|
|
241
|
+
.device-id { display: flex; align-items: center; gap: 4px; color: var(--faint); font-size: 11px; }
|
|
242
|
+
.device-id code { color: var(--muted); }
|
|
243
|
+
.device-meta { display: flex; gap: 12px; color: var(--faint); font-size: 11px; flex-wrap: wrap; }
|
|
244
|
+
.device-actions { display: flex; gap: 6px; flex: 0 0 auto; }
|
|
245
|
+
.inline-edit { display: flex; align-items: center; gap: 6px; }
|
|
246
|
+
.inline-edit input { height: 28px; width: 160px; min-height: 28px; padding: 0 8px; font-size: 12.5px; border-color: var(--accent); }
|
|
247
|
+
.icon-btn { border: 0; background: transparent; color: var(--faint); border-radius: 6px; padding: 2px 5px; font-size: 10.5px; display: inline-flex; align-items: center; gap: 3px; }
|
|
239
248
|
.icon-btn:hover { color: var(--text); background: rgba(255,255,255,.06); }
|
|
240
|
-
.
|
|
241
|
-
.device-actions { display: flex; align-items: flex-start; }
|
|
249
|
+
.icon-btn.danger:hover { color: #ffb0b0; background: var(--bad-soft); }
|
|
242
250
|
|
|
243
|
-
|
|
244
|
-
.session-tools {
|
|
245
|
-
.session-tools input { min-width: 0; flex: 1 1 auto; max-width: 300px; min-height: 32px; }
|
|
251
|
+
.session-tools { display: flex; align-items: center; gap: 7px; margin-bottom: 11px; flex-wrap: wrap; }
|
|
252
|
+
.session-tools input { min-width: 0; flex: 1 1 220px; max-width: 360px; min-height: 32px; }
|
|
246
253
|
.session-tools select { width: auto; min-width: 112px; min-height: 32px; }
|
|
254
|
+
.session-tools .spacer { flex: 1 1 auto; }
|
|
255
|
+
.switch { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font-size: 12px; cursor: pointer; user-select: none; white-space: nowrap; }
|
|
256
|
+
.switch input { display: none; }
|
|
257
|
+
.switch i { width: 30px; height: 17px; border-radius: 999px; background: #303845; position: relative; transition: background .15s; }
|
|
258
|
+
.switch i::after { content: ""; position: absolute; top: 2px; left: 2px; width: 13px; height: 13px; border-radius: 50%; background: #9aa5b5; transition: transform .15s, background .15s; }
|
|
259
|
+
.switch input:checked + i { background: rgba(91,136,255,.6); }
|
|
260
|
+
.switch input:checked + i::after { transform: translateX(13px); background: #fff; }
|
|
261
|
+
|
|
247
262
|
.table-wrap { overflow: auto; border: 1px solid var(--line); border-radius: 10px; }
|
|
248
263
|
table { width: 100%; border-collapse: collapse; font-size: 11.5px; }
|
|
249
264
|
th, td { text-align: left; padding: 9px 10px; border-bottom: 1px solid rgba(255,255,255,.055); white-space: nowrap; }
|
|
250
265
|
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
266
|
tr:last-child td { border-bottom: 0; }
|
|
252
267
|
tbody tr:hover td { background: rgba(255,255,255,.025); }
|
|
253
|
-
|
|
254
|
-
|
|
268
|
+
td.session-id { max-width: 320px; overflow: hidden; text-overflow: ellipsis; }
|
|
269
|
+
td.session-id code { color: #b8c7de; }
|
|
270
|
+
.row-copy { border: 0; background: transparent; color: var(--faint); padding: 2px 4px; border-radius: 5px; font-size: 10px; display: inline-flex; align-items: center; }
|
|
271
|
+
.row-copy:hover { color: var(--text); background: rgba(255,255,255,.07); }
|
|
255
272
|
.session-group td { background: rgba(255,255,255,.03); padding: 7px 10px; }
|
|
256
273
|
.session-group-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
274
|
+
.chev { display: grid; place-items: center; width: 20px; height: 20px; border: 0; border-radius: 5px; background: transparent; color: var(--muted); transition: transform .15s; padding: 0; }
|
|
275
|
+
.chev.closed { transform: rotate(-90deg); }
|
|
257
276
|
.workspace-alias { width: 150px; min-height: 26px; font-size: 11px; }
|
|
258
277
|
.session-group-key { display: flex; align-items: center; gap: 6px; color: var(--faint); font-size: 10.5px; }
|
|
259
278
|
.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
|
-
|
|
262
|
-
|
|
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); }
|
|
279
|
+
.session-group-meta { margin-left: auto; color: var(--faint); font-size: 10.5px; white-space: nowrap; }
|
|
280
|
+
.empty { padding: 30px 16px; text-align: center; color: var(--faint); }
|
|
281
|
+
.empty .icon { color: var(--muted); margin-bottom: 6px; }
|
|
266
282
|
.empty b { display: block; margin-bottom: 3px; color: var(--muted); font-size: 12.5px; }
|
|
267
283
|
.empty .btn { margin-top: 10px; }
|
|
268
|
-
.empty-filter { color: var(--faint); font-size: 11.5px; padding: 16px; text-align: center; }
|
|
269
284
|
|
|
270
285
|
.muted { color: var(--muted); }
|
|
271
286
|
.faint { color: var(--faint); }
|
|
272
287
|
.mono { font-variant-ligatures: none; }
|
|
273
288
|
|
|
274
|
-
|
|
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
|
-
}
|
|
289
|
+
.toast { position: fixed; left: 50%; bottom: 24px; z-index: 80; max-width: min(520px, calc(100vw - 32px)); padding: 9px 14px; border: 1px solid var(--line-strong); border-radius: 10px; background: rgba(30,35,44,.97); color: var(--text); box-shadow: 0 16px 40px rgba(0,0,0,.4); font-size: 12px; opacity: 0; pointer-events: none; transform: translate(-50%, 8px); transition: opacity .18s, transform .18s; }
|
|
285
290
|
.toast.on { opacity: 1; transform: translate(-50%, 0); }
|
|
286
291
|
.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
|
-
}
|
|
292
|
+
.modal { position: fixed; inset: 0; z-index: 70; display: grid; place-items: center; padding: 24px; background: rgba(6,8,11,.66); backdrop-filter: blur(6px); animation: fade-in .18s ease both; }
|
|
293
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
|
-
}
|
|
294
|
+
.modal-card { width: min(520px, 100%); padding: 24px; border: 1px solid var(--line-strong); border-radius: 17px; background: var(--panel); box-shadow: 0 28px 80px rgba(0,0,0,.55); animation: card-in .28s cubic-bezier(.16,1,.3,1) both; }
|
|
300
295
|
.modal-card h2 { margin: 0 0 4px; font-size: 18px; }
|
|
301
296
|
.modal-card p { margin: 0; color: var(--muted); font-size: 12.5px; }
|
|
302
|
-
.
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
}
|
|
297
|
+
.modal-card .field { margin-top: 12px; }
|
|
298
|
+
.modal-card .field input { min-height: 34px; }
|
|
299
|
+
.device-color-btn { border: 0; background: transparent; padding: 0; display: inline-flex; cursor: pointer; border-radius: 8px; }
|
|
300
|
+
.device-color-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
301
|
+
.device-avatar { width: 24px; height: 24px; border-radius: 8px; display: grid; place-items: center; font-size: 12px; font-weight: 700; line-height: 1; box-shadow: inset 0 0 0 1px rgba(255,255,255,.16); }
|
|
302
|
+
.device-status { display: inline-flex; align-items: center; gap: 5px; color: var(--faint); font-size: 11px; font-weight: 400; }
|
|
303
|
+
.device-status .dot { width: 6px; height: 6px; box-shadow: none; }
|
|
304
|
+
.device-status .dot.idle { background: transparent; border: 1px solid var(--faint); }
|
|
305
|
+
.device-status.online { color: var(--good); }
|
|
306
|
+
.color-picker-row { display: flex; align-items: center; gap: 10px; margin-top: 12px; }
|
|
307
|
+
.color-picker-row input[type="color"] { width: 62px; height: 42px; padding: 3px; border: 1px solid var(--line-strong); border-radius: 9px; background: var(--bg-soft); cursor: pointer; }
|
|
308
|
+
.color-picker-row input[type="color"]::-webkit-color-swatch-wrapper { padding: 0; }
|
|
309
|
+
.color-picker-row input[type="color"]::-webkit-color-swatch { border: 0; border-radius: 6px; }
|
|
310
|
+
.color-hex { width: 112px; min-height: 34px; padding: 6px 9px; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--bg-soft); color: var(--text); outline: none; font: 12px ui-monospace, Consolas, monospace; }
|
|
311
|
+
.color-hex:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
|
312
|
+
.color-hint { margin-top: 9px; color: var(--faint); font-size: 11px; }
|
|
313
|
+
.color-presets { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
|
|
314
|
+
.color-preset { width: 28px; height: 28px; padding: 0; border: 2px solid transparent; border-radius: 8px; cursor: pointer; box-shadow: inset 0 0 0 1px rgba(255,255,255,.18); }
|
|
315
|
+
.color-preset.on { border-color: #fff; }
|
|
316
|
+
.color-live { display: flex; align-items: center; gap: 8px; margin-top: 14px; padding: 9px 11px; border: 1px solid var(--line); border-radius: 9px; background: rgba(0,0,0,.12); color: var(--muted); font-size: 11px; flex-wrap: wrap; }
|
|
317
|
+
.color-live .swatch { width: 12px; height: 12px; border-radius: 3px; box-shadow: inset 0 0 0 1px rgba(255,255,255,.25); }
|
|
318
|
+
.color-live .online { width: 7px; height: 7px; border-radius: 50%; background: var(--good); }
|
|
319
|
+
.history-card { width: min(760px, 100%); max-height: calc(100vh - 48px); display: flex; flex-direction: column; }
|
|
320
|
+
.history-summary { display: flex; flex-wrap: wrap; gap: 6px; margin: 12px 0; }
|
|
321
|
+
.history-chip { display: inline-flex; align-items: center; gap: 5px; padding: 3px 8px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: 11px; white-space: nowrap; }
|
|
322
|
+
.history-chip i { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
|
|
323
|
+
.history-tabs { display: flex; gap: 6px; margin: 12px 0 8px; }
|
|
324
|
+
.history-tab { border: 1px solid var(--line); background: transparent; color: var(--muted); border-radius: 7px; min-height: 26px; padding: 0 9px; font-size: 11px; cursor: pointer; }
|
|
325
|
+
.history-tab:hover { color: var(--text); border-color: var(--line-strong); }
|
|
326
|
+
.history-tab.on { color: var(--text); border-color: rgba(91,136,255,.5); background: var(--accent-soft); }
|
|
327
|
+
.history-pane { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
|
|
328
|
+
.history-list { flex: 1 1 auto; min-height: 80px; overflow: auto; border: 1px solid var(--line); border-radius: 10px; background: rgba(0,0,0,.12); }
|
|
329
|
+
.history-gantt { flex: 1 1 auto; overflow: auto; border: 1px solid var(--line); border-radius: 10px; background: rgba(0,0,0,.12); padding: 12px; display: flex; flex-direction: column; gap: 10px; }
|
|
330
|
+
.history-gantt-row { display: grid; grid-template-columns: 110px minmax(0,1fr); gap: 10px; align-items: center; }
|
|
331
|
+
.history-gantt-label { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
332
|
+
.history-gantt-label i { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
|
|
333
|
+
.history-gantt-track { height: 12px; border-radius: 6px; background: rgba(255,255,255,.04); position: relative; }
|
|
334
|
+
.history-gantt-bar { position: absolute; top: 2px; height: 8px; min-width: 3px; border-radius: 4px; }
|
|
335
|
+
.history-heatmap { flex: 1 1 auto; display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 11px); grid-auto-columns: 11px; gap: 3px; width: max-content; overflow: auto; padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: rgba(0,0,0,.12); }
|
|
336
|
+
.history-heatmap .heat-cell { width: 11px; height: 11px; cursor: default; }
|
|
337
|
+
.history-item { display: grid; grid-template-columns: 82px 16px minmax(0, 1fr); gap: 9px; padding: 9px 11px; border-top: 1px solid rgba(255,255,255,.055); }
|
|
338
|
+
.history-item:first-child { border-top: 0; }
|
|
339
|
+
.history-time { color: var(--faint); font-size: 11px; padding-top: 1px; white-space: nowrap; }
|
|
340
|
+
.history-rail { position: relative; display: flex; justify-content: center; }
|
|
341
|
+
.history-rail::before { content: ""; position: absolute; top: -9px; bottom: -9px; width: 1px; background: var(--line); }
|
|
342
|
+
.history-item:first-child .history-rail::before { top: 6px; }
|
|
343
|
+
.history-item:last-child .history-rail::before { bottom: calc(100% - 7px); }
|
|
344
|
+
.history-dot { width: 8px; height: 8px; margin-top: 5px; border-radius: 50%; background: var(--dot-color, var(--muted)); position: relative; z-index: 1; box-shadow: 0 0 0 3px rgba(15,17,21,.9); }
|
|
345
|
+
.history-item.admin .history-dot { --dot-color: var(--warn); }
|
|
346
|
+
.history-item.gone .history-dot { --dot-color: var(--bad); }
|
|
347
|
+
.history-main { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
|
348
|
+
.history-title { color: var(--text); font-size: 12.5px; font-weight: 600; }
|
|
349
|
+
.history-detail { color: var(--faint); font-size: 11px; }
|
|
350
|
+
.history-item.admin .history-title { color: var(--warn); }
|
|
351
|
+
.history-item.gone .history-title { color: #ffb0b0; }
|
|
352
|
+
.history-empty { padding: 30px 16px; text-align: center; color: var(--faint); }
|
|
353
|
+
.token-box { margin: 16px 0 6px; padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--bg); color: #c7dbff; font: 12px/1.6 ui-monospace, SFMono-Regular, Consolas, monospace; word-break: break-all; }
|
|
309
354
|
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
|
|
310
355
|
|
|
356
|
+
/* 单行元素统一禁止换行,避免窄列被压成竖排。 */
|
|
357
|
+
.topbar, .brand, .brand h1, .brand .brand-sub, .version-pill, .badge, .btn, .health,
|
|
358
|
+
.eyebrow, .hero h2, .metric .k, .metric .v, .metric .hint,
|
|
359
|
+
.panel-title b, .panel-head > .badge, .device-label, .device-id, .device-meta,
|
|
360
|
+
.storage-line, .preset, .quota-form, .session-tools, th, td { white-space: nowrap; }
|
|
361
|
+
|
|
311
362
|
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
|
|
312
363
|
@keyframes card-in { from { opacity: 0; transform: translateY(-10px) scale(.98); } to { opacity: 1; transform: none; } }
|
|
364
|
+
@keyframes fade-out { from { opacity: 1; } to { opacity: 0; } }
|
|
365
|
+
@keyframes card-out { from { opacity: 1; transform: none; } to { opacity: 0; transform: translateY(-8px) scale(.98); } }
|
|
366
|
+
.modal.closing { animation: fade-out .16s ease both; }
|
|
367
|
+
.modal.closing .modal-card { animation: card-out .16s ease both; }
|
|
313
368
|
@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
|
-
}
|
|
369
|
+
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } }
|
|
317
370
|
|
|
318
|
-
@media (max-width: 980px) {
|
|
319
|
-
.layout { grid-template-columns: 1fr; grid-template-areas: "sessions" "storage" "devices"; }
|
|
320
|
-
}
|
|
371
|
+
@media (max-width: 980px) { .layout { grid-template-columns: 1fr; grid-template-areas: "sessions" "storage" "devices"; } }
|
|
321
372
|
@media (max-width: 760px) {
|
|
322
373
|
body { font-size: 13px; }
|
|
323
|
-
.app { padding: 16px 14px
|
|
374
|
+
.app { padding: 16px 14px 40px; }
|
|
324
375
|
.topbar { align-items: flex-start; flex-direction: column; gap: 10px; padding-bottom: 16px; }
|
|
325
376
|
.top-actions { width: 100%; justify-content: space-between; }
|
|
326
377
|
.hero { align-items: flex-start; flex-direction: column; gap: 8px; }
|
|
327
|
-
.
|
|
328
|
-
.metric { padding: 12px; }
|
|
329
|
-
.metric .v { font-size: 20px; }
|
|
330
|
-
.session-tools { flex-wrap: wrap; }
|
|
378
|
+
.hero h2 { font-size: 22px; }
|
|
331
379
|
.session-tools input { max-width: none; }
|
|
332
380
|
.session-tools select { flex: 1 1 auto; }
|
|
381
|
+
.device-row { flex-wrap: wrap; }
|
|
382
|
+
.device-actions { width: 100%; justify-content: flex-end; }
|
|
333
383
|
.auth-card { padding: 22px; }
|
|
334
384
|
}
|
|
335
|
-
@media (max-width: 420px) {
|
|
336
|
-
.metrics { grid-template-columns: 1fr; }
|
|
337
|
-
.brand h1 { font-size: 14px; }
|
|
338
|
-
.brand-sub { display: none; }
|
|
339
|
-
}
|
|
340
385
|
</style>
|
|
341
386
|
</head>
|
|
342
387
|
<body>
|
|
388
|
+
<div class="admin-bg" aria-hidden="true">
|
|
389
|
+
<div class="admin-bg-plane">
|
|
390
|
+
<div class="admin-bg-rows" id="adminBgRows"></div>
|
|
391
|
+
</div>
|
|
392
|
+
<div class="admin-bg-shade"></div>
|
|
393
|
+
</div>
|
|
394
|
+
|
|
343
395
|
<div id="loginView" class="login-page">
|
|
344
396
|
<div class="login-inner">
|
|
345
397
|
<div class="brand-mark"><img src="/logo.svg" alt=""></div>
|
|
@@ -368,9 +420,12 @@
|
|
|
368
420
|
<h1>dsh-session-cloudsync</h1>
|
|
369
421
|
<span class="brand-sub">管理台</span>
|
|
370
422
|
</div>
|
|
423
|
+
<span class="version-pill"><i></i><span id="version">连接中…</span></span>
|
|
424
|
+
<span class="spacer"></span>
|
|
425
|
+
<span id="updatedBadge" class="badge">更新于刚刚</span>
|
|
371
426
|
<div class="top-actions">
|
|
372
|
-
<
|
|
373
|
-
<button id="
|
|
427
|
+
<button id="refresh" class="btn secondary" type="button"></button>
|
|
428
|
+
<button id="changePassword" class="btn ghost" type="button">密码</button>
|
|
374
429
|
<button id="logout" class="btn ghost" type="button">退出</button>
|
|
375
430
|
</div>
|
|
376
431
|
</header>
|
|
@@ -382,33 +437,75 @@
|
|
|
382
437
|
<h2>同步概览</h2>
|
|
383
438
|
<p class="muted">自部署节点、设备与会话视图;设备可签发、改名、吊销。</p>
|
|
384
439
|
</div>
|
|
385
|
-
<div class="health"><i></i><span id="healthText">服务正常</span></div>
|
|
440
|
+
<div id="health" class="health"><i></i><span id="healthText">服务正常</span></div>
|
|
386
441
|
</section>
|
|
387
442
|
|
|
388
443
|
<section id="metrics" class="metrics" aria-label="概览指标"></section>
|
|
389
444
|
|
|
445
|
+
<section class="panel activity-panel">
|
|
446
|
+
<div class="panel-head">
|
|
447
|
+
<span class="panel-icon" id="activityIcon"></span>
|
|
448
|
+
<div class="panel-title">
|
|
449
|
+
<b>工作强度</b>
|
|
450
|
+
<span>按天统计新增事件;只反映同步活动,不代表内容。</span>
|
|
451
|
+
</div>
|
|
452
|
+
<span class="spacer"></span>
|
|
453
|
+
<div class="activity-range">
|
|
454
|
+
<button id="activityRangeButton" class="btn small secondary" type="button">最近 90 天</button>
|
|
455
|
+
<div id="activityRangePopover" class="range-popover" hidden>
|
|
456
|
+
<div class="range-presets" id="activityPresets">
|
|
457
|
+
<button type="button" data-days="30">30 天</button>
|
|
458
|
+
<button type="button" data-days="90">90 天</button>
|
|
459
|
+
<button type="button" data-days="365">365 天</button>
|
|
460
|
+
</div>
|
|
461
|
+
<div class="range-fields">
|
|
462
|
+
<label>开始日期<input id="activityFrom" type="date"></label>
|
|
463
|
+
<label>结束日期<input id="activityTo" type="date"></label>
|
|
464
|
+
</div>
|
|
465
|
+
<div class="range-actions">
|
|
466
|
+
<button id="activityCancel" class="btn small ghost" type="button">取消</button>
|
|
467
|
+
<button id="activityApply" class="btn small" type="button">应用</button>
|
|
468
|
+
</div>
|
|
469
|
+
</div>
|
|
470
|
+
</div>
|
|
471
|
+
</div>
|
|
472
|
+
<div class="panel-body">
|
|
473
|
+
<div id="activityDevices" class="activity-legend"></div>
|
|
474
|
+
<div class="activity-grid-wrap"><div id="activityGrid" class="activity-grid"></div></div>
|
|
475
|
+
<div class="activity-foot">
|
|
476
|
+
<span id="activityNote" class="faint">正在读取活动…</span>
|
|
477
|
+
<span class="heat-legend"><span>少</span><i class="heat-cell heat-level-1"></i><i class="heat-cell heat-level-2"></i><i class="heat-cell heat-level-3"></i><i class="heat-cell heat-level-4"></i><span>多</span></span>
|
|
478
|
+
</div>
|
|
479
|
+
</div>
|
|
480
|
+
</section>
|
|
481
|
+
|
|
390
482
|
<div class="layout">
|
|
391
483
|
<section class="panel sessions-panel">
|
|
392
484
|
<div class="panel-head">
|
|
393
|
-
<
|
|
394
|
-
|
|
395
|
-
<
|
|
485
|
+
<span class="panel-icon" id="sessionsIcon"></span>
|
|
486
|
+
<div class="panel-title">
|
|
487
|
+
<b>会话</b>
|
|
488
|
+
<span>按服务端更新时间倒序;只显示同步时间、大小等摘要,内容端到端加密。</span>
|
|
396
489
|
</div>
|
|
490
|
+
<span class="spacer"></span>
|
|
397
491
|
<span id="sessionBadge" class="badge">0 个</span>
|
|
398
492
|
</div>
|
|
399
493
|
<div class="panel-body">
|
|
400
494
|
<div class="session-tools">
|
|
401
|
-
<input id="sessionSearch" type="search" placeholder="搜索 session id
|
|
495
|
+
<input id="sessionSearch" type="search" placeholder="搜索 session id 或工作区…" autocomplete="off">
|
|
402
496
|
<select id="sessionFilter" aria-label="筛选会话">
|
|
403
497
|
<option value="all">全部状态</option>
|
|
404
498
|
<option value="active">正常</option>
|
|
405
499
|
<option value="deleted">已删除</option>
|
|
406
500
|
</select>
|
|
501
|
+
<button id="sessionDayChip" class="badge accent" type="button" hidden></button>
|
|
502
|
+
<span class="spacer"></span>
|
|
503
|
+
<label class="switch"><input id="autoRefresh" type="checkbox" checked><i></i>自动刷新 10s</label>
|
|
407
504
|
</div>
|
|
408
|
-
<div class="table-wrap">
|
|
505
|
+
<div class="table-wrap" id="sessionTableWrap">
|
|
409
506
|
<table>
|
|
410
507
|
<thead>
|
|
411
|
-
<tr><th
|
|
508
|
+
<tr><th>会话</th><th>格式</th><th>批次</th><th>最后序号</th><th>大小</th><th>更新时间</th><th>状态</th><th>操作</th></tr>
|
|
412
509
|
</thead>
|
|
413
510
|
<tbody id="sessions"></tbody>
|
|
414
511
|
</table>
|
|
@@ -419,19 +516,22 @@
|
|
|
419
516
|
|
|
420
517
|
<section class="panel storage-panel">
|
|
421
518
|
<div class="panel-head">
|
|
422
|
-
<
|
|
423
|
-
|
|
424
|
-
<
|
|
519
|
+
<span class="panel-icon" id="storageIcon"></span>
|
|
520
|
+
<div class="panel-title">
|
|
521
|
+
<b>存储</b>
|
|
522
|
+
<span>只统计服务端实际占用的加密数据。</span>
|
|
425
523
|
</div>
|
|
524
|
+
<span class="spacer"></span>
|
|
426
525
|
<span id="storageBadge" class="badge">0%</span>
|
|
427
526
|
</div>
|
|
428
527
|
<div class="panel-body">
|
|
429
|
-
<div class="storage-line">
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
<
|
|
528
|
+
<div class="storage-line"><b id="storageUsed">0 B</b><span id="storageTotal">/ 0 GiB</span></div>
|
|
529
|
+
<div class="bar" role="progressbar" aria-label="存储配额使用率" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><i id="storageBar"></i></div>
|
|
530
|
+
<div class="presets" id="quotaPresets">
|
|
531
|
+
<button class="preset" type="button" data-gb="1">1 GiB</button>
|
|
532
|
+
<button class="preset" type="button" data-gb="5">5 GiB</button>
|
|
533
|
+
<button class="preset" type="button" data-gb="10">10 GiB</button>
|
|
534
|
+
<button class="preset" type="button" data-gb="50">50 GiB</button>
|
|
435
535
|
</div>
|
|
436
536
|
<form id="quotaForm" class="quota-form">
|
|
437
537
|
<input id="quotaGb" type="number" min="1" step="1" inputmode="numeric" aria-label="配额 GiB">
|
|
@@ -444,10 +544,12 @@
|
|
|
444
544
|
|
|
445
545
|
<section class="panel devices-panel">
|
|
446
546
|
<div class="panel-head">
|
|
447
|
-
<
|
|
448
|
-
|
|
449
|
-
<
|
|
547
|
+
<span class="panel-icon" id="devicesIcon"></span>
|
|
548
|
+
<div class="panel-title">
|
|
549
|
+
<b>设备</b>
|
|
550
|
+
<span>每台设备一把接入密钥,只在创建时显示一次;点击头像可自定义颜色。</span>
|
|
450
551
|
</div>
|
|
552
|
+
<span class="spacer"></span>
|
|
451
553
|
<span id="deviceBadge" class="badge">0 台</span>
|
|
452
554
|
</div>
|
|
453
555
|
<div class="panel-body">
|
|
@@ -460,6 +562,40 @@
|
|
|
460
562
|
</div>
|
|
461
563
|
</section>
|
|
462
564
|
</div>
|
|
565
|
+
|
|
566
|
+
<section class="panel audit-panel">
|
|
567
|
+
<div class="panel-head">
|
|
568
|
+
<span class="panel-icon" id="auditIcon"></span>
|
|
569
|
+
<div class="panel-title">
|
|
570
|
+
<b>审计</b>
|
|
571
|
+
<span>管理动作流水;不记录会话内容、设备密钥或管理密码。</span>
|
|
572
|
+
</div>
|
|
573
|
+
<span class="spacer"></span>
|
|
574
|
+
<span id="auditBadge" class="badge">0 条</span>
|
|
575
|
+
</div>
|
|
576
|
+
<div class="panel-body">
|
|
577
|
+
<div class="session-tools">
|
|
578
|
+
<select id="auditFilter" aria-label="筛选审计动作">
|
|
579
|
+
<option value="">全部动作</option>
|
|
580
|
+
</select>
|
|
581
|
+
<input id="auditSearch" type="search" placeholder="搜索操作者或目标…" autocomplete="off" aria-label="搜索审计">
|
|
582
|
+
<span class="spacer"></span>
|
|
583
|
+
<button id="auditRefresh" class="btn small secondary" type="button">刷新</button>
|
|
584
|
+
</div>
|
|
585
|
+
<div class="table-wrap">
|
|
586
|
+
<table>
|
|
587
|
+
<thead>
|
|
588
|
+
<tr><th>时间</th><th>动作</th><th>操作者</th><th>目标</th><th>详情</th></tr>
|
|
589
|
+
</thead>
|
|
590
|
+
<tbody id="auditRows"></tbody>
|
|
591
|
+
</table>
|
|
592
|
+
</div>
|
|
593
|
+
<div id="auditEmpty" class="empty" hidden></div>
|
|
594
|
+
<div id="auditMoreWrap" class="audit-more" hidden>
|
|
595
|
+
<button id="auditMore" class="btn small ghost" type="button">加载更多</button>
|
|
596
|
+
</div>
|
|
597
|
+
</div>
|
|
598
|
+
</section>
|
|
463
599
|
</main>
|
|
464
600
|
</div>
|
|
465
601
|
|
|
@@ -467,20 +603,175 @@
|
|
|
467
603
|
|
|
468
604
|
<div id="tokenDialog" class="modal" hidden>
|
|
469
605
|
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="tokenTitle">
|
|
470
|
-
<h2 id="tokenTitle"
|
|
606
|
+
<h2 id="tokenTitle">设备接入密钥</h2>
|
|
471
607
|
<p>只显示这一次。关闭后无法再次查看,只能重新签发。</p>
|
|
472
608
|
<div id="tokenValue" class="token-box" tabindex="0"></div>
|
|
473
609
|
<div class="modal-actions">
|
|
474
|
-
<button id="copyToken" class="btn secondary" type="button"
|
|
610
|
+
<button id="copyToken" class="btn secondary" type="button">复制密钥</button>
|
|
475
611
|
<button id="closeToken" class="btn" type="button">我已保存</button>
|
|
476
612
|
</div>
|
|
477
613
|
</div>
|
|
478
614
|
</div>
|
|
479
615
|
|
|
616
|
+
<div id="revokeDialog" class="modal" hidden>
|
|
617
|
+
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="revokeTitle">
|
|
618
|
+
<h2 id="revokeTitle">吊销设备</h2>
|
|
619
|
+
<p id="revokeText"></p>
|
|
620
|
+
<div class="modal-actions">
|
|
621
|
+
<button id="cancelRevoke" class="btn ghost" type="button">取消</button>
|
|
622
|
+
<button id="confirmRevoke" class="btn danger" type="button">确认吊销</button>
|
|
623
|
+
</div>
|
|
624
|
+
</div>
|
|
625
|
+
</div>
|
|
626
|
+
|
|
627
|
+
<div id="passwordDialog" class="modal" hidden>
|
|
628
|
+
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="passwordTitle">
|
|
629
|
+
<h2 id="passwordTitle">修改管理密码</h2>
|
|
630
|
+
<p>修改后其它管理会话会退出登录,当前会话保持在线。</p>
|
|
631
|
+
<label class="field">
|
|
632
|
+
<span>当前密码</span>
|
|
633
|
+
<input id="currentPassword" type="password" autocomplete="current-password">
|
|
634
|
+
</label>
|
|
635
|
+
<label class="field">
|
|
636
|
+
<span>新密码</span>
|
|
637
|
+
<input id="newPassword" type="password" autocomplete="new-password">
|
|
638
|
+
</label>
|
|
639
|
+
<label class="field">
|
|
640
|
+
<span>确认新密码</span>
|
|
641
|
+
<input id="confirmPassword" type="password" autocomplete="new-password">
|
|
642
|
+
</label>
|
|
643
|
+
<div id="passwordError" class="inline-result bad" role="alert"></div>
|
|
644
|
+
<div class="modal-actions">
|
|
645
|
+
<button id="cancelPassword" class="btn ghost" type="button">取消</button>
|
|
646
|
+
<button id="savePassword" class="btn" type="button">保存</button>
|
|
647
|
+
</div>
|
|
648
|
+
</div>
|
|
649
|
+
</div>
|
|
650
|
+
|
|
651
|
+
<div id="colorDialog" class="modal" hidden>
|
|
652
|
+
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="colorTitle">
|
|
653
|
+
<h2 id="colorTitle">设备颜色</h2>
|
|
654
|
+
<p id="colorDeviceName"></p>
|
|
655
|
+
<div class="color-picker-row">
|
|
656
|
+
<input id="deviceColorPicker" type="color" value="#5b88ff" aria-label="自定义设备颜色">
|
|
657
|
+
<input id="deviceColorHex" class="color-hex" value="#5b88ff" spellcheck="false" aria-label="颜色十六进制值">
|
|
658
|
+
</div>
|
|
659
|
+
<div id="deviceColorPresets" class="color-presets"></div>
|
|
660
|
+
<div class="color-hint">支持系统调色板、预设色,也可以直接输入 #rrggbb。</div>
|
|
661
|
+
<div class="color-live">
|
|
662
|
+
<span class="device-avatar" id="colorPreviewAvatar">L</span>
|
|
663
|
+
<span id="colorPreviewName"></span>
|
|
664
|
+
<span class="faint">颜色预览</span>
|
|
665
|
+
</div>
|
|
666
|
+
<div id="colorError" class="inline-result bad" role="alert"></div>
|
|
667
|
+
<div class="modal-actions">
|
|
668
|
+
<button id="cancelColor" class="btn ghost" type="button">取消</button>
|
|
669
|
+
<button id="saveColor" class="btn" type="button">保存颜色</button>
|
|
670
|
+
</div>
|
|
671
|
+
</div>
|
|
672
|
+
</div>
|
|
673
|
+
|
|
674
|
+
<div id="historyDialog" class="modal" hidden>
|
|
675
|
+
<div class="modal-card history-card" role="dialog" aria-modal="true" aria-labelledby="historyTitle">
|
|
676
|
+
<h2 id="historyTitle">会话历史</h2>
|
|
677
|
+
<p id="historySession"></p>
|
|
678
|
+
<div id="historySummary" class="history-summary"></div>
|
|
679
|
+
<div class="history-tabs">
|
|
680
|
+
<button class="history-tab on" type="button" data-pane="timeline">时间线</button>
|
|
681
|
+
<button class="history-tab" type="button" data-pane="devices">设备</button>
|
|
682
|
+
<button class="history-tab" type="button" data-pane="heatmap">热力图</button>
|
|
683
|
+
</div>
|
|
684
|
+
<div id="historyTimelinePane" class="history-pane">
|
|
685
|
+
<div id="historyList" class="history-list"></div>
|
|
686
|
+
</div>
|
|
687
|
+
<div id="historyDevicesPane" class="history-pane" hidden>
|
|
688
|
+
<div id="historyGantt" class="history-gantt"></div>
|
|
689
|
+
</div>
|
|
690
|
+
<div id="historyHeatmapPane" class="history-pane" hidden>
|
|
691
|
+
<div id="historyHeatmap" class="history-heatmap"></div>
|
|
692
|
+
<div class="faint" style="margin-top:8px;font-size:11px">最近 90 天 · 按新增事件</div>
|
|
693
|
+
</div>
|
|
694
|
+
<div id="historyError" class="inline-result bad" role="alert"></div>
|
|
695
|
+
<div class="modal-actions">
|
|
696
|
+
<button id="closeHistory" class="btn secondary" type="button">关闭</button>
|
|
697
|
+
</div>
|
|
698
|
+
</div>
|
|
699
|
+
</div>
|
|
700
|
+
|
|
701
|
+
<div id="sessionDialog" class="modal" hidden>
|
|
702
|
+
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="sessionActionTitle">
|
|
703
|
+
<h2 id="sessionActionTitle">会话操作</h2>
|
|
704
|
+
<p id="sessionActionText"></p>
|
|
705
|
+
<label class="field" id="sessionResetField" hidden>
|
|
706
|
+
<span>指定重建设备</span>
|
|
707
|
+
<select id="sessionResetDevice"></select>
|
|
708
|
+
</label>
|
|
709
|
+
<div id="sessionActionError" class="inline-result bad" role="alert"></div>
|
|
710
|
+
<div class="modal-actions">
|
|
711
|
+
<button id="cancelSessionAction" class="btn ghost" type="button">取消</button>
|
|
712
|
+
<button id="confirmSessionAction" class="btn danger" type="button">确认</button>
|
|
713
|
+
</div>
|
|
714
|
+
</div>
|
|
715
|
+
</div>
|
|
716
|
+
|
|
480
717
|
<script>
|
|
481
718
|
const $ = (id) => document.getElementById(id)
|
|
482
|
-
const
|
|
719
|
+
const GiB = 1024 ** 3
|
|
720
|
+
const state = {
|
|
721
|
+
overview: null,
|
|
722
|
+
devices: [],
|
|
723
|
+
sessions: [],
|
|
724
|
+
autoRefresh: true,
|
|
725
|
+
editingDeviceId: null,
|
|
726
|
+
revokeTarget: null,
|
|
727
|
+
sessionAction: null,
|
|
728
|
+
colorTarget: null,
|
|
729
|
+
collapsed: new Set(['__unmapped__']),
|
|
730
|
+
audit: [],
|
|
731
|
+
auditBefore: null,
|
|
732
|
+
auditHasMore: false,
|
|
733
|
+
activityStart: null,
|
|
734
|
+
activityEnd: null,
|
|
735
|
+
activityPresetDays: 90,
|
|
736
|
+
activityDeviceId: null,
|
|
737
|
+
activityDays: [],
|
|
738
|
+
sessionDayFilter: null,
|
|
739
|
+
activityDaySessionIds: null,
|
|
740
|
+
}
|
|
483
741
|
let toastTimer = null
|
|
742
|
+
let updatedAt = 0
|
|
743
|
+
let pollTimer = null
|
|
744
|
+
|
|
745
|
+
const ICONS = {
|
|
746
|
+
devices: ['M2.5 3.5h8v6h-8z', 'M11.5 7h2v5.5h-2z', 'M4.5 12h4'],
|
|
747
|
+
sessions: ['M2 3.5h12v7H7l-3 2.5v-2.5H2z'],
|
|
748
|
+
storage: ['M3 3.5h10v3H3z', 'M3 6.5h10v3H3z', 'M3 9.5h10v3H3z'],
|
|
749
|
+
refresh: ['M13 8a5 5 0 1 1-1.5-3.5', 'M13 3.5V6h-2.5'],
|
|
750
|
+
copy: ['M6 5V3.5A1.5 1.5 0 0 1 7.5 2h5A1.5 1.5 0 0 1 14 3.5v5A1.5 1.5 0 0 1 12.5 10H11', 'M2 6.5A1.5 1.5 0 0 1 3.5 5h5A1.5 1.5 0 0 1 10 6.5v5A1.5 1.5 0 0 1 8.5 13h-5A1.5 1.5 0 0 1 2 11.5z'],
|
|
751
|
+
pencil: ['M11 2.5l2.5 2.5-8 8H3v-2.5z'],
|
|
752
|
+
chevron: ['M4 6l4 4 4-4'],
|
|
753
|
+
audit: ['M3 2.5h10v11H3z', 'M5.5 5.5h5', 'M5.5 8h5', 'M5.5 10.5h3'],
|
|
754
|
+
activity: ['M2.5 13.5h11', 'M3.5 11.5l3-4 2 2 3-5 2 2.5'],
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function icon(name, size = 16) {
|
|
758
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
759
|
+
svg.setAttribute('viewBox', '0 0 16 16')
|
|
760
|
+
svg.setAttribute('width', String(size))
|
|
761
|
+
svg.setAttribute('height', String(size))
|
|
762
|
+
svg.setAttribute('fill', 'none')
|
|
763
|
+
svg.setAttribute('aria-hidden', 'true')
|
|
764
|
+
for (const d of ICONS[name] || []) {
|
|
765
|
+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
|
766
|
+
path.setAttribute('d', d)
|
|
767
|
+
path.setAttribute('stroke', 'currentColor')
|
|
768
|
+
path.setAttribute('stroke-width', '1.5')
|
|
769
|
+
path.setAttribute('stroke-linecap', 'round')
|
|
770
|
+
path.setAttribute('stroke-linejoin', 'round')
|
|
771
|
+
svg.append(path)
|
|
772
|
+
}
|
|
773
|
+
return svg
|
|
774
|
+
}
|
|
484
775
|
|
|
485
776
|
function el(tag, className, text) {
|
|
486
777
|
const node = document.createElement(tag)
|
|
@@ -493,14 +784,44 @@ function formatBytes(value) {
|
|
|
493
784
|
const bytes = Number(value) || 0
|
|
494
785
|
if (bytes < 1024) return bytes + ' B'
|
|
495
786
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
|
496
|
-
if (bytes <
|
|
497
|
-
return (bytes /
|
|
787
|
+
if (bytes < GiB) return (bytes / 1024 / 1024).toFixed(1) + ' MB'
|
|
788
|
+
return (bytes / GiB).toFixed(2) + ' GiB'
|
|
498
789
|
}
|
|
499
790
|
|
|
500
791
|
function time(value) {
|
|
501
792
|
return value === null || value === undefined ? '—' : new Date(value).toLocaleString()
|
|
502
793
|
}
|
|
503
794
|
|
|
795
|
+
function formatAgo(value) {
|
|
796
|
+
if (value === null || value === undefined) return '从未'
|
|
797
|
+
const diff = Date.now() - value
|
|
798
|
+
if (diff < 0) return '刚刚'
|
|
799
|
+
if (diff < 60 * 1000) return '刚刚'
|
|
800
|
+
if (diff < 3600 * 1000) return Math.floor(diff / 60000) + ' 分钟前'
|
|
801
|
+
if (diff < 86400 * 1000) return Math.floor(diff / 3600000) + ' 小时前'
|
|
802
|
+
return Math.floor(diff / 86400000) + ' 天前'
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function agoCell(value) {
|
|
806
|
+
const cell = el('td', 'muted', formatAgo(value))
|
|
807
|
+
cell.title = time(value)
|
|
808
|
+
return cell
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const MODAL_EXIT_MS = 160
|
|
812
|
+
|
|
813
|
+
function closeModal(id) {
|
|
814
|
+
const node = $(id)
|
|
815
|
+
if (node === null || node.hidden || node.classList.contains('closing')) return
|
|
816
|
+
node.classList.add('closing')
|
|
817
|
+
setTimeout(() => {
|
|
818
|
+
node.hidden = true
|
|
819
|
+
node.classList.remove('closing')
|
|
820
|
+
const open = [...document.querySelectorAll('.modal')].some((modal) => !modal.hidden)
|
|
821
|
+
if (!open) document.body.classList.remove('modal-open')
|
|
822
|
+
}, MODAL_EXIT_MS)
|
|
823
|
+
}
|
|
824
|
+
|
|
504
825
|
function showToast(text, bad) {
|
|
505
826
|
const node = $('toast')
|
|
506
827
|
node.textContent = text
|
|
@@ -538,15 +859,36 @@ async function api(path, options) {
|
|
|
538
859
|
throw new Error('unauthorized')
|
|
539
860
|
}
|
|
540
861
|
const payload = await response.json().catch(() => ({}))
|
|
541
|
-
if (!response.ok)
|
|
862
|
+
if (!response.ok) {
|
|
863
|
+
const error = new Error((payload && payload.error && payload.error.message) || '请求失败')
|
|
864
|
+
error.status = response.status
|
|
865
|
+
throw error
|
|
866
|
+
}
|
|
542
867
|
return payload.value
|
|
543
868
|
}
|
|
544
869
|
|
|
870
|
+
function isUninitialized(error) {
|
|
871
|
+
return error !== null && typeof error === 'object'
|
|
872
|
+
&& error.status === 409
|
|
873
|
+
&& /初始化/u.test(String(error.message || ''))
|
|
874
|
+
}
|
|
875
|
+
|
|
545
876
|
function showLogin() {
|
|
546
877
|
$('loginView').hidden = false
|
|
547
878
|
$('appView').hidden = true
|
|
548
879
|
$('loginError').textContent = ''
|
|
880
|
+
if (!$('passwordDialog').hidden) closePassword()
|
|
881
|
+
if (!$('sessionDialog').hidden) closeSessionAction()
|
|
882
|
+
if (!$('historyDialog').hidden) closeHistory()
|
|
883
|
+
if (!$('colorDialog').hidden) closeColorDialog()
|
|
549
884
|
setTimeout(() => $('password').focus(), 30)
|
|
885
|
+
// 服务端数据被清空(开发 --clean 或换库)时,直接回初始化向导。
|
|
886
|
+
fetch('/setup/state', { credentials: 'same-origin' })
|
|
887
|
+
.then((response) => response.json())
|
|
888
|
+
.then((payload) => {
|
|
889
|
+
if (payload && payload.value && payload.value.initialized === false) location.replace('/setup')
|
|
890
|
+
})
|
|
891
|
+
.catch(() => {})
|
|
550
892
|
}
|
|
551
893
|
|
|
552
894
|
function showApp() {
|
|
@@ -557,14 +899,22 @@ function showApp() {
|
|
|
557
899
|
function renderMetrics(overview) {
|
|
558
900
|
const percent = overview.maxBytes > 0 ? Math.min(100, Math.round(overview.usedBytes / overview.maxBytes * 1000) / 10) : 0
|
|
559
901
|
const items = [
|
|
560
|
-
{ key: '设备', value: overview.devices, hint: '
|
|
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' },
|
|
902
|
+
{ icon: 'devices', key: '设备', value: overview.devices, hint: '已签发接入密钥' },
|
|
903
|
+
{ icon: 'sessions', key: '会话', value: overview.sessions, hint: overview.deletedSessions > 0 ? overview.deletedSessions + ' 个已删除' : '没有删除记录' },
|
|
904
|
+
{ icon: 'storage', key: '已用存储', value: formatBytes(overview.usedBytes), hint: percent + '% · 配额 ' + formatBytes(overview.maxBytes), bar: percent },
|
|
564
905
|
]
|
|
565
906
|
$('metrics').replaceChildren(...items.map((item) => {
|
|
566
907
|
const box = el('div', 'metric')
|
|
567
|
-
|
|
908
|
+
const head = el('div', 'k')
|
|
909
|
+
head.append(icon(item.icon, 13), el('span', null, item.key))
|
|
910
|
+
box.append(head, el('div', 'v', String(item.value)), el('div', 'hint', item.hint))
|
|
911
|
+
if (item.bar !== undefined) {
|
|
912
|
+
const bar = el('div', 'bar')
|
|
913
|
+
const fill = el('i', null)
|
|
914
|
+
fill.style.width = item.bar + '%'
|
|
915
|
+
bar.append(fill)
|
|
916
|
+
box.append(bar)
|
|
917
|
+
}
|
|
568
918
|
return box
|
|
569
919
|
}))
|
|
570
920
|
$('version').textContent = 'v' + overview.version + ' · protocol ' + overview.protocol
|
|
@@ -572,9 +922,222 @@ function renderMetrics(overview) {
|
|
|
572
922
|
$('storageUsed').textContent = formatBytes(overview.usedBytes)
|
|
573
923
|
$('storageTotal').textContent = '/ ' + formatBytes(overview.maxBytes)
|
|
574
924
|
$('storageBar').style.width = Math.min(100, percent) + '%'
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
$('quotaGb').value = String(
|
|
925
|
+
$('storageBar').parentElement.setAttribute('aria-valuenow', String(percent))
|
|
926
|
+
const quotaGb = Math.max(1, Math.round(overview.maxBytes / GiB))
|
|
927
|
+
$('quotaGb').value = String(quotaGb)
|
|
928
|
+
for (const button of $('quotaPresets').querySelectorAll('.preset')) {
|
|
929
|
+
const gb = Number(button.dataset.gb)
|
|
930
|
+
button.classList.toggle('on', gb === quotaGb)
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function dateKey(date) {
|
|
935
|
+
const year = date.getFullYear()
|
|
936
|
+
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
937
|
+
const day = String(date.getDate()).padStart(2, '0')
|
|
938
|
+
return year + '-' + month + '-' + day
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function parseDateInput(value) {
|
|
942
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/u.exec(String(value || ''))
|
|
943
|
+
if (match === null) return null
|
|
944
|
+
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
|
|
945
|
+
return Number.isNaN(date.getTime()) ? null : date
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function defaultActivityRange(days) {
|
|
949
|
+
const end = new Date()
|
|
950
|
+
end.setHours(0, 0, 0, 0)
|
|
951
|
+
const start = new Date(end)
|
|
952
|
+
start.setDate(start.getDate() - (days - 1))
|
|
953
|
+
return { start, end }
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function activityThresholds(days) {
|
|
957
|
+
const counts = days.map((day) => day.events).filter((events) => events > 0).sort((left, right) => left - right)
|
|
958
|
+
if (counts.length === 0) return []
|
|
959
|
+
const pick = (quantile) => counts[Math.min(counts.length - 1, Math.floor(quantile * (counts.length - 1)))]
|
|
960
|
+
return [...new Set([pick(0.25), pick(0.5), pick(0.75)])]
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function activityLevel(events, thresholds, maxEvents) {
|
|
964
|
+
if (events <= 0) return 0
|
|
965
|
+
if (maxEvents !== undefined && events >= maxEvents) return 4
|
|
966
|
+
let level = 1
|
|
967
|
+
for (const threshold of thresholds || []) {
|
|
968
|
+
if (events > threshold) level += 1
|
|
969
|
+
}
|
|
970
|
+
return Math.min(4, level)
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function setActivityRange(start, end, presetDays) {
|
|
974
|
+
state.activityStart = start
|
|
975
|
+
state.activityEnd = end
|
|
976
|
+
state.activityPresetDays = presetDays
|
|
977
|
+
$('activityRangeButton').textContent = presetDays === null
|
|
978
|
+
? dateKey(start) + ' → ' + dateKey(end)
|
|
979
|
+
: '最近 ' + presetDays + ' 天'
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function openActivityRange() {
|
|
983
|
+
$('activityFrom').value = dateKey(state.activityStart)
|
|
984
|
+
$('activityTo').value = dateKey(state.activityEnd)
|
|
985
|
+
$('activityRangePopover').hidden = false
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function closeActivityRange() {
|
|
989
|
+
$('activityRangePopover').hidden = true
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function applyActivityRange() {
|
|
993
|
+
const start = parseDateInput($('activityFrom').value)
|
|
994
|
+
const end = parseDateInput($('activityTo').value)
|
|
995
|
+
if (start === null || end === null) { showToast('请选择有效的开始和结束日期', true); return }
|
|
996
|
+
if (start > end) { showToast('开始日期不能晚于结束日期', true); return }
|
|
997
|
+
setActivityRange(start, end, null)
|
|
998
|
+
closeActivityRange()
|
|
999
|
+
void loadActivity()
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function renderActivityDevices() {
|
|
1003
|
+
const wrap = $('activityDevices')
|
|
1004
|
+
const options = [{ id: null, label: '全部设备', color: '#94a3b8' }]
|
|
1005
|
+
for (const device of state.devices) {
|
|
1006
|
+
options.push({ id: device.deviceId, label: device.label || device.deviceId, color: device.color || historyDeviceColor(device.deviceId) })
|
|
1007
|
+
}
|
|
1008
|
+
wrap.replaceChildren(...options.map((option) => {
|
|
1009
|
+
const button = el('button', 'activity-device' + (state.activityDeviceId === option.id ? ' on' : ''))
|
|
1010
|
+
button.type = 'button'
|
|
1011
|
+
const dot = el('i')
|
|
1012
|
+
dot.style.background = option.color
|
|
1013
|
+
button.append(dot, el('span', null, option.label))
|
|
1014
|
+
button.onclick = () => {
|
|
1015
|
+
state.activityDeviceId = state.activityDeviceId === option.id ? null : option.id
|
|
1016
|
+
void loadActivity()
|
|
1017
|
+
}
|
|
1018
|
+
return button
|
|
1019
|
+
}))
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function renderActivity() {
|
|
1023
|
+
renderActivityDevices()
|
|
1024
|
+
const grid = $('activityGrid')
|
|
1025
|
+
if (state.activityStart === null || state.activityEnd === null) {
|
|
1026
|
+
grid.replaceChildren()
|
|
1027
|
+
return
|
|
1028
|
+
}
|
|
1029
|
+
const byDay = new Map(state.activityDays.map((day) => [day.day, day]))
|
|
1030
|
+
const thresholds = activityThresholds(state.activityDays)
|
|
1031
|
+
const maxEvents = state.activityDays.reduce((max, day) => Math.max(max, day.events), 0)
|
|
1032
|
+
const cells = []
|
|
1033
|
+
const startWeekday = (state.activityStart.getDay() + 6) % 7
|
|
1034
|
+
for (let index = 0; index < startWeekday; index += 1) cells.push(el('div', 'heat-cell heat-blank'))
|
|
1035
|
+
let totalEvents = 0
|
|
1036
|
+
let activeDays = 0
|
|
1037
|
+
for (let cursor = new Date(state.activityStart); cursor <= state.activityEnd; cursor.setDate(cursor.getDate() + 1)) {
|
|
1038
|
+
const key = dateKey(cursor)
|
|
1039
|
+
const data = byDay.get(key)
|
|
1040
|
+
const events = data === undefined ? 0 : data.events
|
|
1041
|
+
totalEvents += events
|
|
1042
|
+
if (events > 0) activeDays += 1
|
|
1043
|
+
const cell = el('div', 'heat-cell heat-level-' + activityLevel(events, thresholds, maxEvents))
|
|
1044
|
+
cell.setAttribute('aria-label', key + ':' + events + ' 事件')
|
|
1045
|
+
cell.title = data === undefined
|
|
1046
|
+
? key + ' · 没有同步活动'
|
|
1047
|
+
: key + ' · ' + data.events + ' 事件 · ' + formatBytes(data.bytes) + ' · ' + data.appends + ' 次 · ' + data.sessions + ' 会话 · ' + data.devices + ' 设备'
|
|
1048
|
+
cell.dataset.day = key
|
|
1049
|
+
cell.onclick = () => { void toggleSessionDayFilter(key) }
|
|
1050
|
+
cells.push(cell)
|
|
1051
|
+
}
|
|
1052
|
+
grid.replaceChildren(...cells)
|
|
1053
|
+
const scope = state.activityDeviceId === null ? '全部设备' : historyDeviceLabel(state.activityDeviceId)
|
|
1054
|
+
$('activityNote').textContent = activeDays === 0
|
|
1055
|
+
? scope + ' · 这个范围还没有同步活动'
|
|
1056
|
+
: scope + ' · ' + totalEvents + ' 事件 · ' + activeDays + ' 个活跃日 · 颜色按当前范围分档'
|
|
1057
|
+
const legend = document.querySelector('.heat-legend')
|
|
1058
|
+
if (legend !== null) {
|
|
1059
|
+
legend.title = thresholds.length === 0
|
|
1060
|
+
? '没有活动时不分档'
|
|
1061
|
+
: '事件分档阈值:' + thresholds.join(' / ')
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
async function loadActivity() {
|
|
1066
|
+
if (state.activityStart === null || state.activityEnd === null) return
|
|
1067
|
+
const params = new URLSearchParams({
|
|
1068
|
+
from: String(state.activityStart.getTime()),
|
|
1069
|
+
to: String(state.activityEnd.getTime() + 24 * 60 * 60 * 1000),
|
|
1070
|
+
tzOffset: String(-new Date().getTimezoneOffset()),
|
|
1071
|
+
})
|
|
1072
|
+
if (state.activityDeviceId !== null) params.set('deviceId', state.activityDeviceId)
|
|
1073
|
+
try {
|
|
1074
|
+
const data = await api('/admin/activity?' + params.toString())
|
|
1075
|
+
state.activityDays = data.days
|
|
1076
|
+
renderActivity()
|
|
1077
|
+
} catch (error) {
|
|
1078
|
+
if (error.message !== 'unauthorized') {
|
|
1079
|
+
$('activityNote').textContent = '活动读取失败:' + error.message
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function updateSessionDayChip() {
|
|
1085
|
+
const chip = $('sessionDayChip')
|
|
1086
|
+
if (state.sessionDayFilter === null) {
|
|
1087
|
+
chip.hidden = true
|
|
1088
|
+
chip.textContent = ''
|
|
1089
|
+
return
|
|
1090
|
+
}
|
|
1091
|
+
chip.hidden = false
|
|
1092
|
+
chip.textContent = '活动日 ' + state.sessionDayFilter + ' ×'
|
|
1093
|
+
chip.title = '清除日期筛选'
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
async function toggleSessionDayFilter(day) {
|
|
1097
|
+
if (state.sessionDayFilter === day) {
|
|
1098
|
+
state.sessionDayFilter = null
|
|
1099
|
+
state.activityDaySessionIds = null
|
|
1100
|
+
updateSessionDayChip()
|
|
1101
|
+
renderSessions()
|
|
1102
|
+
return
|
|
1103
|
+
}
|
|
1104
|
+
try {
|
|
1105
|
+
const params = new URLSearchParams({
|
|
1106
|
+
day,
|
|
1107
|
+
tzOffset: String(-new Date().getTimezoneOffset()),
|
|
1108
|
+
})
|
|
1109
|
+
const data = await api('/admin/activity/sessions?' + params.toString())
|
|
1110
|
+
state.sessionDayFilter = day
|
|
1111
|
+
state.activityDaySessionIds = new Set(data.sessions.map((session) => session.sessionId))
|
|
1112
|
+
updateSessionDayChip()
|
|
1113
|
+
renderSessions()
|
|
1114
|
+
} catch (error) {
|
|
1115
|
+
if (error.message !== 'unauthorized') showToast(error.message, true)
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
function deviceOnline(device) {
|
|
1120
|
+
return device.lastSeenAt !== null && device.lastSeenAt !== undefined
|
|
1121
|
+
&& Date.now() - device.lastSeenAt < 5 * 60 * 1000
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function deviceStatus(device) {
|
|
1125
|
+
if (device.lastSeenAt === null || device.lastSeenAt === undefined) return { label: '从未在线', idle: true }
|
|
1126
|
+
if (deviceOnline(device)) return { label: '在线', idle: false }
|
|
1127
|
+
return { label: '离线', idle: true }
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function deviceInitial(device) {
|
|
1131
|
+
const label = String(device.label || device.deviceId || '?').trim()
|
|
1132
|
+
return label === '' ? '?' : label.slice(0, 1).toUpperCase()
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function readableOn(hex) {
|
|
1136
|
+
const color = /^#[0-9a-f]{6}$/iu.test(String(hex)) ? String(hex).slice(1) : '5b88ff'
|
|
1137
|
+
const r = parseInt(color.slice(0, 2), 16)
|
|
1138
|
+
const g = parseInt(color.slice(2, 4), 16)
|
|
1139
|
+
const b = parseInt(color.slice(4, 6), 16)
|
|
1140
|
+
return (r * 299 + g * 587 + b * 114) / 1000 > 150 ? '#10131a' : '#ffffff'
|
|
578
1141
|
}
|
|
579
1142
|
|
|
580
1143
|
function renderDevices(devices) {
|
|
@@ -582,7 +1145,9 @@ function renderDevices(devices) {
|
|
|
582
1145
|
const list = $('devices')
|
|
583
1146
|
if (devices.length === 0) {
|
|
584
1147
|
const empty = el('div', 'empty')
|
|
585
|
-
|
|
1148
|
+
const mark = el('div', 'icon')
|
|
1149
|
+
mark.append(icon('devices', 22))
|
|
1150
|
+
empty.append(mark, el('b', null, '还没有设备'), el('span', null, '签发一把接入密钥,把 DSH 插件接到这台服务器。'))
|
|
586
1151
|
list.replaceChildren(empty)
|
|
587
1152
|
return
|
|
588
1153
|
}
|
|
@@ -590,72 +1155,611 @@ function renderDevices(devices) {
|
|
|
590
1155
|
const row = el('div', 'device-row')
|
|
591
1156
|
const main = el('div', 'device-main')
|
|
592
1157
|
const label = el('div', 'device-label')
|
|
593
|
-
|
|
1158
|
+
const avatarColor = device.color || historyDeviceColor(device.deviceId)
|
|
1159
|
+
const colorButton = el('button', 'device-color-btn')
|
|
1160
|
+
colorButton.type = 'button'
|
|
1161
|
+
colorButton.title = '修改设备颜色'
|
|
1162
|
+
colorButton.setAttribute('aria-label', '修改设备颜色')
|
|
1163
|
+
const avatar = el('span', 'device-avatar', deviceInitial(device))
|
|
1164
|
+
avatar.style.background = avatarColor
|
|
1165
|
+
avatar.style.color = readableOn(avatarColor)
|
|
1166
|
+
colorButton.append(avatar)
|
|
1167
|
+
colorButton.onclick = () => openColorDialog(device)
|
|
1168
|
+
const status = deviceStatus(device)
|
|
1169
|
+
const statusChip = el('span', 'device-status' + (status.idle ? '' : ' online'))
|
|
1170
|
+
statusChip.append(el('i', 'dot' + (status.idle ? ' idle' : ' good')), el('span', null, status.label))
|
|
1171
|
+
statusChip.title = device.lastSeenAt === null || device.lastSeenAt === undefined
|
|
1172
|
+
? '从未在线'
|
|
1173
|
+
: '最后在线:' + time(device.lastSeenAt)
|
|
1174
|
+
label.append(colorButton)
|
|
1175
|
+
if (state.editingDeviceId === device.deviceId) {
|
|
1176
|
+
const form = el('div', 'inline-edit')
|
|
1177
|
+
const input = el('input')
|
|
1178
|
+
input.value = device.label || 'device'
|
|
1179
|
+
input.maxLength = 64
|
|
1180
|
+
input.setAttribute('aria-label', '设备名称')
|
|
1181
|
+
const save = el('button', 'btn small', '保存')
|
|
1182
|
+
save.type = 'button'
|
|
1183
|
+
save.onclick = () => saveDeviceLabel(device, input.value)
|
|
1184
|
+
const cancel = el('button', 'btn small ghost', '取消')
|
|
1185
|
+
cancel.type = 'button'
|
|
1186
|
+
cancel.onclick = () => { state.editingDeviceId = null; renderDevices(state.devices) }
|
|
1187
|
+
form.append(input, save, cancel)
|
|
1188
|
+
label.append(form, statusChip)
|
|
1189
|
+
input.addEventListener('keydown', (event) => {
|
|
1190
|
+
if (event.key === 'Enter') save.click()
|
|
1191
|
+
if (event.key === 'Escape') cancel.click()
|
|
1192
|
+
})
|
|
1193
|
+
setTimeout(() => input.focus(), 0)
|
|
1194
|
+
} else {
|
|
1195
|
+
label.append(el('b', null, device.label || 'device'), statusChip)
|
|
1196
|
+
}
|
|
594
1197
|
const idLine = el('div', 'device-id')
|
|
595
|
-
|
|
596
|
-
const copy = el('button', 'row-copy', '复制')
|
|
1198
|
+
const copy = el('button', 'row-copy')
|
|
597
1199
|
copy.type = 'button'
|
|
1200
|
+
copy.title = '复制设备 ID'
|
|
1201
|
+
copy.append(icon('copy', 11))
|
|
598
1202
|
copy.onclick = () => copyText(device.deviceId)
|
|
599
|
-
idLine.append(copy)
|
|
1203
|
+
idLine.append(el('code', null, device.deviceId), copy)
|
|
600
1204
|
const meta = el('div', 'device-meta')
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
)
|
|
1205
|
+
const created = el('span', null, '创建 ' + formatAgo(device.createdAt))
|
|
1206
|
+
created.title = time(device.createdAt)
|
|
1207
|
+
const seen = el('span', null, '最后在线 ' + formatAgo(device.lastSeenAt))
|
|
1208
|
+
seen.title = time(device.lastSeenAt)
|
|
1209
|
+
meta.append(created, seen)
|
|
605
1210
|
main.append(label, idLine, meta)
|
|
606
1211
|
const actions = el('div', 'device-actions')
|
|
607
|
-
const rename = el('button', 'btn
|
|
1212
|
+
const rename = el('button', 'icon-btn')
|
|
608
1213
|
rename.type = 'button'
|
|
609
|
-
rename.
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
const label = next.trim()
|
|
613
|
-
if (label === '') {
|
|
614
|
-
showToast('设备名不能为空', true)
|
|
615
|
-
return
|
|
616
|
-
}
|
|
617
|
-
rename.disabled = true
|
|
618
|
-
rename.textContent = '改名中…'
|
|
619
|
-
try {
|
|
620
|
-
await api('/admin/devices/' + encodeURIComponent(device.deviceId), {
|
|
621
|
-
method: 'PATCH',
|
|
622
|
-
body: JSON.stringify({ label }),
|
|
623
|
-
})
|
|
624
|
-
showToast('设备已改名')
|
|
625
|
-
await refresh(true)
|
|
626
|
-
} catch (error) {
|
|
627
|
-
showToast(error.message, true)
|
|
628
|
-
rename.disabled = false
|
|
629
|
-
rename.textContent = '改名'
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
const revoke = el('button', 'btn small danger', '吊销')
|
|
1214
|
+
rename.append(icon('pencil', 12), el('span', null, '改名'))
|
|
1215
|
+
rename.onclick = () => { state.editingDeviceId = device.deviceId; renderDevices(state.devices) }
|
|
1216
|
+
const revoke = el('button', 'icon-btn danger')
|
|
633
1217
|
revoke.type = 'button'
|
|
634
|
-
revoke.
|
|
635
|
-
|
|
636
|
-
revoke.disabled = true
|
|
637
|
-
revoke.textContent = '吊销中…'
|
|
638
|
-
try {
|
|
639
|
-
await api('/admin/devices/' + encodeURIComponent(device.deviceId), { method: 'DELETE' })
|
|
640
|
-
showToast('设备已吊销')
|
|
641
|
-
await refresh(true)
|
|
642
|
-
} catch (error) {
|
|
643
|
-
showToast(error.message, true)
|
|
644
|
-
revoke.disabled = false
|
|
645
|
-
revoke.textContent = '吊销'
|
|
646
|
-
}
|
|
647
|
-
}
|
|
1218
|
+
revoke.append(el('span', null, '吊销'))
|
|
1219
|
+
revoke.onclick = () => openRevoke(device)
|
|
648
1220
|
actions.append(rename, revoke)
|
|
649
1221
|
row.append(main, actions)
|
|
650
1222
|
return row
|
|
651
1223
|
}))
|
|
652
1224
|
}
|
|
653
1225
|
|
|
1226
|
+
async function saveDeviceLabel(device, raw) {
|
|
1227
|
+
const label = String(raw || '').trim()
|
|
1228
|
+
if (label === '') { showToast('设备名不能为空', true); return }
|
|
1229
|
+
try {
|
|
1230
|
+
await api('/admin/devices/' + encodeURIComponent(device.deviceId), {
|
|
1231
|
+
method: 'PATCH',
|
|
1232
|
+
body: JSON.stringify({ label }),
|
|
1233
|
+
})
|
|
1234
|
+
state.editingDeviceId = null
|
|
1235
|
+
showToast('设备已改名')
|
|
1236
|
+
await refresh(true)
|
|
1237
|
+
} catch (error) {
|
|
1238
|
+
showToast(error.message, true)
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
function openRevoke(device) {
|
|
1243
|
+
state.revokeTarget = device.deviceId
|
|
1244
|
+
$('revokeText').textContent = '吊销「' + (device.label || 'device') + '」后,它的接入密钥立即失效;该设备将无法再推送或拉取。'
|
|
1245
|
+
$('revokeDialog').classList.remove('closing')
|
|
1246
|
+
$('revokeDialog').hidden = false
|
|
1247
|
+
document.body.classList.add('modal-open')
|
|
1248
|
+
setTimeout(() => $('confirmRevoke').focus(), 30)
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
function closeRevoke() {
|
|
1252
|
+
state.revokeTarget = null
|
|
1253
|
+
closeModal('revokeDialog')
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
function openPassword() {
|
|
1257
|
+
$('currentPassword').value = ''
|
|
1258
|
+
$('newPassword').value = ''
|
|
1259
|
+
$('confirmPassword').value = ''
|
|
1260
|
+
$('passwordError').textContent = ''
|
|
1261
|
+
$('passwordDialog').classList.remove('closing')
|
|
1262
|
+
$('passwordDialog').hidden = false
|
|
1263
|
+
document.body.classList.add('modal-open')
|
|
1264
|
+
setTimeout(() => $('currentPassword').focus(), 30)
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function closePassword() {
|
|
1268
|
+
closeModal('passwordDialog')
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
const SESSION_ACTIONS = {
|
|
1272
|
+
archive: {
|
|
1273
|
+
title: '存档会话',
|
|
1274
|
+
text: '停止同步,但保留服务端保存的完整数据;之后可以恢复。其他设备上的本地副本不会被删除。',
|
|
1275
|
+
confirm: '存档',
|
|
1276
|
+
danger: false,
|
|
1277
|
+
},
|
|
1278
|
+
restore: {
|
|
1279
|
+
title: '恢复会话',
|
|
1280
|
+
text: '让这个会话重新参与同步。其他设备上的副本可能与服务端版本不一致,请确认。',
|
|
1281
|
+
confirm: '恢复',
|
|
1282
|
+
danger: false,
|
|
1283
|
+
},
|
|
1284
|
+
remove: {
|
|
1285
|
+
title: '移除并释放空间',
|
|
1286
|
+
text: '停止同步并永久删除服务端保存的数据,以释放空间;不可恢复。其他设备上的本地副本仍会保留。',
|
|
1287
|
+
confirm: '移除并释放',
|
|
1288
|
+
danger: true,
|
|
1289
|
+
},
|
|
1290
|
+
reset: {
|
|
1291
|
+
title: '重置远端会话',
|
|
1292
|
+
text: '删除服务端保存的这个会话及删除标记,允许指定设备把本地完整副本重新上传。会中断正在进行的同步;请先让目标设备同步。',
|
|
1293
|
+
confirm: '重置',
|
|
1294
|
+
danger: true,
|
|
1295
|
+
},
|
|
1296
|
+
'clear-reset': {
|
|
1297
|
+
title: '取消重置',
|
|
1298
|
+
text: '清除等待重建状态;之后任意设备都可以重新上传这个会话。',
|
|
1299
|
+
confirm: '取消重置',
|
|
1300
|
+
danger: false,
|
|
1301
|
+
},
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
function openSessionAction(session, action) {
|
|
1305
|
+
const info = SESSION_ACTIONS[action]
|
|
1306
|
+
if (info === undefined) return
|
|
1307
|
+
state.sessionAction = { session, action }
|
|
1308
|
+
$('sessionActionTitle').textContent = info.title
|
|
1309
|
+
$('sessionActionText').textContent = info.text
|
|
1310
|
+
$('sessionActionError').textContent = ''
|
|
1311
|
+
const field = $('sessionResetField')
|
|
1312
|
+
const select = $('sessionResetDevice')
|
|
1313
|
+
const confirm = $('confirmSessionAction')
|
|
1314
|
+
confirm.className = 'btn' + (info.danger ? ' danger' : '')
|
|
1315
|
+
confirm.textContent = info.confirm
|
|
1316
|
+
confirm.disabled = false
|
|
1317
|
+
if (action === 'reset') {
|
|
1318
|
+
field.hidden = false
|
|
1319
|
+
select.replaceChildren()
|
|
1320
|
+
for (const device of state.devices) {
|
|
1321
|
+
const option = el('option', null, device.label || device.deviceId)
|
|
1322
|
+
option.value = device.deviceId
|
|
1323
|
+
select.append(option)
|
|
1324
|
+
}
|
|
1325
|
+
if (state.devices.length === 0) {
|
|
1326
|
+
const option = el('option', null, '没有可用设备')
|
|
1327
|
+
option.value = ''
|
|
1328
|
+
select.append(option)
|
|
1329
|
+
$('sessionActionError').textContent = '没有可指定的设备;请先创建设备接入密钥'
|
|
1330
|
+
confirm.disabled = true
|
|
1331
|
+
}
|
|
1332
|
+
} else {
|
|
1333
|
+
field.hidden = true
|
|
1334
|
+
}
|
|
1335
|
+
$('sessionDialog').classList.remove('closing')
|
|
1336
|
+
$('sessionDialog').hidden = false
|
|
1337
|
+
document.body.classList.add('modal-open')
|
|
1338
|
+
setTimeout(() => confirm.focus(), 30)
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
function closeSessionAction() {
|
|
1342
|
+
state.sessionAction = null
|
|
1343
|
+
closeModal('sessionDialog')
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function normalizeColor(value) {
|
|
1347
|
+
const color = String(value || '').trim().toLowerCase()
|
|
1348
|
+
return /^#[0-9a-f]{6}$/u.test(color) ? color : null
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function applyColorDraft(value) {
|
|
1352
|
+
const color = normalizeColor(value)
|
|
1353
|
+
if (color === null) return
|
|
1354
|
+
$('deviceColorPicker').value = color
|
|
1355
|
+
$('deviceColorHex').value = color
|
|
1356
|
+
$('colorPreviewAvatar').style.background = color
|
|
1357
|
+
$('colorPreviewAvatar').style.color = readableOn(color)
|
|
1358
|
+
for (const button of $('deviceColorPresets').querySelectorAll('.color-preset')) {
|
|
1359
|
+
button.classList.toggle('on', button.dataset.color === color)
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
function openColorDialog(device) {
|
|
1364
|
+
state.colorTarget = device
|
|
1365
|
+
$('colorDeviceName').textContent = device.label || device.deviceId
|
|
1366
|
+
$('colorPreviewName').textContent = device.label || device.deviceId
|
|
1367
|
+
$('colorPreviewAvatar').textContent = deviceInitial(device)
|
|
1368
|
+
$('colorError').textContent = ''
|
|
1369
|
+
$('deviceColorPresets').replaceChildren(...DEVICE_COLOR_PRESETS.map((color) => {
|
|
1370
|
+
const button = el('button', 'color-preset')
|
|
1371
|
+
button.type = 'button'
|
|
1372
|
+
button.dataset.color = color
|
|
1373
|
+
button.style.background = color
|
|
1374
|
+
button.title = color
|
|
1375
|
+
button.onclick = () => applyColorDraft(color)
|
|
1376
|
+
return button
|
|
1377
|
+
}))
|
|
1378
|
+
applyColorDraft(device.color || historyDeviceColor(device.deviceId))
|
|
1379
|
+
$('colorDialog').classList.remove('closing')
|
|
1380
|
+
$('colorDialog').hidden = false
|
|
1381
|
+
document.body.classList.add('modal-open')
|
|
1382
|
+
setTimeout(() => $('deviceColorPicker').focus(), 30)
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function closeColorDialog() {
|
|
1386
|
+
state.colorTarget = null
|
|
1387
|
+
closeModal('colorDialog')
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
async function saveDeviceColor() {
|
|
1391
|
+
const target = state.colorTarget
|
|
1392
|
+
if (target === null) return
|
|
1393
|
+
const color = normalizeColor($('deviceColorHex').value)
|
|
1394
|
+
if (color === null) {
|
|
1395
|
+
$('colorError').textContent = '请输入 #rrggbb 格式的颜色'
|
|
1396
|
+
return
|
|
1397
|
+
}
|
|
1398
|
+
const button = $('saveColor')
|
|
1399
|
+
button.disabled = true
|
|
1400
|
+
button.textContent = '保存中…'
|
|
1401
|
+
try {
|
|
1402
|
+
await api('/admin/devices/' + encodeURIComponent(target.deviceId), {
|
|
1403
|
+
method: 'PATCH',
|
|
1404
|
+
body: JSON.stringify({ color }),
|
|
1405
|
+
})
|
|
1406
|
+
closeColorDialog()
|
|
1407
|
+
showToast('设备颜色已更新')
|
|
1408
|
+
await refresh(true)
|
|
1409
|
+
} catch (error) {
|
|
1410
|
+
$('colorError').textContent = error.message
|
|
1411
|
+
} finally {
|
|
1412
|
+
button.disabled = false
|
|
1413
|
+
button.textContent = '保存颜色'
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
function switchHistoryPane(name) {
|
|
1418
|
+
for (const tab of document.querySelectorAll('.history-tab')) {
|
|
1419
|
+
tab.classList.toggle('on', tab.dataset.pane === name)
|
|
1420
|
+
}
|
|
1421
|
+
for (const pane of ['timeline', 'devices', 'heatmap']) {
|
|
1422
|
+
const node = $('history' + pane[0].toUpperCase() + pane.slice(1) + 'Pane')
|
|
1423
|
+
if (node !== null) node.hidden = pane !== name
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
function renderHistoryGantt(events) {
|
|
1428
|
+
const wrap = $('historyGantt')
|
|
1429
|
+
const byDevice = new Map()
|
|
1430
|
+
for (const event of events) {
|
|
1431
|
+
if ((event.kind !== 'create' && event.kind !== 'append') || !event.deviceId) continue
|
|
1432
|
+
const entry = byDevice.get(event.deviceId) || {
|
|
1433
|
+
deviceId: event.deviceId,
|
|
1434
|
+
label: event.deviceLabel || historyDeviceLabel(event.deviceId),
|
|
1435
|
+
color: event.deviceColor || historyDeviceColor(event.deviceId),
|
|
1436
|
+
firstAt: event.at,
|
|
1437
|
+
lastAt: event.at,
|
|
1438
|
+
events: 0,
|
|
1439
|
+
bytes: 0,
|
|
1440
|
+
}
|
|
1441
|
+
entry.firstAt = Math.min(entry.firstAt, event.at)
|
|
1442
|
+
entry.lastAt = Math.max(entry.lastAt, event.at)
|
|
1443
|
+
entry.events += event.events
|
|
1444
|
+
entry.bytes += event.bytes
|
|
1445
|
+
byDevice.set(event.deviceId, entry)
|
|
1446
|
+
}
|
|
1447
|
+
if (byDevice.size === 0) {
|
|
1448
|
+
wrap.replaceChildren(el('div', 'history-empty', '还没有设备活动。'))
|
|
1449
|
+
return
|
|
1450
|
+
}
|
|
1451
|
+
const entries = [...byDevice.values()]
|
|
1452
|
+
const minAt = Math.min(...entries.map((entry) => entry.firstAt))
|
|
1453
|
+
const maxAt = Math.max(...entries.map((entry) => entry.lastAt))
|
|
1454
|
+
const span = Math.max(1, maxAt - minAt)
|
|
1455
|
+
wrap.replaceChildren(...entries.map((entry) => {
|
|
1456
|
+
const row = el('div', 'history-gantt-row')
|
|
1457
|
+
const label = el('div', 'history-gantt-label')
|
|
1458
|
+
const dot = el('i')
|
|
1459
|
+
dot.style.background = entry.color
|
|
1460
|
+
label.append(dot, el('span', null, entry.label))
|
|
1461
|
+
const track = el('div', 'history-gantt-track')
|
|
1462
|
+
const bar = el('div', 'history-gantt-bar')
|
|
1463
|
+
bar.style.background = entry.color
|
|
1464
|
+
bar.style.left = ((entry.firstAt - minAt) / span * 100) + '%'
|
|
1465
|
+
bar.style.width = Math.max(2, (entry.lastAt - entry.firstAt) / span * 100) + '%'
|
|
1466
|
+
bar.title = entry.label + ' · ' + formatAgo(entry.firstAt) + ' → ' + formatAgo(entry.lastAt) + ' · ' + entry.events + ' 事件'
|
|
1467
|
+
track.append(bar)
|
|
1468
|
+
row.append(label, track)
|
|
1469
|
+
return row
|
|
1470
|
+
}))
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
async function renderHistoryHeatmap(session) {
|
|
1474
|
+
const grid = $('historyHeatmap')
|
|
1475
|
+
grid.replaceChildren(el('div', 'history-empty', '正在读取…'))
|
|
1476
|
+
const end = new Date()
|
|
1477
|
+
end.setHours(0, 0, 0, 0)
|
|
1478
|
+
const start = new Date(end)
|
|
1479
|
+
start.setDate(start.getDate() - 89)
|
|
1480
|
+
const params = new URLSearchParams({
|
|
1481
|
+
from: String(start.getTime()),
|
|
1482
|
+
to: String(end.getTime() + 24 * 60 * 60 * 1000),
|
|
1483
|
+
tzOffset: String(-new Date().getTimezoneOffset()),
|
|
1484
|
+
sessionId: session.sessionId,
|
|
1485
|
+
})
|
|
1486
|
+
try {
|
|
1487
|
+
const data = await api('/admin/activity?' + params.toString())
|
|
1488
|
+
const byDay = new Map(data.days.map((day) => [day.day, day]))
|
|
1489
|
+
const thresholds = activityThresholds(data.days)
|
|
1490
|
+
const maxEvents = data.days.reduce((max, day) => Math.max(max, day.events), 0)
|
|
1491
|
+
const cells = []
|
|
1492
|
+
const startWeekday = (start.getDay() + 6) % 7
|
|
1493
|
+
for (let index = 0; index < startWeekday; index += 1) cells.push(el('div', 'heat-cell heat-blank'))
|
|
1494
|
+
for (let cursor = new Date(start); cursor <= end; cursor.setDate(cursor.getDate() + 1)) {
|
|
1495
|
+
const key = dateKey(cursor)
|
|
1496
|
+
const day = byDay.get(key)
|
|
1497
|
+
const events = day === undefined ? 0 : day.events
|
|
1498
|
+
const cell = el('div', 'heat-cell heat-level-' + activityLevel(events, thresholds, maxEvents))
|
|
1499
|
+
cell.title = key + ' · ' + events + ' 事件' + (day === undefined ? '' : ' · ' + formatBytes(day.bytes))
|
|
1500
|
+
cells.push(cell)
|
|
1501
|
+
}
|
|
1502
|
+
grid.replaceChildren(...cells)
|
|
1503
|
+
} catch (error) {
|
|
1504
|
+
if (error.message !== 'unauthorized') grid.replaceChildren(el('div', 'history-empty', '热力图读取失败:' + error.message))
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
function openHistory(session) {
|
|
1509
|
+
$('historySession').textContent = shortDisplayId(session.sessionId) + (session.workspaceKey ? ' · 工作区 ' + session.workspaceKey.slice(0, 12) + '…' : '')
|
|
1510
|
+
$('historySummary').replaceChildren()
|
|
1511
|
+
$('historyList').replaceChildren(el('div', 'history-empty', '正在读取历史…'))
|
|
1512
|
+
$('historyError').textContent = ''
|
|
1513
|
+
switchHistoryPane('timeline')
|
|
1514
|
+
$('historyDialog').classList.remove('closing')
|
|
1515
|
+
$('historyDialog').hidden = false
|
|
1516
|
+
document.body.classList.add('modal-open')
|
|
1517
|
+
void loadHistory(session)
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
function closeHistory() {
|
|
1521
|
+
closeModal('historyDialog')
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
function shortDisplayId(id) {
|
|
1525
|
+
return id.length > 32 ? id.slice(0, 30) + '…' : id
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
function historyEventLabel(kind) {
|
|
1529
|
+
const labels = {
|
|
1530
|
+
create: '创建并上传',
|
|
1531
|
+
append: '更新',
|
|
1532
|
+
'device-delete': '设备删除',
|
|
1533
|
+
archive: '存档',
|
|
1534
|
+
remove: '移除',
|
|
1535
|
+
reset: '重置',
|
|
1536
|
+
restore: '恢复',
|
|
1537
|
+
}
|
|
1538
|
+
return labels[kind] || kind
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
const DEVICE_COLOR_PRESETS = ['#5b88ff', '#5ed392', '#f0b64a', '#c084fc', '#38bdf8', '#f472b6', '#a3e635', '#fb923c', '#94a3b8', '#f87171']
|
|
1542
|
+
const HISTORY_DEVICE_COLORS = DEVICE_COLOR_PRESETS
|
|
1543
|
+
|
|
1544
|
+
function historyDeviceColor(id) {
|
|
1545
|
+
let hash = 0
|
|
1546
|
+
for (const char of String(id || '')) hash = (hash * 31 + char.charCodeAt(0)) | 0
|
|
1547
|
+
return HISTORY_DEVICE_COLORS[Math.abs(hash) % HISTORY_DEVICE_COLORS.length]
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
function historyDeviceLabel(id) {
|
|
1551
|
+
if (!id) return '管理台'
|
|
1552
|
+
const device = state.devices.find((item) => item.deviceId === id)
|
|
1553
|
+
return device ? device.label || device.deviceId : id
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
function historyEventDetail(event) {
|
|
1557
|
+
const parts = []
|
|
1558
|
+
if (event.kind === 'create' || event.kind === 'append') {
|
|
1559
|
+
if (event.events > 0) parts.push('+' + event.events + ' 事件')
|
|
1560
|
+
if (event.bytes > 0) parts.push(formatBytes(event.bytes))
|
|
1561
|
+
if (event.batchCount > 0) parts.push(event.batchCount + ' 批')
|
|
1562
|
+
if (event.seqFrom !== null && event.seqTo !== null) parts.push('seq ' + event.seqFrom + '–' + event.seqTo)
|
|
1563
|
+
if (event.detail && event.detail.tailReplaced === true) parts.push('替换尾部')
|
|
1564
|
+
} else if (event.kind === 'reset') {
|
|
1565
|
+
const deviceId = event.detail && typeof event.detail === 'object' ? event.detail.deviceId : null
|
|
1566
|
+
if (deviceId) parts.push('指定 ' + historyDeviceLabel(deviceId))
|
|
1567
|
+
if (event.bytes > 0) parts.push('释放 ' + formatBytes(event.bytes))
|
|
1568
|
+
} else if (event.kind === 'remove') {
|
|
1569
|
+
if (event.bytes > 0) parts.push('释放 ' + formatBytes(event.bytes))
|
|
1570
|
+
}
|
|
1571
|
+
if (event.detail && event.detail.backfilled === true) parts.push('回填')
|
|
1572
|
+
return parts.join(' · ')
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
function historyRunDetail(run) {
|
|
1576
|
+
const parts = []
|
|
1577
|
+
if (run.events > 0) parts.push('+' + run.events + ' 事件')
|
|
1578
|
+
if (run.bytes > 0) parts.push(formatBytes(run.bytes))
|
|
1579
|
+
if (run.batches > 0) parts.push(run.batches + ' 批')
|
|
1580
|
+
if (run.seqFrom !== null && run.seqTo !== null) parts.push('seq ' + run.seqFrom + '–' + run.seqTo)
|
|
1581
|
+
return parts.join(' · ')
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
async function loadHistory(session) {
|
|
1585
|
+
try {
|
|
1586
|
+
const data = await api('/admin/sessions/' + encodeURIComponent(session.sessionId) + '/history?limit=200')
|
|
1587
|
+
const summary = data.summary
|
|
1588
|
+
const chips = [
|
|
1589
|
+
'总事件 ' + summary.events,
|
|
1590
|
+
'总批次 ' + summary.batches,
|
|
1591
|
+
'总大小 ' + formatBytes(summary.bytes),
|
|
1592
|
+
'设备 ' + summary.devices,
|
|
1593
|
+
summary.firstAt !== null ? '首次 ' + formatAgo(summary.firstAt) : '还没有事件',
|
|
1594
|
+
summary.lastAt !== null ? '最后 ' + formatAgo(summary.lastAt) : '',
|
|
1595
|
+
].filter((chip) => chip !== '')
|
|
1596
|
+
const deviceLegend = new Map()
|
|
1597
|
+
for (const event of data.events) {
|
|
1598
|
+
if (event.deviceId && !deviceLegend.has(event.deviceId)) {
|
|
1599
|
+
deviceLegend.set(event.deviceId, {
|
|
1600
|
+
label: event.deviceLabel || historyDeviceLabel(event.deviceId),
|
|
1601
|
+
color: event.deviceColor || historyDeviceColor(event.deviceId),
|
|
1602
|
+
})
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
function deviceLegendColor(deviceId) {
|
|
1606
|
+
return deviceLegend.get(deviceId)?.color || historyDeviceColor(deviceId)
|
|
1607
|
+
}
|
|
1608
|
+
$('historySummary').replaceChildren(
|
|
1609
|
+
...chips.map((chip) => el('span', 'history-chip', chip)),
|
|
1610
|
+
...[...deviceLegend.entries()].map(([deviceId, entry]) => {
|
|
1611
|
+
const chip = el('span', 'history-chip')
|
|
1612
|
+
const dot = el('i')
|
|
1613
|
+
dot.style.background = deviceLegendColor(deviceId)
|
|
1614
|
+
chip.append(dot, el('span', null, entry.label))
|
|
1615
|
+
return chip
|
|
1616
|
+
}),
|
|
1617
|
+
)
|
|
1618
|
+
const list = $('historyList')
|
|
1619
|
+
if (data.events.length === 0) {
|
|
1620
|
+
list.replaceChildren(el('div', 'history-empty', '还没有这个会话的历史记录。'))
|
|
1621
|
+
renderHistoryGantt([])
|
|
1622
|
+
void renderHistoryHeatmap(session)
|
|
1623
|
+
return
|
|
1624
|
+
}
|
|
1625
|
+
const ordered = [...data.events].reverse()
|
|
1626
|
+
const rows = []
|
|
1627
|
+
let run = null
|
|
1628
|
+
for (const event of ordered) {
|
|
1629
|
+
if (event.kind === 'create') {
|
|
1630
|
+
if (run !== null) { rows.push(run); run = null }
|
|
1631
|
+
rows.push({ kind: 'event', event })
|
|
1632
|
+
continue
|
|
1633
|
+
}
|
|
1634
|
+
if (event.kind === 'append') {
|
|
1635
|
+
if (run !== null && run.deviceId === event.deviceId) {
|
|
1636
|
+
run.events += event.events
|
|
1637
|
+
run.bytes += event.bytes
|
|
1638
|
+
run.batches += event.batchCount
|
|
1639
|
+
run.lastAt = event.at
|
|
1640
|
+
if (event.seqFrom !== null) run.seqFrom = run.seqFrom === null ? event.seqFrom : Math.min(run.seqFrom, event.seqFrom)
|
|
1641
|
+
if (event.seqTo !== null) run.seqTo = run.seqTo === null ? event.seqTo : Math.max(run.seqTo, event.seqTo)
|
|
1642
|
+
run.last = event
|
|
1643
|
+
continue
|
|
1644
|
+
}
|
|
1645
|
+
if (run !== null) rows.push(run)
|
|
1646
|
+
run = {
|
|
1647
|
+
kind: 'run', deviceId: event.deviceId, first: event, last: event,
|
|
1648
|
+
events: event.events, bytes: event.bytes, batches: event.batchCount,
|
|
1649
|
+
seqFrom: event.seqFrom, seqTo: event.seqTo, firstAt: event.at, lastAt: event.at,
|
|
1650
|
+
}
|
|
1651
|
+
continue
|
|
1652
|
+
}
|
|
1653
|
+
if (run !== null) { rows.push(run); run = null }
|
|
1654
|
+
rows.push({ kind: 'event', event })
|
|
1655
|
+
}
|
|
1656
|
+
if (run !== null) rows.push(run)
|
|
1657
|
+
list.replaceChildren(...rows.map((row) => {
|
|
1658
|
+
if (row.kind === 'run') {
|
|
1659
|
+
const timeCell = el('div', 'history-time', formatAgo(row.firstAt))
|
|
1660
|
+
timeCell.title = time(row.firstAt) + ' → ' + time(row.lastAt)
|
|
1661
|
+
const main = el('div', 'history-main')
|
|
1662
|
+
main.append(
|
|
1663
|
+
el('div', 'history-title', historyEventLabel(row.first.kind) + ' · ' + historyDeviceLabel(row.deviceId)),
|
|
1664
|
+
el('div', 'history-detail', historyRunDetail(row)),
|
|
1665
|
+
)
|
|
1666
|
+
const rail = el('div', 'history-rail')
|
|
1667
|
+
const dot = el('i', 'history-dot')
|
|
1668
|
+
dot.style.setProperty('--dot-color', row.first.deviceColor || historyDeviceColor(row.deviceId))
|
|
1669
|
+
rail.append(dot)
|
|
1670
|
+
const item = el('div', 'history-item')
|
|
1671
|
+
item.append(timeCell, rail, main)
|
|
1672
|
+
return item
|
|
1673
|
+
}
|
|
1674
|
+
const event = row.event
|
|
1675
|
+
const timeCell = el('div', 'history-time', formatAgo(event.at))
|
|
1676
|
+
timeCell.title = time(event.at)
|
|
1677
|
+
const main = el('div', 'history-main')
|
|
1678
|
+
const title = historyEventLabel(event.kind) + (event.kind === 'reset' || event.kind === 'archive' || event.kind === 'remove' || event.kind === 'restore' ? ' · 管理台' : ' · ' + historyDeviceLabel(event.deviceId))
|
|
1679
|
+
main.append(el('div', 'history-title', title))
|
|
1680
|
+
const detail = historyEventDetail(event)
|
|
1681
|
+
if (detail !== '') main.append(el('div', 'history-detail', detail))
|
|
1682
|
+
const className = event.kind === 'remove' || event.kind === 'reset' || event.kind === 'device-delete' ? 'history-item gone' : event.kind === 'archive' || event.kind === 'restore' ? 'history-item admin' : 'history-item'
|
|
1683
|
+
const rail = el('div', 'history-rail')
|
|
1684
|
+
const dot = el('i', 'history-dot')
|
|
1685
|
+
if (event.kind === 'create' || event.kind === 'append') dot.style.setProperty('--dot-color', event.deviceColor || historyDeviceColor(event.deviceId))
|
|
1686
|
+
rail.append(dot)
|
|
1687
|
+
const item = el('div', className)
|
|
1688
|
+
item.append(timeCell, rail, main)
|
|
1689
|
+
return item
|
|
1690
|
+
}))
|
|
1691
|
+
renderHistoryGantt(data.events)
|
|
1692
|
+
void renderHistoryHeatmap(session)
|
|
1693
|
+
} catch (error) {
|
|
1694
|
+
$('historyError').textContent = error.message
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
async function confirmSessionAction() {
|
|
1699
|
+
const pending = state.sessionAction
|
|
1700
|
+
if (pending === null) return
|
|
1701
|
+
const { session, action } = pending
|
|
1702
|
+
const button = $('confirmSessionAction')
|
|
1703
|
+
$('sessionActionError').textContent = ''
|
|
1704
|
+
button.disabled = true
|
|
1705
|
+
const label = button.textContent
|
|
1706
|
+
button.textContent = '处理中…'
|
|
1707
|
+
try {
|
|
1708
|
+
const body = { createdAt: session.createdAt }
|
|
1709
|
+
if (action === 'reset') {
|
|
1710
|
+
const deviceId = $('sessionResetDevice').value
|
|
1711
|
+
if (deviceId === '') throw new Error('请选择一台设备')
|
|
1712
|
+
body.deviceId = deviceId
|
|
1713
|
+
}
|
|
1714
|
+
await api('/admin/sessions/' + encodeURIComponent(session.sessionId) + '/' + action, {
|
|
1715
|
+
method: 'POST',
|
|
1716
|
+
body: JSON.stringify(body),
|
|
1717
|
+
})
|
|
1718
|
+
closeSessionAction()
|
|
1719
|
+
showToast('会话操作完成')
|
|
1720
|
+
await refresh(true)
|
|
1721
|
+
await loadAudit(true)
|
|
1722
|
+
} catch (error) {
|
|
1723
|
+
$('sessionActionError').textContent = error.message
|
|
1724
|
+
} finally {
|
|
1725
|
+
button.disabled = false
|
|
1726
|
+
button.textContent = label
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
async function savePassword() {
|
|
1731
|
+
const current = $('currentPassword').value
|
|
1732
|
+
const next = $('newPassword').value
|
|
1733
|
+
const confirm = $('confirmPassword').value
|
|
1734
|
+
$('passwordError').textContent = ''
|
|
1735
|
+
if (current === '') { $('passwordError').textContent = '请输入当前密码'; return }
|
|
1736
|
+
if (next !== confirm) { $('passwordError').textContent = '两次输入的新密码不一致'; return }
|
|
1737
|
+
if (next === '') { $('passwordError').textContent = '请输入新密码'; return }
|
|
1738
|
+
const button = $('savePassword')
|
|
1739
|
+
button.disabled = true
|
|
1740
|
+
button.textContent = '保存中…'
|
|
1741
|
+
try {
|
|
1742
|
+
await api('/admin/password', {
|
|
1743
|
+
method: 'POST',
|
|
1744
|
+
body: JSON.stringify({ currentPassword: current, newPassword: next }),
|
|
1745
|
+
})
|
|
1746
|
+
closePassword()
|
|
1747
|
+
showToast('管理密码已修改')
|
|
1748
|
+
await loadAudit(true)
|
|
1749
|
+
} catch (error) {
|
|
1750
|
+
$('passwordError').textContent = error.message
|
|
1751
|
+
} finally {
|
|
1752
|
+
button.disabled = false
|
|
1753
|
+
button.textContent = '保存'
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
|
|
654
1757
|
function filteredSessions() {
|
|
655
1758
|
const query = $('sessionSearch').value.trim().toLowerCase()
|
|
656
1759
|
const mode = $('sessionFilter').value
|
|
657
1760
|
return state.sessions.filter((session) => {
|
|
658
|
-
if (
|
|
1761
|
+
if (state.activityDaySessionIds !== null && !state.activityDaySessionIds.has(session.sessionId)) return false
|
|
1762
|
+
if (query && !session.sessionId.toLowerCase().includes(query) && !(session.workspaceKey || '').toLowerCase().includes(query)) return false
|
|
659
1763
|
if (mode === 'active' && session.deletedAt !== null) return false
|
|
660
1764
|
if (mode === 'deleted' && session.deletedAt === null) return false
|
|
661
1765
|
return true
|
|
@@ -664,42 +1768,125 @@ function filteredSessions() {
|
|
|
664
1768
|
|
|
665
1769
|
function sessionRow(session) {
|
|
666
1770
|
const row = el('tr')
|
|
1771
|
+
const resetting = session.resetExpiresAt !== null && session.resetExpiresAt !== undefined && session.resetExpiresAt > Date.now()
|
|
667
1772
|
const idCell = el('td', 'session-id')
|
|
668
1773
|
idCell.append(el('code', null, session.sessionId))
|
|
669
|
-
const copy = el('button', 'row-copy'
|
|
1774
|
+
const copy = el('button', 'row-copy')
|
|
670
1775
|
copy.type = 'button'
|
|
1776
|
+
copy.title = '复制 session id'
|
|
1777
|
+
copy.append(icon('copy', 11))
|
|
671
1778
|
copy.onclick = () => copyText(session.sessionId)
|
|
672
1779
|
idCell.append(copy)
|
|
673
|
-
row.append(
|
|
1780
|
+
row.append(
|
|
1781
|
+
idCell,
|
|
1782
|
+
el('td', null, resetting ? '—' : 'v' + session.formatVersion),
|
|
1783
|
+
el('td', null, resetting ? '—' : String(session.batchCount)),
|
|
1784
|
+
el('td', null, resetting ? '—' : String(session.lastSeq)),
|
|
1785
|
+
el('td', null, resetting ? '—' : formatBytes(session.storedBytes)),
|
|
1786
|
+
agoCell(session.updatedAt),
|
|
1787
|
+
)
|
|
674
1788
|
const status = el('td')
|
|
675
|
-
|
|
676
|
-
|
|
1789
|
+
const purged = session.purgedAt !== null && session.purgedAt !== undefined
|
|
1790
|
+
const deleted = session.deletedAt !== null && session.deletedAt !== undefined
|
|
1791
|
+
status.append(el('span', 'badge' + (resetting ? ' accent' : deleted ? ' bad' : ' good'), resetting ? '重置中' : purged ? '已移除' : deleted ? (session.deletedBy === 'admin:archive' ? '已存档' : '已删除') : '正常'))
|
|
1792
|
+
const actions = el('td')
|
|
1793
|
+
const actionBox = el('div', 'row-actions')
|
|
1794
|
+
if (resetting) {
|
|
1795
|
+
actionBox.append(sessionActionButton(session, '取消重置', 'clear-reset'))
|
|
1796
|
+
} else {
|
|
1797
|
+
if (!deleted) actionBox.append(sessionActionButton(session, '存档', 'archive'))
|
|
1798
|
+
if (deleted && !purged) actionBox.append(sessionActionButton(session, '恢复', 'restore'))
|
|
1799
|
+
if (!purged) actionBox.append(sessionActionButton(session, '移除', 'remove', true))
|
|
1800
|
+
actionBox.append(sessionActionButton(session, '重置', 'reset'))
|
|
1801
|
+
}
|
|
1802
|
+
actionBox.append(sessionHistoryButton(session))
|
|
1803
|
+
actions.append(actionBox)
|
|
1804
|
+
row.append(status, actions)
|
|
677
1805
|
return row
|
|
678
1806
|
}
|
|
679
1807
|
|
|
1808
|
+
/**
|
|
1809
|
+
* 生成会话操作按钮;具体行为由 openSessionAction 处理。
|
|
1810
|
+
* @param {object} session - 会话行。
|
|
1811
|
+
* @param {string} label - 按钮文字。
|
|
1812
|
+
* @param {'archive'|'restore'|'remove'|'reset'|'clear-reset'} action - 动作。
|
|
1813
|
+
* @param {boolean} danger - 是否危险色。
|
|
1814
|
+
*/
|
|
1815
|
+
function sessionHistoryButton(session) {
|
|
1816
|
+
const button = el('button', 'btn tiny secondary')
|
|
1817
|
+
button.type = 'button'
|
|
1818
|
+
button.textContent = '详情'
|
|
1819
|
+
button.onclick = () => openHistory(session)
|
|
1820
|
+
return button
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
function sessionActionButton(session, label, action, danger) {
|
|
1824
|
+
const button = el('button', 'btn tiny' + (danger ? ' danger' : ' secondary'))
|
|
1825
|
+
button.type = 'button'
|
|
1826
|
+
button.textContent = label
|
|
1827
|
+
button.onclick = () => openSessionAction(session, action)
|
|
1828
|
+
return button
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
function workspaceAlias(key) {
|
|
1832
|
+
if (!key) return ''
|
|
1833
|
+
try { return localStorage.getItem(WORKSPACE_ALIAS_PREFIX + key) || '' } catch { return '' }
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
function saveWorkspaceAlias(key, value) {
|
|
1837
|
+
if (!key) return
|
|
1838
|
+
try {
|
|
1839
|
+
const trimmed = String(value || '').trim()
|
|
1840
|
+
if (trimmed === '') localStorage.removeItem(WORKSPACE_ALIAS_PREFIX + key)
|
|
1841
|
+
else localStorage.setItem(WORKSPACE_ALIAS_PREFIX + key, trimmed)
|
|
1842
|
+
} catch {
|
|
1843
|
+
// 浏览器禁用 localStorage 时别名不持久化,不影响服务端数据。
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
const WORKSPACE_ALIAS_PREFIX = 'dsh-sync-workspace-alias:'
|
|
1847
|
+
|
|
680
1848
|
function workspaceGroupRow(group) {
|
|
681
1849
|
const row = el('tr', 'session-group')
|
|
682
1850
|
const cell = el('td')
|
|
683
|
-
cell.colSpan =
|
|
1851
|
+
cell.colSpan = 8
|
|
684
1852
|
const head = el('div', 'session-group-head')
|
|
1853
|
+
const collapseKey = group.key || '__unmapped__'
|
|
1854
|
+
const collapsed = state.collapsed.has(collapseKey)
|
|
1855
|
+
const chev = el('button', 'chev' + (collapsed ? ' closed' : ''))
|
|
1856
|
+
chev.type = 'button'
|
|
1857
|
+
chev.title = collapsed ? '展开' : '折叠'
|
|
1858
|
+
chev.append(icon('chevron', 12))
|
|
1859
|
+
chev.onclick = (event) => {
|
|
1860
|
+
event.stopPropagation()
|
|
1861
|
+
if (collapsed) state.collapsed.delete(collapseKey)
|
|
1862
|
+
else state.collapsed.add(collapseKey)
|
|
1863
|
+
renderSessions()
|
|
1864
|
+
}
|
|
1865
|
+
head.append(chev)
|
|
685
1866
|
if (group.key) {
|
|
686
1867
|
const input = el('input', 'workspace-alias')
|
|
687
1868
|
input.value = workspaceAlias(group.key)
|
|
688
|
-
input.placeholder = '
|
|
1869
|
+
input.placeholder = '工作区别名(可选)'
|
|
689
1870
|
input.setAttribute('aria-label', '工作区别名')
|
|
1871
|
+
input.onclick = (event) => event.stopPropagation()
|
|
690
1872
|
input.onchange = () => saveWorkspaceAlias(group.key, input.value)
|
|
691
1873
|
head.append(input)
|
|
1874
|
+
} else {
|
|
1875
|
+
head.append(el('b', null, '未归属'))
|
|
692
1876
|
}
|
|
693
1877
|
const keyLine = el('div', 'session-group-key')
|
|
694
|
-
keyLine.append(el('code', null, group.key ? group.key.slice(0, 16) + '…' : '
|
|
1878
|
+
keyLine.append(el('code', null, group.key ? group.key.slice(0, 16) + '…' : '子代理 / 较早版本'))
|
|
695
1879
|
if (group.key) {
|
|
696
|
-
const copy = el('button', 'row-copy'
|
|
1880
|
+
const copy = el('button', 'row-copy')
|
|
697
1881
|
copy.type = 'button'
|
|
698
|
-
copy.
|
|
1882
|
+
copy.title = '复制工作区标识'
|
|
1883
|
+
copy.append(icon('copy', 11))
|
|
1884
|
+
copy.onclick = (event) => { event.stopPropagation(); copyText(group.key) }
|
|
699
1885
|
keyLine.append(copy)
|
|
700
1886
|
}
|
|
701
1887
|
head.append(keyLine)
|
|
702
|
-
|
|
1888
|
+
const totalBytes = group.sessions.reduce((sum, session) => sum + (session.storedBytes || 0), 0)
|
|
1889
|
+
head.append(el('div', 'session-group-meta', group.sessions.length + ' 个 · ' + formatBytes(totalBytes)))
|
|
703
1890
|
cell.append(head)
|
|
704
1891
|
row.append(cell)
|
|
705
1892
|
return row
|
|
@@ -710,7 +1897,6 @@ function renderSessions() {
|
|
|
710
1897
|
const sessions = filteredSessions()
|
|
711
1898
|
$('sessionBadge').textContent = (sessions.length === all.length ? all.length : sessions.length + ' / ' + all.length) + ' 个'
|
|
712
1899
|
const body = $('sessions')
|
|
713
|
-
// 会话按 manifest 里的 opaque workspaceKey 分组;子代理没有 key,归到“未归属”。
|
|
714
1900
|
const groups = new Map()
|
|
715
1901
|
for (const session of sessions) {
|
|
716
1902
|
const key = session.workspaceKey || null
|
|
@@ -725,18 +1911,21 @@ function renderSessions() {
|
|
|
725
1911
|
})
|
|
726
1912
|
const rows = []
|
|
727
1913
|
for (const group of ordered) {
|
|
1914
|
+
const collapseKey = group.key || '__unmapped__'
|
|
728
1915
|
rows.push(workspaceGroupRow(group))
|
|
1916
|
+
if (state.collapsed.has(collapseKey)) continue
|
|
729
1917
|
for (const session of group.sessions) rows.push(sessionRow(session))
|
|
730
1918
|
}
|
|
731
1919
|
body.replaceChildren(...rows)
|
|
732
1920
|
const empty = $('sessionsEmpty')
|
|
733
|
-
const table =
|
|
1921
|
+
const table = $('sessionTableWrap')
|
|
734
1922
|
if (sessions.length === 0) {
|
|
735
1923
|
table.hidden = true
|
|
736
1924
|
empty.hidden = false
|
|
737
1925
|
empty.replaceChildren()
|
|
738
1926
|
if (all.length === 0) {
|
|
739
|
-
|
|
1927
|
+
const mark = el('div', 'icon'); mark.append(icon('sessions', 22))
|
|
1928
|
+
empty.append(mark, el('b', null, '还没有同步过会话'), el('span', null, '接入 DSH 插件后,会话会出现在这里。'))
|
|
740
1929
|
const button = el('button', 'btn secondary small', '签发第一台设备')
|
|
741
1930
|
button.type = 'button'
|
|
742
1931
|
button.onclick = () => { $('deviceLabel').focus(); $('deviceLabel').scrollIntoView({ behavior: 'smooth', block: 'center' }) }
|
|
@@ -754,29 +1943,140 @@ function renderSessions() {
|
|
|
754
1943
|
}
|
|
755
1944
|
}
|
|
756
1945
|
|
|
757
|
-
const
|
|
1946
|
+
const AUDIT_ACTIONS = {
|
|
1947
|
+
'login': '登录',
|
|
1948
|
+
'login_failed': '登录失败',
|
|
1949
|
+
'logout': '退出登录',
|
|
1950
|
+
'device.create': '签发设备',
|
|
1951
|
+
'device.rename': '设备改名',
|
|
1952
|
+
'device.revoke': '吊销设备',
|
|
1953
|
+
'quota.change': '修改配额',
|
|
1954
|
+
'password.change': '修改密码',
|
|
1955
|
+
'password.change_failed': '修改密码失败',
|
|
1956
|
+
'password.reset_cli': 'CLI 重置密码',
|
|
1957
|
+
'setup.password': '初始化密码',
|
|
1958
|
+
'setup.quota': '初始化配额',
|
|
1959
|
+
'setup.device': '初始化设备',
|
|
1960
|
+
'setup.complete': '完成初始化',
|
|
1961
|
+
'session.delete': '删除会话',
|
|
1962
|
+
'session.archive': '存档会话',
|
|
1963
|
+
'session.remove': '移除会话',
|
|
1964
|
+
'session.reset': '重置会话',
|
|
1965
|
+
'session.restore': '恢复会话',
|
|
1966
|
+
'session.reset_cleared': '取消重置',
|
|
1967
|
+
}
|
|
758
1968
|
|
|
759
|
-
function
|
|
760
|
-
|
|
761
|
-
try { return localStorage.getItem(WORKSPACE_ALIAS_PREFIX + key) || '' } catch { return '' }
|
|
1969
|
+
function auditActionLabel(action) {
|
|
1970
|
+
return AUDIT_ACTIONS[action] || action
|
|
762
1971
|
}
|
|
763
1972
|
|
|
764
|
-
function
|
|
765
|
-
if (
|
|
1973
|
+
function auditActionTone(action) {
|
|
1974
|
+
if (action === 'device.revoke' || action === 'session.delete' || action === 'session.remove' || action === 'session.reset' || action === 'login_failed' || action === 'password.change_failed') return 'bad'
|
|
1975
|
+
if (action === 'device.create' || action === 'login' || action === 'setup.complete') return 'good'
|
|
1976
|
+
if (action === 'device.rename' || action === 'quota.change' || action === 'password.change' || action === 'password.reset_cli' || action === 'session.archive' || action === 'session.restore' || action === 'session.reset_cleared') return 'warn'
|
|
1977
|
+
return ''
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
function auditTargetText(target) {
|
|
1981
|
+
if (target === null || target === '') return '—'
|
|
1982
|
+
return target
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
function auditDetailText(action, detail) {
|
|
1986
|
+
if (detail === null || detail === undefined) return '—'
|
|
1987
|
+
if (typeof detail !== 'object') return String(detail)
|
|
1988
|
+
if ('reason' in detail) return String(detail.reason)
|
|
1989
|
+
if ('from' in detail && 'to' in detail) {
|
|
1990
|
+
if (action === 'quota.change') return formatBytes(detail.from) + ' → ' + formatBytes(detail.to)
|
|
1991
|
+
return String(detail.from) + ' → ' + String(detail.to)
|
|
1992
|
+
}
|
|
1993
|
+
if ('maxBytes' in detail) return formatBytes(detail.maxBytes)
|
|
1994
|
+
if ('label' in detail) return '标签 ' + String(detail.label)
|
|
1995
|
+
if ('createdAt' in detail) return 'createdAt ' + time(detail.createdAt)
|
|
1996
|
+
return Object.entries(detail).map(([key, value]) => {
|
|
1997
|
+
const text = value !== null && typeof value === 'object' ? JSON.stringify(value) : String(value)
|
|
1998
|
+
return key + ': ' + text
|
|
1999
|
+
}).join(' · ')
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
function renderAudit() {
|
|
2003
|
+
const query = $('auditSearch').value.trim().toLowerCase()
|
|
2004
|
+
const rows = state.audit.filter((event) => {
|
|
2005
|
+
if (query === '') return true
|
|
2006
|
+
return (event.actorLabel || '').toLowerCase().includes(query)
|
|
2007
|
+
|| (event.target || '').toLowerCase().includes(query)
|
|
2008
|
+
|| (event.action || '').toLowerCase().includes(query)
|
|
2009
|
+
})
|
|
2010
|
+
const body = $('auditRows')
|
|
2011
|
+
body.replaceChildren(...rows.map((event) => {
|
|
2012
|
+
const row = el('tr')
|
|
2013
|
+
const timeCell = el('td', 'muted', formatAgo(event.at))
|
|
2014
|
+
timeCell.title = time(event.at)
|
|
2015
|
+
const actionCell = el('td')
|
|
2016
|
+
actionCell.append(el('span', 'audit-action ' + auditActionTone(event.action), auditActionLabel(event.action)))
|
|
2017
|
+
const actorCell = el('td')
|
|
2018
|
+
actorCell.append(el('span', null, event.actorLabel || event.actorId))
|
|
2019
|
+
if (event.actorKind === 'device') {
|
|
2020
|
+
const id = el('span', 'audit-target', ' · ' + event.actorId)
|
|
2021
|
+
id.title = event.actorId
|
|
2022
|
+
actorCell.append(id)
|
|
2023
|
+
}
|
|
2024
|
+
const targetCell = el('td')
|
|
2025
|
+
const target = auditTargetText(event.target)
|
|
2026
|
+
targetCell.append(el('code', 'audit-target', target))
|
|
2027
|
+
targetCell.title = target
|
|
2028
|
+
const detailCell = el('td', 'audit-detail', auditDetailText(event.action, event.detail))
|
|
2029
|
+
const meta = [event.ip, event.userAgent].filter((value) => value !== null && value !== undefined && value !== '')
|
|
2030
|
+
if (meta.length > 0) detailCell.title = meta.join(' · ')
|
|
2031
|
+
row.title = event.actorId + (meta.length > 0 ? ' · ' + meta.join(' · ') : '')
|
|
2032
|
+
row.append(timeCell, actionCell, actorCell, targetCell, detailCell)
|
|
2033
|
+
return row
|
|
2034
|
+
}))
|
|
2035
|
+
$('auditBadge').textContent = rows.length + ' 条'
|
|
2036
|
+
const table = body.closest('.table-wrap')
|
|
2037
|
+
table.hidden = rows.length === 0
|
|
2038
|
+
const empty = $('auditEmpty')
|
|
2039
|
+
empty.hidden = rows.length > 0
|
|
2040
|
+
empty.replaceChildren()
|
|
2041
|
+
if (rows.length === 0) {
|
|
2042
|
+
const mark = el('div', 'icon'); mark.append(icon('audit', 22))
|
|
2043
|
+
empty.append(mark, el('b', null, '还没有审计记录'), el('span', null, '登录、签发设备、修改配额等动作会出现在这里。'))
|
|
2044
|
+
}
|
|
2045
|
+
$('auditMoreWrap').hidden = !state.auditHasMore
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
async function loadAudit(reset) {
|
|
2049
|
+
const button = $('auditRefresh')
|
|
2050
|
+
button.disabled = true
|
|
766
2051
|
try {
|
|
767
|
-
const
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
2052
|
+
const action = $('auditFilter').value
|
|
2053
|
+
const params = new URLSearchParams({ limit: '100' })
|
|
2054
|
+
if (action !== '') params.set('action', action)
|
|
2055
|
+
if (!reset && state.auditBefore !== null) params.set('before', String(state.auditBefore))
|
|
2056
|
+
const data = await api('/admin/audit?' + params.toString())
|
|
2057
|
+
state.audit = reset ? data.events : state.audit.concat(data.events)
|
|
2058
|
+
state.auditBefore = data.nextBefore
|
|
2059
|
+
state.auditHasMore = data.nextBefore !== null
|
|
2060
|
+
renderAudit()
|
|
2061
|
+
} catch (error) {
|
|
2062
|
+
if (isUninitialized(error)) {
|
|
2063
|
+
location.replace('/setup')
|
|
2064
|
+
return
|
|
2065
|
+
}
|
|
2066
|
+
if (error.message !== 'unauthorized') showToast(error.message, true)
|
|
2067
|
+
} finally {
|
|
2068
|
+
button.disabled = false
|
|
772
2069
|
}
|
|
773
2070
|
}
|
|
774
2071
|
|
|
775
2072
|
async function refresh(silent) {
|
|
776
2073
|
const button = $('refresh')
|
|
2074
|
+
const svg = button.querySelector('svg')
|
|
777
2075
|
if (!silent) {
|
|
778
2076
|
button.disabled = true
|
|
779
|
-
|
|
2077
|
+
if (svg) svg.classList.add('spinner')
|
|
2078
|
+
button.textContent = ''
|
|
2079
|
+
button.append(svg || icon('refresh', 14), el('span', null, '刷新中…'))
|
|
780
2080
|
}
|
|
781
2081
|
try {
|
|
782
2082
|
const [overview, devices, sessions] = await Promise.all([
|
|
@@ -787,25 +2087,53 @@ async function refresh(silent) {
|
|
|
787
2087
|
state.overview = overview
|
|
788
2088
|
state.devices = devices
|
|
789
2089
|
state.sessions = sessions
|
|
2090
|
+
updatedAt = Date.now()
|
|
790
2091
|
showApp()
|
|
791
2092
|
renderMetrics(overview)
|
|
792
|
-
renderDevices(devices)
|
|
793
|
-
// 正在编辑工作区别名时不重绘会话表,避免 10 秒轮询抢走焦点。
|
|
2093
|
+
if (state.editingDeviceId === null) renderDevices(devices)
|
|
794
2094
|
if (!$('sessions').contains(document.activeElement)) renderSessions()
|
|
795
2095
|
$('healthText').textContent = '服务正常'
|
|
2096
|
+
$('health').classList.remove('bad')
|
|
2097
|
+
$('updatedBadge').textContent = '更新于 ' + formatAgo(updatedAt)
|
|
2098
|
+
void loadActivity()
|
|
796
2099
|
} catch (error) {
|
|
2100
|
+
if (isUninitialized(error)) {
|
|
2101
|
+
location.replace('/setup')
|
|
2102
|
+
return
|
|
2103
|
+
}
|
|
797
2104
|
if (error.message !== 'unauthorized') {
|
|
798
2105
|
$('healthText').textContent = '连接异常'
|
|
2106
|
+
$('health').classList.add('bad')
|
|
799
2107
|
showToast(error.message, true)
|
|
800
2108
|
}
|
|
801
2109
|
} finally {
|
|
802
2110
|
if (!silent) {
|
|
803
2111
|
button.disabled = false
|
|
804
|
-
button.textContent = '
|
|
2112
|
+
button.textContent = ''
|
|
2113
|
+
button.append(icon('refresh', 14), el('span', null, '刷新'))
|
|
805
2114
|
}
|
|
806
2115
|
}
|
|
807
2116
|
}
|
|
808
2117
|
|
|
2118
|
+
function startPolling() {
|
|
2119
|
+
if (pollTimer !== null) clearInterval(pollTimer)
|
|
2120
|
+
pollTimer = setInterval(() => {
|
|
2121
|
+
if (state.autoRefresh && !$('appView').hidden) refresh(true)
|
|
2122
|
+
}, 10000)
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
$('refresh').append(icon('refresh', 14), el('span', null, '刷新'))
|
|
2126
|
+
$('sessionsIcon').append(icon('sessions', 15))
|
|
2127
|
+
$('storageIcon').append(icon('storage', 15))
|
|
2128
|
+
$('devicesIcon').append(icon('devices', 15))
|
|
2129
|
+
$('activityIcon').append(icon('activity', 15))
|
|
2130
|
+
$('auditIcon').append(icon('audit', 15))
|
|
2131
|
+
for (const [action, label] of Object.entries(AUDIT_ACTIONS)) {
|
|
2132
|
+
const option = el('option', null, label)
|
|
2133
|
+
option.value = action
|
|
2134
|
+
$('auditFilter').append(option)
|
|
2135
|
+
}
|
|
2136
|
+
|
|
809
2137
|
$('loginButton').onclick = async () => {
|
|
810
2138
|
const button = $('loginButton')
|
|
811
2139
|
$('loginError').textContent = ''
|
|
@@ -824,11 +2152,17 @@ $('loginButton').onclick = async () => {
|
|
|
824
2152
|
})
|
|
825
2153
|
if (!response.ok) {
|
|
826
2154
|
const payload = await response.json().catch(() => ({}))
|
|
827
|
-
|
|
2155
|
+
const message = (payload && payload.error && payload.error.message) || '登录失败'
|
|
2156
|
+
if (response.status === 409 && /初始化/u.test(message)) {
|
|
2157
|
+
location.replace('/setup')
|
|
2158
|
+
return
|
|
2159
|
+
}
|
|
2160
|
+
$('loginError').textContent = message
|
|
828
2161
|
return
|
|
829
2162
|
}
|
|
830
2163
|
$('password').value = ''
|
|
831
2164
|
await refresh(false)
|
|
2165
|
+
await loadAudit(true)
|
|
832
2166
|
} catch (error) {
|
|
833
2167
|
$('loginError').textContent = error.message || '登录失败'
|
|
834
2168
|
} finally {
|
|
@@ -848,7 +2182,14 @@ $('logout').onclick = async () => {
|
|
|
848
2182
|
await fetch('/admin/logout', { method: 'POST' }).catch(() => {})
|
|
849
2183
|
showLogin()
|
|
850
2184
|
}
|
|
851
|
-
$('refresh').onclick = () => refresh(false)
|
|
2185
|
+
$('refresh').onclick = () => { refresh(false); loadAudit(true) }
|
|
2186
|
+
$('autoRefresh').addEventListener('change', (event) => { state.autoRefresh = event.target.checked })
|
|
2187
|
+
$('quotaPresets').addEventListener('click', (event) => {
|
|
2188
|
+
const button = event.target.closest('.preset')
|
|
2189
|
+
if (!button) return
|
|
2190
|
+
$('quotaGb').value = button.dataset.gb
|
|
2191
|
+
for (const item of $('quotaPresets').querySelectorAll('.preset')) item.classList.toggle('on', item === button)
|
|
2192
|
+
})
|
|
852
2193
|
$('quotaForm').addEventListener('submit', async (event) => {
|
|
853
2194
|
event.preventDefault()
|
|
854
2195
|
const button = $('saveQuota')
|
|
@@ -863,7 +2204,7 @@ $('quotaForm').addEventListener('submit', async (event) => {
|
|
|
863
2204
|
button.disabled = true
|
|
864
2205
|
button.textContent = '保存中…'
|
|
865
2206
|
try {
|
|
866
|
-
await api('/admin/quota', { method: 'POST', body: JSON.stringify({ maxBytes: Math.round(gib *
|
|
2207
|
+
await api('/admin/quota', { method: 'POST', body: JSON.stringify({ maxBytes: Math.round(gib * GiB) }) })
|
|
867
2208
|
$('quotaResult').textContent = '已保存'
|
|
868
2209
|
$('quotaResult').className = 'inline-result good'
|
|
869
2210
|
await refresh(true)
|
|
@@ -895,26 +2236,221 @@ $('deviceForm').addEventListener('submit', async (event) => {
|
|
|
895
2236
|
})
|
|
896
2237
|
function showToken(created) {
|
|
897
2238
|
$('tokenValue').textContent = created.token
|
|
2239
|
+
$('tokenDialog').classList.remove('closing')
|
|
898
2240
|
$('tokenDialog').hidden = false
|
|
899
2241
|
document.body.classList.add('modal-open')
|
|
900
2242
|
$('copyToken').onclick = () => copyText(created.token)
|
|
901
2243
|
setTimeout(() => $('copyToken').focus(), 30)
|
|
902
2244
|
}
|
|
903
2245
|
function closeToken() {
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
$('tokenValue').textContent = ''
|
|
2246
|
+
closeModal('tokenDialog')
|
|
2247
|
+
setTimeout(() => { $('tokenValue').textContent = '' }, MODAL_EXIT_MS)
|
|
907
2248
|
}
|
|
908
2249
|
$('closeToken').onclick = closeToken
|
|
909
2250
|
$('tokenDialog').addEventListener('click', (event) => { if (event.target === $('tokenDialog')) closeToken() })
|
|
2251
|
+
$('cancelRevoke').onclick = closeRevoke
|
|
2252
|
+
$('revokeDialog').addEventListener('click', (event) => { if (event.target === $('revokeDialog')) closeRevoke() })
|
|
2253
|
+
$('changePassword').onclick = openPassword
|
|
2254
|
+
$('cancelPassword').onclick = closePassword
|
|
2255
|
+
$('passwordDialog').addEventListener('click', (event) => { if (event.target === $('passwordDialog')) closePassword() })
|
|
2256
|
+
$('savePassword').onclick = savePassword
|
|
2257
|
+
$('cancelSessionAction').onclick = closeSessionAction
|
|
2258
|
+
$('sessionDialog').addEventListener('click', (event) => { if (event.target === $('sessionDialog')) closeSessionAction() })
|
|
2259
|
+
$('confirmSessionAction').onclick = confirmSessionAction
|
|
2260
|
+
$('closeHistory').onclick = closeHistory
|
|
2261
|
+
for (const tab of document.querySelectorAll('.history-tab')) {
|
|
2262
|
+
tab.addEventListener('click', () => switchHistoryPane(tab.dataset.pane))
|
|
2263
|
+
}
|
|
2264
|
+
$('historyDialog').addEventListener('click', (event) => { if (event.target === $('historyDialog')) closeHistory() })
|
|
2265
|
+
$('cancelColor').onclick = closeColorDialog
|
|
2266
|
+
$('colorDialog').addEventListener('click', (event) => { if (event.target === $('colorDialog')) closeColorDialog() })
|
|
2267
|
+
$('saveColor').onclick = saveDeviceColor
|
|
2268
|
+
$('deviceColorPicker').addEventListener('input', () => applyColorDraft($('deviceColorPicker').value))
|
|
2269
|
+
$('deviceColorHex').addEventListener('change', () => {
|
|
2270
|
+
const color = normalizeColor($('deviceColorHex').value)
|
|
2271
|
+
if (color === null) {
|
|
2272
|
+
$('colorError').textContent = '请输入 #rrggbb 格式的颜色'
|
|
2273
|
+
return
|
|
2274
|
+
}
|
|
2275
|
+
$('colorError').textContent = ''
|
|
2276
|
+
applyColorDraft(color)
|
|
2277
|
+
})
|
|
2278
|
+
$('deviceColorHex').addEventListener('keydown', (event) => { if (event.key === 'Enter') saveDeviceColor() })
|
|
2279
|
+
for (const id of ['currentPassword', 'newPassword', 'confirmPassword']) {
|
|
2280
|
+
$(id).addEventListener('keydown', (event) => { if (event.key === 'Enter') savePassword() })
|
|
2281
|
+
}
|
|
2282
|
+
$('confirmRevoke').onclick = async () => {
|
|
2283
|
+
const deviceId = state.revokeTarget
|
|
2284
|
+
if (deviceId === null) return
|
|
2285
|
+
const button = $('confirmRevoke')
|
|
2286
|
+
button.disabled = true
|
|
2287
|
+
button.textContent = '吊销中…'
|
|
2288
|
+
try {
|
|
2289
|
+
await api('/admin/devices/' + encodeURIComponent(deviceId), { method: 'DELETE' })
|
|
2290
|
+
closeRevoke()
|
|
2291
|
+
showToast('设备已吊销')
|
|
2292
|
+
await refresh(true)
|
|
2293
|
+
} catch (error) {
|
|
2294
|
+
showToast(error.message, true)
|
|
2295
|
+
} finally {
|
|
2296
|
+
button.disabled = false
|
|
2297
|
+
button.textContent = '确认吊销'
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
910
2300
|
document.addEventListener('keydown', (event) => {
|
|
911
|
-
if (event.key
|
|
2301
|
+
if (event.key !== 'Escape') return
|
|
2302
|
+
if (!$('tokenDialog').hidden) closeToken()
|
|
2303
|
+
if (!$('revokeDialog').hidden) closeRevoke()
|
|
2304
|
+
if (!$('passwordDialog').hidden) closePassword()
|
|
2305
|
+
if (!$('sessionDialog').hidden) closeSessionAction()
|
|
2306
|
+
if (!$('historyDialog').hidden) closeHistory()
|
|
2307
|
+
if (!$('colorDialog').hidden) closeColorDialog()
|
|
2308
|
+
if (!$('activityRangePopover').hidden) closeActivityRange()
|
|
912
2309
|
})
|
|
913
2310
|
$('sessionSearch').addEventListener('input', renderSessions)
|
|
914
2311
|
$('sessionFilter').addEventListener('change', renderSessions)
|
|
2312
|
+
$('sessionDayChip').addEventListener('click', () => {
|
|
2313
|
+
state.sessionDayFilter = null
|
|
2314
|
+
state.activityDaySessionIds = null
|
|
2315
|
+
updateSessionDayChip()
|
|
2316
|
+
renderSessions()
|
|
2317
|
+
})
|
|
2318
|
+
$('auditSearch').addEventListener('input', renderAudit)
|
|
2319
|
+
$('auditFilter').addEventListener('change', () => loadAudit(true))
|
|
2320
|
+
$('auditRefresh').addEventListener('click', () => loadAudit(true))
|
|
2321
|
+
$('auditMore').addEventListener('click', () => loadAudit(false))
|
|
2322
|
+
$('activityRangeButton').addEventListener('click', (event) => {
|
|
2323
|
+
event.stopPropagation()
|
|
2324
|
+
if ($('activityRangePopover').hidden) openActivityRange()
|
|
2325
|
+
else closeActivityRange()
|
|
2326
|
+
})
|
|
2327
|
+
$('activityCancel').addEventListener('click', closeActivityRange)
|
|
2328
|
+
$('activityApply').addEventListener('click', applyActivityRange)
|
|
2329
|
+
$('activityPresets').addEventListener('click', (event) => {
|
|
2330
|
+
const button = event.target.closest('[data-days]')
|
|
2331
|
+
if (!button) return
|
|
2332
|
+
const days = Number(button.dataset.days)
|
|
2333
|
+
const range = defaultActivityRange(days)
|
|
2334
|
+
setActivityRange(range.start, range.end, days)
|
|
2335
|
+
closeActivityRange()
|
|
2336
|
+
void loadActivity()
|
|
2337
|
+
})
|
|
2338
|
+
for (const id of ['activityFrom', 'activityTo']) {
|
|
2339
|
+
$(id).addEventListener('keydown', (event) => { if (event.key === 'Enter') applyActivityRange() })
|
|
2340
|
+
}
|
|
2341
|
+
document.addEventListener('click', (event) => {
|
|
2342
|
+
if ($('activityRangePopover').hidden) return
|
|
2343
|
+
const wrap = document.querySelector('.activity-range')
|
|
2344
|
+
if (wrap !== null && !wrap.contains(event.target)) closeActivityRange()
|
|
2345
|
+
})
|
|
915
2346
|
|
|
2347
|
+
const initialDays = window.matchMedia('(max-width: 900px)').matches ? 90 : 365
|
|
2348
|
+
const initialActivity = defaultActivityRange(initialDays)
|
|
2349
|
+
setActivityRange(initialActivity.start, initialActivity.end, initialDays)
|
|
916
2350
|
refresh(false)
|
|
917
|
-
|
|
2351
|
+
loadAudit(true)
|
|
2352
|
+
startPolling()
|
|
2353
|
+
|
|
2354
|
+
// 背景:同尺寸胶囊条带;偶数行向右、奇数行向左,入场结束后只保留低速漂移。
|
|
2355
|
+
// 参数来自 site/bg-tune.html,控制台里降低透明度与速度,避免干扰阅读。
|
|
2356
|
+
;(function initAdminBackground() {
|
|
2357
|
+
var CONFIG = {
|
|
2358
|
+
length: 48, height: 10, radius: 5, gap: 9, row: 28,
|
|
2359
|
+
alpha: 0.26, tint: '76, 98, 150', duration: 2.2, variance: 2.8,
|
|
2360
|
+
rotation: 37, jitter: true,
|
|
2361
|
+
}
|
|
2362
|
+
var rowsBox = document.getElementById('adminBgRows')
|
|
2363
|
+
if (rowsBox === null) return
|
|
2364
|
+
|
|
2365
|
+
var seed = 20261113
|
|
2366
|
+
function random() {
|
|
2367
|
+
seed = (seed + 0x6D2B79F5) | 0
|
|
2368
|
+
var value = Math.imul(seed ^ (seed >>> 15), 1 | seed)
|
|
2369
|
+
value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value
|
|
2370
|
+
return ((value ^ (value >>> 14)) >>> 0) / 4294967296
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
var lo = CONFIG.duration
|
|
2374
|
+
var hi = Math.max(CONFIG.duration + CONFIG.variance, CONFIG.duration * 2)
|
|
2375
|
+
var usedDurations = {}
|
|
2376
|
+
var durations = []
|
|
2377
|
+
|
|
2378
|
+
function takeDuration(index) {
|
|
2379
|
+
var previous = index >= 2 ? durations[index - 2] : undefined
|
|
2380
|
+
for (var attempt = 0; attempt < 80; attempt += 1) {
|
|
2381
|
+
var candidate = lo + random() * (hi - lo)
|
|
2382
|
+
var key = candidate.toFixed(3)
|
|
2383
|
+
if (usedDurations[key] === true) continue
|
|
2384
|
+
if (previous !== undefined) {
|
|
2385
|
+
var ratio = candidate / previous
|
|
2386
|
+
if (ratio >= 0.7 && ratio <= 1.3) continue
|
|
2387
|
+
}
|
|
2388
|
+
usedDurations[key] = true
|
|
2389
|
+
return candidate
|
|
2390
|
+
}
|
|
2391
|
+
var fallback = previous !== undefined && previous * 1.3 <= hi ? previous * 1.31 : lo
|
|
2392
|
+
usedDurations[fallback.toFixed(3)] = true
|
|
2393
|
+
return fallback
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
var stageW = window.innerWidth
|
|
2397
|
+
var stageH = window.innerHeight
|
|
2398
|
+
var planeWidth = stageW * 1.6
|
|
2399
|
+
var planeHeight = stageH * 2.1
|
|
2400
|
+
var lineLeft = -280
|
|
2401
|
+
var lineRight = planeWidth + 280
|
|
2402
|
+
var angle = CONFIG.rotation * Math.PI / 180
|
|
2403
|
+
var cos = Math.cos(angle)
|
|
2404
|
+
var sin = Math.sin(angle)
|
|
2405
|
+
var margin = 300
|
|
2406
|
+
var height = planeHeight
|
|
2407
|
+
var count = Math.ceil(height / CONFIG.row) + 2
|
|
2408
|
+
var fragment = document.createDocumentFragment()
|
|
2409
|
+
|
|
2410
|
+
for (var index = 0; index < count; index += 1) {
|
|
2411
|
+
var rowGap = CONFIG.jitter
|
|
2412
|
+
? Math.max(2, Math.round(CONFIG.gap * (0.7 + random() * 0.6)))
|
|
2413
|
+
: CONFIG.gap
|
|
2414
|
+
var rowTile = CONFIG.length + rowGap
|
|
2415
|
+
var odd = index % 2 === 1
|
|
2416
|
+
var top = (CONFIG.row - CONFIG.height) / 2
|
|
2417
|
+
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + rowTile + '" height="' + CONFIG.row + '">'
|
|
2418
|
+
+ '<rect x="0" y="' + top + '" width="' + CONFIG.length + '" height="' + CONFIG.height
|
|
2419
|
+
+ '" rx="' + Math.min(CONFIG.radius, CONFIG.height / 2) + '" fill="rgba(' + CONFIG.tint + ',' + CONFIG.alpha + ')"/></svg>'
|
|
2420
|
+
var duration = takeDuration(index)
|
|
2421
|
+
durations[index] = duration
|
|
2422
|
+
var row = document.createElement('div')
|
|
2423
|
+
row.className = 'admin-bg-row' + (odd ? ' odd' : '')
|
|
2424
|
+
row.style.top = (index * CONFIG.row - CONFIG.row) + 'px'
|
|
2425
|
+
row.style.setProperty('--row', CONFIG.row + 'px')
|
|
2426
|
+
row.style.backgroundImage = 'url("data:image/svg+xml,' + encodeURIComponent(svg) + '")'
|
|
2427
|
+
row.style.backgroundPositionX = (-random() * rowTile).toFixed(1) + 'px'
|
|
2428
|
+
row.style.setProperty('--dur', duration.toFixed(2) + 's')
|
|
2429
|
+
row.style.animationDelay = (-random() * duration).toFixed(2) + 's, 0s'
|
|
2430
|
+
row.style.setProperty('--tile', rowTile + 'px')
|
|
2431
|
+
|
|
2432
|
+
var yRel = (index * CONFIG.row - CONFIG.row + CONFIG.row / 2) - planeHeight / 2
|
|
2433
|
+
var halfW = stageW / 2 + CONFIG.length
|
|
2434
|
+
var halfH = stageH / 2 + CONFIG.height
|
|
2435
|
+
var xHi = Math.min((halfW + yRel * sin) / cos, (halfH - yRel * cos) / sin)
|
|
2436
|
+
var xLo = Math.max((-halfW + yRel * sin) / cos, (-halfH - yRel * cos) / sin)
|
|
2437
|
+
var visHi = xHi + planeWidth / 2
|
|
2438
|
+
var visLo = xLo + planeWidth / 2
|
|
2439
|
+
var enterFrom
|
|
2440
|
+
var enterTo
|
|
2441
|
+
if (odd) {
|
|
2442
|
+
enterFrom = visHi + margin - lineLeft
|
|
2443
|
+
enterTo = visLo - margin - lineLeft
|
|
2444
|
+
} else {
|
|
2445
|
+
enterFrom = visLo - margin - lineRight
|
|
2446
|
+
enterTo = visHi + margin - lineRight
|
|
2447
|
+
}
|
|
2448
|
+
row.style.setProperty('--enter-from', enterFrom.toFixed(1) + 'px')
|
|
2449
|
+
row.style.setProperty('--enter-to', enterTo.toFixed(1) + 'px')
|
|
2450
|
+
fragment.appendChild(row)
|
|
2451
|
+
}
|
|
2452
|
+
rowsBox.replaceChildren(fragment)
|
|
2453
|
+
})()
|
|
918
2454
|
</script>
|
|
919
2455
|
</body>
|
|
920
2456
|
</html>
|