dscs-server 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -17
- package/dist/admin-password.js +27 -0
- package/dist/audit.js +73 -0
- package/dist/db.js +174 -3
- package/dist/http.js +498 -78
- package/dist/index.js +23 -7
- package/dist/session-admin.js +200 -0
- package/dist/session-history.js +134 -0
- package/dist/sessions.js +103 -73
- package/dist/shared/chain.js +3 -0
- package/dist/shared/wire.js +1 -0
- package/package.json +1 -1
- package/web/admin.html +1836 -275
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>
|
|
@@ -380,35 +435,77 @@
|
|
|
380
435
|
<div>
|
|
381
436
|
<span class="eyebrow">Overview</span>
|
|
382
437
|
<h2>同步概览</h2>
|
|
383
|
-
<p class="muted"
|
|
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,47 +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
|
|
1212
|
+
const rename = el('button', 'icon-btn')
|
|
1213
|
+
rename.type = 'button'
|
|
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')
|
|
608
1217
|
revoke.type = 'button'
|
|
609
|
-
revoke.
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
revoke.textContent = '吊销中…'
|
|
613
|
-
try {
|
|
614
|
-
await api('/admin/devices/' + encodeURIComponent(device.deviceId), { method: 'DELETE' })
|
|
615
|
-
showToast('设备已吊销')
|
|
616
|
-
await refresh(true)
|
|
617
|
-
} catch (error) {
|
|
618
|
-
showToast(error.message, true)
|
|
619
|
-
revoke.disabled = false
|
|
620
|
-
revoke.textContent = '吊销'
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
actions.append(revoke)
|
|
1218
|
+
revoke.append(el('span', null, '吊销'))
|
|
1219
|
+
revoke.onclick = () => openRevoke(device)
|
|
1220
|
+
actions.append(rename, revoke)
|
|
624
1221
|
row.append(main, actions)
|
|
625
1222
|
return row
|
|
626
1223
|
}))
|
|
627
1224
|
}
|
|
628
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
|
+
|
|
629
1757
|
function filteredSessions() {
|
|
630
1758
|
const query = $('sessionSearch').value.trim().toLowerCase()
|
|
631
1759
|
const mode = $('sessionFilter').value
|
|
632
1760
|
return state.sessions.filter((session) => {
|
|
633
|
-
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
|
|
634
1763
|
if (mode === 'active' && session.deletedAt !== null) return false
|
|
635
1764
|
if (mode === 'deleted' && session.deletedAt === null) return false
|
|
636
1765
|
return true
|
|
@@ -639,42 +1768,125 @@ function filteredSessions() {
|
|
|
639
1768
|
|
|
640
1769
|
function sessionRow(session) {
|
|
641
1770
|
const row = el('tr')
|
|
1771
|
+
const resetting = session.resetExpiresAt !== null && session.resetExpiresAt !== undefined && session.resetExpiresAt > Date.now()
|
|
642
1772
|
const idCell = el('td', 'session-id')
|
|
643
1773
|
idCell.append(el('code', null, session.sessionId))
|
|
644
|
-
const copy = el('button', 'row-copy'
|
|
1774
|
+
const copy = el('button', 'row-copy')
|
|
645
1775
|
copy.type = 'button'
|
|
1776
|
+
copy.title = '复制 session id'
|
|
1777
|
+
copy.append(icon('copy', 11))
|
|
646
1778
|
copy.onclick = () => copyText(session.sessionId)
|
|
647
1779
|
idCell.append(copy)
|
|
648
|
-
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
|
+
)
|
|
649
1788
|
const status = el('td')
|
|
650
|
-
|
|
651
|
-
|
|
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)
|
|
652
1805
|
return row
|
|
653
1806
|
}
|
|
654
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
|
+
|
|
655
1848
|
function workspaceGroupRow(group) {
|
|
656
1849
|
const row = el('tr', 'session-group')
|
|
657
1850
|
const cell = el('td')
|
|
658
|
-
cell.colSpan =
|
|
1851
|
+
cell.colSpan = 8
|
|
659
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)
|
|
660
1866
|
if (group.key) {
|
|
661
1867
|
const input = el('input', 'workspace-alias')
|
|
662
1868
|
input.value = workspaceAlias(group.key)
|
|
663
|
-
input.placeholder = '
|
|
1869
|
+
input.placeholder = '工作区别名(可选)'
|
|
664
1870
|
input.setAttribute('aria-label', '工作区别名')
|
|
1871
|
+
input.onclick = (event) => event.stopPropagation()
|
|
665
1872
|
input.onchange = () => saveWorkspaceAlias(group.key, input.value)
|
|
666
1873
|
head.append(input)
|
|
1874
|
+
} else {
|
|
1875
|
+
head.append(el('b', null, '未归属'))
|
|
667
1876
|
}
|
|
668
1877
|
const keyLine = el('div', 'session-group-key')
|
|
669
|
-
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) + '…' : '子代理 / 较早版本'))
|
|
670
1879
|
if (group.key) {
|
|
671
|
-
const copy = el('button', 'row-copy'
|
|
1880
|
+
const copy = el('button', 'row-copy')
|
|
672
1881
|
copy.type = 'button'
|
|
673
|
-
copy.
|
|
1882
|
+
copy.title = '复制工作区标识'
|
|
1883
|
+
copy.append(icon('copy', 11))
|
|
1884
|
+
copy.onclick = (event) => { event.stopPropagation(); copyText(group.key) }
|
|
674
1885
|
keyLine.append(copy)
|
|
675
1886
|
}
|
|
676
1887
|
head.append(keyLine)
|
|
677
|
-
|
|
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)))
|
|
678
1890
|
cell.append(head)
|
|
679
1891
|
row.append(cell)
|
|
680
1892
|
return row
|
|
@@ -685,7 +1897,6 @@ function renderSessions() {
|
|
|
685
1897
|
const sessions = filteredSessions()
|
|
686
1898
|
$('sessionBadge').textContent = (sessions.length === all.length ? all.length : sessions.length + ' / ' + all.length) + ' 个'
|
|
687
1899
|
const body = $('sessions')
|
|
688
|
-
// 会话按 manifest 里的 opaque workspaceKey 分组;子代理没有 key,归到“未归属”。
|
|
689
1900
|
const groups = new Map()
|
|
690
1901
|
for (const session of sessions) {
|
|
691
1902
|
const key = session.workspaceKey || null
|
|
@@ -700,18 +1911,21 @@ function renderSessions() {
|
|
|
700
1911
|
})
|
|
701
1912
|
const rows = []
|
|
702
1913
|
for (const group of ordered) {
|
|
1914
|
+
const collapseKey = group.key || '__unmapped__'
|
|
703
1915
|
rows.push(workspaceGroupRow(group))
|
|
1916
|
+
if (state.collapsed.has(collapseKey)) continue
|
|
704
1917
|
for (const session of group.sessions) rows.push(sessionRow(session))
|
|
705
1918
|
}
|
|
706
1919
|
body.replaceChildren(...rows)
|
|
707
1920
|
const empty = $('sessionsEmpty')
|
|
708
|
-
const table =
|
|
1921
|
+
const table = $('sessionTableWrap')
|
|
709
1922
|
if (sessions.length === 0) {
|
|
710
1923
|
table.hidden = true
|
|
711
1924
|
empty.hidden = false
|
|
712
1925
|
empty.replaceChildren()
|
|
713
1926
|
if (all.length === 0) {
|
|
714
|
-
|
|
1927
|
+
const mark = el('div', 'icon'); mark.append(icon('sessions', 22))
|
|
1928
|
+
empty.append(mark, el('b', null, '还没有同步过会话'), el('span', null, '接入 DSH 插件后,会话会出现在这里。'))
|
|
715
1929
|
const button = el('button', 'btn secondary small', '签发第一台设备')
|
|
716
1930
|
button.type = 'button'
|
|
717
1931
|
button.onclick = () => { $('deviceLabel').focus(); $('deviceLabel').scrollIntoView({ behavior: 'smooth', block: 'center' }) }
|
|
@@ -729,29 +1943,140 @@ function renderSessions() {
|
|
|
729
1943
|
}
|
|
730
1944
|
}
|
|
731
1945
|
|
|
732
|
-
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
|
+
}
|
|
733
1968
|
|
|
734
|
-
function
|
|
735
|
-
|
|
736
|
-
try { return localStorage.getItem(WORKSPACE_ALIAS_PREFIX + key) || '' } catch { return '' }
|
|
1969
|
+
function auditActionLabel(action) {
|
|
1970
|
+
return AUDIT_ACTIONS[action] || action
|
|
737
1971
|
}
|
|
738
1972
|
|
|
739
|
-
function
|
|
740
|
-
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
|
|
741
2051
|
try {
|
|
742
|
-
const
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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
|
|
747
2069
|
}
|
|
748
2070
|
}
|
|
749
2071
|
|
|
750
2072
|
async function refresh(silent) {
|
|
751
2073
|
const button = $('refresh')
|
|
2074
|
+
const svg = button.querySelector('svg')
|
|
752
2075
|
if (!silent) {
|
|
753
2076
|
button.disabled = true
|
|
754
|
-
|
|
2077
|
+
if (svg) svg.classList.add('spinner')
|
|
2078
|
+
button.textContent = ''
|
|
2079
|
+
button.append(svg || icon('refresh', 14), el('span', null, '刷新中…'))
|
|
755
2080
|
}
|
|
756
2081
|
try {
|
|
757
2082
|
const [overview, devices, sessions] = await Promise.all([
|
|
@@ -762,25 +2087,53 @@ async function refresh(silent) {
|
|
|
762
2087
|
state.overview = overview
|
|
763
2088
|
state.devices = devices
|
|
764
2089
|
state.sessions = sessions
|
|
2090
|
+
updatedAt = Date.now()
|
|
765
2091
|
showApp()
|
|
766
2092
|
renderMetrics(overview)
|
|
767
|
-
renderDevices(devices)
|
|
768
|
-
// 正在编辑工作区别名时不重绘会话表,避免 10 秒轮询抢走焦点。
|
|
2093
|
+
if (state.editingDeviceId === null) renderDevices(devices)
|
|
769
2094
|
if (!$('sessions').contains(document.activeElement)) renderSessions()
|
|
770
2095
|
$('healthText').textContent = '服务正常'
|
|
2096
|
+
$('health').classList.remove('bad')
|
|
2097
|
+
$('updatedBadge').textContent = '更新于 ' + formatAgo(updatedAt)
|
|
2098
|
+
void loadActivity()
|
|
771
2099
|
} catch (error) {
|
|
2100
|
+
if (isUninitialized(error)) {
|
|
2101
|
+
location.replace('/setup')
|
|
2102
|
+
return
|
|
2103
|
+
}
|
|
772
2104
|
if (error.message !== 'unauthorized') {
|
|
773
2105
|
$('healthText').textContent = '连接异常'
|
|
2106
|
+
$('health').classList.add('bad')
|
|
774
2107
|
showToast(error.message, true)
|
|
775
2108
|
}
|
|
776
2109
|
} finally {
|
|
777
2110
|
if (!silent) {
|
|
778
2111
|
button.disabled = false
|
|
779
|
-
button.textContent = '
|
|
2112
|
+
button.textContent = ''
|
|
2113
|
+
button.append(icon('refresh', 14), el('span', null, '刷新'))
|
|
780
2114
|
}
|
|
781
2115
|
}
|
|
782
2116
|
}
|
|
783
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
|
+
|
|
784
2137
|
$('loginButton').onclick = async () => {
|
|
785
2138
|
const button = $('loginButton')
|
|
786
2139
|
$('loginError').textContent = ''
|
|
@@ -799,11 +2152,17 @@ $('loginButton').onclick = async () => {
|
|
|
799
2152
|
})
|
|
800
2153
|
if (!response.ok) {
|
|
801
2154
|
const payload = await response.json().catch(() => ({}))
|
|
802
|
-
|
|
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
|
|
803
2161
|
return
|
|
804
2162
|
}
|
|
805
2163
|
$('password').value = ''
|
|
806
2164
|
await refresh(false)
|
|
2165
|
+
await loadAudit(true)
|
|
807
2166
|
} catch (error) {
|
|
808
2167
|
$('loginError').textContent = error.message || '登录失败'
|
|
809
2168
|
} finally {
|
|
@@ -823,7 +2182,14 @@ $('logout').onclick = async () => {
|
|
|
823
2182
|
await fetch('/admin/logout', { method: 'POST' }).catch(() => {})
|
|
824
2183
|
showLogin()
|
|
825
2184
|
}
|
|
826
|
-
$('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
|
+
})
|
|
827
2193
|
$('quotaForm').addEventListener('submit', async (event) => {
|
|
828
2194
|
event.preventDefault()
|
|
829
2195
|
const button = $('saveQuota')
|
|
@@ -838,7 +2204,7 @@ $('quotaForm').addEventListener('submit', async (event) => {
|
|
|
838
2204
|
button.disabled = true
|
|
839
2205
|
button.textContent = '保存中…'
|
|
840
2206
|
try {
|
|
841
|
-
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) }) })
|
|
842
2208
|
$('quotaResult').textContent = '已保存'
|
|
843
2209
|
$('quotaResult').className = 'inline-result good'
|
|
844
2210
|
await refresh(true)
|
|
@@ -870,26 +2236,221 @@ $('deviceForm').addEventListener('submit', async (event) => {
|
|
|
870
2236
|
})
|
|
871
2237
|
function showToken(created) {
|
|
872
2238
|
$('tokenValue').textContent = created.token
|
|
2239
|
+
$('tokenDialog').classList.remove('closing')
|
|
873
2240
|
$('tokenDialog').hidden = false
|
|
874
2241
|
document.body.classList.add('modal-open')
|
|
875
2242
|
$('copyToken').onclick = () => copyText(created.token)
|
|
876
2243
|
setTimeout(() => $('copyToken').focus(), 30)
|
|
877
2244
|
}
|
|
878
2245
|
function closeToken() {
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
$('tokenValue').textContent = ''
|
|
2246
|
+
closeModal('tokenDialog')
|
|
2247
|
+
setTimeout(() => { $('tokenValue').textContent = '' }, MODAL_EXIT_MS)
|
|
882
2248
|
}
|
|
883
2249
|
$('closeToken').onclick = closeToken
|
|
884
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
|
+
}
|
|
885
2300
|
document.addEventListener('keydown', (event) => {
|
|
886
|
-
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()
|
|
887
2309
|
})
|
|
888
2310
|
$('sessionSearch').addEventListener('input', renderSessions)
|
|
889
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
|
+
})
|
|
890
2346
|
|
|
2347
|
+
const initialDays = window.matchMedia('(max-width: 900px)').matches ? 90 : 365
|
|
2348
|
+
const initialActivity = defaultActivityRange(initialDays)
|
|
2349
|
+
setActivityRange(initialActivity.start, initialActivity.end, initialDays)
|
|
891
2350
|
refresh(false)
|
|
892
|
-
|
|
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
|
+
})()
|
|
893
2454
|
</script>
|
|
894
2455
|
</body>
|
|
895
2456
|
</html>
|