protocol-proxy 2.7.0 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/public/app.js +103 -3
- package/public/index.html +12 -0
- package/public/style.css +33 -0
- package/server.js +98 -0
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -6,6 +6,8 @@ let importData = null;
|
|
|
6
6
|
let statsRange = 'daily';
|
|
7
7
|
let statsProxyId = '';
|
|
8
8
|
let providerPoolItems = [];
|
|
9
|
+
let keyHealth = {};
|
|
10
|
+
let statsAutoRefreshTimer = null;
|
|
9
11
|
|
|
10
12
|
// ==================== 主题切换 ====================
|
|
11
13
|
|
|
@@ -81,6 +83,30 @@ function updateStats() {
|
|
|
81
83
|
proxies.filter(p => p.running).length;
|
|
82
84
|
}
|
|
83
85
|
|
|
86
|
+
async function loadKeyHealth() {
|
|
87
|
+
try {
|
|
88
|
+
const res = await fetch('/api/key-health');
|
|
89
|
+
keyHealth = await res.json();
|
|
90
|
+
refreshHealthUI();
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.error('加载 Key 健康状态失败:', err);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function refreshHealthUI() {
|
|
97
|
+
// 更新每个代理卡片上的健康点
|
|
98
|
+
document.querySelectorAll('.health-dot[data-provider]').forEach(dot => {
|
|
99
|
+
const h = keyHealth[dot.dataset.provider];
|
|
100
|
+
dot.className = 'health-dot';
|
|
101
|
+
if (!h || h.status === 'unknown') { dot.classList.add('health-unknown'); dot.title = '未检测'; }
|
|
102
|
+
else if (h.status === 'healthy') { dot.classList.add('health-ok'); dot.title = 'Key 正常'; }
|
|
103
|
+
else if (h.status === 'partial') { dot.classList.add('health-warn'); dot.title = '部分 Key 异常'; }
|
|
104
|
+
else { dot.classList.add('health-error'); dot.title = 'Key 全部异常'; }
|
|
105
|
+
});
|
|
106
|
+
// 更新汇总卡片
|
|
107
|
+
renderProviderHealthSummary();
|
|
108
|
+
}
|
|
109
|
+
|
|
84
110
|
function parseProviderPool(value) {
|
|
85
111
|
const text = (value || '').trim();
|
|
86
112
|
if (!text) return [];
|
|
@@ -991,9 +1017,18 @@ function initStatsRangeBtns() {
|
|
|
991
1017
|
document.querySelectorAll('.stats-range-btn').forEach(b => b.classList.remove('active'));
|
|
992
1018
|
btn.classList.add('active');
|
|
993
1019
|
statsRange = btn.dataset.range;
|
|
994
|
-
//
|
|
995
|
-
|
|
996
|
-
|
|
1020
|
+
// 清除自动刷新
|
|
1021
|
+
if (statsAutoRefreshTimer) { clearInterval(statsAutoRefreshTimer); statsAutoRefreshTimer = null; }
|
|
1022
|
+
if (statsRange === 'hourly') {
|
|
1023
|
+
// 实时模式:设为今天 + 30 秒自动刷新
|
|
1024
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
1025
|
+
document.getElementById('stats-start-date').value = today;
|
|
1026
|
+
document.getElementById('stats-end-date').value = today;
|
|
1027
|
+
statsAutoRefreshTimer = setInterval(loadStats, 30000);
|
|
1028
|
+
} else {
|
|
1029
|
+
document.getElementById('stats-start-date').value = '';
|
|
1030
|
+
document.getElementById('stats-end-date').value = '';
|
|
1031
|
+
}
|
|
997
1032
|
loadStats();
|
|
998
1033
|
});
|
|
999
1034
|
});
|
|
@@ -1054,7 +1089,16 @@ function initProviderPoolDropdown() {
|
|
|
1054
1089
|
}
|
|
1055
1090
|
|
|
1056
1091
|
async function init() {
|
|
1092
|
+
// 默认统计范围:当天(HTML 内联脚本已优先设置,此处兜底)
|
|
1093
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
1094
|
+
const sd = document.getElementById('stats-start-date');
|
|
1095
|
+
const ed = document.getElementById('stats-end-date');
|
|
1096
|
+
if (!sd.value) sd.value = today;
|
|
1097
|
+
if (!ed.value) ed.value = today;
|
|
1057
1098
|
await Promise.all([loadProxies(), loadProviders(), loadStats()]);
|
|
1099
|
+
// 延迟加载 health(等后端启动检测完成),之后每 5 分钟刷新
|
|
1100
|
+
setTimeout(() => loadKeyHealth(), 6000);
|
|
1101
|
+
setInterval(() => loadKeyHealth(), 5 * 60 * 1000);
|
|
1058
1102
|
renderProxies();
|
|
1059
1103
|
initProviderDropdown();
|
|
1060
1104
|
initModelDropdown();
|
|
@@ -1327,9 +1371,64 @@ function filterProxies() {
|
|
|
1327
1371
|
renderProxies();
|
|
1328
1372
|
}
|
|
1329
1373
|
|
|
1374
|
+
function healthDot(providerId) {
|
|
1375
|
+
const h = keyHealth[providerId];
|
|
1376
|
+
const cls = !h || h.status === 'unknown' ? 'health-unknown'
|
|
1377
|
+
: h.status === 'healthy' ? 'health-ok'
|
|
1378
|
+
: h.status === 'partial' ? 'health-warn' : 'health-error';
|
|
1379
|
+
const title = !h || h.status === 'unknown' ? '未检测'
|
|
1380
|
+
: h.status === 'healthy' ? 'Key 正常'
|
|
1381
|
+
: h.status === 'partial' ? '部分 Key 异常' : 'Key 全部异常';
|
|
1382
|
+
return `<span class="health-dot ${cls}" data-provider="${escapeHtml(providerId)}" title="${title}"></span>`;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function renderProviderHealthSummary() {
|
|
1386
|
+
const el = document.getElementById('provider-health-summary');
|
|
1387
|
+
if (!el) return;
|
|
1388
|
+
const allProviders = proxies.map(p => p.providerId).filter(Boolean);
|
|
1389
|
+
const unique = [...new Set(allProviders)];
|
|
1390
|
+
if (unique.length === 0) { el.style.display = 'none'; return; }
|
|
1391
|
+
let healthy = 0, partial = 0, unhealthy = 0, unknown = 0;
|
|
1392
|
+
for (const id of unique) {
|
|
1393
|
+
const h = keyHealth[id];
|
|
1394
|
+
if (!h || h.status === 'unknown') unknown++;
|
|
1395
|
+
else if (h.status === 'healthy') healthy++;
|
|
1396
|
+
else if (h.status === 'partial') partial++;
|
|
1397
|
+
else unhealthy++;
|
|
1398
|
+
}
|
|
1399
|
+
el.style.display = '';
|
|
1400
|
+
el.innerHTML = `
|
|
1401
|
+
<div class="health-stat"><span class="health-dot health-ok"></span><span>正常 ${healthy}</span></div>
|
|
1402
|
+
<div class="health-stat"><span class="health-dot health-warn"></span><span>部分异常 ${partial}</span></div>
|
|
1403
|
+
<div class="health-stat"><span class="health-dot health-error"></span><span>异常 ${unhealthy}</span></div>
|
|
1404
|
+
<div class="health-stat"><span class="health-dot health-unknown"></span><span>未检测 ${unknown}</span></div>
|
|
1405
|
+
<button class="btn btn-sm" onclick="recheckKeys()">重新检测</button>
|
|
1406
|
+
`;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
let rechecking = false;
|
|
1410
|
+
async function recheckKeys() {
|
|
1411
|
+
if (rechecking) return;
|
|
1412
|
+
rechecking = true;
|
|
1413
|
+
const btn = document.querySelector('.provider-health-summary .btn');
|
|
1414
|
+
if (btn) { btn.disabled = true; btn.textContent = '检测中...'; }
|
|
1415
|
+
showToast('正在检测...');
|
|
1416
|
+
try {
|
|
1417
|
+
await fetch('/api/key-health/check', { method: 'POST' });
|
|
1418
|
+
await loadKeyHealth();
|
|
1419
|
+
showToast('检测完成');
|
|
1420
|
+
} catch (err) {
|
|
1421
|
+
showToast('检测失败: ' + err.message, true);
|
|
1422
|
+
} finally {
|
|
1423
|
+
rechecking = false;
|
|
1424
|
+
if (btn) { btn.disabled = false; btn.textContent = '重新检测'; }
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1330
1428
|
function renderProxies() {
|
|
1331
1429
|
const container = document.getElementById('proxy-list');
|
|
1332
1430
|
const list = getFilteredProxies();
|
|
1431
|
+
renderProviderHealthSummary();
|
|
1333
1432
|
if (proxies.length === 0) {
|
|
1334
1433
|
container.innerHTML = '<div class="empty">暂无代理配置,点击右上角创建</div>';
|
|
1335
1434
|
return;
|
|
@@ -1374,6 +1473,7 @@ function renderProxies() {
|
|
|
1374
1473
|
</div>
|
|
1375
1474
|
<div class="proxy-meta">
|
|
1376
1475
|
<span>端口: <strong>${p.port}</strong></span>
|
|
1476
|
+
<span>供应商: ${healthDot(p.providerId)} ${escapeHtml(p.providerName || '-')}</span>
|
|
1377
1477
|
<span>认证: ${p.requireAuth ? '已启用' : '未启用'}</span>
|
|
1378
1478
|
</div>
|
|
1379
1479
|
<div class="proxy-address">
|
package/public/index.html
CHANGED
|
@@ -7,6 +7,16 @@
|
|
|
7
7
|
<link rel="stylesheet" href="style.css">
|
|
8
8
|
</head>
|
|
9
9
|
<body>
|
|
10
|
+
<script>
|
|
11
|
+
// 尽早设置统计日期默认值,避免 init() 时序问题
|
|
12
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
13
|
+
var today = new Date().toISOString().slice(0, 10);
|
|
14
|
+
var sd = document.getElementById('stats-start-date');
|
|
15
|
+
var ed = document.getElementById('stats-end-date');
|
|
16
|
+
if (sd && !sd.value) sd.value = today;
|
|
17
|
+
if (ed && !ed.value) ed.value = today;
|
|
18
|
+
});
|
|
19
|
+
</script>
|
|
10
20
|
<div class="container">
|
|
11
21
|
<button class="theme-toggle" id="theme-toggle" onclick="toggleTheme()" title="切换主题">
|
|
12
22
|
<span id="theme-icon">☾</span>
|
|
@@ -43,6 +53,7 @@
|
|
|
43
53
|
</div>
|
|
44
54
|
</div>
|
|
45
55
|
<div class="stats-range-btns">
|
|
56
|
+
<button class="btn btn-sm stats-range-btn" data-range="hourly">实时</button>
|
|
46
57
|
<button class="btn btn-sm stats-range-btn active" data-range="daily">每日</button>
|
|
47
58
|
<button class="btn btn-sm stats-range-btn" data-range="monthly">每月</button>
|
|
48
59
|
<button class="btn btn-sm stats-range-btn" data-range="yearly">每年</button>
|
|
@@ -97,6 +108,7 @@
|
|
|
97
108
|
<button class="btn btn-sm" onclick="openLogViewer()">日志</button>
|
|
98
109
|
</div>
|
|
99
110
|
</div>
|
|
111
|
+
<div class="provider-health-summary" id="provider-health-summary" style="display:none"></div>
|
|
100
112
|
<div id="proxy-list" class="proxy-list">
|
|
101
113
|
<div class="empty">加载中...</div>
|
|
102
114
|
</div>
|
package/public/style.css
CHANGED
|
@@ -1871,3 +1871,36 @@ form {
|
|
|
1871
1871
|
font-size: 0.78rem;
|
|
1872
1872
|
white-space: nowrap;
|
|
1873
1873
|
}
|
|
1874
|
+
|
|
1875
|
+
/* Health dots */
|
|
1876
|
+
.health-dot {
|
|
1877
|
+
display: inline-block;
|
|
1878
|
+
width: 8px;
|
|
1879
|
+
height: 8px;
|
|
1880
|
+
border-radius: 50%;
|
|
1881
|
+
vertical-align: middle;
|
|
1882
|
+
margin-right: 4px;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
.health-ok { background: #22c55e; box-shadow: 0 0 6px rgba(34, 197, 94, 0.4); }
|
|
1886
|
+
.health-warn { background: #f59e0b; box-shadow: 0 0 6px rgba(245, 158, 11, 0.4); }
|
|
1887
|
+
.health-error { background: #ef4444; box-shadow: 0 0 6px rgba(239, 68, 68, 0.4); }
|
|
1888
|
+
.health-unknown { background: #64748b; }
|
|
1889
|
+
|
|
1890
|
+
/* Provider health summary */
|
|
1891
|
+
.provider-health-summary {
|
|
1892
|
+
display: flex;
|
|
1893
|
+
align-items: center;
|
|
1894
|
+
gap: 18px;
|
|
1895
|
+
padding: 12px 28px;
|
|
1896
|
+
border-bottom: 1px solid var(--border-light);
|
|
1897
|
+
background: var(--bg-surface-alt);
|
|
1898
|
+
font-size: 0.82rem;
|
|
1899
|
+
color: var(--text-muted);
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
.health-stat {
|
|
1903
|
+
display: flex;
|
|
1904
|
+
align-items: center;
|
|
1905
|
+
gap: 5px;
|
|
1906
|
+
}
|
package/server.js
CHANGED
|
@@ -249,6 +249,89 @@ async function init() {
|
|
|
249
249
|
return proxyManager.startProxy(proxyConfig);
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
// ==================== API Key 健康检查 ====================
|
|
253
|
+
|
|
254
|
+
const keyHealth = new Map(); // providerId -> { status, lastCheck, keys: [{index, ok, message}] }
|
|
255
|
+
let healthCheckRunning = false;
|
|
256
|
+
|
|
257
|
+
async function checkAllProviderKeys() {
|
|
258
|
+
if (healthCheckRunning) return;
|
|
259
|
+
healthCheckRunning = true;
|
|
260
|
+
try {
|
|
261
|
+
const providers = configStore.getProviders();
|
|
262
|
+
logger.log(`[Health] 开始检查 ${providers.length} 个供应商的 API Key...`);
|
|
263
|
+
for (const provider of providers) {
|
|
264
|
+
await checkProviderKeys(provider);
|
|
265
|
+
}
|
|
266
|
+
logger.log('[Health] API Key 健康检查完成');
|
|
267
|
+
} finally {
|
|
268
|
+
healthCheckRunning = false;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function checkProviderKeys(provider) {
|
|
273
|
+
const keys = (provider.apiKeys || []).filter(k => k.enabled !== false);
|
|
274
|
+
if (keys.length === 0) {
|
|
275
|
+
keyHealth.set(provider.id, { status: 'unknown', lastCheck: Date.now(), keys: [] });
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const protocol = provider.protocol || 'openai';
|
|
280
|
+
const base = provider.url.replace(/\/$/, '');
|
|
281
|
+
const hasV1Suffix = base.endsWith('/v1');
|
|
282
|
+
const isAzure = protocol === 'openai' && !!provider.azureDeployment;
|
|
283
|
+
|
|
284
|
+
const results = await Promise.all(keys.map(async (k, i) => {
|
|
285
|
+
try {
|
|
286
|
+
let testUrl, fetchOpts;
|
|
287
|
+
if (protocol === 'openai') {
|
|
288
|
+
if (isAzure) {
|
|
289
|
+
const ver = provider.azureApiVersion || '2024-02-01';
|
|
290
|
+
testUrl = `${base}/openai/deployments/${provider.azureDeployment}/models?api-version=${ver}`;
|
|
291
|
+
fetchOpts = { headers: { 'api-key': k.key } };
|
|
292
|
+
} else {
|
|
293
|
+
testUrl = hasV1Suffix ? `${base}/models` : `${base}/v1/models`;
|
|
294
|
+
fetchOpts = { headers: { 'Authorization': `Bearer ${k.key}` } };
|
|
295
|
+
}
|
|
296
|
+
} else if (protocol === 'anthropic') {
|
|
297
|
+
const testModel = (provider.models && provider.models[0]) || 'claude-3-haiku-20240307';
|
|
298
|
+
testUrl = hasV1Suffix ? `${base}/messages` : `${base}/v1/messages`;
|
|
299
|
+
fetchOpts = {
|
|
300
|
+
method: 'POST',
|
|
301
|
+
headers: { 'Content-Type': 'application/json', 'x-api-key': k.key, 'anthropic-version': '2023-06-01' },
|
|
302
|
+
body: JSON.stringify({ model: testModel, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),
|
|
303
|
+
};
|
|
304
|
+
} else if (protocol === 'gemini') {
|
|
305
|
+
testUrl = `${base}/v1beta/models?key=${k.key}`;
|
|
306
|
+
fetchOpts = {};
|
|
307
|
+
} else {
|
|
308
|
+
return { index: i, ok: false, message: '不支持的协议' };
|
|
309
|
+
}
|
|
310
|
+
const res = await fetch(testUrl, { ...fetchOpts, signal: AbortSignal.timeout(15000) });
|
|
311
|
+
if (!res.ok) {
|
|
312
|
+
const hint = res.status === 401 || res.status === 403 ? 'Key 无效或无权限' : `HTTP ${res.status}`;
|
|
313
|
+
return { index: i, ok: false, message: hint };
|
|
314
|
+
}
|
|
315
|
+
return { index: i, ok: true };
|
|
316
|
+
} catch (err) {
|
|
317
|
+
return { index: i, ok: false, message: err.name === 'TimeoutError' ? '连接超时' : err.message };
|
|
318
|
+
}
|
|
319
|
+
}));
|
|
320
|
+
|
|
321
|
+
const allOk = results.every(r => r.ok);
|
|
322
|
+
const anyOk = results.some(r => r.ok);
|
|
323
|
+
keyHealth.set(provider.id, {
|
|
324
|
+
status: allOk ? 'healthy' : anyOk ? 'partial' : 'unhealthy',
|
|
325
|
+
lastCheck: Date.now(),
|
|
326
|
+
keys: results,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// 启动后延迟 5 秒执行首次检查
|
|
331
|
+
setTimeout(() => checkAllProviderKeys(), 5000);
|
|
332
|
+
// 每 24 小时检查一次
|
|
333
|
+
setInterval(() => checkAllProviderKeys(), 24 * 60 * 60 * 1000);
|
|
334
|
+
|
|
252
335
|
// ==================== 供应商 API ====================
|
|
253
336
|
|
|
254
337
|
app.get('/api/providers', (req, res) => {
|
|
@@ -720,6 +803,21 @@ async function init() {
|
|
|
720
803
|
});
|
|
721
804
|
});
|
|
722
805
|
|
|
806
|
+
// API Key 健康状态
|
|
807
|
+
app.get('/api/key-health', (req, res) => {
|
|
808
|
+
const result = {};
|
|
809
|
+
for (const [providerId, health] of keyHealth) {
|
|
810
|
+
result[providerId] = health;
|
|
811
|
+
}
|
|
812
|
+
res.json(result);
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
// 手动触发健康检查
|
|
816
|
+
app.post('/api/key-health/check', async (req, res) => {
|
|
817
|
+
await checkAllProviderKeys();
|
|
818
|
+
res.json({ success: true });
|
|
819
|
+
});
|
|
820
|
+
|
|
723
821
|
// 设置
|
|
724
822
|
app.get('/api/settings', (req, res) => {
|
|
725
823
|
res.json(configStore.getSettings());
|