Update server.js
Browse files
server.js
CHANGED
@@ -9,14 +9,6 @@ const port = process.env.PORT || 8080;
|
|
9 |
app.use(express.json());
|
10 |
app.use(express.urlencoded({ extended: true }));
|
11 |
|
12 |
-
// 添加安全响应头
|
13 |
-
app.use((req, res, next) => {
|
14 |
-
res.setHeader('X-Content-Type-Options', 'nosniff');
|
15 |
-
res.setHeader('X-Frame-Options', 'DENY');
|
16 |
-
res.setHeader('X-XSS-Protection', '1; mode=block');
|
17 |
-
next();
|
18 |
-
});
|
19 |
-
|
20 |
// 从环境变量获取 HuggingFace 用户名和对应的 API Token 映射
|
21 |
const userTokenMapping = {};
|
22 |
const usernames = [];
|
@@ -39,11 +31,15 @@ if (hfUserConfig) {
|
|
39 |
const ADMIN_USERNAME = process.env.USER_NAME || 'admin';
|
40 |
const ADMIN_PASSWORD = process.env.USER_PASSWORD || 'password';
|
41 |
|
|
|
|
|
|
|
|
|
42 |
// 存储会话 token 的简单内存数据库(生产环境中应使用数据库或 Redis)
|
43 |
const sessions = new Map();
|
44 |
const SESSION_TIMEOUT = 24 * 60 * 60 * 1000; // 24小时超时
|
45 |
|
46 |
-
// 缓存管理
|
47 |
class SpaceCache {
|
48 |
constructor() {
|
49 |
this.spaces = {};
|
@@ -58,33 +54,53 @@ class SpaceCache {
|
|
58 |
getAll() {
|
59 |
return Object.values(this.spaces);
|
60 |
}
|
61 |
-
|
62 |
-
// 新增:获取安全的空间数据(不包含token)
|
63 |
-
getSafeSpaces() {
|
64 |
-
return Object.values(this.spaces).map(space => {
|
65 |
-
const { token, ...safeSpace } = space;
|
66 |
-
return safeSpace;
|
67 |
-
});
|
68 |
-
}
|
69 |
|
70 |
isExpired(expireMinutes = 5) {
|
71 |
if (!this.lastUpdate) return true;
|
72 |
return (Date.now() - this.lastUpdate) > (expireMinutes * 60 * 1000);
|
73 |
}
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
return this.spaces[repoId]?.token || '';
|
78 |
}
|
79 |
}
|
80 |
|
81 |
const spaceCache = new SpaceCache();
|
82 |
|
83 |
-
//
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
88 |
|
89 |
// 提供静态文件(前端文件)
|
90 |
app.use(express.static(path.join(__dirname, 'public')));
|
@@ -153,67 +169,111 @@ const authenticateToken = (req, res, next) => {
|
|
153 |
}
|
154 |
};
|
155 |
|
156 |
-
// 获取所有 spaces
|
157 |
app.get('/api/proxy/spaces', async (req, res) => {
|
158 |
try {
|
159 |
-
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
|
|
|
|
|
|
|
|
170 |
}
|
|
|
|
|
|
|
171 |
|
172 |
-
|
173 |
-
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
|
|
|
|
|
|
|
|
178 |
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
-
|
183 |
-
|
184 |
-
|
185 |
-
|
186 |
-
|
187 |
-
|
188 |
-
|
189 |
-
|
190 |
-
|
191 |
-
|
192 |
-
|
193 |
-
|
194 |
-
|
195 |
-
|
196 |
-
|
197 |
-
|
198 |
-
|
199 |
-
|
200 |
-
|
201 |
-
|
202 |
-
|
203 |
-
|
|
|
|
|
|
|
204 |
}
|
|
|
|
|
205 |
}
|
206 |
-
} catch (error) {
|
207 |
-
console.error(`获取 Spaces 列表失败 for ${username}:`, error.message);
|
208 |
}
|
209 |
-
}
|
210 |
|
211 |
-
|
212 |
-
|
213 |
-
|
214 |
-
|
215 |
-
|
216 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
217 |
} catch (error) {
|
218 |
console.error(`代理获取 spaces 列表失败:`, error.message);
|
219 |
res.status(500).json({ error: '获取 spaces 列表失败', details: error.message });
|
@@ -225,15 +285,14 @@ app.post('/api/proxy/restart/:repoId(*)', authenticateToken, async (req, res) =>
|
|
225 |
try {
|
226 |
const { repoId } = req.params;
|
227 |
console.log(`尝试重启 Space: ${repoId}`);
|
228 |
-
|
229 |
-
|
230 |
-
|
231 |
-
if (!token) {
|
232 |
console.error(`Space ${repoId} 未找到或无 Token 配置`);
|
233 |
return res.status(404).json({ error: 'Space 未找到或无 Token 配置' });
|
234 |
}
|
235 |
|
236 |
-
const headers = { 'Authorization': `Bearer ${
|
237 |
const response = await axios.post(`https://huggingface.co/api/spaces/${repoId}/restart`, {}, { headers });
|
238 |
console.log(`重启 Space ${repoId} 成功,状态码: ${response.status}`);
|
239 |
res.json({ success: true, message: `Space ${repoId} 重启成功` });
|
@@ -253,15 +312,14 @@ app.post('/api/proxy/rebuild/:repoId(*)', authenticateToken, async (req, res) =>
|
|
253 |
try {
|
254 |
const { repoId } = req.params;
|
255 |
console.log(`尝试重建 Space: ${repoId}`);
|
256 |
-
|
257 |
-
|
258 |
-
|
259 |
-
if (!token) {
|
260 |
console.error(`Space ${repoId} 未找到或无 Token 配置`);
|
261 |
return res.status(404).json({ error: 'Space 未找到或无 Token 配置' });
|
262 |
}
|
263 |
|
264 |
-
const headers = { 'Authorization': `Bearer ${
|
265 |
// 将 factory_reboot 参数作为查询参数传递,而非请求体
|
266 |
const response = await axios.post(
|
267 |
`https://huggingface.co/api/spaces/${repoId}/restart?factory=true`,
|
@@ -387,56 +445,230 @@ app.post('/api/v1/action/:token/:spaceId(*)/rebuild', async (req, res) => {
|
|
387 |
}
|
388 |
});
|
389 |
|
390 |
-
//
|
391 |
-
|
392 |
-
|
393 |
-
|
394 |
-
|
|
|
|
|
395 |
|
396 |
-
|
397 |
-
|
398 |
-
|
399 |
-
|
400 |
-
console.log(`实例 ${username}/${instanceId} 未找到,不尝试获取监控数据`);
|
401 |
-
return res.status(404).json({ error: '实例未找到,无法获取监控数据' });
|
402 |
-
}
|
403 |
-
if (space.status.toLowerCase() !== 'running') {
|
404 |
-
console.log(`实例 ${username}/${instanceId} 状态为 ${space.status},不尝试获取监控数据`);
|
405 |
-
return res.status(400).json({ error: '实例未运行,无法获取监控数据' });
|
406 |
}
|
407 |
|
408 |
-
const
|
409 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
410 |
'Accept': 'text/event-stream',
|
411 |
'Cache-Control': 'no-cache',
|
412 |
'Connection': 'keep-alive'
|
413 |
};
|
414 |
-
|
415 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
416 |
}
|
|
|
417 |
|
418 |
-
|
419 |
-
|
420 |
-
|
421 |
-
|
422 |
-
|
423 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
424 |
});
|
|
|
425 |
|
426 |
-
|
427 |
-
|
428 |
-
|
429 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
430 |
});
|
431 |
-
response.data.pipe(res);
|
432 |
|
433 |
-
|
434 |
-
|
|
|
|
|
|
|
|
|
|
|
435 |
});
|
436 |
-
|
437 |
-
|
438 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
439 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
440 |
});
|
441 |
|
442 |
// 处理其他请求,重定向到 index.html
|
@@ -455,8 +687,68 @@ setInterval(() => {
|
|
455 |
}
|
456 |
}, 60 * 60 * 1000); // 每小时清理一次
|
457 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
458 |
app.listen(port, () => {
|
459 |
console.log(`Server running on port ${port}`);
|
460 |
console.log(`User configurations:`, usernames.map(user => `${user}: ${userTokenMapping[user] ? 'Token Configured' : 'No Token'}`).join(', ') || 'None');
|
461 |
console.log(`Admin login enabled: Username=${ADMIN_USERNAME}, Password=${ADMIN_PASSWORD ? 'Configured' : 'Not Configured'}`);
|
462 |
-
|
|
|
|
9 |
app.use(express.json());
|
10 |
app.use(express.urlencoded({ extended: true }));
|
11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
12 |
// 从环境变量获取 HuggingFace 用户名和对应的 API Token 映射
|
13 |
const userTokenMapping = {};
|
14 |
const usernames = [];
|
|
|
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 = {};
|
|
|
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')));
|
|
|
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 });
|
|
|
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} 重启成功` });
|
|
|
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`,
|
|
|
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
|
|
|
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 |
+
});
|