Solvaxis commited on
Commit
af4f86e
·
1 Parent(s): b2ba14f

Add application file

Browse files
Files changed (5) hide show
  1. Dockerfile +21 -0
  2. README.md +3 -0
  3. package.json +15 -0
  4. public/index.html +1764 -0
  5. server.js +754 -0
Dockerfile ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:18-alpine
2
+
3
+ WORKDIR /app
4
+
5
+ # 安装依赖
6
+ COPY package.json .
7
+ RUN npm install --production
8
+
9
+ # 复制代码
10
+ COPY . .
11
+
12
+ # 暴露端口
13
+ EXPOSE 8080
14
+
15
+ # 设置环境变量(可以通过 docker run -e 覆盖)
16
+ ENV PORT=8080
17
+ ENV HF_USER=""
18
+ ENV API_KEY="your_api_key_here"
19
+
20
+ # 启动应用
21
+ CMD ["npm", "start"]
README.md CHANGED
@@ -5,6 +5,9 @@ colorFrom: gray
5
  colorTo: green
6
  sdk: docker
7
  pinned: false
 
 
 
8
  ---
9
 
10
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
5
  colorTo: green
6
  sdk: docker
7
  pinned: false
8
+ license: apache-2.0
9
+ app_port: 8080
10
+ short_description: HF Space Manager
11
  ---
12
 
13
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
package.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "hf-space-manager",
3
+ "version": "1.0.0",
4
+ "description": "HuggingFace instance manager dashboard",
5
+ "main": "server.js",
6
+ "dependencies": {
7
+ "express": "^4.18.2",
8
+ "axios": "^1.6.2"
9
+ },
10
+ "scripts": {
11
+ "start": "node server.js"
12
+ },
13
+ "author": "",
14
+ "license": "MIT"
15
+ }
public/index.html ADDED
@@ -0,0 +1,1764 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
6
+ <title>HF Space Manager</title>
7
+ <!-- 引入 Chart.js CDN -->
8
+ <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
9
+ <!-- 引入科技感字体 Orbitron -->
10
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;700&display=swap">
11
+ <style>
12
+ * {
13
+ margin: 0;
14
+ padding: 0;
15
+ box-sizing: border-box;
16
+ -webkit-font-smoothing: antialiased;
17
+ -moz-osx-font-smoothing: grayscale;
18
+ }
19
+ body {
20
+ font-family: 'Orbitron', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
21
+ background: var(--background-color);
22
+ color: var(--text-color);
23
+ padding: 20px;
24
+ min-height: 100vh;
25
+ background-image: radial-gradient(rgba(0, 212, 255, 0.1) 1px, transparent 1px), radial-gradient(rgba(255, 0, 255, 0.1) 1px, transparent 1px);
26
+ background-size: 40px 40px;
27
+ background-position: 0 0, 20px 20px;
28
+ }
29
+ :root {
30
+ /* 深色模式变量(默认且唯一) */
31
+ --background-color: #0A0A1E;
32
+ --text-color: #E0E0FF;
33
+ --secondary-text: #A0A0CC;
34
+ --card-background: rgba(20, 20, 40, 0.7);
35
+ --card-border: rgba(0, 212, 255, 0.3);
36
+ --metric-background: rgba(30, 30, 50, 0.6);
37
+ --metric-border: rgba(0, 212, 255, 0.2);
38
+ --metric-hover: rgba(40, 40, 70, 0.8);
39
+ --label-color: #A0A0CC;
40
+ --action-button-bg: rgba(0, 212, 255, 0.2);
41
+ --action-button-hover: rgba(0, 212, 255, 0.4);
42
+ --accent-color: #00D4FF;
43
+ --neon-pink: #FF00FF;
44
+ --neon-green: #00FFAA;
45
+ }
46
+ .container {
47
+ max-width: 1400px;
48
+ margin: 0 auto;
49
+ padding: 0 15px;
50
+ }
51
+ .overview {
52
+ background: var(--card-background);
53
+ border-radius: 8px;
54
+ padding: 20px;
55
+ margin-bottom: 25px;
56
+ border: 1px solid var(--card-border);
57
+ box-shadow: 0 0 10px rgba(0, 212, 255, 0.3), inset 0 0 2px rgba(0, 212, 255, 0.5);
58
+ transition: background 0.3s ease, border 0.3s ease;
59
+ }
60
+ .overview-title {
61
+ font-size: 18px;
62
+ font-weight: 700;
63
+ display: flex;
64
+ align-items: center;
65
+ gap: 8px;
66
+ margin-bottom: 16px;
67
+ color: var(--accent-color);
68
+ text-shadow: 0 0 5px rgba(0, 212, 255, 0.5);
69
+ }
70
+ #summary {
71
+ display: grid;
72
+ grid-template-columns: repeat(6, 1fr);
73
+ gap: 12px;
74
+ }
75
+ #summary div {
76
+ background: var(--metric-background);
77
+ padding: 14px;
78
+ border-radius: 6px;
79
+ border: 1px solid var(--metric-border);
80
+ transition: background 0.3s ease;
81
+ box-shadow: inset 0 0 3px rgba(0, 212, 255, 0.3);
82
+ }
83
+ #summary div {
84
+ font-size: 13px;
85
+ font-weight: 500;
86
+ color: var(--secondary-text);
87
+ }
88
+ #summary span {
89
+ display: block;
90
+ font-size: 22px;
91
+ font-weight: 700;
92
+ margin-top: 6px;
93
+ color: var(--text-color);
94
+ text-shadow: 0 0 3px rgba(0, 212, 255, 0.3);
95
+ overflow: hidden;
96
+ text-overflow: ellipsis;
97
+ white-space: nowrap;
98
+ }
99
+ .stats-container {
100
+ display: grid;
101
+ grid-template-columns: 1fr;
102
+ gap: 24px;
103
+ margin-top: 20px;
104
+ }
105
+ .user-group {
106
+ background: var(--card-background);
107
+ border-radius: 8px;
108
+ border: 1px solid var(--card-border);
109
+ overflow: hidden;
110
+ box-shadow: 0 0 8px rgba(0, 212, 255, 0.2);
111
+ transition: background 0.3s ease, border 0.3s ease;
112
+ }
113
+ .user-group summary {
114
+ padding: 16px;
115
+ font-weight: 600;
116
+ cursor: pointer;
117
+ color: var(--accent-color);
118
+ background: var(--metric-background);
119
+ transition: background 0.2s ease;
120
+ text-shadow: 0 0 4px rgba(0, 212, 255, 0.4);
121
+ }
122
+ .user-group summary:hover {
123
+ background: var(--metric-hover);
124
+ box-shadow: inset 0 0 5px rgba(0, 212, 255, 0.5);
125
+ }
126
+ .user-group summary::-webkit-details-marker {
127
+ color: var(--accent-color);
128
+ }
129
+ .user-servers {
130
+ display: grid;
131
+ grid-template-columns: repeat(2, 1fr);
132
+ gap: 16px;
133
+ padding: 16px;
134
+ }
135
+ .server-card {
136
+ background: var(--metric-background);
137
+ border-radius: 6px;
138
+ padding: 16px;
139
+ border: 1px solid var(--metric-border);
140
+ transition: background 0.2s ease, box-shadow 0.2s ease;
141
+ min-height: 150px;
142
+ display: flex;
143
+ flex-direction: column;
144
+ box-shadow: 0 0 5px rgba(0, 212, 255, 0.2);
145
+ position: relative;
146
+ overflow: hidden;
147
+ }
148
+ .server-card.not-logged-in {
149
+ min-height: 120px;
150
+ }
151
+ .server-card:hover {
152
+ background: var(--metric-hover);
153
+ box-shadow: 0 0 12px rgba(0, 212, 255, 0.4);
154
+ }
155
+ .server-card::before {
156
+ content: '';
157
+ position: absolute;
158
+ top: 0;
159
+ left: 0;
160
+ width: 100%;
161
+ height: 2px;
162
+ background: linear-gradient(90deg, var(--accent-color), var(--neon-pink));
163
+ opacity: 0.7;
164
+ }
165
+ .server-header {
166
+ display: flex;
167
+ justify-content: space-between;
168
+ align-items: center;
169
+ margin-bottom: 12px;
170
+ font-size: 14px;
171
+ }
172
+ .server-name {
173
+ display: flex;
174
+ align-items: center;
175
+ gap: 8px;
176
+ flex: 1;
177
+ min-width: 0;
178
+ cursor: pointer;
179
+ position: relative;
180
+ }
181
+ .server-name:hover {
182
+ opacity: 0.8;
183
+ }
184
+ .server-name::after {
185
+ content: '▼';
186
+ font-size: 12px;
187
+ color: var(--accent-color);
188
+ margin-left: 8px;
189
+ transition: transform 0.3s ease;
190
+ }
191
+ .server-card.info-expanded .server-name::after {
192
+ transform: rotate(180deg);
193
+ }
194
+ .server-name div {
195
+ overflow: hidden;
196
+ text-overflow: ellipsis;
197
+ white-space: nowrap;
198
+ max-width: 100%;
199
+ font-weight: 500;
200
+ color: var(--accent-color);
201
+ text-shadow: 0 0 3px rgba(0, 212, 255, 0.5);
202
+ }
203
+ .server-flag {
204
+ width: 20px;
205
+ height: 20px;
206
+ border-radius: 6px;
207
+ flex-shrink: 0;
208
+ filter: drop-shadow(0 0 3px rgba(0, 212, 255, 0.3));
209
+ }
210
+ .metric-grid {
211
+ display: grid;
212
+ grid-template-columns: repeat(5, 1fr);
213
+ gap: 10px;
214
+ margin-top: 12px;
215
+ }
216
+ .metric-item {
217
+ background: var(--card-background);
218
+ padding: 10px;
219
+ border-radius: 4px;
220
+ border: 1px solid var(--metric-border);
221
+ transition: background 0.2s ease;
222
+ overflow: hidden;
223
+ box-shadow: inset 0 0 3px rgba(0, 212, 255, 0.2);
224
+ }
225
+ .metric-item:hover {
226
+ background: var(--metric-hover);
227
+ box-shadow: inset 0 0 5px rgba(0, 212, 255, 0.4);
228
+ }
229
+ .metric-label {
230
+ color: var(--label-color);
231
+ font-size: 12px;
232
+ margin-bottom: 4px;
233
+ white-space: nowrap;
234
+ }
235
+ .metric-value {
236
+ font-size: 14px;
237
+ font-weight: 600;
238
+ overflow: hidden;
239
+ text-overflow: ellipsis;
240
+ white-space: nowrap;
241
+ max-width: 100%;
242
+ color: var(--text-color);
243
+ text-shadow: 0 0 2px rgba(0, 212, 255, 0.3);
244
+ }
245
+ /* 新增信息块样式 */
246
+ .info-block {
247
+ background: var(--card-background);
248
+ padding: 10px;
249
+ border-radius: 4px;
250
+ border: 1px solid var(--metric-border);
251
+ margin-top: 12px;
252
+ transition: background 0.2s ease, height 0.3s ease;
253
+ overflow: hidden;
254
+ box-shadow: inset 0 0 3px rgba(0, 212, 255, 0.2);
255
+ display: none;
256
+ }
257
+ .server-card.info-expanded .info-block {
258
+ display: block;
259
+ }
260
+ .info-block:hover {
261
+ background: var(--metric-hover);
262
+ box-shadow: inset 0 0 5px rgba(0, 212, 255, 0.4);
263
+ }
264
+ .info-item {
265
+ margin-bottom: 6px;
266
+ }
267
+ .info-item:last-child {
268
+ margin-bottom: 0;
269
+ }
270
+ .info-label {
271
+ color: var(--label-color);
272
+ font-size: 12px;
273
+ margin-bottom: 4px;
274
+ white-space: nowrap;
275
+ }
276
+ .info-value {
277
+ font-size: 14px;
278
+ font-weight: 500;
279
+ color: var(--text-color);
280
+ text-shadow: 0 0 2px rgba(0, 212, 255, 0.3);
281
+ overflow: hidden;
282
+ text-overflow: ellipsis;
283
+ display: -webkit-box;
284
+ -webkit-line-clamp: 2;
285
+ -webkit-box-orient: vertical;
286
+ line-clamp: 2;
287
+ }
288
+ .status-dot {
289
+ display: inline-block;
290
+ border-radius: 50%;
291
+ animation: pulse 2s infinite;
292
+ width: 10px;
293
+ height: 10px;
294
+ flex-shrink: 0;
295
+ }
296
+ .status-online {
297
+ background-color: var(--neon-green);
298
+ color: var(--neon-green);
299
+ box-shadow: 0 0 8px rgba(0, 255, 170, 0.6);
300
+ }
301
+ .status-offline {
302
+ background-color: #ff3b30;
303
+ color: #ff3b30;
304
+ box-shadow: 0 0 8px rgba(255, 59, 48, 0.6);
305
+ }
306
+ .status-sleep {
307
+ background-color: var(--neon-pink);
308
+ color: var(--neon-pink);
309
+ box-shadow: 0 0 8px rgba(255, 0, 255, 0.6);
310
+ animation: none;
311
+ }
312
+ .action-buttons {
313
+ display: flex;
314
+ gap: 10px;
315
+ margin-top: 12px;
316
+ }
317
+ .action-button {
318
+ background: var(--action-button-bg);
319
+ color: var(--accent-color);
320
+ border: none;
321
+ padding: 8px 14px;
322
+ border-radius: 6px;
323
+ cursor: pointer;
324
+ font-size: 14px;
325
+ font-weight: 500;
326
+ transition: background 0.2s ease, box-shadow 0.2s ease;
327
+ text-shadow: 0 0 3px rgba(0, 212, 255, 0.5);
328
+ position: relative;
329
+ overflow: hidden;
330
+ }
331
+ .action-button:hover {
332
+ background: var(--action-button-hover);
333
+ box-shadow: 0 0 10px rgba(0, 212, 255, 0.5);
334
+ }
335
+ .action-button::after {
336
+ content: '';
337
+ position: absolute;
338
+ top: 50%;
339
+ left: 50%;
340
+ width: 200%;
341
+ height: 200%;
342
+ background: radial-gradient(circle, rgba(0, 212, 255, 0.2) 0%, transparent 70%);
343
+ transform: translate(-50%, -50%);
344
+ opacity: 0;
345
+ transition: opacity 0.3s ease;
346
+ }
347
+ .action-button:hover::after {
348
+ opacity: 1;
349
+ }
350
+ .network-stats {
351
+ background: var(--metric-background);
352
+ border: 1px solid var(--metric-border);
353
+ margin-top: 20px;
354
+ padding: 16px;
355
+ border-radius: 6px;
356
+ transition: background 0.3s ease;
357
+ box-shadow: 0 0 5px rgba(0, 212, 255, 0.2);
358
+ }
359
+ .network-item {
360
+ font-size: 14px;
361
+ color: var(--secondary-text);
362
+ }
363
+ @keyframes pulse {
364
+ 0% { box-shadow: 0 0 0 0 rgba(0, 255, 170, 0.6); }
365
+ 70% { box-shadow: 0 0 0 8px rgba(0, 255, 170, 0); }
366
+ 100% { box-shadow: 0 0 0 0 rgba(0, 255, 170, 0); }
367
+ }
368
+ @media (max-width: 900px) {
369
+ #summary {
370
+ grid-template-columns: repeat(3, 1fr);
371
+ gap: 10px;
372
+ }
373
+ #summary div {
374
+ padding: 10px;
375
+ }
376
+ #summary span {
377
+ font-size: 20px;
378
+ }
379
+ .metric-grid {
380
+ grid-template-columns: repeat(3, 1fr); /* 与系统概览同步,变为3列 */
381
+ gap: 8px;
382
+ }
383
+ .metric-item {
384
+ padding: 8px;
385
+ }
386
+ .metric-value {
387
+ font-size: 13px;
388
+ }
389
+ .info-block {
390
+ padding: 8px;
391
+ }
392
+ .info-value {
393
+ font-size: 13px;
394
+ }
395
+ }
396
+ @media (max-width: 600px) {
397
+ #summary {
398
+ grid-template-columns: repeat(2, 1fr);
399
+ gap: 8px;
400
+ }
401
+ #summary div {
402
+ padding: 8px;
403
+ }
404
+ #summary span {
405
+ font-size: 18px;
406
+ }
407
+ .user-servers {
408
+ grid-template-columns: 1fr !important;
409
+ }
410
+ .metric-grid {
411
+ grid-template-columns: repeat(2, 1fr); /* 与系统概览同步,变为2列 */
412
+ gap: 8px;
413
+ }
414
+ .metric-item {
415
+ padding: 6px;
416
+ }
417
+ .metric-value {
418
+ font-size: 13px;
419
+ }
420
+ .server-header {
421
+ flex-direction: row;
422
+ flex-wrap: wrap;
423
+ gap: 8px;
424
+ }
425
+ .container {
426
+ padding: 0 10px;
427
+ }
428
+ .overview {
429
+ padding: 16px;
430
+ margin-bottom: 20px;
431
+ }
432
+ .info-block {
433
+ padding: 6px;
434
+ }
435
+ .info-value {
436
+ font-size: 12px;
437
+ -webkit-line-clamp: 1;
438
+ line-clamp: 1;
439
+ }
440
+ }
441
+ @media (max-width: 400px) {
442
+ #summary {
443
+ grid-template-columns: repeat(2, 1fr);
444
+ gap: 6px;
445
+ }
446
+ #summary div {
447
+ padding: 6px;
448
+ }
449
+ #summary span {
450
+ font-size: 16px;
451
+ }
452
+ .metric-grid {
453
+ grid-template-columns: repeat(2, 1fr); /* 保持2列,进一步优化间距和字体 */
454
+ gap: 6px;
455
+ }
456
+ .metric-item {
457
+ padding: 5px;
458
+ }
459
+ .metric-value {
460
+ font-size: 12px;
461
+ }
462
+ .info-block {
463
+ padding: 5px;
464
+ }
465
+ .info-value {
466
+ font-size: 11px;
467
+ }
468
+ }
469
+ .login-overlay, .confirm-overlay, .loading-overlay {
470
+ position: fixed;
471
+ top: 0;
472
+ left: 0;
473
+ width: 100%;
474
+ height: 100%;
475
+ background: rgba(10, 10, 30, 0.7);
476
+ display: flex;
477
+ align-items: center;
478
+ justify-content: center;
479
+ z-index: 1000;
480
+ display: none;
481
+ }
482
+ .login-box, .confirm-box {
483
+ background: var(--card-background);
484
+ padding: 24px;
485
+ border-radius: 8px;
486
+ border: 1px solid var(--card-border);
487
+ width: 320px;
488
+ text-align: center;
489
+ box-shadow: 0 0 15px rgba(0, 212, 255, 0.4);
490
+ position: relative;
491
+ }
492
+ .login-box::before, .confirm-box::before {
493
+ content: '';
494
+ position: absolute;
495
+ top: 0;
496
+ left: 0;
497
+ width: 100%;
498
+ height: 3px;
499
+ background: linear-gradient(90deg, var(--accent-color), var(--neon-pink));
500
+ }
501
+ .login-box h2, .confirm-box h2 {
502
+ margin-bottom: 20px;
503
+ color: var(--accent-color);
504
+ font-size: 18px;
505
+ font-weight: 600;
506
+ text-shadow: 0 0 5px rgba(0, 212, 255, 0.5);
507
+ }
508
+ .login-box input {
509
+ width: 100%;
510
+ padding: 12px 16px;
511
+ margin: 12px 0;
512
+ border: 1px solid var(--metric-border);
513
+ border-radius: 6px;
514
+ background: var(--metric-background);
515
+ color: var(--text-color);
516
+ font-size: 16px;
517
+ transition: border 0.2s ease, box-shadow 0.2s ease;
518
+ }
519
+ .login-box input:focus {
520
+ border-color: var(--accent-color);
521
+ box-shadow: 0 0 8px rgba(0, 212, 255, 0.5);
522
+ outline: none;
523
+ }
524
+ .login-box button, .confirm-box button {
525
+ width: 48%;
526
+ padding: 12px;
527
+ background: var(--action-button-bg);
528
+ border: none;
529
+ border-radius: 6px;
530
+ color: var(--accent-color);
531
+ cursor: pointer;
532
+ font-size: 16px;
533
+ font-weight: 500;
534
+ transition: background 0.2s ease, box-shadow 0.2s ease;
535
+ margin: 10px 1%;
536
+ text-shadow: 0 0 3px rgba(0, 212, 255, 0.5);
537
+ }
538
+ .login-box button:hover, .confirm-box button:hover {
539
+ background: var(--action-button-hover);
540
+ box-shadow: 0 0 10px rgba(0, 212, 255, 0.5);
541
+ }
542
+ .login-box button:last-child, .confirm-box button:last-child {
543
+ background: transparent;
544
+ color: var(--neon-pink);
545
+ }
546
+ .login-box button:last-child:hover, .confirm-box button:last-child:hover {
547
+ background: rgba(255, 0, 255, 0.1);
548
+ box-shadow: 0 0 10px rgba(255, 0, 255, 0.5);
549
+ }
550
+ .login-error {
551
+ color: #ff3b30;
552
+ margin-top: 12px;
553
+ font-size: 14px;
554
+ text-shadow: 0 0 3px rgba(255, 59, 48, 0.5);
555
+ }
556
+ .login-button, .logout-button {
557
+ background: var(--action-button-bg);
558
+ border: none;
559
+ color: var(--accent-color);
560
+ padding: 8px 16px;
561
+ border-radius: 6px;
562
+ cursor: pointer;
563
+ font-size: 14px;
564
+ font-weight: 500;
565
+ transition: background 0.2s ease, box-shadow 0.2s ease;
566
+ text-shadow: 0 0 3px rgba(0, 212, 255, 0.5);
567
+ }
568
+ .login-button:hover, .logout-button:hover {
569
+ background: var(--action-button-hover);
570
+ box-shadow: 0 0 8px rgba(0, 212, 255, 0.5);
571
+ }
572
+ .header-container {
573
+ display: flex;
574
+ justify-content: space-between;
575
+ align-items: center;
576
+ margin-bottom: 16px;
577
+ }
578
+ .auth-buttons {
579
+ display: flex;
580
+ gap: 10px;
581
+ }
582
+ /* 加载状态指示器样式 */
583
+ .loader {
584
+ border: 5px solid transparent;
585
+ border-top: 5px solid var(--accent-color);
586
+ border-radius: 50%;
587
+ width: 50px;
588
+ height: 50px;
589
+ animation: spin 1s linear infinite;
590
+ box-shadow: 0 0 15px rgba(0, 212, 255, 0.6);
591
+ }
592
+ @keyframes spin {
593
+ 0% { transform: rotate(0deg); }
594
+ 100% { transform: rotate(360deg); }
595
+ }
596
+ /* 优化后的过滤与排序控件样式 */
597
+ .filter-sort-panel {
598
+ background: var(--card-background);
599
+ border: 1px solid var(--card-border);
600
+ border-radius: 8px;
601
+ padding: 16px;
602
+ margin-bottom: 20px;
603
+ display: flex;
604
+ flex-wrap: wrap;
605
+ gap: 16px;
606
+ align-items: center;
607
+ box-shadow: 0 0 8px rgba(0, 212, 255, 0.2);
608
+ transition: background 0.3s ease;
609
+ }
610
+ .filter-sort-group {
611
+ display: flex;
612
+ align-items: center;
613
+ gap: 10px;
614
+ font-size: 14px;
615
+ color: var(--text-color);
616
+ min-width: 200px;
617
+ }
618
+ .filter-sort-group label {
619
+ white-space: nowrap;
620
+ color: var(--secondary-text);
621
+ font-weight: 500;
622
+ text-shadow: 0 0 2px rgba(0, 212, 255, 0.3);
623
+ }
624
+ .filter-sort-group select {
625
+ flex: 1;
626
+ background: var(--metric-background);
627
+ border: 1px solid var(--metric-border);
628
+ color: var(--text-color);
629
+ padding: 10px 14px;
630
+ border-radius: 6px;
631
+ cursor: pointer;
632
+ font-size: 14px;
633
+ transition: background 0.2s ease, border 0.2s ease, box-shadow 0.2s ease;
634
+ outline: none;
635
+ appearance: none;
636
+ background-image: url("data:image/svg+xml;utf8,<svg fill='%2300D4FF' height='24' viewBox='0 0 24 24' width='24' xmlns='http://www.w3.org/2000/svg'><path d='M7 10l5 5 5-5z'/><path d='M0 0h24v24H0z' fill='none'/></svg>");
637
+ background-repeat: no-repeat;
638
+ background-position: right 10px center;
639
+ padding-right: 36px;
640
+ text-shadow: 0 0 2px rgba(0, 212, 255, 0.3);
641
+ }
642
+ .filter-sort-group select:hover {
643
+ background-color: var(--metric-hover);
644
+ box-shadow: 0 0 6px rgba(0, 212, 255, 0.4);
645
+ }
646
+ .filter-sort-group select:focus {
647
+ border-color: var(--accent-color);
648
+ box-shadow: 0 0 8px rgba(0, 212, 255, 0.5);
649
+ }
650
+ .refresh-button {
651
+ background: var(--action-button-bg);
652
+ border: none;
653
+ color: var(--accent-color);
654
+ padding: 8px 16px; /* 与 .chart-toggle-button 一致 */
655
+ border-radius: 6px;
656
+ cursor: pointer;
657
+ font-size: 14px;
658
+ font-weight: 500;
659
+ transition: background 0.2s ease, box-shadow 0.2s ease;
660
+ display: flex;
661
+ align-items: center;
662
+ gap: 8px;
663
+ text-shadow: 0 0 3px rgba(0, 212, 255, 0.5);
664
+ }
665
+ .refresh-button:hover {
666
+ background: var(--action-button-hover);
667
+ box-shadow: 0 0 10px rgba(0, 212, 255, 0.5);
668
+ }
669
+ .refresh-icon {
670
+ width: 16px;
671
+ height: 16px;
672
+ fill: var(--accent-color);
673
+ }
674
+ @media (max-width: 600px) {
675
+ .filter-sort-group {
676
+ min-width: 100%;
677
+ }
678
+ .filter-sort-panel {
679
+ gap: 14px;
680
+ padding: 14px;
681
+ }
682
+ }
683
+ /* 图表相关样式 */
684
+ .chart-container {
685
+ display: none;
686
+ margin-top: 16px;
687
+ background: var(--card-background);
688
+ border: 1px solid var(--card-border);
689
+ border-radius: 6px;
690
+ padding: 12px;
691
+ height: 300px;
692
+ transition: background 0.3s ease;
693
+ box-shadow: 0 0 8px rgba(0, 212, 255, 0.3);
694
+ }
695
+ .chart-toggle-button {
696
+ background: var(--action-button-bg);
697
+ color: var(--accent-color);
698
+ border: none;
699
+ padding: 8px 16px;
700
+ border-radius: 6px;
701
+ cursor: pointer;
702
+ font-size: 14px;
703
+ font-weight: 500;
704
+ transition: background 0.2s ease, box-shadow 0.2s ease;
705
+ margin-left: auto;
706
+ white-space: nowrap;
707
+ text-shadow: 0 0 3px rgba(0, 212, 255, 0.5);
708
+ }
709
+ .chart-toggle-button:hover {
710
+ background: var(--action-button-hover);
711
+ box-shadow: 0 0 8px rgba(0, 212, 255, 0.5);
712
+ }
713
+ .expanded .chart-container {
714
+ display: block;
715
+ }
716
+ canvas {
717
+ width: 100% !important;
718
+ height: auto !important;
719
+ }
720
+ @media (max-width: 600px) {
721
+ .chart-container {
722
+ height: 250px;
723
+ }
724
+ }
725
+ </style>
726
+ </head>
727
+ <body>
728
+ <div class="container">
729
+ <div class="overview">
730
+ <div class="header-container">
731
+ <div class="overview-title">🤖 系统概览</div>
732
+ <div class="auth-buttons">
733
+ <button class="login-button" id="loginButton" onclick="showLoginForm()">登录</button>
734
+ <button class="logout-button" id="logoutButton" style="display: none;" onclick="logout()">登出</button>
735
+ </div>
736
+ </div>
737
+ <div id="summary">
738
+ <div>总用户数: <span id="totalUsers">0</span></div>
739
+ <div>总实例数: <span id="totalServers">0</span></div>
740
+ <div>在线实例: <span id="onlineServers">0</span></div>
741
+ <div>离线实例: <span id="offlineServers">0</span></div>
742
+ <div>总上传: <span id="totalUpload">0 B/s</span></div>
743
+ <div>总下载: <span id="totalDownload">0 B/s</span></div>
744
+ </div>
745
+ </div>
746
+ <!-- 优化后的过滤与排序面板 -->
747
+ <div class="filter-sort-panel">
748
+ <div class="filter-sort-group">
749
+ <label for="statusFilter">过滤状态:</label>
750
+ <select id="statusFilter" onchange="applyFiltersAndSort()">
751
+ <option value="all">全部状态</option>
752
+ <option value="running">运行中</option>
753
+ <option value="sleeping">休眠中</option>
754
+ <option value="stopped">已停止</option>
755
+ </select>
756
+ </div>
757
+ <div class="filter-sort-group">
758
+ <label for="userFilter">过滤用户:</label>
759
+ <select id="userFilter" onchange="applyFiltersAndSort()">
760
+ <option value="all">全部用户</option>
761
+ </select>
762
+ </div>
763
+ <div class="filter-sort-group">
764
+ <label for="sortBy">排序方式:</label>
765
+ <select id="sortBy" onchange="applyFiltersAndSort()">
766
+ <option value="name-asc">名称 (A-Z)</option>
767
+ <option value="name-desc">名称 (Z-A)</option>
768
+ <option value="status-asc">状态 (运行-停止)</option>
769
+ <option value="status-desc">状态 (停止-运行)</option>
770
+ </select>
771
+ </div>
772
+ <button class="refresh-button" onclick="refreshData()">
773
+ <svg class="refresh-icon" viewBox="0 0 24 24" fill="currentColor">
774
+ <path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/>
775
+ </svg>
776
+ 刷新数据
777
+ </button>
778
+ </div>
779
+ <div id="servers" class="stats-container">
780
+ </div>
781
+ </div>
782
+ <!-- 登录弹窗 -->
783
+ <div id="loginOverlay" class="login-overlay">
784
+ <div class="login-box">
785
+ <h2>登录</h2>
786
+ <input type="text" id="username" placeholder="用户名" aria-label="用户名">
787
+ <input type="password" id="password" placeholder="密码" aria-label="密码">
788
+ <div style="display: flex; justify-content: center; gap: 10px; margin-top: 20px;">
789
+ <button onclick="login()">登录</button>
790
+ <button onclick="hideLoginForm()">取消</button>
791
+ </div>
792
+ <div id="loginError" class="login-error" style="display: none;"></div>
793
+ </div>
794
+ </div>
795
+ <!-- 确认弹窗 -->
796
+ <div id="confirmOverlay" class="confirm-overlay">
797
+ <div class="confirm-box">
798
+ <h2 id="confirmTitle">确认操作</h2>
799
+ <p id="confirmMessage" style="margin-bottom: 20px; color: var(--text-color);"></p>
800
+ <button onclick="confirmAction()">确认</button>
801
+ <button onclick="cancelAction()">取消</button>
802
+ </div>
803
+ </div>
804
+ <!-- 加载状态覆盖层 -->
805
+ <div id="loadingOverlay" class="loading-overlay">
806
+ <div class="loader"></div>
807
+ </div>
808
+
809
+ <script>
810
+ // 全局变量,表示当前是否已登录
811
+ let isLoggedIn = false;
812
+
813
+ // 加载状态控制函数
814
+ function showLoading() {
815
+ document.getElementById('loadingOverlay').style.display = 'flex';
816
+ }
817
+
818
+ function hideLoading() {
819
+ document.getElementById('loadingOverlay').style.display = 'none';
820
+ }
821
+
822
+ // 登录状态管理
823
+ function checkLoginStatus() {
824
+ const token = localStorage.getItem('authToken');
825
+ const loginButton = document.getElementById('loginButton');
826
+ const logoutButton = document.getElementById('logoutButton');
827
+
828
+ if (token) {
829
+ console.log('本地存储中找到 token,尝试验证:', token.slice(0, 8) + '...');
830
+ showLoading();
831
+ return fetch('/api/verify-token', {
832
+ method: 'POST',
833
+ headers: {
834
+ 'Content-Type': 'application/json',
835
+ },
836
+ body: JSON.stringify({ token })
837
+ })
838
+ .then(response => response.json())
839
+ .then(data => {
840
+ hideLoading();
841
+ if (data.success) {
842
+ console.log('Token 验证成功,用户已登录');
843
+ isLoggedIn = true;
844
+ loginButton.style.display = 'none';
845
+ logoutButton.style.display = 'block';
846
+ updateActionButtons(true);
847
+ } else {
848
+ console.log('Token 验证失败,清除本地存储:', data.message);
849
+ localStorage.removeItem('authToken');
850
+ isLoggedIn = false;
851
+ loginButton.style.display = 'block';
852
+ logoutButton.style.display = 'none';
853
+ updateActionButtons(false);
854
+ }
855
+ return data.success;
856
+ })
857
+ .catch(error => {
858
+ hideLoading();
859
+ console.error('验证 token 失败,清除本地存储:', error);
860
+ localStorage.removeItem('authToken');
861
+ isLoggedIn = false;
862
+ loginButton.style.display = 'block';
863
+ logoutButton.style.display = 'none';
864
+ updateActionButtons(false);
865
+ return false;
866
+ });
867
+ } else {
868
+ console.log('本地存储中无 token,显示未登录状态');
869
+ isLoggedIn = false;
870
+ loginButton.style.display = 'block';
871
+ logoutButton.style.display = 'none';
872
+ updateActionButtons(false);
873
+ return Promise.resolve(false);
874
+ }
875
+ }
876
+
877
+ function showLoginForm() {
878
+ document.getElementById('loginOverlay').style.display = 'flex';
879
+ document.getElementById('username').value = '';
880
+ document.getElementById('password').value = '';
881
+ document.getElementById('loginError').style.display = 'none';
882
+ document.getElementById('username').focus();
883
+ }
884
+
885
+ function hideLoginForm() {
886
+ document.getElementById('loginOverlay').style.display = 'none';
887
+ }
888
+
889
+ function login() {
890
+ const username = document.getElementById('username').value;
891
+ const password = document.getElementById('password').value;
892
+ const loginError = document.getElementById('loginError');
893
+ showLoading();
894
+ fetch('/api/login', {
895
+ method: 'POST',
896
+ headers: {
897
+ 'Content-Type': 'application/json',
898
+ },
899
+ body: JSON.stringify({ username, password })
900
+ })
901
+ .then(response => response.json())
902
+ .then(data => {
903
+ hideLoading();
904
+ if (data.success) {
905
+ console.log('登录成功,保存 token');
906
+ localStorage.setItem('authToken', data.token);
907
+ isLoggedIn = true;
908
+ hideLoginForm();
909
+ document.getElementById('loginButton').style.display = 'none';
910
+ document.getElementById('logoutButton').style.display = 'block';
911
+ updateActionButtons(true);
912
+ refreshData(); // 登录成功后刷新数据以显示所有实例包括 private
913
+ } else {
914
+ console.log('登录失败:', data.message);
915
+ loginError.textContent = data.message || '登录失败';
916
+ loginError.style.display = 'block';
917
+ }
918
+ })
919
+ .catch(error => {
920
+ hideLoading();
921
+ console.error('登录请求失败:', error);
922
+ loginError.textContent = '登录请求失败,请稍后重试';
923
+ loginError.style.display = 'block';
924
+ });
925
+ }
926
+
927
+ function logout() {
928
+ const token = localStorage.getItem('authToken');
929
+ if (token) {
930
+ showLoading();
931
+ fetch('/api/logout', {
932
+ method: 'POST',
933
+ headers: {
934
+ 'Content-Type': 'application/json',
935
+ },
936
+ body: JSON.stringify({ token })
937
+ })
938
+ .then(response => response.json())
939
+ .then(data => {
940
+ hideLoading();
941
+ console.log('登出成功,清除 token');
942
+ localStorage.removeItem('authToken');
943
+ isLoggedIn = false;
944
+ document.getElementById('loginButton').style.display = 'block';
945
+ document.getElementById('logoutButton').style.display = 'none';
946
+ updateActionButtons(false);
947
+ refreshData(); // 登出后刷新数据以隐藏 private 实例
948
+ })
949
+ .catch(error => {
950
+ hideLoading();
951
+ console.error('登出失败,但仍清除 token:', error);
952
+ localStorage.removeItem('authToken');
953
+ isLoggedIn = false;
954
+ document.getElementById('loginButton').style.display = 'block';
955
+ document.getElementById('logoutButton').style.display = 'none';
956
+ updateActionButtons(false);
957
+ refreshData(); // 登出后刷新数据以隐藏 private 实例
958
+ });
959
+ } else {
960
+ console.log('本地无 token,直接设置为未登录');
961
+ isLoggedIn = false;
962
+ document.getElementById('loginButton').style.display = 'block';
963
+ document.getElementById('logoutButton').style.display = 'none';
964
+ updateActionButtons(false);
965
+ refreshData(); // 登出后刷新数据以隐藏 private 实例
966
+ }
967
+ }
968
+
969
+ function updateActionButtons(loggedIn) {
970
+ console.log('更新操作按钮状态,是否已登录:', loggedIn);
971
+ isLoggedIn = loggedIn;
972
+ const cards = document.querySelectorAll('.server-card');
973
+ cards.forEach(card => {
974
+ const buttons = card.querySelector('.action-buttons');
975
+ if (buttons) {
976
+ buttons.style.display = loggedIn ? 'flex' : 'none';
977
+ }
978
+ // 动态添加或去除 not-logged-in 类以调整卡片高度
979
+ if (loggedIn) {
980
+ card.classList.remove('not-logged-in');
981
+ } else {
982
+ card.classList.add('not-logged-in');
983
+ }
984
+ // 确保图表切换按钮总是可见,不管是否登录
985
+ const chartToggleButton = card.querySelector('.chart-toggle-button');
986
+ if (chartToggleButton) {
987
+ chartToggleButton.style.display = 'inline-block';
988
+ }
989
+ });
990
+ }
991
+
992
+ // 使用 window.onload 确保页面完全加载后检查登录状态
993
+ window.onload = async function() {
994
+ console.log('页面加载完成,开始检查登录状态');
995
+ await checkLoginStatus();
996
+ console.log('登录状态检查完成,初始化数据');
997
+ await initialize();
998
+ };
999
+
1000
+ // 二次确认弹窗逻辑
1001
+ let pendingAction = null;
1002
+ let pendingRepoId = null;
1003
+
1004
+ function showConfirmDialog(action, repoId, title, message) {
1005
+ pendingAction = action;
1006
+ pendingRepoId = repoId;
1007
+ document.getElementById('confirmTitle').textContent = title;
1008
+ document.getElementById('confirmMessage').textContent = message;
1009
+ document.getElementById('confirmOverlay').style.display = 'flex';
1010
+ }
1011
+
1012
+ function confirmAction() {
1013
+ if (pendingAction === 'restart') {
1014
+ restartSpace(pendingRepoId);
1015
+ } else if (pendingAction === 'rebuild') {
1016
+ rebuildSpace(pendingRepoId);
1017
+ }
1018
+ cancelAction();
1019
+ }
1020
+
1021
+ function cancelAction() {
1022
+ pendingAction = null;
1023
+ pendingRepoId = null;
1024
+ document.getElementById('confirmOverlay').style.display = 'none';
1025
+ }
1026
+
1027
+ async function getUsernames() {
1028
+ try {
1029
+ showLoading();
1030
+ const token = localStorage.getItem('authToken');
1031
+ const headers = {};
1032
+ if (token) {
1033
+ headers['Authorization'] = `Bearer ${token}`;
1034
+ console.log('getUsernames 请求中附加 Token:', token.slice(0, 8) + '...');
1035
+ }
1036
+ const response = await fetch('/api/config', { headers });
1037
+ const config = await response.json();
1038
+ hideLoading();
1039
+ const usernamesList = config.usernames ? config.usernames.split(',').map(name => name.trim()).filter(name => name) : [];
1040
+ document.getElementById('totalUsers').textContent = usernamesList.length;
1041
+ // 更新用户过滤下拉菜单
1042
+ const userFilter = document.getElementById('userFilter');
1043
+ userFilter.innerHTML = '<option value="all">全部用户</option>';
1044
+ usernamesList.forEach(username => {
1045
+ const option = document.createElement('option');
1046
+ option.value = username;
1047
+ option.textContent = username;
1048
+ userFilter.appendChild(option);
1049
+ });
1050
+ return usernamesList;
1051
+ } catch (error) {
1052
+ hideLoading();
1053
+ console.error('Failed to fetch usernames:', error);
1054
+ document.getElementById('totalUsers').textContent = 0;
1055
+ return [];
1056
+ }
1057
+ }
1058
+
1059
+ async function fetchInstances() {
1060
+ try {
1061
+ showLoading();
1062
+ const token = localStorage.getItem('authToken');
1063
+ const headers = {};
1064
+ if (token) {
1065
+ headers['Authorization'] = `Bearer ${token}`;
1066
+ console.log('fetchInstances 请求中附加 Token:', token.slice(0, 8) + '...');
1067
+ } else {
1068
+ console.log('无可用 Token,未附加 Authorization 头');
1069
+ }
1070
+ const response = await fetch('/api/proxy/spaces', { headers });
1071
+ const instances = await response.json();
1072
+ console.log('从后端获取的实例列表:', instances);
1073
+ hideLoading();
1074
+ if (instances.length === 0) {
1075
+ alert('未获取到实例数据,可能是网络问题或数据暂不可用。');
1076
+ }
1077
+ return instances;
1078
+ } catch (error) {
1079
+ hideLoading();
1080
+ console.error("获取实例列表失败:", error);
1081
+ alert('获取实例列表失败,请稍后重试。');
1082
+ return [];
1083
+ }
1084
+ }
1085
+
1086
+ class MetricsStreamManager {
1087
+ constructor() {
1088
+ this.eventSource = null;
1089
+ }
1090
+
1091
+ connect(subscribedInstances = []) {
1092
+ if (this.eventSource) {
1093
+ this.eventSource.close();
1094
+ }
1095
+
1096
+ const instancesParam = subscribedInstances.join(',');
1097
+ const token = localStorage.getItem('authToken');
1098
+ // 由于 EventSource 不支持直接设置 Authorization 头,这里通过查询参数传递 token
1099
+ const url = `/api/proxy/live-metrics-stream?instances=${encodeURIComponent(instancesParam)}&token=${encodeURIComponent(token || '')}`;
1100
+ console.log('SSE 连接 URL:', url.split('&token=')[0] + (token ? '&token=... (隐藏)' : '&token=空'));
1101
+ this.eventSource = new EventSource(url);
1102
+
1103
+ this.eventSource.addEventListener("metric", (event) => {
1104
+ try {
1105
+ const data = JSON.parse(event.data);
1106
+ const { repoId, metrics } = data;
1107
+ updateServerCard(metrics, repoId);
1108
+ } catch (error) {
1109
+ console.error(`解析监控数据失败:`, error);
1110
+ }
1111
+ });
1112
+
1113
+ this.eventSource.onerror = (error) => {
1114
+ console.error(`SSE 连接错误:`, error);
1115
+ this.eventSource.close();
1116
+ this.eventSource = null;
1117
+ // 可选:尝试重新连接
1118
+ setTimeout(() => this.connect(subscribedInstances), 5000);
1119
+ };
1120
+
1121
+ console.log(`SSE 连接已建立,订阅实例: ${instancesParam || '无'}`);
1122
+ }
1123
+
1124
+ disconnect() {
1125
+ if (this.eventSource) {
1126
+ this.eventSource.close();
1127
+ this.eventSource = null;
1128
+ console.log(`SSE 连接已断开`);
1129
+ }
1130
+ }
1131
+ }
1132
+
1133
+ const metricsStreamManager = new MetricsStreamManager();
1134
+ const instanceMap = new Map();
1135
+ const serverStatus = new Map();
1136
+ let allInstances = []; // 存储所有实例数据,用于过滤和排序
1137
+ const chartInstances = new Map(); // 存储每个实例的图表实例
1138
+ const chartDataBuffer = new Map(); // 存储图表数据缓冲以减少更新频率
1139
+
1140
+ async function initialize() {
1141
+ await getUsernames();
1142
+ const instances = await fetchInstances();
1143
+ allInstances = instances;
1144
+ renderInstances(allInstances);
1145
+
1146
+ // 默认只监控状态为 running 的实例
1147
+ const runningInstances = instances
1148
+ .filter(instance => instance.status.toLowerCase() === 'running')
1149
+ .map(instance => instance.repo_id);
1150
+
1151
+ metricsStreamManager.connect(runningInstances);
1152
+
1153
+ updateSummary();
1154
+ updateActionButtons(isLoggedIn);
1155
+ }
1156
+
1157
+ // 手动刷新数据函数
1158
+ async function refreshData() {
1159
+ metricsStreamManager.disconnect();
1160
+ // 销毁所有图表实例
1161
+ chartInstances.forEach(chart => {
1162
+ if (chart) {
1163
+ chart.destroy();
1164
+ }
1165
+ });
1166
+ chartInstances.clear();
1167
+ chartDataBuffer.clear();
1168
+ await initialize();
1169
+ applyFiltersAndSort(); // 确保刷新后重新应用过滤和排序
1170
+ }
1171
+
1172
+ function renderInstances(instances) {
1173
+ const serversContainer = document.getElementById('servers');
1174
+ serversContainer.innerHTML = ''; // 清空现有内容
1175
+ const userGroups = {};
1176
+
1177
+ // 按用户分组
1178
+ instances.forEach(instance => {
1179
+ if (!userGroups[instance.owner]) {
1180
+ userGroups[instance.owner] = [];
1181
+ }
1182
+ userGroups[instance.owner].push(instance);
1183
+ });
1184
+
1185
+ // 渲染每个用户组
1186
+ Object.keys(userGroups).forEach(owner => {
1187
+ let userGroup = document.createElement('details');
1188
+ userGroup.className = 'user-group';
1189
+ userGroup.id = `user-${owner}`;
1190
+ userGroup.setAttribute('open', '');
1191
+
1192
+ const summary = document.createElement('summary');
1193
+ summary.textContent = `用户: ${owner}`;
1194
+ userGroup.appendChild(summary);
1195
+
1196
+ const userServers = document.createElement('div');
1197
+ userServers.className = 'user-servers';
1198
+ userGroup.appendChild(userServers);
1199
+
1200
+ serversContainer.appendChild(userGroup);
1201
+
1202
+ // 渲染该用户下的实例
1203
+ userGroups[owner].forEach(instance => {
1204
+ renderInstanceCard(instance, userServers);
1205
+ });
1206
+ });
1207
+ }
1208
+
1209
+ // 图表配置函数
1210
+ function createChart(instanceId) {
1211
+ const canvasId = `chart-${instanceId}`;
1212
+ const canvas = document.getElementById(canvasId);
1213
+ if (!canvas) return null;
1214
+
1215
+ const gridColor = 'rgba(0, 212, 255, 0.1)';
1216
+ const textColor = '#E0E0FF';
1217
+ const cpuColor = '#00FFAA';
1218
+ const memoryColor = '#00D4FF';
1219
+ const uploadColor = '#FF9500';
1220
+ const downloadColor = '#FF00FF';
1221
+
1222
+ const ctx = canvas.getContext('2d');
1223
+ const chart = new Chart(ctx, {
1224
+ type: 'line',
1225
+ data: {
1226
+ labels: Array(30).fill(''), // 初始为空标签,最多30个数据点
1227
+ datasets: [
1228
+ {
1229
+ label: 'CPU 使用率 (%)',
1230
+ data: [],
1231
+ borderColor: cpuColor,
1232
+ backgroundColor: 'rgba(0, 255, 170, 0.2)',
1233
+ tension: 0.4,
1234
+ fill: true,
1235
+ },
1236
+ {
1237
+ label: '内存使用率 (%)',
1238
+ data: [],
1239
+ borderColor: memoryColor,
1240
+ backgroundColor: 'rgba(0, 212, 255, 0.2)',
1241
+ tension: 0.4,
1242
+ fill: true,
1243
+ },
1244
+ {
1245
+ label: '上传速度 (KB/s)',
1246
+ data: [],
1247
+ borderColor: uploadColor,
1248
+ backgroundColor: 'rgba(255, 149, 0, 0.2)',
1249
+ tension: 0.4,
1250
+ fill: true,
1251
+ },
1252
+ {
1253
+ label: '下载速度 (KB/s)',
1254
+ data: [],
1255
+ borderColor: downloadColor,
1256
+ backgroundColor: 'rgba(255, 0, 255, 0.2)',
1257
+ tension: 0.4,
1258
+ fill: true,
1259
+ },
1260
+ ]
1261
+ },
1262
+ options: {
1263
+ responsive: true,
1264
+ maintainAspectRatio: false,
1265
+ plugins: {
1266
+ legend: {
1267
+ labels: {
1268
+ color: textColor,
1269
+ font: { size: 12, family: "'Orbitron', sans-serif" }
1270
+ }
1271
+ },
1272
+ tooltip: {
1273
+ mode: 'index',
1274
+ intersect: false,
1275
+ backgroundColor: 'rgba(20, 20, 40, 0.9)',
1276
+ titleColor: textColor,
1277
+ bodyColor: textColor,
1278
+ padding: 12,
1279
+ boxPadding: 8,
1280
+ borderRadius: 6,
1281
+ borderWidth: 1,
1282
+ borderColor: 'rgba(0, 212, 255, 0.3)'
1283
+ }
1284
+ },
1285
+ scales: {
1286
+ y: {
1287
+ beginAtZero: true,
1288
+ grid: { color: gridColor, drawTicks: false },
1289
+ ticks: {
1290
+ color: textColor,
1291
+ font: { size: 11, family: "'Orbitron', sans-serif" }
1292
+ }
1293
+ },
1294
+ x: {
1295
+ grid: { color: gridColor, drawTicks: false },
1296
+ ticks: {
1297
+ color: textColor,
1298
+ font: { size: 11, family: "'Orbitron', sans-serif" },
1299
+ maxRotation: 0,
1300
+ minRotation: 0,
1301
+ autoSkip: true,
1302
+ autoSkipPadding: 10
1303
+ }
1304
+ }
1305
+ },
1306
+ elements: {
1307
+ point: {
1308
+ radius: 0, // 隐藏数据点
1309
+ hitRadius: 5 // 确保悬停仍有效
1310
+ },
1311
+ line: {
1312
+ borderWidth: 2
1313
+ }
1314
+ },
1315
+ animation: false // 禁用图表动画,减少性能开销
1316
+ }
1317
+ });
1318
+ chartInstances.set(instanceId, chart);
1319
+ return chart;
1320
+ }
1321
+
1322
+ // 更新图表数据,带有缓冲机制减少更新频率
1323
+ function updateChart(instanceId, data) {
1324
+ let buffer = chartDataBuffer.get(instanceId) || { data: [], count: 0 };
1325
+ buffer.data.push(data);
1326
+ buffer.count++;
1327
+
1328
+ if (buffer.count < 2) { // 每 2 次数据更新才渲染一次
1329
+ chartDataBuffer.set(instanceId, buffer);
1330
+ return;
1331
+ }
1332
+
1333
+ let chart = chartInstances.get(instanceId);
1334
+ if (!chart) {
1335
+ chart = createChart(instanceId);
1336
+ if (!chart) return;
1337
+ }
1338
+
1339
+ // 获取当前数据集
1340
+ const cpuData = chart.data.datasets[0].data;
1341
+ const memoryData = chart.data.datasets[1].data;
1342
+ const uploadData = chart.data.datasets[2].data;
1343
+ const downloadData = chart.data.datasets[3].data;
1344
+
1345
+ // 追加最新数据
1346
+ const latestData = buffer.data[buffer.data.length - 1];
1347
+ cpuData.push(latestData.cpu_usage_pct);
1348
+ memoryData.push(((latestData.memory_used_bytes / latestData.memory_total_bytes) * 100).toFixed(2));
1349
+ uploadData.push((latestData.tx_bps / 1024).toFixed(2)); // 转换为 KB/s
1350
+ downloadData.push((latestData.rx_bps / 1024).toFixed(2)); // 转换为 KB/s
1351
+
1352
+ // 限制数据点数量为 30 个
1353
+ if (cpuData.length > 30) {
1354
+ cpuData.shift();
1355
+ memoryData.shift();
1356
+ uploadData.shift();
1357
+ downloadData.shift();
1358
+ }
1359
+
1360
+ // 更新图表
1361
+ chart.update();
1362
+
1363
+ // 清空缓冲
1364
+ chartDataBuffer.set(instanceId, { data: [], count: 0 });
1365
+ }
1366
+
1367
+ // 计算相对时间的辅助函数
1368
+ function formatRelativeTime(dateStr) {
1369
+ if (!dateStr) return '未知时间';
1370
+ try {
1371
+ const date = new Date(dateStr);
1372
+ const now = new Date();
1373
+ const diffMs = now - date;
1374
+ const diffSecs = Math.floor(diffMs / 1000);
1375
+ const diffMins = Math.floor(diffSecs / 60);
1376
+ const diffHrs = Math.floor(diffMins / 60);
1377
+ const diffDays = Math.floor(diffHrs / 24);
1378
+
1379
+ if (diffDays > 7) {
1380
+ return date.toLocaleDateString(); // 超过7天显示日期
1381
+ } else if (diffDays > 0) {
1382
+ return `${diffDays}天前`;
1383
+ } else if (diffHrs > 0) {
1384
+ return `${diffHrs}小时前`;
1385
+ } else if (diffMins > 0) {
1386
+ return `${diffMins}分钟前`;
1387
+ } else {
1388
+ return '刚刚';
1389
+ }
1390
+ } catch (error) {
1391
+ console.error('时间格式化失败:', error);
1392
+ return '未知时间';
1393
+ }
1394
+ }
1395
+
1396
+ // 切换信息块显示/隐藏的函数
1397
+ function toggleInfoBlock(instanceId) {
1398
+ const card = document.getElementById(`instance-${instanceId}`);
1399
+ if (!card) return;
1400
+
1401
+ card.classList.toggle('info-expanded');
1402
+ }
1403
+
1404
+ function renderInstanceCard(instance, container) {
1405
+ const instanceId = instance.repo_id;
1406
+ instanceMap.set(instanceId, instance);
1407
+
1408
+ const cardId = `instance-${instanceId}`;
1409
+ let card = document.getElementById(cardId);
1410
+ if (!card) {
1411
+ card = document.createElement('div');
1412
+ card.id = cardId;
1413
+ card.className = 'server-card';
1414
+ if (!isLoggedIn) {
1415
+ card.classList.add('not-logged-in'); // 未登录时添加类以调整高度
1416
+ }
1417
+ // 根据 instance.private 属性选择不同的图标
1418
+ const iconSvg = instance.private
1419
+ ? `<svg class="server-flag" width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
1420
+ <path d="M18 8v-3c0-1.656-1.344-3-3-3h-6c-1.656 0-3 1.344-3 3v3h-3v14h18v-14h-3zm-10-1.5c0-.828.672-1.5 1.5-1.5h5c.828 0 1.5.672 1.5 1.5v2.5h-8v-2.5zm4 11.5c-1.105 0-2-.895-2-2s.895-2 2-2 2 .895 2 2-.895 2-2 2z"/>
1421
+ </svg>` // 锁图标,表示 private 实例
1422
+ : `<svg class="server-flag" width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
1423
+ <path d="M21 3H3C1.9 3 1 3.9 1 5v3c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-1 5H4V6h16v2zm1 4H3c-1.1 0-2 .9-2 2v3c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2v-3c0-1.1-.9-2-2-2zm-1 5H4v-2h16v2zm1 4H3c-1.1 0-2 .9-2 2v3c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2v-3c0-1.1-.9-2-2-2zm-1 5H4v-2h16v2z"/>
1424
+ </svg>`; // 服务器图标,表示非 private 实例
1425
+
1426
+ // 处理 short_description,默认值“暂无描述”
1427
+ const description = instance.short_description && instance.short_description.trim() ? instance.short_description : 'N/A';
1428
+ // 处理 last_modified,格式化为相对时间
1429
+ const lastModified = formatRelativeTime(instance.last_modified);
1430
+
1431
+ card.innerHTML = `
1432
+ <div class="server-header">
1433
+ <div class="server-name" onclick="toggleInfoBlock('${instanceId}')">
1434
+ <div class="status-dot status-sleep"></div>
1435
+ ${iconSvg}
1436
+ <div>${instance.name}</div>
1437
+ </div>
1438
+ <div>
1439
+ <button class="chart-toggle-button" onclick="toggleChart('${instanceId}')">查看图表</button>
1440
+ </div>
1441
+ </div>
1442
+ <div class="metric-grid">
1443
+ <div class="metric-item">
1444
+ <div class="metric-label">状态</div>
1445
+ <div class="metric-value status">${instance.status}</div>
1446
+ </div>
1447
+ <div class="metric-item">
1448
+ <div class="metric-label">CPU</div>
1449
+ <div class="metric-value cpu-usage">N/A</div>
1450
+ </div>
1451
+ <div class="metric-item">
1452
+ <div class="metric-label">内存</div>
1453
+ <div class="metric-value memory-usage">N/A</div>
1454
+ </div>
1455
+ <div class="metric-item">
1456
+ <div class="metric-label">上传</div>
1457
+ <div class="metric-value upload">N/A</div>
1458
+ </div>
1459
+ <div class="metric-item">
1460
+ <div class="metric-label">下载</div>
1461
+ <div class="metric-value download">N/A</div>
1462
+ </div>
1463
+ </div>
1464
+ <div class="info-block">
1465
+ <div class="info-item">
1466
+ <div class="info-label">描述</div>
1467
+ <div class="info-value" title="${description}">${description}</div>
1468
+ </div>
1469
+ <div class="info-item">
1470
+ <div class="info-label">最近更新</div>
1471
+ <div class="info-value">${lastModified}</div>
1472
+ </div>
1473
+ </div>
1474
+ <div class="action-buttons" style="display: ${isLoggedIn ? 'flex' : 'none'};">
1475
+ <button class="action-button view-button" onclick="viewInstance('${instance.url}')">查看</button>
1476
+ <button class="action-button" onclick="manageInstance('${instance.repo_id}')">管理</button>
1477
+ <button class="action-button" onclick="showConfirmDialog('restart', '${instance.repo_id}', '确认重启', '您确定要重启实例 ${instance.name} (${instance.repo_id}) 吗?')">重启</button>
1478
+ <button class="action-button" onclick="showConfirmDialog('rebuild', '${instance.repo_id}', '确认重建', '您确定要重建实例 ${instance.name} (${instance.repo_id}) 吗?')">重建</button>
1479
+ </div>
1480
+ <div class="chart-container" id="chart-container-${instanceId}">
1481
+ <canvas id="chart-${instanceId}"></canvas>
1482
+ </div>
1483
+ `;
1484
+ container.appendChild(card);
1485
+ }
1486
+ const statusDot = card.querySelector('.status-dot');
1487
+ const initialStatus = instance.status.toLowerCase();
1488
+ if (initialStatus === 'running') {
1489
+ statusDot.className = 'status-dot status-online';
1490
+ } else if (initialStatus === 'sleeping') {
1491
+ statusDot.className = 'status-dot status-sleep';
1492
+ } else {
1493
+ statusDot.className = 'status-dot status-offline';
1494
+ }
1495
+ serverStatus.set(instanceId, { lastSeen: Date.now(), isOnline: initialStatus === 'running', isSleep: initialStatus === 'sleeping', data: null, status: instance.status });
1496
+ }
1497
+
1498
+ // 切换图表显示/隐藏
1499
+ function toggleChart(instanceId) {
1500
+ const card = document.getElementById(`instance-${instanceId}`);
1501
+ const chartContainer = document.getElementById(`chart-container-${instanceId}`);
1502
+ const toggleButton = card.querySelector('.chart-toggle-button');
1503
+ if (!card || !chartContainer) return;
1504
+
1505
+ if (card.classList.contains('expanded')) {
1506
+ card.classList.remove('expanded');
1507
+ toggleButton.textContent = '查看图表';
1508
+ } else {
1509
+ card.classList.add('expanded');
1510
+ toggleButton.textContent = '收起图表';
1511
+ // 如果图表未初始化,则创建
1512
+ if (!chartInstances.has(instanceId)) {
1513
+ createChart(instanceId);
1514
+ }
1515
+ }
1516
+ }
1517
+
1518
+ function updateServerCard(data, instanceId, isSleep = false) {
1519
+ const cardId = `instance-${instanceId}`;
1520
+ let card = document.getElementById(cardId);
1521
+ const instance = instanceMap.get(instanceId);
1522
+
1523
+ if (!card && instance) {
1524
+ // 如果卡片不存在,但实例存在,说明可能被过滤掉了,不渲染
1525
+ return;
1526
+ }
1527
+
1528
+ if (card) {
1529
+ const statusDot = card.querySelector('.status-dot');
1530
+ let upload = 'N/A', download = 'N/A', cpuUsage = 'N/A', memoryUsage = 'N/A';
1531
+ let isOnline = false;
1532
+
1533
+ if (data) {
1534
+ cpuUsage = `${data.cpu_usage_pct}%`;
1535
+ memoryUsage = `${((data.memory_used_bytes / data.memory_total_bytes) * 100).toFixed(2)}%`;
1536
+ upload = `${formatBytes(data.tx_bps)}/s`;
1537
+ download = `${formatBytes(data.rx_bps)}/s`;
1538
+ statusDot.className = 'status-dot status-online';
1539
+ isOnline = true;
1540
+ isSleep = false;
1541
+ // 更新图表数据
1542
+ updateChart(instanceId, data);
1543
+ } else {
1544
+ const currentStatus = instance?.status.toLowerCase() || 'unknown';
1545
+ if (currentStatus === 'running') {
1546
+ statusDot.className = 'status-dot status-online';
1547
+ isOnline = true;
1548
+ isSleep = false;
1549
+ } else if (currentStatus === 'sleeping') {
1550
+ statusDot.className = 'status-dot status-sleep';
1551
+ isOnline = false;
1552
+ isSleep = true;
1553
+ } else {
1554
+ statusDot.className = 'status-dot status-offline';
1555
+ isOnline = false;
1556
+ isSleep = false;
1557
+ }
1558
+ }
1559
+
1560
+ card.querySelector('.cpu-usage').textContent = cpuUsage;
1561
+ card.querySelector('.memory-usage').textContent = memoryUsage;
1562
+ card.querySelector('.upload').textContent = upload;
1563
+ card.querySelector('.download').textContent = download;
1564
+
1565
+ serverStatus.set(instanceId, { lastSeen: Date.now(), isOnline, isSleep, data: data || null, status: instance?.status || 'unknown' });
1566
+ updateSummary();
1567
+ }
1568
+ }
1569
+
1570
+ async function restartSpace(repoId) {
1571
+ try {
1572
+ const token = localStorage.getItem('authToken');
1573
+ if (!token || !isLoggedIn) {
1574
+ alert('请先登录以执行此操作');
1575
+ showLoginForm();
1576
+ return;
1577
+ }
1578
+ showLoading();
1579
+ const encodedRepoId = encodeURIComponent(repoId);
1580
+ const response = await fetch(`/api/proxy/restart/${encodedRepoId}`, {
1581
+ method: 'POST',
1582
+ headers: {
1583
+ 'Authorization': `Bearer ${token}`
1584
+ }
1585
+ });
1586
+ const result = await response.json();
1587
+ hideLoading();
1588
+ if (result.success) {
1589
+ alert(`重启成功: ${repoId}`);
1590
+ // 操作成功后立即刷新数据
1591
+ refreshData();
1592
+ } else {
1593
+ if (response.status === 401) {
1594
+ alert('登录已过期,请重新登录');
1595
+ localStorage.removeItem('authToken');
1596
+ isLoggedIn = false;
1597
+ document.getElementById('loginButton').style.display = 'block';
1598
+ document.getElementById('logoutButton').style.display = 'none';
1599
+ updateActionButtons(false);
1600
+ showLoginForm();
1601
+ } else {
1602
+ alert(`重启失败: ${result.error || '未知错误'}`);
1603
+ console.error(`重启失败 (${repoId}):`, result.error, result.details);
1604
+ }
1605
+ }
1606
+ } catch (error) {
1607
+ hideLoading();
1608
+ console.error(`重启失败 (${repoId}):`, error);
1609
+ alert(`重启失败: ${error.message}`);
1610
+ }
1611
+ }
1612
+
1613
+ async function rebuildSpace(repoId) {
1614
+ try {
1615
+ const token = localStorage.getItem('authToken');
1616
+ if (!token || !isLoggedIn) {
1617
+ alert('请先登录以执行此操作');
1618
+ showLoginForm();
1619
+ return;
1620
+ }
1621
+ showLoading();
1622
+ const encodedRepoId = encodeURIComponent(repoId);
1623
+ const response = await fetch(`/api/proxy/rebuild/${encodedRepoId}`, {
1624
+ method: 'POST',
1625
+ headers: {
1626
+ 'Authorization': `Bearer ${token}`
1627
+ }
1628
+ });
1629
+ const result = await response.json();
1630
+ hideLoading();
1631
+ if (result.success) {
1632
+ alert(`重建成功: ${repoId}`);
1633
+ // 操作成功后立即刷新数据
1634
+ refreshData();
1635
+ } else {
1636
+ if (response.status === 401) {
1637
+ alert('登录已过期,请重新登录');
1638
+ localStorage.removeItem('authToken');
1639
+ isLoggedIn = false;
1640
+ document.getElementById('loginButton').style.display = 'block';
1641
+ document.getElementById('logoutButton').style.display = 'none';
1642
+ updateActionButtons(false);
1643
+ showLoginForm();
1644
+ } else {
1645
+ alert(`重建失败: ${result.error || '未知错误'}`);
1646
+ console.error(`重建失败 (${repoId}):`, result.error, result.details);
1647
+ }
1648
+ }
1649
+ } catch (error) {
1650
+ hideLoading();
1651
+ console.error(`重建失败 (${repoId}):`, error);
1652
+ alert(`重建失败: ${error.message}`);
1653
+ }
1654
+ }
1655
+
1656
+ function updateSummary() {
1657
+ let online = 0;
1658
+ let offline = 0;
1659
+ let totalUpload = 0;
1660
+ let totalDownload = 0;
1661
+
1662
+ serverStatus.forEach((status, instanceId) => {
1663
+ const isRecentlyOnline = status.isOnline || status.status.toLowerCase() === 'running';
1664
+ if (isRecentlyOnline) {
1665
+ online++;
1666
+ if (status.data) {
1667
+ totalUpload += parseFloat(status.data.tx_bps) || 0;
1668
+ totalDownload += parseFloat(status.data.rx_bps) || 0;
1669
+ }
1670
+ } else {
1671
+ offline++;
1672
+ }
1673
+ });
1674
+
1675
+ document.getElementById('totalServers').textContent = serverStatus.size;
1676
+ document.getElementById('onlineServers').textContent = online;
1677
+ document.getElementById('offlineServers').textContent = offline;
1678
+ document.getElementById('totalUpload').textContent = `${formatBytes(totalUpload)}/s`;
1679
+ document.getElementById('totalDownload').textContent = `${formatBytes(totalDownload)}/s`;
1680
+ }
1681
+
1682
+ function formatBytes(bytes) {
1683
+ if (bytes === 0) return '0 B';
1684
+ const k = 1024;
1685
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
1686
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
1687
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
1688
+ }
1689
+
1690
+ setInterval(updateSummary, 5000);
1691
+
1692
+ setInterval(async () => {
1693
+ metricsStreamManager.disconnect();
1694
+ await initialize();
1695
+ }, 300000);
1696
+
1697
+ // 应用过滤和排序
1698
+ function applyFiltersAndSort() {
1699
+ const statusFilter = document.getElementById('statusFilter').value;
1700
+ const userFilter = document.getElementById('userFilter').value;
1701
+ const sortBy = document.getElementById('sortBy').value;
1702
+
1703
+ // 过滤实例
1704
+ let filteredInstances = allInstances;
1705
+ if (statusFilter !== 'all') {
1706
+ filteredInstances = filteredInstances.filter(instance => instance.status.toLowerCase() === statusFilter);
1707
+ }
1708
+ if (userFilter !== 'all') {
1709
+ filteredInstances = filteredInstances.filter(instance => instance.owner === userFilter);
1710
+ }
1711
+
1712
+ // 排序实例
1713
+ filteredInstances.sort((a, b) => {
1714
+ if (sortBy === 'name-asc') {
1715
+ return a.name.localeCompare(b.name);
1716
+ } else if (sortBy === 'name-desc') {
1717
+ return b.name.localeCompare(a.name);
1718
+ } else if (sortBy === 'status-asc') {
1719
+ const statusOrder = { 'running': 0, 'sleeping': 1, 'stopped': 2 };
1720
+ return statusOrder[a.status.toLowerCase()] - statusOrder[b.status.toLowerCase()];
1721
+ } else if (sortBy === 'status-desc') {
1722
+ const statusOrder = { 'running': 2, 'sleeping': 1, 'stopped': 0 };
1723
+ return statusOrder[a.status.toLowerCase()] - statusOrder[b.status.toLowerCase()];
1724
+ }
1725
+ return 0;
1726
+ });
1727
+
1728
+ // 重新渲染过滤和排序后的实例
1729
+ instanceMap.clear();
1730
+ serverStatus.clear();
1731
+ metricsStreamManager.disconnect();
1732
+ // 销毁现有图表实例
1733
+ chartInstances.forEach(chart => {
1734
+ if (chart) {
1735
+ chart.destroy();
1736
+ }
1737
+ });
1738
+ chartInstances.clear();
1739
+ chartDataBuffer.clear();
1740
+ renderInstances(filteredInstances);
1741
+
1742
+ // 默认监控状态为 running 的实例
1743
+ const runningInstances = filteredInstances
1744
+ .filter(instance => instance.status.toLowerCase() === 'running')
1745
+ .map(instance => instance.repo_id);
1746
+ metricsStreamManager.connect(runningInstances);
1747
+
1748
+ updateSummary();
1749
+ updateActionButtons(isLoggedIn);
1750
+ }
1751
+
1752
+ // 新增函数:在新标签页中打开实例的URL
1753
+ function viewInstance(url) {
1754
+ window.open(url, '_blank');
1755
+ }
1756
+
1757
+ // 新增函数:在新标签页中打开实例的管理页面
1758
+ function manageInstance(repoId) {
1759
+ const manageUrl = `https://huggingface.co/spaces/${repoId}`;
1760
+ window.open(manageUrl, '_blank');
1761
+ }
1762
+ </script>
1763
+ </body>
1764
+ </html>
server.js ADDED
@@ -0,0 +1,754 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express');
2
+ const path = require('path');
3
+ const axios = require('axios');
4
+ const crypto = require('crypto');
5
+ const app = express();
6
+ const port = process.env.PORT || 8080;
7
+
8
+ // 启用 JSON 和 URL-encoded 请求解析
9
+ app.use(express.json());
10
+ app.use(express.urlencoded({ extended: true }));
11
+
12
+ // 从环境变量获取 HuggingFace 用户名和对应的 API Token 映射
13
+ const userTokenMapping = {};
14
+ const usernames = [];
15
+ const hfUserConfig = process.env.HF_USER || '';
16
+ if (hfUserConfig) {
17
+ hfUserConfig.split(',').forEach(pair => {
18
+ const parts = pair.split(':').map(part => part.trim());
19
+ const username = parts[0];
20
+ const token = parts[1] || '';
21
+ if (username) {
22
+ usernames.push(username);
23
+ if (token) {
24
+ userTokenMapping[username] = token;
25
+ }
26
+ }
27
+ });
28
+ }
29
+
30
+ // 从环境变量获取登录凭据
31
+ const ADMIN_USERNAME = process.env.USER_NAME || 'admin';
32
+ const ADMIN_PASSWORD = process.env.USER_PASSWORD || 'password';
33
+
34
+ // 从环境变量获取是否在未登录时展示 private 实例的配置,默认值为 false
35
+ const SHOW_PRIVATE = process.env.SHOW_PRIVATE === 'true';
36
+ console.log(`SHOW_PRIVATE 配置: ${SHOW_PRIVATE ? '未登录时展示 private 实例' : '未登录时隐藏 private 实例'}`);
37
+
38
+ // 存储会话 token 的简单内存数据库(生产环境中应使用数据库或 Redis)
39
+ const sessions = new Map();
40
+ const SESSION_TIMEOUT = 24 * 60 * 60 * 1000; // 24小时超时
41
+
42
+ // 缓存管理
43
+ class SpaceCache {
44
+ constructor() {
45
+ this.spaces = {};
46
+ this.lastUpdate = null;
47
+ }
48
+
49
+ updateAll(spacesData) {
50
+ this.spaces = spacesData.reduce((acc, space) => ({ ...acc, [space.repo_id]: space }), {});
51
+ this.lastUpdate = Date.now();
52
+ }
53
+
54
+ getAll() {
55
+ return Object.values(this.spaces);
56
+ }
57
+
58
+ isExpired(expireMinutes = 5) {
59
+ if (!this.lastUpdate) return true;
60
+ return (Date.now() - this.lastUpdate) > (expireMinutes * 60 * 1000);
61
+ }
62
+
63
+ invalidate() {
64
+ this.lastUpdate = null;
65
+ }
66
+ }
67
+
68
+ const spaceCache = new SpaceCache();
69
+
70
+ // 用于获取 Spaces 数据的函数,带有重试机制
71
+ async function fetchSpacesWithRetry(username, token, maxRetries = 3, retryDelay = 2000) {
72
+ let retries = 0;
73
+ while (retries < maxRetries) {
74
+ try {
75
+ // 仅在 token 存在时添加 Authorization 头
76
+ const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
77
+ const response = await axios.get(`https://huggingface.co/api/spaces?author=${username}`, {
78
+ headers,
79
+ timeout: 10000 // 设置 10 秒超时
80
+ });
81
+ const spaces = response.data;
82
+ console.log(`获取到 ${spaces.length} 个 Spaces for ${username} (尝试 ${retries + 1}/${maxRetries}),使用 ${token ? 'Token 认证' : '无认证'}`);
83
+ return spaces;
84
+ } catch (error) {
85
+ retries++;
86
+ let errorDetail = error.message;
87
+ if (error.response) {
88
+ errorDetail += `, HTTP Status: ${error.response.status}`;
89
+ } else if (error.request) {
90
+ errorDetail += ', No response received (possible network issue)';
91
+ }
92
+ console.error(`获取 Spaces 列表失败 for ${username} (尝试 ${retries}/${maxRetries}): ${errorDetail},使用 ${token ? 'Token 认证' : '无认证'}`);
93
+ if (retries < maxRetries) {
94
+ console.log(`等待 ${retryDelay/1000} 秒后重试...`);
95
+ await new Promise(resolve => setTimeout(resolve, retryDelay));
96
+ } else {
97
+ console.error(`达到最大重试次数 (${maxRetries}),放弃重试 for ${username}`);
98
+ return [];
99
+ }
100
+ }
101
+ }
102
+ return [];
103
+ }
104
+
105
+ // 提供静态文件(前端文件)
106
+ app.use(express.static(path.join(__dirname, 'public')));
107
+
108
+ // 提供配置信息的 API 接口
109
+ app.get('/api/config', (req, res) => {
110
+ res.json({ usernames: usernames.join(',') });
111
+ });
112
+
113
+ // 登录 API 接口
114
+ app.post('/api/login', (req, res) => {
115
+ const { username, password } = req.body;
116
+ if (username === ADMIN_USERNAME && password === ADMIN_PASSWORD) {
117
+ // 生成一个随机 token 作为会话标识
118
+ const token = crypto.randomBytes(16).toString('hex');
119
+ const expiresAt = Date.now() + SESSION_TIMEOUT;
120
+ sessions.set(token, { username, expiresAt });
121
+ console.log(`用户 ${username} 登录成功,生成 token: ${token.slice(0, 8)}...`);
122
+ res.json({ success: true, token });
123
+ } else {
124
+ console.log(`用户 ${username} 登录失败,凭据无效`);
125
+ res.status(401).json({ success: false, message: '用户名或密码错误' });
126
+ }
127
+ });
128
+
129
+ // 验证登录状态 API 接口
130
+ app.post('/api/verify-token', (req, res) => {
131
+ const { token } = req.body;
132
+ const session = sessions.get(token);
133
+ if (session && session.expiresAt > Date.now()) {
134
+ res.json({ success: true, message: 'Token 有效' });
135
+ } else {
136
+ if (session) {
137
+ sessions.delete(token); // 删除过期的 token
138
+ console.log(`Token ${token.slice(0, 8)}... 已过期,已删除`);
139
+ }
140
+ res.status(401).json({ success: false, message: 'Token 无效或已过期' });
141
+ }
142
+ });
143
+
144
+ // 登出 API 接口
145
+ app.post('/api/logout', (req, res) => {
146
+ const { token } = req.body;
147
+ sessions.delete(token);
148
+ console.log(`Token ${token.slice(0, 8)}... 已手动登出`);
149
+ res.json({ success: true, message: '登出成功' });
150
+ });
151
+
152
+ // 中间件:验证请求中的 token
153
+ const authenticateToken = (req, res, next) => {
154
+ const authHeader = req.headers['authorization'];
155
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
156
+ return res.status(401).json({ error: '未提供有效的认证令牌' });
157
+ }
158
+ const token = authHeader.split(' ')[1];
159
+ const session = sessions.get(token);
160
+ if (session && session.expiresAt > Date.now()) {
161
+ req.session = session;
162
+ next();
163
+ } else {
164
+ if (session) {
165
+ sessions.delete(token); // 删除过期的 token
166
+ console.log(`Token ${token.slice(0, 8)}... 已过期,拒绝访问`);
167
+ }
168
+ return res.status(401).json({ error: '认证令牌无效或已过期' });
169
+ }
170
+ };
171
+
172
+ // 获取所有 spaces 列表(包括私有)
173
+ app.get('/api/proxy/spaces', async (req, res) => {
174
+ try {
175
+ // 检查是否登录
176
+ let isAuthenticated = false;
177
+ const authHeader = req.headers['authorization'];
178
+ if (authHeader && authHeader.startsWith('Bearer ')) {
179
+ const token = authHeader.split(' ')[1];
180
+ const session = sessions.get(token);
181
+ if (session && session.expiresAt > Date.now()) {
182
+ isAuthenticated = true;
183
+ console.log(`用户已登录,Token: ${token.slice(0, 8)}...`);
184
+ } else {
185
+ if (session) {
186
+ sessions.delete(token); // 删除过期的 token
187
+ console.log(`Token ${token.slice(0, 8)}... 已过期,拒绝访问`);
188
+ }
189
+ console.log('用户认证失败,无有效 Token');
190
+ }
191
+ } else {
192
+ console.log('用户未提供认证令牌');
193
+ }
194
+
195
+ // 如果缓存为空或已过期,强制重新获取数据
196
+ const cachedSpaces = spaceCache.getAll();
197
+ if (cachedSpaces.length === 0 || spaceCache.isExpired()) {
198
+ console.log(cachedSpaces.length === 0 ? '缓存为空,强制重新获取数据' : '缓存已过期,重新获取数据');
199
+ const allSpaces = [];
200
+ for (const username of usernames) {
201
+ const token = userTokenMapping[username];
202
+ if (!token) {
203
+ console.warn(`用户 ${username} 没有配置 API Token,将尝试无认证访问公开数据`);
204
+ }
205
+
206
+ try {
207
+ const spaces = await fetchSpacesWithRetry(username, token);
208
+ for (const space of spaces) {
209
+ try {
210
+ // 仅在 token 存在时添加 Authorization 头
211
+ const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
212
+ const spaceInfoResponse = await axios.get(`https://huggingface.co/api/spaces/${space.id}`, { headers });
213
+ const spaceInfo = spaceInfoResponse.data;
214
+ const spaceRuntime = spaceInfo.runtime || {};
215
+
216
+ allSpaces.push({
217
+ repo_id: spaceInfo.id,
218
+ name: spaceInfo.cardData?.title || spaceInfo.id.split('/')[1],
219
+ owner: spaceInfo.author,
220
+ username: username,
221
+ url: `https://${spaceInfo.author}-${spaceInfo.id.split('/')[1]}.hf.space`,
222
+ status: spaceRuntime.stage || 'unknown',
223
+ last_modified: spaceInfo.lastModified || 'unknown',
224
+ created_at: spaceInfo.createdAt || 'unknown',
225
+ sdk: spaceInfo.sdk || 'unknown',
226
+ tags: spaceInfo.tags || [],
227
+ private: spaceInfo.private || false,
228
+ app_port: spaceInfo.cardData?.app_port || 'unknown',
229
+ short_description: spaceInfo.cardData?.short_description || '' // 新增字段,确保为空时返回空字符串
230
+ });
231
+ } catch (error) {
232
+ console.error(`处理 Space ${space.id} 失败:`, error.message, `使用 ${token ? 'Token 认证' : '无认证'}`);
233
+ }
234
+ }
235
+ } catch (error) {
236
+ console.error(`获取 Spaces 列表失败 for ${username}:`, error.message, `使用 ${token ? 'Token 认证' : '无认证'}`);
237
+ }
238
+ }
239
+
240
+ allSpaces.sort((a, b) => a.name.localeCompare(b.name));
241
+ spaceCache.updateAll(allSpaces);
242
+ console.log(`总共获取到 ${allSpaces.length} 个 Spaces`);
243
+
244
+ const safeSpaces = allSpaces.map(space => {
245
+ const { token, ...safeSpace } = space;
246
+ return safeSpace;
247
+ });
248
+
249
+ if (isAuthenticated) {
250
+ console.log('用户已登录,返回所有实例(包括 private)');
251
+ res.json(safeSpaces);
252
+ } else if (SHOW_PRIVATE) {
253
+ console.log('用户未登录,但 SHOW_PRIVATE 为 true,返回所有实例');
254
+ res.json(safeSpaces);
255
+ } else {
256
+ console.log('用户未登录,SHOW_PRIVATE 为 false,过滤 private 实例');
257
+ res.json(safeSpaces.filter(space => !space.private));
258
+ }
259
+ } else {
260
+ console.log('从缓存获取 Spaces 数据');
261
+ const safeSpaces = cachedSpaces.map(space => {
262
+ const { token, ...safeSpace } = space;
263
+ return safeSpace;
264
+ });
265
+
266
+ if (isAuthenticated) {
267
+ console.log('用户已登录,返回所有缓存实例(包括 private)');
268
+ return res.json(safeSpaces);
269
+ } else if (SHOW_PRIVATE) {
270
+ console.log('用户未登录,但 SHOW_PRIVATE 为 true,返回所有缓存实例');
271
+ return res.json(safeSpaces);
272
+ } else {
273
+ console.log('用户未登录,SHOW_PRIVATE 为 false,过滤 private 实例');
274
+ return res.json(safeSpaces.filter(space => !space.private));
275
+ }
276
+ }
277
+ } catch (error) {
278
+ console.error(`代理获取 spaces 列表失败:`, error.message);
279
+ res.status(500).json({ error: '获取 spaces 列表失败', details: error.message });
280
+ }
281
+ });
282
+
283
+ // 代理重启 Space(需要认证)
284
+ app.post('/api/proxy/restart/:repoId(*)', authenticateToken, async (req, res) => {
285
+ try {
286
+ const { repoId } = req.params;
287
+ console.log(`尝试重启 Space: ${repoId}`);
288
+ const spaces = spaceCache.getAll();
289
+ const space = spaces.find(s => s.repo_id === repoId);
290
+ if (!space || !userTokenMapping[space.username]) {
291
+ console.error(`Space ${repoId} 未找到或无 Token 配置`);
292
+ return res.status(404).json({ error: 'Space 未找到或无 Token 配置' });
293
+ }
294
+
295
+ const headers = { 'Authorization': `Bearer ${userTokenMapping[space.username]}`, 'Content-Type': 'application/json' };
296
+ const response = await axios.post(`https://huggingface.co/api/spaces/${repoId}/restart`, {}, { headers });
297
+ console.log(`重启 Space ${repoId} 成功,状态码: ${response.status}`);
298
+ res.json({ success: true, message: `Space ${repoId} 重启成功` });
299
+ } catch (error) {
300
+ console.error(`重启 space 失败 (${req.params.repoId}):`, error.message);
301
+ if (error.response) {
302
+ console.error(`状态码: ${error.response.status}, 响应数据:`, error.response.data);
303
+ res.status(error.response.status || 500).json({ error: '重启 space 失败', details: error.response.data?.message || error.message });
304
+ } else {
305
+ res.status(500).json({ error: '重启 space 失败', details: error.message });
306
+ }
307
+ }
308
+ });
309
+
310
+ // 代理重建 Space(需要认证)
311
+ app.post('/api/proxy/rebuild/:repoId(*)', authenticateToken, async (req, res) => {
312
+ try {
313
+ const { repoId } = req.params;
314
+ console.log(`尝试重建 Space: ${repoId}`);
315
+ const spaces = spaceCache.getAll();
316
+ const space = spaces.find(s => s.repo_id === repoId);
317
+ if (!space || !userTokenMapping[space.username]) {
318
+ console.error(`Space ${repoId} 未找到或无 Token 配置`);
319
+ return res.status(404).json({ error: 'Space 未找到或无 Token 配置' });
320
+ }
321
+
322
+ const headers = { 'Authorization': `Bearer ${userTokenMapping[space.username]}`, 'Content-Type': 'application/json' };
323
+ // 将 factory_reboot 参数作为查询参数传递,而非请求体
324
+ const response = await axios.post(
325
+ `https://huggingface.co/api/spaces/${repoId}/restart?factory=true`,
326
+ {},
327
+ { headers }
328
+ );
329
+ console.log(`重建 Space ${repoId} 成功,状态码: ${response.status}`);
330
+ res.json({ success: true, message: `Space ${repoId} 重建成功` });
331
+ } catch (error) {
332
+ console.error(`重建 space 失败 (${req.params.repoId}):`, error.message);
333
+ if (error.response) {
334
+ console.error(`状态码: ${error.response.status}, 响应数据:`, error.response.data);
335
+ res.status(error.response.status || 500).json({ error: '重建 space 失败', details: error.response.data?.message || error.message });
336
+ } else {
337
+ res.status(500).json({ error: '重建 space 失败', details: error.message });
338
+ }
339
+ }
340
+ });
341
+
342
+ // 外部 API 服务(类似于 Flask 的 /api/v1)
343
+ app.get('/api/v1/info/:token', async (req, res) => {
344
+ try {
345
+ const { token } = req.params;
346
+ const authHeader = req.headers.authorization;
347
+ if (!authHeader || !authHeader.startsWith('Bearer ') || authHeader.split(' ')[1] !== process.env.API_KEY) {
348
+ return res.status(401).json({ error: '无效的 API 密钥' });
349
+ }
350
+
351
+ const headers = { 'Authorization': `Bearer ${token}` };
352
+ const userInfoResponse = await axios.get('https://huggingface.co/api/whoami-v2', { headers });
353
+ const username = userInfoResponse.data.name;
354
+ const spacesResponse = await axios.get(`https://huggingface.co/api/spaces?author=${username}`, { headers });
355
+ const spaces = spacesResponse.data;
356
+ const spaceList = [];
357
+
358
+ for (const space of spaces) {
359
+ try {
360
+ const spaceInfoResponse = await axios.get(`https://huggingface.co/api/spaces/${space.id}`, { headers });
361
+ spaceList.push(spaceInfoResponse.data.id);
362
+ } catch (error) {
363
+ console.error(`获取 Space 信息失败 (${space.id}):`, error.message);
364
+ }
365
+ }
366
+
367
+ res.json({ spaces: spaceList, total: spaceList.length });
368
+ } catch (error) {
369
+ console.error(`获取 spaces 列表失败 (外部 API):`, error.message);
370
+ res.status(500).json({ error: error.message });
371
+ }
372
+ });
373
+
374
+ app.get('/api/v1/info/:token/:spaceId(*)', async (req, res) => {
375
+ try {
376
+ const { token, spaceId } = req.params;
377
+ const authHeader = req.headers.authorization;
378
+ if (!authHeader || !authHeader.startsWith('Bearer ') || authHeader.split(' ')[1] !== process.env.API_KEY) {
379
+ return res.status(401).json({ error: '无效的 API 密钥' });
380
+ }
381
+
382
+ const headers = { 'Authorization': `Bearer ${token}` };
383
+ const spaceInfoResponse = await axios.get(`https://huggingface.co/api/spaces/${spaceId}`, { headers });
384
+ const spaceInfo = spaceInfoResponse.data;
385
+ const spaceRuntime = spaceInfo.runtime || {};
386
+
387
+ res.json({
388
+ id: spaceInfo.id,
389
+ status: spaceRuntime.stage || 'unknown',
390
+ last_modified: spaceInfo.lastModified || null,
391
+ created_at: spaceInfo.createdAt || null,
392
+ sdk: spaceInfo.sdk || 'unknown',
393
+ tags: spaceInfo.tags || [],
394
+ private: spaceInfo.private || false
395
+ });
396
+ } catch (error) {
397
+ console.error(`获取 space 信息失败 (外部 API):`, error.message);
398
+ res.status(error.response?.status || 404).json({ error: error.message });
399
+ }
400
+ });
401
+
402
+ app.post('/api/v1/action/:token/:spaceId(*)/restart', async (req, res) => {
403
+ try {
404
+ const { token, spaceId } = req.params;
405
+ const authHeader = req.headers.authorization;
406
+ if (!authHeader || !authHeader.startsWith('Bearer ') || authHeader.split(' ')[1] !== process.env.API_KEY) {
407
+ return res.status(401).json({ error: '无效的 API 密钥' });
408
+ }
409
+
410
+ const headers = { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' };
411
+ await axios.post(`https://huggingface.co/api/spaces/${spaceId}/restart`, {}, { headers });
412
+ res.json({ success: true, message: `Space ${spaceId} 重启成功` });
413
+ } catch (error) {
414
+ console.error(`重启 space 失败 (外部 API):`, error.message);
415
+ res.status(error.response?.status || 500).json({ success: false, error: error.message });
416
+ }
417
+ });
418
+
419
+ app.post('/api/v1/action/:token/:spaceId(*)/rebuild', async (req, res) => {
420
+ try {
421
+ const { token, spaceId } = req.params;
422
+ const authHeader = req.headers.authorization;
423
+ if (!authHeader || !authHeader.startsWith('Bearer ') || authHeader.split(' ')[1] !== process.env.API_KEY) {
424
+ return res.status(401).json({ error: '无效的 API 密钥' });
425
+ }
426
+
427
+ const headers = { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' };
428
+ console.log(`外部 API 发送重建请求,spaceId: ${spaceId}`);
429
+ // 将 factory_reboot 参数作为查询参数传递,而非请求体
430
+ const response = await axios.post(
431
+ `https://huggingface.co/api/spaces/${spaceId}/restart?factory=true`,
432
+ {},
433
+ { headers }
434
+ );
435
+ console.log(`外部 API 重建 Space ${spaceId} 成功,状态码: ${response.status}`);
436
+ res.json({ success: true, message: `Space ${spaceId} 重建成功` });
437
+ } catch (error) {
438
+ console.error(`重建 space 失败 (外部 API):`, error.message);
439
+ if (error.response) {
440
+ console.error(`状态码: ${error.response.status}, 响应数据:`, error.response.data);
441
+ res.status(error.response.status || 500).json({ success: false, error: error.response.data?.message || error.message });
442
+ } else {
443
+ res.status(500).json({ success: false, error: error.message });
444
+ }
445
+ }
446
+ });
447
+
448
+ // 监控数据管理类
449
+ class MetricsConnectionManager {
450
+ constructor() {
451
+ this.connections = new Map(); // 存储 HuggingFace API 的监控连接
452
+ this.clients = new Map(); // 存储前端客户端的 SSE 连接
453
+ this.instanceData = new Map(); // 存储每个实例的最新监控数据
454
+ }
455
+
456
+ // 建立到 HuggingFace API 的监控连接
457
+ async connectToInstance(repoId, username, token) {
458
+ if (this.connections.has(repoId)) {
459
+ return this.connections.get(repoId);
460
+ }
461
+
462
+ const instanceId = repoId.split('/')[1];
463
+ const url = `https://api.hf.space/v1/${username}/${instanceId}/live-metrics/sse`;
464
+ // 仅在 token 存在且非空时添加 Authorization 头
465
+ const headers = token ? {
466
+ 'Authorization': `Bearer ${token}`,
467
+ 'Accept': 'text/event-stream',
468
+ 'Cache-Control': 'no-cache',
469
+ 'Connection': 'keep-alive'
470
+ } : {
471
+ 'Accept': 'text/event-stream',
472
+ 'Cache-Control': 'no-cache',
473
+ 'Connection': 'keep-alive'
474
+ };
475
+
476
+ try {
477
+ const response = await axios({
478
+ method: 'get',
479
+ url,
480
+ headers,
481
+ responseType: 'stream',
482
+ timeout: 10000
483
+ });
484
+
485
+ const stream = response.data;
486
+ stream.on('data', (chunk) => {
487
+ const chunkStr = chunk.toString();
488
+ if (chunkStr.includes('event: metric')) {
489
+ const dataMatch = chunkStr.match(/data: (.*)/);
490
+ if (dataMatch && dataMatch[1]) {
491
+ try {
492
+ const metrics = JSON.parse(dataMatch[1]);
493
+ this.instanceData.set(repoId, metrics);
494
+ // 推送给所有订阅了该实例的客户端
495
+ this.clients.forEach((clientRes, clientId) => {
496
+ if (clientRes.subscribedInstances && clientRes.subscribedInstances.includes(repoId)) {
497
+ clientRes.write(`event: metric\n`);
498
+ clientRes.write(`data: ${JSON.stringify({ repoId, metrics })}\n\n`);
499
+ }
500
+ });
501
+ } catch (error) {
502
+ console.error(`解析监控数据失败 (${repoId}):`, error.message);
503
+ }
504
+ }
505
+ }
506
+ });
507
+
508
+ stream.on('error', (error) => {
509
+ console.error(`监控连接错误 (${repoId}):`, error.message);
510
+ this.connections.delete(repoId);
511
+ this.instanceData.delete(repoId);
512
+ });
513
+
514
+ stream.on('end', () => {
515
+ console.log(`监控连接结束 (${repoId})`);
516
+ this.connections.delete(repoId);
517
+ this.instanceData.delete(repoId);
518
+ });
519
+
520
+ this.connections.set(repoId, stream);
521
+ console.log(`已建立监控连接 (${repoId}),使用 ${token ? 'Token 认证' : '无认证'}`);
522
+ return stream;
523
+ } catch (error) {
524
+ console.error(`无法连接到监控端点 (${repoId}):`, error.message);
525
+ this.connections.delete(repoId);
526
+ return null;
527
+ }
528
+ }
529
+
530
+ // 注册前端客户端的 SSE 连接
531
+ registerClient(clientId, res, subscribedInstances) {
532
+ res.subscribedInstances = subscribedInstances || [];
533
+ this.clients.set(clientId, res);
534
+ console.log(`客户端 ${clientId} 注册,订阅实例: ${res.subscribedInstances.join(', ') || '无'}`);
535
+
536
+ // 首次连接时,推送已缓存的最新数据
537
+ res.subscribedInstances.forEach(repoId => {
538
+ if (this.instanceData.has(repoId)) {
539
+ const metrics = this.instanceData.get(repoId);
540
+ res.write(`event: metric\n`);
541
+ res.write(`data: ${JSON.stringify({ repoId, metrics })}\n\n`);
542
+ }
543
+ });
544
+ }
545
+
546
+ // 客户端断开连接
547
+ unregisterClient(clientId) {
548
+ this.clients.delete(clientId);
549
+ console.log(`客户端 ${clientId} 断开连接`);
550
+ this.cleanupConnections();
551
+ }
552
+
553
+ // 更新客户端订阅的实例列表
554
+ updateClientSubscriptions(clientId, subscribedInstances) {
555
+ const clientRes = this.clients.get(clientId);
556
+ if (clientRes) {
557
+ clientRes.subscribedInstances = subscribedInstances || [];
558
+ console.log(`客户端 ${clientId} 更新订阅: ${clientRes.subscribedInstances.join(', ') || '无'}`);
559
+ // 更新后推送最新的缓存数据
560
+ subscribedInstances.forEach(repoId => {
561
+ if (this.instanceData.has(repoId)) {
562
+ const metrics = this.instanceData.get(repoId);
563
+ clientRes.write(`event: metric\n`);
564
+ clientRes.write(`data: ${JSON.stringify({ repoId, metrics })}\n\n`);
565
+ }
566
+ });
567
+ }
568
+ this.cleanupConnections();
569
+ }
570
+
571
+ // 清理未被任何客户端订阅的连接
572
+ cleanupConnections() {
573
+ const subscribedRepoIds = new Set();
574
+ this.clients.forEach(clientRes => {
575
+ clientRes.subscribedInstances.forEach(repoId => subscribedRepoIds.add(repoId));
576
+ });
577
+
578
+ const toRemove = [];
579
+ this.connections.forEach((stream, repoId) => {
580
+ if (!subscribedRepoIds.has(repoId)) {
581
+ toRemove.push(repoId);
582
+ stream.destroy();
583
+ console.log(`清理未订阅的监控连接 (${repoId})`);
584
+ }
585
+ });
586
+
587
+ toRemove.forEach(repoId => {
588
+ this.connections.delete(repoId);
589
+ this.instanceData.delete(repoId);
590
+ });
591
+ }
592
+ }
593
+
594
+ const metricsManager = new MetricsConnectionManager();
595
+
596
+ // 新增统一监控数据的SSE端点
597
+ app.get('/api/proxy/live-metrics-stream', (req, res) => {
598
+ // 设置 SSE 所需的响应头
599
+ res.set({
600
+ 'Content-Type': 'text/event-stream',
601
+ 'Cache-Control': 'no-cache',
602
+ 'Connection': 'keep-alive'
603
+ });
604
+
605
+ // 生成唯一的客户端ID
606
+ const clientId = crypto.randomBytes(8).toString('hex');
607
+
608
+ // 获取查询参数中的实例列表和 token
609
+ const instancesParam = req.query.instances || '';
610
+ const token = req.query.token || '';
611
+ const subscribedInstances = instancesParam.split(',').filter(id => id.trim() !== '');
612
+
613
+ // 检查登录状态
614
+ let isAuthenticated = false;
615
+ if (token) {
616
+ const session = sessions.get(token);
617
+ if (session && session.expiresAt > Date.now()) {
618
+ isAuthenticated = true;
619
+ console.log(`SSE 用户已登录,Token: ${token.slice(0, 8)}...`);
620
+ } else {
621
+ if (session) {
622
+ sessions.delete(token);
623
+ console.log(`SSE Token ${token.slice(0, 8)}... 已过期,拒绝访问`);
624
+ }
625
+ console.log('SSE 用户认证失败,无有效 Token');
626
+ }
627
+ } else {
628
+ console.log('SSE 用户未提供认证令牌');
629
+ }
630
+
631
+ // 注册客户端
632
+ metricsManager.registerClient(clientId, res, subscribedInstances);
633
+
634
+ // 根据订阅列表建立监控连接
635
+ const spaces = spaceCache.getAll();
636
+ subscribedInstances.forEach(repoId => {
637
+ const space = spaces.find(s => s.repo_id === repoId);
638
+ if (space) {
639
+ const username = space.username;
640
+ const token = userTokenMapping[username] || '';
641
+ metricsManager.connectToInstance(repoId, username, token);
642
+ }
643
+ });
644
+
645
+ // 监听客户端断开连接
646
+ req.on('close', () => {
647
+ metricsManager.unregisterClient(clientId);
648
+ console.log(`客户端 ${clientId} 断开 SSE 连接`);
649
+ });
650
+ });
651
+
652
+ // 新增接口:更新客户端订阅的实例列表
653
+ app.post('/api/proxy/update-subscriptions', (req, res) => {
654
+ const { clientId, instances } = req.body;
655
+ if (!clientId || !instances || !Array.isArray(instances)) {
656
+ return res.status(400).json({ error: '缺少 clientId 或 instances 参数' });
657
+ }
658
+
659
+ metricsManager.updateClientSubscriptions(clientId, instances);
660
+ // 根据新订阅列表建立监控连接
661
+ const spaces = spaceCache.getAll();
662
+ instances.forEach(repoId => {
663
+ const space = spaces.find(s => s.repo_id === repoId);
664
+ if (space) {
665
+ const username = space.username;
666
+ const token = userTokenMapping[username] || '';
667
+ metricsManager.connectToInstance(repoId, username, token);
668
+ }
669
+ });
670
+
671
+ res.json({ success: true, message: '订阅列表已更新' });
672
+ });
673
+
674
+ // 处理其他请求,重定向到 index.html
675
+ app.get('*', (req, res) => {
676
+ res.sendFile(path.join(__dirname, 'public', 'index.html'));
677
+ });
678
+
679
+ // 定期清理过期的会话
680
+ setInterval(() => {
681
+ const now = Date.now();
682
+ for (const [token, session] of sessions.entries()) {
683
+ if (session.expiresAt < now) {
684
+ sessions.delete(token);
685
+ console.log(`Token ${token.slice(0, 8)}... 已过期,自动清理`);
686
+ }
687
+ }
688
+ }, 60 * 60 * 1000); // 每小时清理一次
689
+
690
+ // 定时刷新缓存任务
691
+ const REFRESH_INTERVAL = 5 * 60 * 1000; // 每 5 分钟检查一次
692
+ async function refreshSpacesCachePeriodically() {
693
+ console.log('启动定时刷新缓存任务...');
694
+ setInterval(async () => {
695
+ try {
696
+ const cachedSpaces = spaceCache.getAll();
697
+ if (spaceCache.isExpired() || cachedSpaces.length === 0) {
698
+ console.log('定时任务:缓存已过期或为空,重新获取 Spaces 数据');
699
+ const allSpaces = [];
700
+ for (const username of usernames) {
701
+ const token = userTokenMapping[username];
702
+ if (!token) {
703
+ console.warn(`用户 ${username} 没有配置 API Token,将尝试无认证访问公开数据`);
704
+ }
705
+ try {
706
+ const spaces = await fetchSpacesWithRetry(username, token);
707
+ for (const space of spaces) {
708
+ try {
709
+ const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
710
+ const spaceInfoResponse = await axios.get(`https://huggingface.co/api/spaces/${space.id}`, { headers });
711
+ const spaceInfo = spaceInfoResponse.data;
712
+ const spaceRuntime = spaceInfo.runtime || {};
713
+
714
+ allSpaces.push({
715
+ repo_id: spaceInfo.id,
716
+ name: spaceInfo.cardData?.title || spaceInfo.id.split('/')[1],
717
+ owner: spaceInfo.author,
718
+ username: username,
719
+ url: `https://${spaceInfo.author}-${spaceInfo.id.split('/')[1]}.hf.space`,
720
+ status: spaceRuntime.stage || 'unknown',
721
+ last_modified: spaceInfo.lastModified || 'unknown',
722
+ created_at: spaceInfo.createdAt || 'unknown',
723
+ sdk: spaceInfo.sdk || 'unknown',
724
+ tags: spaceInfo.tags || [],
725
+ private: spaceInfo.private || false,
726
+ app_port: spaceInfo.cardData?.app_port || 'unknown',
727
+ short_description: spaceInfo.cardData?.short_description || '' // 新增字段,确保为空时返回空字符串
728
+ });
729
+ } catch (error) {
730
+ console.error(`处理 Space ${space.id} 失败:`, error.message);
731
+ }
732
+ }
733
+ } catch (error) {
734
+ console.error(`获取 Spaces 列表失败 for ${username}:`, error.message);
735
+ }
736
+ }
737
+ allSpaces.sort((a, b) => a.name.localeCompare(b.name));
738
+ spaceCache.updateAll(allSpaces);
739
+ console.log(`定时任务:总共获取到 ${allSpaces.length} 个 Spaces,缓存已更新`);
740
+ } else {
741
+ console.log('定时任务:缓存有效且不为空,无需更新');
742
+ }
743
+ } catch (error) {
744
+ console.error('定时任务:刷新缓存失败:', error.message);
745
+ }
746
+ }, REFRESH_INTERVAL);
747
+ }
748
+
749
+ app.listen(port, () => {
750
+ console.log(`Server running on port ${port}`);
751
+ console.log(`User configurations:`, usernames.map(user => `${user}: ${userTokenMapping[user] ? 'Token Configured' : 'No Token'}`).join(', ') || 'None');
752
+ console.log(`Admin login enabled: Username=${ADMIN_USERNAME}, Password=${ADMIN_PASSWORD ? 'Configured' : 'Not Configured'}`);
753
+ refreshSpacesCachePeriodically(); // 启动定时任务
754
+ });