freegate 0.6.14 → 0.6.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -7
- package/README.ru.md +93 -7
- package/assets/dashboard-models.png +0 -0
- package/assets/dashboard.png +0 -0
- package/bin/freegate.js +32 -5
- package/config.example.json +13 -0
- package/lib/bandit.js +8 -1
- package/lib/cache.js +54 -5
- package/lib/clean.js +7 -2
- package/lib/compactor.js +220 -0
- package/lib/contextstats.js +224 -0
- package/lib/dashboard.js +782 -127
- package/lib/economics.js +43 -0
- package/lib/health.js +56 -9
- package/lib/logger.js +1 -1
- package/lib/memory-store.js +68 -0
- package/lib/memory.js +167 -0
- package/lib/methodology.js +70 -0
- package/lib/modeldb.js +125 -0
- package/lib/modelmanager.js +335 -0
- package/lib/modelscan.js +190 -0
- package/lib/normalize.js +14 -4
- package/lib/providers.js +68 -5
- package/lib/routing.js +14 -2
- package/lib/semcache.js +31 -0
- package/lib/setup.js +309 -0
- package/lib/taskclassify.js +59 -0
- package/package.json +1 -1
- package/providers.json +113 -137
- package/server.js +728 -99
package/lib/dashboard.js
CHANGED
|
@@ -1,171 +1,826 @@
|
|
|
1
|
-
// lib/dashboard.js
|
|
1
|
+
// lib/dashboard.js — панель управления Freegate.
|
|
2
|
+
// Каркас: sticky KPI-полоска + 5 табов (Обзор / Провайдеры / Модели / Контекст /
|
|
3
|
+
// Настройки). Zero-dependency: весь HTML/CSS/JS инлайн. Таблицы с сортировкой,
|
|
4
|
+
// фильтрами и поиском на vanilla JS. Данные: /v1/stats, /v1/recent, /v1/rpm,
|
|
5
|
+
// /v1/models-db, /v1/setup/keys.
|
|
2
6
|
const HTML = `<!DOCTYPE html>
|
|
3
7
|
<html lang="ru">
|
|
4
8
|
<head>
|
|
5
9
|
<meta charset="UTF-8">
|
|
6
10
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
11
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='7' fill='%2300d4ff'/%3E%3C/svg%3E">
|
|
7
12
|
<title>Freegate — Панель управления</title>
|
|
8
13
|
<style>
|
|
14
|
+
:root {
|
|
15
|
+
--bg: #0d1117; --panel: #161b22; --panel2: #1c2330; --border: #232a3d;
|
|
16
|
+
--accent: #00d4ff; --ok: #00ff88; --warn: #ffaa00; --err: #ff4444;
|
|
17
|
+
--text: #e6e6e6; --muted: #8b949e;
|
|
18
|
+
}
|
|
9
19
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
10
|
-
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', monospace; background:
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
.
|
|
16
|
-
.
|
|
17
|
-
.
|
|
18
|
-
.
|
|
19
|
-
.
|
|
20
|
-
.
|
|
21
|
-
.
|
|
22
|
-
.
|
|
23
|
-
.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
20
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', monospace; background: var(--bg); color: var(--text); padding-bottom: 40px; }
|
|
21
|
+
|
|
22
|
+
/* ── Sticky KPI-полоска ── */
|
|
23
|
+
#topbar { position: sticky; top: 0; z-index: 10; background: rgba(13,17,23,.95); backdrop-filter: blur(6px);
|
|
24
|
+
border-bottom: 1px solid var(--border); padding: 12px 20px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
|
25
|
+
.brand { font-size: 17px; font-weight: bold; color: var(--accent); display: flex; align-items: center; gap: 8px; margin-right: 8px; }
|
|
26
|
+
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; background: var(--muted); }
|
|
27
|
+
.dot.ok { background: var(--ok); box-shadow: 0 0 6px var(--ok); }
|
|
28
|
+
.dot.bad { background: var(--err); box-shadow: 0 0 6px var(--err); }
|
|
29
|
+
.kpi { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
30
|
+
.kpi-chip { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 6px 12px; font-size: 12px; }
|
|
31
|
+
.kpi-chip b { color: var(--accent); font-size: 13px; margin-left: 6px; }
|
|
32
|
+
.kpi-chip.warn b { color: var(--warn); }
|
|
33
|
+
.kpi-chip.err b { color: var(--err); }
|
|
34
|
+
|
|
35
|
+
/* ── Табы ── */
|
|
36
|
+
#tabs { display: flex; gap: 6px; padding: 12px 20px 0; flex-wrap: wrap; }
|
|
37
|
+
.tab { padding: 9px 18px; border-radius: 8px 8px 0 0; cursor: pointer; background: var(--panel);
|
|
38
|
+
color: var(--muted); border: 1px solid var(--border); border-bottom: none; font-size: 13px; user-select: none; }
|
|
39
|
+
.tab.active { color: var(--accent); background: var(--panel2); border-color: var(--accent); }
|
|
40
|
+
.tab:hover { color: var(--text); }
|
|
41
|
+
|
|
42
|
+
main { padding: 0 20px; }
|
|
43
|
+
.panel { display: none; padding-top: 16px; }
|
|
44
|
+
.panel.active { display: block; }
|
|
45
|
+
|
|
46
|
+
/* ── Переключатель языка ── */
|
|
47
|
+
.lang-switch { margin-left: auto; display: flex; gap: 4px; }
|
|
48
|
+
.lang-btn { padding: 5px 11px; border-radius: 6px; background: var(--panel); border: 1px solid var(--border);
|
|
49
|
+
color: var(--muted); font-size: 11px; font-weight: bold; cursor: pointer; }
|
|
50
|
+
.lang-btn.active { color: var(--accent); border-color: var(--accent); }
|
|
51
|
+
.lang-btn:hover { color: var(--text); }
|
|
52
|
+
|
|
53
|
+
/* ── Карточки/сетки ── */
|
|
54
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px; }
|
|
55
|
+
.card { background: var(--panel); border-radius: 10px; padding: 16px 18px; border: 1px solid var(--border); }
|
|
56
|
+
.card h2 { color: var(--accent); font-size: 12px; margin-bottom: 12px; text-transform: uppercase; letter-spacing: .5px; }
|
|
57
|
+
.stat { display: flex; justify-content: space-between; margin-bottom: 7px; gap: 10px; }
|
|
58
|
+
.stat-label { color: var(--muted); }
|
|
59
|
+
.stat-value { color: #fff; font-weight: bold; text-align: right; }
|
|
60
|
+
|
|
61
|
+
/* ── Алерты ── */
|
|
62
|
+
#alerts { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
|
|
63
|
+
.alert { padding: 10px 14px; border-radius: 8px; font-size: 13px; border: 1px solid; }
|
|
64
|
+
.alert.err { background: rgba(255,68,68,.08); border-color: var(--err); color: #ffb3b3; }
|
|
65
|
+
.alert.warn { background: rgba(255,170,0,.07); border-color: var(--warn); color: #ffd999; }
|
|
66
|
+
.alert.ok { background: rgba(0,255,136,.06); border-color: var(--ok); color: #b3ffd9; }
|
|
67
|
+
|
|
68
|
+
/* ── Тулбар: поиск + фильтры ── */
|
|
69
|
+
.toolbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 14px; }
|
|
70
|
+
.search { flex: 1; min-width: 200px; max-width: 340px; padding: 8px 12px; border-radius: 8px;
|
|
71
|
+
border: 1px solid var(--border); background: var(--panel); color: #fff; font-size: 13px; outline: none; }
|
|
72
|
+
.search:focus { border-color: var(--accent); }
|
|
73
|
+
select.filter { padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border);
|
|
74
|
+
background: var(--panel); color: var(--text); font-size: 12px; outline: none; cursor: pointer; }
|
|
75
|
+
.chips { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
76
|
+
.chip { padding: 6px 12px; border-radius: 999px; background: var(--panel); border: 1px solid var(--border);
|
|
77
|
+
color: var(--muted); font-size: 12px; cursor: pointer; user-select: none; }
|
|
78
|
+
.chip.active { color: var(--accent); border-color: var(--accent); }
|
|
79
|
+
.count-hint { color: var(--muted); font-size: 12px; margin-left: auto; }
|
|
80
|
+
|
|
81
|
+
/* ── Таблицы ── */
|
|
82
|
+
.table-wrap { overflow-x: auto; background: var(--panel); border-radius: 10px; border: 1px solid var(--border); }
|
|
83
|
+
table { width: 100%; border-collapse: collapse; min-width: 640px; }
|
|
84
|
+
th, td { text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--border); font-size: 12.5px; white-space: nowrap; }
|
|
85
|
+
th { color: var(--accent); font-size: 11px; text-transform: uppercase; letter-spacing: .4px; cursor: pointer; user-select: none; }
|
|
86
|
+
th:hover { color: #fff; }
|
|
87
|
+
th .arr { font-size: 9px; margin-left: 3px; }
|
|
88
|
+
td { color: var(--text); }
|
|
89
|
+
tr:hover td { background: rgba(0,212,255,.03); }
|
|
90
|
+
.badge { padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: bold; display: inline-block; }
|
|
91
|
+
.badge.up, .badge.active { background: rgba(0,255,136,.12); color: var(--ok); }
|
|
92
|
+
.badge.ratelimited { background: rgba(255,170,0,.12); color: var(--warn); }
|
|
93
|
+
.badge.error, .badge.dead { background: rgba(255,68,68,.12); color: var(--err); }
|
|
94
|
+
.badge.unknown, .badge.untested { background: rgba(139,148,158,.15); color: var(--muted); }
|
|
95
|
+
.badge.disabled, .badge.user-disabled { background: rgba(139,148,158,.1); color: var(--muted); }
|
|
96
|
+
.limit-bar { width: 110px; height: 6px; background: var(--panel2); border-radius: 4px; overflow: hidden; display: inline-block; vertical-align: middle; margin-right: 7px; }
|
|
97
|
+
.limit-fill { height: 100%; border-radius: 4px; }
|
|
98
|
+
.score { font-weight: bold; color: var(--accent); }
|
|
99
|
+
.mono { font-family: ui-monospace, Menlo, monospace; font-size: 11.5px; }
|
|
100
|
+
|
|
101
|
+
/* ── Действия над моделями ── */
|
|
102
|
+
.model-actions { display: flex; gap: 6px; align-items: center; white-space: nowrap; }
|
|
103
|
+
.mini-btn { padding: 4px 9px; border-radius: 6px; border: 1px solid var(--border); background: var(--panel2);
|
|
104
|
+
color: var(--text); font-size: 11px; cursor: pointer; }
|
|
105
|
+
.mini-btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
106
|
+
.mini-btn.off { color: var(--warn); }
|
|
107
|
+
|
|
108
|
+
/* ── Рекомендации ── */
|
|
109
|
+
.rec { font-size: 12px; margin-bottom: 7px; line-height: 1.5; }
|
|
110
|
+
.rec-badge { padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: bold; margin-right: 6px; }
|
|
111
|
+
.rec-badge.ok { background: rgba(0,255,136,.12); color: var(--ok); }
|
|
112
|
+
.rec-badge.warn { background: rgba(255,170,0,.12); color: var(--warn); }
|
|
113
|
+
.rec-badge { background: rgba(139,148,158,.15); color: var(--muted); }
|
|
114
|
+
|
|
115
|
+
/* ── Экономия ── */
|
|
116
|
+
.savings .savings-usd { color: var(--ok); font-size: 24px; }
|
|
117
|
+
|
|
118
|
+
canvas { width: 100%; height: 160px; background: var(--panel); border-radius: 10px; border: 1px solid var(--border); }
|
|
119
|
+
.refresh { color: var(--muted); font-size: 11px; margin-top: 14px; }
|
|
120
|
+
.task-bar { display: flex; align-items: center; gap: 10px; margin-bottom: 9px; font-size: 12px; }
|
|
121
|
+
.task-bar .name { width: 90px; color: var(--muted); }
|
|
122
|
+
.task-bar .bar { flex: 1; height: 8px; background: var(--panel2); border-radius: 5px; overflow: hidden; }
|
|
123
|
+
.task-bar .fill { height: 100%; background: var(--accent); border-radius: 5px; }
|
|
124
|
+
.task-bar .num { width: 74px; text-align: right; }
|
|
125
|
+
|
|
126
|
+
/* ── Setup ── */
|
|
127
|
+
.setup-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 14px; }
|
|
128
|
+
.key-card { background: var(--panel); border-radius: 10px; padding: 16px; border: 1px solid var(--border); }
|
|
129
|
+
.key-card h3 { color: var(--accent); font-size: 14px; margin-bottom: 8px; }
|
|
130
|
+
.key-card .desc { color: var(--muted); font-size: 12px; margin-bottom: 10px; }
|
|
131
|
+
.key-card ol { color: #aaa; font-size: 11px; padding-left: 18px; margin-bottom: 10px; }
|
|
132
|
+
.key-card li { margin-bottom: 4px; }
|
|
133
|
+
.key-card a { color: var(--accent); text-decoration: none; }
|
|
134
|
+
.key-input-row { display: flex; gap: 8px; align-items: center; }
|
|
135
|
+
.key-input { flex: 1; padding: 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: #fff; font-size: 12px; font-family: monospace; }
|
|
136
|
+
.btn { padding: 8px 14px; border-radius: 6px; border: none; cursor: pointer; font-size: 12px; font-weight: bold; }
|
|
137
|
+
.btn-validate { background: var(--panel2); color: var(--accent); }
|
|
138
|
+
.btn-save { background: var(--accent); color: #000; }
|
|
139
|
+
.btn-test-all { background: var(--panel2); color: var(--ok); }
|
|
140
|
+
.status-ok { color: var(--ok); font-size: 11px; }
|
|
141
|
+
.status-err { color: var(--err); font-size: 11px; }
|
|
142
|
+
.status-wait { color: var(--muted); font-size: 11px; }
|
|
143
|
+
.hidden { display: none; }
|
|
144
|
+
|
|
145
|
+
@media (max-width: 640px) {
|
|
146
|
+
#topbar { padding: 10px 12px; }
|
|
147
|
+
main, #tabs { padding-left: 12px; padding-right: 12px; }
|
|
148
|
+
.kpi-chip { font-size: 11px; padding: 5px 9px; }
|
|
149
|
+
}
|
|
30
150
|
</style>
|
|
31
151
|
</head>
|
|
32
152
|
<body>
|
|
33
|
-
<
|
|
34
|
-
|
|
35
|
-
<div class="
|
|
36
|
-
<div class="
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
<div class="card"><h2>Очередь</h2><div id="pool">Загрузка...</div></div>
|
|
40
|
-
<div class="card" style="grid-column: span 2;"><h2>Лимиты (остаток за сегодня)</h2><div id="limits">Загрузка...</div></div>
|
|
41
|
-
<div class="card" style="grid-column: span 2;"><h2>График запросов (в минуту)</h2><canvas id="rpmChart"></canvas></div>
|
|
42
|
-
<div class="card" style="grid-column: span 2;">
|
|
43
|
-
<h2>Недавние запросы</h2>
|
|
44
|
-
<table id="recentTable"><tr><th>Время</th><th>Модель</th><th>Провайдер</th><th>Статус</th><th>Задержка</th></tr></table>
|
|
153
|
+
<header id="topbar">
|
|
154
|
+
<div class="brand"><span id="statusDot" class="dot"></span>Freegate</div>
|
|
155
|
+
<div class="kpi" id="kpiBar"></div>
|
|
156
|
+
<div class="lang-switch">
|
|
157
|
+
<button class="lang-btn" data-lang="ru" onclick="setLang('ru')">RU</button>
|
|
158
|
+
<button class="lang-btn" data-lang="en" onclick="setLang('en')">EN</button>
|
|
45
159
|
</div>
|
|
46
|
-
</
|
|
47
|
-
|
|
160
|
+
</header>
|
|
161
|
+
|
|
162
|
+
<nav id="tabs">
|
|
163
|
+
<div class="tab active" data-i18n="tabOverview" onclick="showTab('overview')"></div>
|
|
164
|
+
<div class="tab" data-i18n="tabProviders" onclick="showTab('providers')"></div>
|
|
165
|
+
<div class="tab" data-i18n="tabModels" onclick="showTab('models')"></div>
|
|
166
|
+
<div class="tab" data-i18n="tabContext" onclick="showTab('context')"></div>
|
|
167
|
+
<div class="tab" data-i18n="tabSetup" onclick="showTab('setup')"></div>
|
|
168
|
+
</nav>
|
|
169
|
+
|
|
170
|
+
<main>
|
|
171
|
+
<!-- ═══ ОБЗОР ═══ -->
|
|
172
|
+
<section id="panel-overview" class="panel active">
|
|
173
|
+
<div id="alerts"></div>
|
|
174
|
+
<div class="grid">
|
|
175
|
+
<div class="card"><h2 data-i18n="cardCache"></h2><div id="cache" data-i18n="loading"></div></div>
|
|
176
|
+
<div class="card"><h2 data-i18n="cardTokens"></h2><div id="tokens" data-i18n="loading"></div></div>
|
|
177
|
+
<div class="card"><h2 data-i18n="cardPool"></h2><div id="pool" data-i18n="loading"></div></div>
|
|
178
|
+
<div class="card"><h2 data-i18n="cardSavings"></h2><div id="savings" data-i18n="loading"></div></div>
|
|
179
|
+
</div>
|
|
180
|
+
<div class="card" style="margin-top:16px;"><h2 data-i18n="cardRpm"></h2><canvas id="rpmChart"></canvas></div>
|
|
181
|
+
<div class="card" style="margin-top:16px;">
|
|
182
|
+
<h2 data-i18n="cardRecent"></h2>
|
|
183
|
+
<div class="table-wrap"><table id="recentTable"></table></div>
|
|
184
|
+
</div>
|
|
185
|
+
</section>
|
|
186
|
+
|
|
187
|
+
<!-- ═══ ПРОВАЙДЕРЫ ═══ -->
|
|
188
|
+
<section id="panel-providers" class="panel">
|
|
189
|
+
<div class="toolbar">
|
|
190
|
+
<input id="providerSearch" class="search" type="text" placeholder="" data-i18n-ph="searchProvider" oninput="renderProvidersTable()">
|
|
191
|
+
<div class="chips" id="providerChips">
|
|
192
|
+
<div class="chip active" data-i18n="filterAll" onclick="setProviderFilter('all', this)"></div>
|
|
193
|
+
<div class="chip" data-i18n="filterUp" onclick="setProviderFilter('up', this)"></div>
|
|
194
|
+
<div class="chip" data-i18n="filterLimit" onclick="setProviderFilter('ratelimited', this)"></div>
|
|
195
|
+
<div class="chip" data-i18n="filterError" onclick="setProviderFilter('error', this)"></div>
|
|
196
|
+
<div class="chip" data-i18n="filterUnknown" onclick="setProviderFilter('unknown', this)"></div>
|
|
197
|
+
</div>
|
|
198
|
+
<span class="count-hint" id="providerCount"></span>
|
|
199
|
+
</div>
|
|
200
|
+
<div class="table-wrap">
|
|
201
|
+
<table id="providerTable"></table>
|
|
202
|
+
</div>
|
|
203
|
+
</section>
|
|
204
|
+
|
|
205
|
+
<!-- ═══ МОДЕЛИ ═══ -->
|
|
206
|
+
<section id="panel-models" class="panel">
|
|
207
|
+
<div class="toolbar">
|
|
208
|
+
<input id="modelSearch" class="search" type="text" placeholder="" data-i18n-ph="searchModel" oninput="renderModelsTable()">
|
|
209
|
+
<select id="modelCategoryFilter" class="filter" data-i18n="allCategories" onchange="renderModelsTable()"><option value="all"></option></select>
|
|
210
|
+
<select id="modelStatusFilter" class="filter" data-i18n="allStatuses" onchange="renderModelsTable()"><option value="all"></option></select>
|
|
211
|
+
<select id="modelSourceFilter" class="filter" data-i18n="allSources" onchange="renderModelsTable()"><option value="all"></option></select>
|
|
212
|
+
<span class="count-hint" id="modelCount"></span>
|
|
213
|
+
</div>
|
|
214
|
+
<div class="card" style="margin-bottom:14px;"><h2 data-i18n="recBlock"></h2><div id="modelRecommend"></div></div>
|
|
215
|
+
<div class="table-wrap">
|
|
216
|
+
<table id="modelTable"></table>
|
|
217
|
+
</div>
|
|
218
|
+
<div class="refresh" id="modelManagerInfo"></div>
|
|
219
|
+
</section>
|
|
220
|
+
|
|
221
|
+
<!-- ═══ КОНТЕКСТ ═══ -->
|
|
222
|
+
<section id="panel-context" class="panel">
|
|
223
|
+
<div class="grid">
|
|
224
|
+
<div class="card"><h2 data-i18n="cardCtx"></h2><div id="ctxBlock" data-i18n="loading"></div></div>
|
|
225
|
+
<div class="card"><h2 data-i18n="cardTasks"></h2><div id="taskBars" data-i18n="loading"></div></div>
|
|
226
|
+
</div>
|
|
227
|
+
</section>
|
|
228
|
+
|
|
229
|
+
<!-- ═══ НАСТРОЙКИ ═══ -->
|
|
230
|
+
<section id="panel-setup" class="panel">
|
|
231
|
+
<div style="margin-bottom: 16px;">
|
|
232
|
+
<button class="btn btn-test-all" data-i18n="validateAll" onclick="validateAll()"></button>
|
|
233
|
+
<button class="btn btn-save" data-i18n="saveAllKeys" onclick="saveAllKeys()" style="margin-left: 8px;"></button>
|
|
234
|
+
<span id="save-status" class="status-wait" style="margin-left: 12px;"></span>
|
|
235
|
+
</div>
|
|
236
|
+
<div id="setup-grid" class="setup-grid" data-i18n="loading"></div>
|
|
237
|
+
</section>
|
|
238
|
+
</main>
|
|
239
|
+
|
|
240
|
+
<div class="refresh" style="padding: 0 20px;" data-i18n="autoRefresh"></div>
|
|
241
|
+
|
|
48
242
|
<script>
|
|
49
243
|
// Carry the ?key= param from the dashboard URL to API calls
|
|
50
244
|
var _key = new URLSearchParams(location.search).get('key') || '';
|
|
51
245
|
function _api(path) {
|
|
52
246
|
return _key ? path + (path.includes('?') ? '&' : '?') + 'key=' + encodeURIComponent(_key) : path;
|
|
53
247
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
'
|
|
87
|
-
'
|
|
88
|
-
'
|
|
89
|
-
'
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
'
|
|
96
|
-
'
|
|
97
|
-
|
|
98
|
-
'
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
248
|
+
|
|
249
|
+
// ── i18n: RU/EN ──
|
|
250
|
+
var I18N = {
|
|
251
|
+
ru: {
|
|
252
|
+
tabOverview: 'Обзор', tabProviders: 'Провайдеры', tabModels: 'Модели',
|
|
253
|
+
tabContext: 'Контекст', tabSetup: 'Настройки (ключи)',
|
|
254
|
+
kpiProviders: 'Провайдеры', kpiRequests: 'Запросы', kpiRpm: 'RPM', kpiCache: 'Кэш',
|
|
255
|
+
kpiLimit: 'Лимит', kpiModels: 'Модели', kpiUptime: 'Аптайм',
|
|
256
|
+
cardSavings: 'Экономия (против платных API)', savings: 'Сэкономлено', tokens: 'токенов',
|
|
257
|
+
savingsBasis: 'расчёт: input $3/M, output $15/M (репрезентативно)',
|
|
258
|
+
cardCache: 'Кэш', cardTokens: 'Токены', cardPool: 'Очередь',
|
|
259
|
+
cardRpm: 'Запросов в минуту', cardRecent: 'Недавние запросы',
|
|
260
|
+
cardCtx: 'Контекст (узкое место)', cardTasks: 'Категории задач (24ч)',
|
|
261
|
+
loading: 'Загрузка...', noData: 'Нет данных',
|
|
262
|
+
cacheHits: 'Хиты', cacheMisses: 'Промахи', cacheSize: 'Размер', cacheRate: 'Точность',
|
|
263
|
+
poolAll: 'без очереди', poolActive: 'активных', poolWait: 'ждут',
|
|
264
|
+
searchProvider: 'Поиск провайдера...', searchModel: 'Поиск модели...',
|
|
265
|
+
filterAll: 'Все', filterUp: 'Up', filterLimit: 'Лимит', filterError: 'Ошибка', filterUnknown: 'Неизвестно',
|
|
266
|
+
thStatus: 'Статус', thProvider: 'Провайдер', thLatency: 'Задержка', thReliability: 'Стабильность',
|
|
267
|
+
thLimit: 'Лимит', thReason: 'Причина', dot: '—',
|
|
268
|
+
thScore: 'Скор', thKey: 'Ключ', thModel: 'Модель', thSource: 'Источник', thCategory: 'Категория',
|
|
269
|
+
thWindow: 'Окно', thDayLimit: 'Лимит/день', thStatus2: 'Статус', thChecked: 'Проверка', thAction: 'Действие',
|
|
270
|
+
thRecentTime: 'Время', thRecentModel: 'Модель', thRecentProvider: 'Провайдер', thRecentStatus: 'Статус', thRecentLatency: 'Задержка',
|
|
271
|
+
allCategories: 'Все категории', allStatuses: 'Все статусы', allSources: 'Все источники',
|
|
272
|
+
enable: 'Вкл', disable: 'Выкл', testNow: 'Тест', testing: 'Проверка...',
|
|
273
|
+
recBlock: 'Что включить', recActiveFree: 'Рабочие free — включи', recPaid: 'Платные/нестабильные', recLocal: 'Локальные (слабый Mac)',
|
|
274
|
+
modelManagerInfo: 'Автопоиск', every: 'каждые', off: 'выключен', cycleRunning: 'цикл выполняется...',
|
|
275
|
+
overviewLineOk: 'Всё работает штатно', overviewDown: 'Упали', overviewLimit: 'Лимит исчерпан',
|
|
276
|
+
overviewRisky: 'Лимит близок к исчерпанию', overviewCtx: 'Контекст',
|
|
277
|
+
autoRefresh: 'Автообновление: 5 сек · модели: 30 сек',
|
|
278
|
+
validateAll: 'Проверить все ключи', saveAllKeys: 'Сохранить все ключи',
|
|
279
|
+
keySet: 'ключ установлен', keyMissing: 'не задан', checkKey: 'Проверить', valid: 'Ключ валиден', invalid: 'Неверный ключ',
|
|
280
|
+
saving: 'Сохранение...', savedMsg: 'Сохранено! Перезапусти Freegate для применения.', saveErr: 'Ошибка сохранения',
|
|
281
|
+
ctxStatus: 'Статус', ctxReq: 'Запросов (24ч)', ctxRate: 'Rate (1/ч)', ctxAvgRatio: 'Avg ratio / окно',
|
|
282
|
+
ctxMaxRatio: 'Max ratio', ctxNear: 'Впритык к окну', ctxOver: 'Поверх окна', ctxCompact: 'Компакций',
|
|
283
|
+
ctxUpgrade: 'Апгрейдов (окно)', ctxCacheRate: 'Кэш-hit %', ctxSys: 'System-доля', ctxNarrow: 'Узкие',
|
|
284
|
+
ctxRecap: 'для recap: node tools/context-diag.js', ctxNoTask: 'Категорий пока нет',
|
|
285
|
+
noModels: 'База пуста — дождись первого цикла автопоиска', tipsRecap: 'для recap',
|
|
286
|
+
},
|
|
287
|
+
en: {
|
|
288
|
+
tabOverview: 'Overview', tabProviders: 'Providers', tabModels: 'Models',
|
|
289
|
+
tabContext: 'Context', tabSetup: 'Settings (keys)',
|
|
290
|
+
kpiProviders: 'Providers', kpiRequests: 'Requests', kpiRpm: 'RPM', kpiCache: 'Cache',
|
|
291
|
+
kpiLimit: 'Limit', kpiModels: 'Models', kpiUptime: 'Uptime',
|
|
292
|
+
cardSavings: 'Savings (vs paid APIs)', savings: 'Saved', tokens: 'tokens',
|
|
293
|
+
savingsBasis: 'estimate: input $3/M, output $15/M (representative)',
|
|
294
|
+
cardCache: 'Cache', cardTokens: 'Tokens', cardPool: 'Queue',
|
|
295
|
+
cardRpm: 'Requests per minute', cardRecent: 'Recent requests',
|
|
296
|
+
cardCtx: 'Context (bottleneck)', cardTasks: 'Task categories (24h)',
|
|
297
|
+
loading: 'Loading...', noData: 'No data',
|
|
298
|
+
cacheHits: 'Hits', cacheMisses: 'Misses', cacheSize: 'Size', cacheRate: 'Rate',
|
|
299
|
+
poolAll: 'no queue', poolActive: 'active', poolWait: 'waiting',
|
|
300
|
+
searchProvider: 'Search provider...', searchModel: 'Search model...',
|
|
301
|
+
filterAll: 'All', filterUp: 'Up', filterLimit: 'Limit', filterError: 'Error', filterUnknown: 'Unknown',
|
|
302
|
+
thStatus: 'Status', thProvider: 'Provider', thLatency: 'Latency', thReliability: 'Reliability',
|
|
303
|
+
thLimit: 'Limit', thReason: 'Reason', dot: '—',
|
|
304
|
+
thScore: 'Score', thKey: 'Key', thModel: 'Model', thSource: 'Source', thCategory: 'Category',
|
|
305
|
+
thWindow: 'Window', thDayLimit: 'Limit/day', thStatus2: 'Status', thChecked: 'Checked', thAction: 'Action',
|
|
306
|
+
thRecentTime: 'Time', thRecentModel: 'Model', thRecentProvider: 'Provider', thRecentStatus: 'Status', thRecentLatency: 'Latency',
|
|
307
|
+
allCategories: 'All categories', allStatuses: 'All statuses', allSources: 'All sources',
|
|
308
|
+
enable: 'On', disable: 'Off', testNow: 'Test', testing: 'Testing...',
|
|
309
|
+
recBlock: 'What to enable', recActiveFree: 'Working free — enable', recPaid: 'Paid/unstable', recLocal: 'Local (weak Mac)',
|
|
310
|
+
modelManagerInfo: 'Auto-discovery', every: 'every', off: 'off', cycleRunning: 'cycle running...',
|
|
311
|
+
overviewLineOk: 'All systems operational', overviewDown: 'Down', overviewLimit: 'Limit exhausted',
|
|
312
|
+
overviewRisky: 'Limit near exhaustion', overviewCtx: 'Context',
|
|
313
|
+
autoRefresh: 'Auto-refresh: 5s · models: 30s',
|
|
314
|
+
validateAll: 'Validate all keys', saveAllKeys: 'Save all keys',
|
|
315
|
+
keySet: 'key set', keyMissing: 'not set', checkKey: 'Validate', valid: 'Key valid', invalid: 'Invalid key',
|
|
316
|
+
saving: 'Saving...', savedMsg: 'Saved! Restart Freegate to apply.', saveErr: 'Save error',
|
|
317
|
+
ctxStatus: 'Status', ctxReq: 'Requests (24h)', ctxRate: 'Rate (1/h)', ctxAvgRatio: 'Avg ratio / window',
|
|
318
|
+
ctxMaxRatio: 'Max ratio', ctxNear: 'Near window', ctxOver: 'Over window', ctxCompact: 'Compactions',
|
|
319
|
+
ctxUpgrade: 'Upgrades (window)', ctxCacheRate: 'Cache-hit %', ctxSys: 'System share', ctxNarrow: 'Narrow',
|
|
320
|
+
ctxRecap: 'for recap: node tools/context-diag.js', ctxNoTask: 'No categories yet',
|
|
321
|
+
noModels: 'Base is empty — wait for the first auto-discovery cycle', tipsRecap: 'for recap',
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
var _lang = localStorage.getItem('fg_lang') || (navigator.language && navigator.language.toLowerCase().indexOf('ru') === 0 ? 'ru' : 'en');
|
|
325
|
+
function t(key) { return (I18N[_lang] && I18N[_lang][key]) || (I18N.ru[key]) || key; }
|
|
326
|
+
function setLang(lang) { _lang = lang; localStorage.setItem('fg_lang', lang); applyLang(); refresh(); }
|
|
327
|
+
function applyLang() {
|
|
328
|
+
document.querySelectorAll('[data-i18n]').forEach(function (el) { el.textContent = t(el.getAttribute('data-i18n')); });
|
|
329
|
+
document.querySelectorAll('input[data-i18n-ph]').forEach(function (el) { el.placeholder = t(el.getAttribute('data-i18n-ph')); });
|
|
330
|
+
var btns = document.querySelectorAll('.lang-btn');
|
|
331
|
+
btns.forEach(function (b) { b.classList.toggle('active', b.getAttribute('data-lang') === _lang); });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ── состояние клиента ──
|
|
335
|
+
var _health = {}, _limits = {}, _cache = null, _pool = {}, _statsData = null, _ctxSummary = null;
|
|
336
|
+
var _rpmData = [], _recent = [];
|
|
337
|
+
var _models = [], _modelStats = null, _modelManager = null, _modelsFetchedAt = 0;
|
|
338
|
+
var _savings = null;
|
|
339
|
+
var _providerFilter = 'all', _providerQuery = '';
|
|
340
|
+
var _provSort = { key: null, dir: 1 };
|
|
341
|
+
var _modelSort = { key: 'score', dir: -1 };
|
|
342
|
+
|
|
343
|
+
// ── табы ──
|
|
344
|
+
var _tabByKey = { overview: 'tabOverview', providers: 'tabProviders', models: 'tabModels', context: 'tabContext', setup: 'tabSetup' };
|
|
345
|
+
function showTab(tab) {
|
|
346
|
+
var key = _tabByKey[tab];
|
|
347
|
+
document.querySelectorAll('#tabs .tab').forEach(function (t) {
|
|
348
|
+
t.classList.toggle('active', t.getAttribute('data-i18n') === key);
|
|
349
|
+
});
|
|
350
|
+
document.querySelectorAll('.panel').forEach(function (p) { p.classList.remove('active'); });
|
|
351
|
+
document.getElementById('panel-' + tab).classList.add('active');
|
|
352
|
+
if (tab === 'setup' && !_setupData) loadSetup();
|
|
353
|
+
if (tab === 'models') fetchModels();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ── форматтеры ──
|
|
357
|
+
function fmtTokens(n) {
|
|
358
|
+
if (!n || n < 1000) return String(n || 0);
|
|
359
|
+
if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\\.0$/, '') + 'M';
|
|
360
|
+
var k = n / 1000;
|
|
361
|
+
var s = k < 10 ? (Math.round(k * 10) / 10).toFixed(1).replace(/\\.0$/, '') : Math.round(k).toString();
|
|
362
|
+
return s + 'K';
|
|
363
|
+
}
|
|
364
|
+
function fmtUptime(sec) {
|
|
365
|
+
if (!sec || sec < 0) return '—';
|
|
366
|
+
var h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60);
|
|
367
|
+
return h > 0 ? h + 'ч ' + m + 'м' : m + 'м';
|
|
368
|
+
}
|
|
369
|
+
function fmtDate(ts) { return ts ? new Date(ts).toLocaleString('ru-RU') : '—'; }
|
|
370
|
+
function esc(s) {
|
|
371
|
+
return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
372
|
+
}
|
|
373
|
+
function limitColor(p) { return p > 90 ? 'var(--err)' : p > 60 ? 'var(--warn)' : 'var(--ok)'; }
|
|
374
|
+
|
|
375
|
+
// ── главный цикл обновления ──
|
|
376
|
+
function refresh() {
|
|
377
|
+
Promise.all([
|
|
378
|
+
fetch(_api('/v1/stats')).then(function (r) { return r.json(); }),
|
|
379
|
+
fetch(_api('/v1/recent')).then(function (r) { return r.json(); }),
|
|
380
|
+
fetch(_api('/v1/rpm')).then(function (r) { return r.json(); })
|
|
381
|
+
]).then(function (res) {
|
|
382
|
+
_statsData = res[0];
|
|
383
|
+
_recent = (res[1].data) || [];
|
|
384
|
+
_rpmData = (res[2].data) || [];
|
|
385
|
+
_health = _statsData.health || {};
|
|
386
|
+
_limits = _statsData.limits || {};
|
|
387
|
+
_cache = _statsData.cache || null;
|
|
388
|
+
_pool = _statsData.pool || {};
|
|
389
|
+
_ctxSummary = _statsData.context_summary || null;
|
|
390
|
+
_savings = _statsData.savings || null;
|
|
391
|
+
renderKpi();
|
|
392
|
+
renderAlerts();
|
|
393
|
+
renderOverviewCards();
|
|
394
|
+
renderRpm(_rpmData);
|
|
395
|
+
renderRecent(_recent);
|
|
396
|
+
renderProvidersTable();
|
|
397
|
+
renderContext(_ctxSummary);
|
|
398
|
+
}).catch(function (e) { console.error('Ошибка обновления:', e); });
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ── KPI-полоска ──
|
|
402
|
+
function renderKpi() {
|
|
403
|
+
var up = 0, total = 0;
|
|
404
|
+
for (var k in _health) { total++; if (_health[k].status === 'up') up++; }
|
|
405
|
+
var rpm = _rpmData.length ? _rpmData[_rpmData.length - 1].count : 0;
|
|
406
|
+
var cacheRate = _cache ? (_cache.hitRate || 0) : 0;
|
|
407
|
+
var riskKey = null, riskPct = 0;
|
|
408
|
+
for (var lk in _limits) {
|
|
409
|
+
var p = _limits[lk].percent || 0;
|
|
410
|
+
if (p > riskPct) { riskPct = p; riskKey = lk; }
|
|
411
|
+
}
|
|
412
|
+
var html = '';
|
|
413
|
+
html += '<div class="kpi-chip">' + t('kpiProviders') + '<b>' + up + '/' + total + '</b></div>';
|
|
414
|
+
html += '<div class="kpi-chip">' + t('kpiRequests') + '<b>' + (_statsData ? _statsData.total_requests : 0) + '</b></div>';
|
|
415
|
+
html += '<div class="kpi-chip">' + t('kpiRpm') + '<b>' + rpm + '</b></div>';
|
|
416
|
+
html += '<div class="kpi-chip">' + t('kpiCache') + '<b>' + cacheRate + '%</b></div>';
|
|
417
|
+
if (riskKey) {
|
|
418
|
+
var cls = riskPct > 90 ? ' err' : riskPct > 60 ? ' warn' : '';
|
|
419
|
+
html += '<div class="kpi-chip' + cls + '">' + t('kpiLimit') + ': ' + esc(riskKey) + '<b>' + riskPct + '%</b></div>';
|
|
117
420
|
}
|
|
118
|
-
if (
|
|
119
|
-
|
|
421
|
+
if (_modelStats) html += '<div class="kpi-chip">' + t('kpiModels') + '<b>' + (_modelStats.byStatus.active || 0) + '/' + _modelStats.total + '</b></div>';
|
|
422
|
+
html += '<div class="kpi-chip">' + t('kpiUptime') + '<b>' + fmtUptime(_statsData ? _statsData.uptime_seconds : 0) + '</b></div>';
|
|
423
|
+
document.getElementById('kpiBar').innerHTML = html;
|
|
424
|
+
var dot = document.getElementById('statusDot');
|
|
425
|
+
dot.className = 'dot ' + (up > 0 ? 'ok' : 'bad');
|
|
120
426
|
}
|
|
121
427
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
428
|
+
// ── алерты ──
|
|
429
|
+
function renderAlerts() {
|
|
430
|
+
var alerts = [];
|
|
431
|
+
var down = [], limited = [];
|
|
432
|
+
for (var k in _health) {
|
|
433
|
+
var h = _health[k];
|
|
434
|
+
if (h.status === 'error') down.push(k + (h.reason ? ' (' + h.reason + ')' : ''));
|
|
435
|
+
else if (h.status === 'ratelimited') limited.push(k);
|
|
436
|
+
}
|
|
437
|
+
if (down.length) alerts.push('<div class="alert err">' + t('overviewDown') + ': ' + esc(down.join(', ')) + '</div>');
|
|
438
|
+
if (limited.length) alerts.push('<div class="alert warn">' + t('overviewLimit') + ': ' + esc(limited.join(', ')) + '</div>');
|
|
439
|
+
var risky = [];
|
|
440
|
+
for (var lk in _limits) { if ((_limits[lk].percent || 0) > 90) risky.push(lk + ' ' + _limits[lk].percent + '%'); }
|
|
441
|
+
if (risky.length) alerts.push('<div class="alert warn">' + t('overviewRisky') + ': ' + esc(risky.join(', ')) + '</div>');
|
|
442
|
+
if (_ctxSummary && _ctxSummary.status && _ctxSummary.status !== 'OK') {
|
|
443
|
+
alerts.push('<div class="alert warn">' + t('overviewCtx') + ': ' + esc(_ctxSummary.status) + ' (' + t('ctxNarrow') + ': ' + esc((_ctxSummary.narrowProviders || []).join(', ') || '—') + ')</div>');
|
|
444
|
+
}
|
|
445
|
+
if (alerts.length === 0) alerts.push('<div class="alert ok">' + t('overviewLineOk') + '</div>');
|
|
446
|
+
document.getElementById('alerts').innerHTML = alerts.join('');
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ── карточки обзора ──
|
|
450
|
+
function renderOverviewCards() {
|
|
451
|
+
if (_cache) {
|
|
452
|
+
document.getElementById('cache').innerHTML =
|
|
453
|
+
'<div class="stat"><span class="stat-label">' + t('cacheHits') + '</span><span class="stat-value">' + _cache.hits + '</span></div>' +
|
|
454
|
+
'<div class="stat"><span class="stat-label">' + t('cacheMisses') + '</span><span class="stat-value">' + _cache.misses + '</span></div>' +
|
|
455
|
+
'<div class="stat"><span class="stat-label">' + t('cacheSize') + '</span><span class="stat-value">' + _cache.size + '/' + _cache.maxSize + '</span></div>' +
|
|
456
|
+
'<div class="stat"><span class="stat-label">' + t('cacheRate') + '</span><span class="stat-value">' + _cache.hitRate + '%</span></div>';
|
|
457
|
+
}
|
|
458
|
+
var tokens = _statsData.token_usage || {};
|
|
459
|
+
var th = '';
|
|
460
|
+
for (var tk in tokens) th += '<div class="stat"><span class="stat-label">' + esc(tk) + '</span><span class="stat-value">' + fmtTokens(tokens[tk].totalTokens) + ' ток.</span></div>';
|
|
461
|
+
document.getElementById('tokens').innerHTML = th || '<div>' + t('noData') + '</div>';
|
|
462
|
+
var ph = '', anyQueue = false;
|
|
463
|
+
for (var pk in _pool) {
|
|
464
|
+
var s = _pool[pk];
|
|
465
|
+
if (s.queued > 0) { anyQueue = true; ph += '<div class="stat"><span class="stat-label">' + esc(pk) + '</span><span class="stat-value">' + t('poolActive') + ' ' + s.active + ', ' + t('poolWait') + ' ' + s.queued + '</span></div>'; }
|
|
466
|
+
}
|
|
467
|
+
document.getElementById('pool').innerHTML = anyQueue ? ph : '<div class="stat"><span class="stat-label">' + t('tabProviders') + '</span><span class="stat-value">' + t('poolAll') + '</span></div>';
|
|
468
|
+
|
|
469
|
+
// Карточка экономии: сколько стоил бы этот трафик у платных API.
|
|
470
|
+
var sv = document.getElementById('savings');
|
|
471
|
+
if (sv) {
|
|
472
|
+
if (_savings && _savings.totalTokens > 0) {
|
|
473
|
+
sv.innerHTML =
|
|
474
|
+
'<div class="stat savings"><span class="stat-label">' + t('savings') + '</span><span class="stat-value savings-usd">$' + _savings.savedUsd.toFixed(2) + '</span></div>' +
|
|
475
|
+
'<div class="stat"><span class="stat-label">' + t('tokens') + '</span><span class="stat-value">' + fmtTokens(_savings.totalTokens) + '</span></div>' +
|
|
476
|
+
'<div class="stat"><span class="stat-label">in / out</span><span class="stat-value" style="font-size:11px">' + fmtTokens(_savings.promptTokens) + ' / ' + fmtTokens(_savings.completionTokens) + '</span></div>' +
|
|
477
|
+
'<div style="margin-top:6px;font-size:10px;color:#888;">' + t('savingsBasis') + '</div>';
|
|
478
|
+
} else {
|
|
479
|
+
sv.innerHTML = '<div class="stat"><span class="stat-label">' + t('savings') + '</span><span class="stat-value">$0.00</span></div>';
|
|
480
|
+
}
|
|
130
481
|
}
|
|
131
|
-
document.getElementById('limits').innerHTML = html || '<div>Нет данных</div>';
|
|
132
482
|
}
|
|
133
483
|
|
|
484
|
+
// ── RPM-график с сеткой ──
|
|
134
485
|
function renderRpm(rpmData) {
|
|
135
|
-
|
|
136
|
-
|
|
486
|
+
var canvas = document.getElementById('rpmChart');
|
|
487
|
+
var ctx = canvas.getContext('2d');
|
|
137
488
|
canvas.width = canvas.offsetWidth;
|
|
138
489
|
canvas.height = canvas.offsetHeight;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
490
|
+
var w = canvas.width, h = canvas.height, padL = 34, padB = 16, padT = 8;
|
|
491
|
+
ctx.clearRect(0, 0, w, h);
|
|
492
|
+
ctx.font = '10px monospace';
|
|
493
|
+
ctx.fillStyle = '#8b949e';
|
|
494
|
+
var max = 1;
|
|
495
|
+
for (var i = 0; i < rpmData.length; i++) if (rpmData[i].count > max) max = rpmData[i].count;
|
|
496
|
+
// сетка: 4 линии
|
|
497
|
+
for (var g = 0; g <= 4; g++) {
|
|
498
|
+
var gy = padT + (h - padT - padB) * (1 - g / 4);
|
|
499
|
+
ctx.strokeStyle = 'rgba(139,148,158,.15)';
|
|
500
|
+
ctx.beginPath(); ctx.moveTo(padL, gy); ctx.lineTo(w - 4, gy); ctx.stroke();
|
|
501
|
+
ctx.fillText(String(Math.round(max * g / 4)), 4, gy + 3);
|
|
502
|
+
}
|
|
503
|
+
if (!rpmData || rpmData.length === 0) { ctx.fillText('нет данных', padL + 10, h / 2); return; }
|
|
143
504
|
ctx.strokeStyle = '#00d4ff';
|
|
144
505
|
ctx.lineWidth = 2;
|
|
145
506
|
ctx.beginPath();
|
|
146
|
-
rpmData.
|
|
147
|
-
|
|
148
|
-
|
|
507
|
+
var step = (w - padL - 4) / Math.max(rpmData.length - 1, 1);
|
|
508
|
+
rpmData.forEach(function (r, i) {
|
|
509
|
+
var x = padL + i * step;
|
|
510
|
+
var y = padT + (h - padT - padB) * (1 - r.count / max);
|
|
149
511
|
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
|
150
512
|
});
|
|
151
513
|
ctx.stroke();
|
|
514
|
+
ctx.fillText('-60м', padL, h - 3);
|
|
515
|
+
ctx.fillText('сейчас', w - 44, h - 3);
|
|
152
516
|
}
|
|
153
517
|
|
|
518
|
+
// ── недавние запросы ──
|
|
154
519
|
function renderRecent(recent) {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
recent.forEach(r
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
520
|
+
var table = document.getElementById('recentTable');
|
|
521
|
+
var html = '<tr><th>' + t('thRecentTime') + '</th><th>' + t('thRecentModel') + '</th><th>' + t('thRecentProvider') + '</th><th>' + t('thRecentStatus') + '</th><th>' + t('thRecentLatency') + '</th></tr>';
|
|
522
|
+
recent.forEach(function (r) {
|
|
523
|
+
var sc = r.status === 200 ? 'var(--ok)' : 'var(--err)';
|
|
524
|
+
html += '<tr><td>' + new Date(r.timestamp).toLocaleTimeString() + '</td><td>' + esc(r.model || '-') + '</td><td>' + esc(r.provider || '-') + '</td><td style="color:' + sc + '">' + (r.status || '-') + '</td><td>' + (r.latency || 0) + 'ms</td></tr>';
|
|
525
|
+
});
|
|
526
|
+
table.innerHTML = html;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ── ПРОВАЙДЕРЫ: фильтры/сортировка/таблица ──
|
|
530
|
+
function setProviderFilter(f, el) {
|
|
531
|
+
_providerFilter = f;
|
|
532
|
+
document.querySelectorAll('#providerChips .chip').forEach(function (c) { c.classList.remove('active'); });
|
|
533
|
+
el.classList.add('active');
|
|
534
|
+
renderProvidersTable();
|
|
535
|
+
}
|
|
536
|
+
function sortProvidersBy(key) {
|
|
537
|
+
if (_provSort.key === key) _provSort.dir *= -1; else { _provSort.key = key; _provSort.dir = 1; }
|
|
538
|
+
renderProvidersTable();
|
|
539
|
+
}
|
|
540
|
+
function limitOf(key) { return (_limits[key] && _limits[key].percent) || 0; }
|
|
541
|
+
function renderProvidersTable() {
|
|
542
|
+
_providerQuery = (document.getElementById('providerSearch').value || '').toLowerCase();
|
|
543
|
+
var rows = [];
|
|
544
|
+
for (var k in _health) {
|
|
545
|
+
var h = _health[k];
|
|
546
|
+
var st = h.status || 'unknown';
|
|
547
|
+
if (_providerFilter !== 'all' && st !== _providerFilter) continue;
|
|
548
|
+
if (_providerQuery && k.toLowerCase().indexOf(_providerQuery) < 0) continue;
|
|
549
|
+
rows.push({ key: k, h: h, st: st, limit: limitOf(k) });
|
|
550
|
+
}
|
|
551
|
+
if (_provSort.key) {
|
|
552
|
+
var dir = _provSort.dir;
|
|
553
|
+
rows.sort(function (a, b) {
|
|
554
|
+
var av, bv;
|
|
555
|
+
if (_provSort.key === 'limit') { av = a.limit; bv = b.limit; }
|
|
556
|
+
else if (_provSort.key === 'name') { av = a.key; bv = b.key; return av < bv ? -dir : av > bv ? dir : 0; }
|
|
557
|
+
else { av = a.h[_provSort.key] || 0; bv = b.h[_provSort.key] || 0; }
|
|
558
|
+
return (av - bv) * dir;
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
var arr = function (col) { return _provSort.key === col ? '<span class="arr">' + (_provSort.dir > 0 ? '▲' : '▼') + '</span>' : ''; };
|
|
562
|
+
var html = '<tr><th>' + t('thStatus') + '</th><th onclick="sortProvidersBy(\\'name\\')">' + t('thProvider') + arr('name') + '</th>' +
|
|
563
|
+
'<th onclick="sortProvidersBy(\\'latency_ms\\')">' + t('thLatency') + arr('latency_ms') + '</th>' +
|
|
564
|
+
'<th onclick="sortProvidersBy(\\'reliability\\')">' + t('thReliability') + arr('reliability') + '</th>' +
|
|
565
|
+
'<th onclick="sortProvidersBy(\\'limit\\')">' + t('thLimit') + arr('limit') + '</th><th>' + t('thReason') + '</th></tr>';
|
|
566
|
+
rows.forEach(function (r) {
|
|
567
|
+
var rel = (r.h.reliability !== null && r.h.reliability !== undefined) ? r.h.reliability + '%' : '—';
|
|
568
|
+
html += '<tr>' +
|
|
569
|
+
'<td><span class="badge ' + esc(r.st) + '">' + esc(r.st) + '</span></td>' +
|
|
570
|
+
'<td>' + esc(r.key) + '</td>' +
|
|
571
|
+
'<td>' + (r.h.latency_ms || 0) + 'ms</td>' +
|
|
572
|
+
'<td>' + rel + '</td>' +
|
|
573
|
+
'<td><span class="limit-bar"><span class="limit-fill" style="width:' + Math.min(r.limit, 100) + '%;background:' + limitColor(r.limit) + '"></span></span>' + r.limit + '%</td>' +
|
|
574
|
+
'<td class="mono">' + esc(r.h.reason || '—') + '</td></tr>';
|
|
164
575
|
});
|
|
576
|
+
document.getElementById('providerTable').innerHTML = html;
|
|
577
|
+
document.getElementById('providerCount').textContent = rows.length + ' из ' + Object.keys(_health).length;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ── МОДЕЛИ ──
|
|
581
|
+
function fetchModels() {
|
|
582
|
+
var age = Date.now() - _modelsFetchedAt;
|
|
583
|
+
if (_models.length > 0 && age < 30000) return;
|
|
584
|
+
fetch(_api('/v1/models-db')).then(function (r) { return r.json(); }).then(function (d) {
|
|
585
|
+
_models = d.models || [];
|
|
586
|
+
_modelStats = d.stats || null;
|
|
587
|
+
_modelManager = d.manager || null;
|
|
588
|
+
_modelsFetchedAt = Date.now();
|
|
589
|
+
fillModelFilters();
|
|
590
|
+
renderModelsTable();
|
|
591
|
+
renderModelRecommend();
|
|
592
|
+
renderKpi();
|
|
593
|
+
}).catch(function (e) { console.error('models-db:', e); });
|
|
594
|
+
}
|
|
595
|
+
function fillModelFilters() {
|
|
596
|
+
if (!_modelStats) return;
|
|
597
|
+
fillSelect('modelCategoryFilter', _modelStats.byCategory);
|
|
598
|
+
fillSelect('modelStatusFilter', _modelStats.byStatus);
|
|
599
|
+
fillSelect('modelSourceFilter', _modelStats.bySource);
|
|
600
|
+
}
|
|
601
|
+
function fillSelect(id, counts) {
|
|
602
|
+
var sel = document.getElementById(id);
|
|
603
|
+
var cur = sel.value;
|
|
604
|
+
var firstLabel = t(sel.getAttribute('data-i18n')) || sel.options[0].text;
|
|
605
|
+
var opts = '<option value="all">' + firstLabel + '</option>';
|
|
606
|
+
for (var k in counts) opts += '<option value="' + esc(k) + '">' + esc(k) + ' (' + counts[k] + ')</option>';
|
|
607
|
+
sel.innerHTML = opts;
|
|
608
|
+
sel.value = cur && (cur === 'all' || counts[cur]) ? cur : 'all';
|
|
609
|
+
}
|
|
610
|
+
function sortModelsBy(key) {
|
|
611
|
+
if (_modelSort.key === key) _modelSort.dir *= -1; else { _modelSort.key = key; _modelSort.dir = key === 'score' ? -1 : 1; }
|
|
612
|
+
renderModelsTable();
|
|
613
|
+
}
|
|
614
|
+
function renderModelsTable() {
|
|
615
|
+
if (_models.length === 0) { document.getElementById('modelTable').innerHTML = '<tr><td>' + t('noModels') + '</td></tr>'; return; }
|
|
616
|
+
var q = (document.getElementById('modelSearch').value || '').toLowerCase();
|
|
617
|
+
var cat = document.getElementById('modelCategoryFilter').value;
|
|
618
|
+
var st = document.getElementById('modelStatusFilter').value;
|
|
619
|
+
var src = document.getElementById('modelSourceFilter').value;
|
|
620
|
+
var rows = _models.filter(function (m) {
|
|
621
|
+
if (cat !== 'all' && m.category !== cat) return false;
|
|
622
|
+
if (st !== 'all' && m.status !== st) return false;
|
|
623
|
+
if (src !== 'all' && m.source !== src) return false;
|
|
624
|
+
if (q && (m.key + ' ' + m.model).toLowerCase().indexOf(q) < 0) return false;
|
|
625
|
+
return true;
|
|
626
|
+
});
|
|
627
|
+
if (_modelSort.key) {
|
|
628
|
+
var dir = _modelSort.dir, sk = _modelSort.key;
|
|
629
|
+
rows.sort(function (a, b) {
|
|
630
|
+
if (sk === 'key' || sk === 'model') { var av = a[sk], bv = b[sk]; return av < bv ? -dir : av > bv ? dir : 0; }
|
|
631
|
+
return ((a[sk] || 0) - (b[sk] || 0)) * dir;
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
var arr = function (col) { return _modelSort.key === col ? '<span class="arr">' + (_modelSort.dir > 0 ? '▲' : '▼') + '</span>' : ''; };
|
|
635
|
+
var html = '<tr>' +
|
|
636
|
+
'<th onclick="sortModelsBy(\\'score\\')">' + t('thScore') + arr('score') + '</th>' +
|
|
637
|
+
'<th onclick="sortModelsBy(\\'key\\')">' + t('thKey') + arr('key') + '</th>' +
|
|
638
|
+
'<th onclick="sortModelsBy(\\'model\\')">' + t('thModel') + arr('model') + '</th>' +
|
|
639
|
+
'<th>' + t('thSource') + '</th><th>' + t('thCategory') + '</th>' +
|
|
640
|
+
'<th onclick="sortModelsBy(\\'contextWindow\\')">' + t('thWindow') + arr('contextWindow') + '</th>' +
|
|
641
|
+
'<th>' + t('thDayLimit') + '</th><th>' + t('thStatus2') + '</th>' +
|
|
642
|
+
'<th>' + t('thChecked') + '</th><th>' + t('thAction') + '</th></tr>';
|
|
643
|
+
rows.forEach(function (m) {
|
|
644
|
+
var on = m.status !== 'user-disabled';
|
|
645
|
+
html += '<tr>' +
|
|
646
|
+
'<td class="score">' + (m.score || 0) + '</td>' +
|
|
647
|
+
'<td class="mono">' + esc(m.key) + '</td>' +
|
|
648
|
+
'<td class="mono">' + esc(m.model) + '</td>' +
|
|
649
|
+
'<td>' + esc(m.source) + '</td>' +
|
|
650
|
+
'<td>' + esc(m.category) + '</td>' +
|
|
651
|
+
'<td>' + (m.contextWindow ? Math.round(m.contextWindow / 1000) + 'K' : '—') + '</td>' +
|
|
652
|
+
'<td>' + (m.dailyLimit || '—') + '</td>' +
|
|
653
|
+
'<td><span class="badge ' + esc(m.status) + '">' + esc(m.status) + '</span></td>' +
|
|
654
|
+
'<td>' + fmtDate(m.lastCheckedAt) + '</td>' +
|
|
655
|
+
'<td class="model-actions">' +
|
|
656
|
+
'<button class="mini-btn ' + (on ? '' : 'off') + '" onclick="toggleModel(\\'' + esc(m.key) + '\\')">' + (on ? '✓' : '✗') + '</button>' +
|
|
657
|
+
'<button class="mini-btn" onclick="testModelLive(\\'' + esc(m.key) + '\\')" title="' + t('testNow') + '">' + t('testNow') + '</button>' +
|
|
658
|
+
'</td></tr>';
|
|
659
|
+
});
|
|
660
|
+
document.getElementById('modelTable').innerHTML = html;
|
|
661
|
+
document.getElementById('modelCount').textContent = rows.length + ' / ' + _models.length;
|
|
662
|
+
if (_modelManager) {
|
|
663
|
+
document.getElementById('modelManagerInfo').textContent = t('modelManagerInfo') + ': ' +
|
|
664
|
+
(_modelManager.enabled ? t('every') + ' ' + _modelManager.intervalHours + 'h' : t('off')) +
|
|
665
|
+
(_modelManager.running ? ' · ' + t('cycleRunning') : '');
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// ── МОДЕЛИ: тумблер вкл/выкл + живой тест + рекомендации ──
|
|
670
|
+
function toggleModel(key) {
|
|
671
|
+
fetch(_api('/v1/models/' + encodeURIComponent(key) + '/toggle'), { method: 'POST' })
|
|
672
|
+
.then(function (r) { return r.json(); })
|
|
673
|
+
.then(function (d) { fetchModels(); renderKpi(); })
|
|
674
|
+
.catch(function (e) { alert(e.message); });
|
|
675
|
+
}
|
|
676
|
+
function testModelLive(key) {
|
|
677
|
+
fetch(_api('/v1/models/' + encodeURIComponent(key) + '/test'), { method: 'POST' })
|
|
678
|
+
.then(function (r) { return r.json(); })
|
|
679
|
+
.then(function (d) {
|
|
680
|
+
alert((d.ok ? 'OK' : 'FAIL (' + d.status + ')') + ' · ' + d.latencyMs + 'ms' + (d.error ? ' · ' + d.error : ''));
|
|
681
|
+
fetchModels();
|
|
682
|
+
})
|
|
683
|
+
.catch(function (e) { alert(e.message); });
|
|
684
|
+
}
|
|
685
|
+
function renderModelRecommend() {
|
|
686
|
+
var el = document.getElementById('modelRecommend');
|
|
687
|
+
if (!el) return;
|
|
688
|
+
var actFree = [], paid = [], local = [], other = [];
|
|
689
|
+
for (var i = 0; i < _models.length; i++) {
|
|
690
|
+
var m = _models[i];
|
|
691
|
+
if (m.status !== 'user-disabled') continue;
|
|
692
|
+
if (m.source === 'local' || m.source === 'ollama' || m.source === 'lmstudio') { local.push(m.key); continue; }
|
|
693
|
+
if (/deepseek|glm-5|nemotron-550b|nemotron-120b|ox-alpha|minimax-m2/.test(m.model)) { paid.push(m.key); continue; }
|
|
694
|
+
actFree.push(m.key);
|
|
695
|
+
}
|
|
696
|
+
var html = '';
|
|
697
|
+
if (actFree.length) {
|
|
698
|
+
html += '<div class="rec"><span class="rec-badge ok">' + t('recActiveFree') + ':</span> <span class="mono">' + esc(actFree.join(', ')) + '</span></div>';
|
|
699
|
+
}
|
|
700
|
+
if (paid.length) html += '<div class="rec"><span class="rec-badge warn">' + t('recPaid') + ':</span> <span class="mono">' + esc(paid.join(', ')) + '</span></div>';
|
|
701
|
+
if (local.length) html += '<div class="rec"><span class="rec-badge">' + t('recLocal') + ':</span> <span class="mono">' + esc(local.join(', ')) + '</span></div>';
|
|
702
|
+
if (!html) html = '<div class="rec"><span class="rec-badge ok">' + t('overviewLineOk') + '</span></div>';
|
|
703
|
+
el.innerHTML = html;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// ── КОНТЕКСТ ──
|
|
707
|
+
function renderContext(sum) {
|
|
708
|
+
if (!sum || typeof sum !== 'object') { document.getElementById('ctxBlock').innerHTML = t('noData'); document.getElementById('taskBars').innerHTML = t('noData'); return; }
|
|
709
|
+
var fmt3 = function (v) { return (typeof v === 'number' && v > 0) ? v.toFixed(3) : '—'; };
|
|
710
|
+
var html = '<div class="stat"><span class="stat-label">' + t('ctxStatus') + '</span><span class="stat-value">' + (sum.status || 'OK') + '</span></div>';
|
|
711
|
+
html += '<div class="stat"><span class="stat-label">' + t('ctxReq') + '</span><span class="stat-value">' + (sum.totalRequests || 0) + '</span></div>';
|
|
712
|
+
html += '<div class="stat"><span class="stat-label">' + t('ctxRate') + '</span><span class="stat-value">' + (sum.ratePerHour || 0) + '</span></div>';
|
|
713
|
+
html += '<div class="stat"><span class="stat-label">' + t('ctxAvgRatio') + '</span><span class="stat-value">' + fmt3(sum.avgRatio) + '</span></div>';
|
|
714
|
+
html += '<div class="stat"><span class="stat-label">' + t('ctxMaxRatio') + '</span><span class="stat-value">' + fmt3(sum.ratioMax) + '</span></div>';
|
|
715
|
+
html += '<div class="stat"><span class="stat-label">' + t('ctxNear') + '</span><span class="stat-value">' + (sum.nearWindow || 0) + '</span></div>';
|
|
716
|
+
html += '<div class="stat"><span class="stat-label">' + t('ctxOver') + '</span><span class="stat-value">' + (sum.overWindow || 0) + '</span></div>';
|
|
717
|
+
html += '<div class="stat"><span class="stat-label">' + t('ctxCompact') + '</span><span class="stat-value">' + (sum.compactCount || 0) + '</span></div>';
|
|
718
|
+
html += '<div class="stat"><span class="stat-label">' + t('ctxUpgrade') + '</span><span class="stat-value">' + (sum.upgradedCount || 0) + '</span></div>';
|
|
719
|
+
html += '<div class="stat"><span class="stat-label">' + t('ctxCacheRate') + '</span><span class="stat-value">' + (typeof sum.cacheHitRate === 'number' ? sum.cacheHitRate : 0) + '%</span></div>';
|
|
720
|
+
html += '<div class="stat"><span class="stat-label">' + t('ctxSys') + '</span><span class="stat-value">' + (sum.avgSysShare ? (sum.avgSysShare * 100).toFixed(0) + '%' : '—') + '</span></div>';
|
|
721
|
+
if (sum.narrowProviders && sum.narrowProviders.length > 0) {
|
|
722
|
+
html += '<div class="stat"><span class="stat-label">' + t('ctxNarrow') + '</span><span class="stat-value">' + esc(sum.narrowProviders.join(', ')) + '</span></div>';
|
|
723
|
+
}
|
|
724
|
+
document.getElementById('ctxBlock').innerHTML = html + '<div style="margin-top:8px;font-size:11px;color:#888;">' + t('ctxRecap') + '</div>';
|
|
725
|
+
|
|
726
|
+
// категории задач — прогресс-бары
|
|
727
|
+
var bars = '';
|
|
728
|
+
if (sum.tasks && sum.taskTotal > 0) {
|
|
729
|
+
var colors = { coding: '#00d4ff', reasoning: '#b388ff', search: '#00ff88', chat: '#ffaa00' };
|
|
730
|
+
for (var tk in sum.tasks) {
|
|
731
|
+
var n = sum.tasks[tk];
|
|
732
|
+
if (n <= 0) continue;
|
|
733
|
+
var pct = Math.round((n / sum.taskTotal) * 100);
|
|
734
|
+
bars += '<div class="task-bar"><span class="name">' + tk + '</span>' +
|
|
735
|
+
'<span class="bar"><span class="fill" style="width:' + pct + '%;background:' + (colors[tk] || 'var(--accent)') + '"></span></span>' +
|
|
736
|
+
'<span class="num">' + n + ' (' + pct + '%)</span></div>';
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
document.getElementById('taskBars').innerHTML = bars || '<div class="stat"><span class="stat-label">' + t('ctxNoTask') + '</span><span class="stat-value">—</span></div>';
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// ── Setup (API keys) ──
|
|
743
|
+
var _setupData = null;
|
|
744
|
+
async function loadSetup() {
|
|
745
|
+
try {
|
|
746
|
+
var res = await fetch(_api('/v1/setup/keys'));
|
|
747
|
+
_setupData = await res.json();
|
|
748
|
+
renderSetup(_setupData);
|
|
749
|
+
} catch (e) { console.error('Setup load error:', e); }
|
|
750
|
+
}
|
|
751
|
+
function renderSetup(data) {
|
|
752
|
+
var grid = document.getElementById('setup-grid');
|
|
753
|
+
var html = '';
|
|
754
|
+
for (var envVar in data.groups) {
|
|
755
|
+
var info = data.groups[envVar];
|
|
756
|
+
var isEn = _lang === 'en';
|
|
757
|
+
var name = (isEn && info.name_en) || info.name;
|
|
758
|
+
var desc = (isEn && info.description_en) || info.description;
|
|
759
|
+
var steps = (isEn && info.steps_en) || info.steps;
|
|
760
|
+
var maskedVal = (data.masked && data.masked[envVar]) || '';
|
|
761
|
+
var stepsHtml = (steps || []).map(function (s) { return '<li>' + s + '</li>'; }).join('');
|
|
762
|
+
html += '<div class="key-card" id="card-' + envVar + '">';
|
|
763
|
+
html += '<h3>' + esc(name) + '</h3>';
|
|
764
|
+
html += '<div class="desc">' + esc(desc) + '</div>';
|
|
765
|
+
html += '<ol>' + stepsHtml + '</ol>';
|
|
766
|
+
html += '<div class="key-input-row">';
|
|
767
|
+
html += '<input class="key-input" id="input-' + envVar + '" type="password" placeholder="' + (maskedVal ? t('keySet') + ' (' + maskedVal + ')' : t('checkKey') + '...') + '" autocomplete="off">';
|
|
768
|
+
html += '<button class="btn btn-validate" data-validate="' + envVar + '">' + t('checkKey') + '</button>';
|
|
769
|
+
html += '</div>';
|
|
770
|
+
html += '<div id="status-' + envVar + '" class="status-wait" style="margin-top: 6px;">' + (maskedVal ? t('keySet') + ' (' + maskedVal + ')' : t('keyMissing')) + '</div>';
|
|
771
|
+
html += '<a href="' + info.url + '" target="_blank" style="font-size: 11px;">' + esc(info.url) + '</a>';
|
|
772
|
+
html += '</div>';
|
|
773
|
+
}
|
|
774
|
+
grid.innerHTML = html;
|
|
775
|
+
}
|
|
776
|
+
document.addEventListener('click', function (e) {
|
|
777
|
+
if (e.target.dataset && e.target.dataset.validate) validateKey(e.target.dataset.validate);
|
|
778
|
+
});
|
|
779
|
+
async function validateKey(envVar) {
|
|
780
|
+
var input = document.getElementById('input-' + envVar);
|
|
781
|
+
var status = document.getElementById('status-' + envVar);
|
|
782
|
+
var key = input.value.trim();
|
|
783
|
+
status.className = 'status-wait'; status.textContent = t('testing');
|
|
784
|
+
try {
|
|
785
|
+
var url = _api('/v1/setup/validate?envVar=' + encodeURIComponent(envVar)) + (key ? '&apiKey=' + encodeURIComponent(key) : '');
|
|
786
|
+
var res = await fetch(url);
|
|
787
|
+
var result = await res.json();
|
|
788
|
+
if (result.valid) { status.className = 'status-ok'; status.textContent = t('valid'); }
|
|
789
|
+
else { status.className = 'status-err'; status.textContent = result.error || t('invalid'); }
|
|
790
|
+
} catch (e) { status.className = 'status-err'; status.textContent = e.message; }
|
|
791
|
+
}
|
|
792
|
+
async function validateAll() {
|
|
793
|
+
for (var envVar in _setupData.groups) {
|
|
794
|
+
var input = document.getElementById('input-' + envVar);
|
|
795
|
+
if (input && input.value.trim()) await validateKey(envVar);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
async function saveAllKeys() {
|
|
799
|
+
var status = document.getElementById('save-status');
|
|
800
|
+
status.className = 'status-wait'; status.textContent = t('saving');
|
|
801
|
+
var keys = {};
|
|
802
|
+
for (var envVar in _setupData.groups) {
|
|
803
|
+
var input = document.getElementById('input-' + envVar);
|
|
804
|
+
if (input && input.value.trim()) keys[envVar] = input.value.trim();
|
|
805
|
+
}
|
|
806
|
+
try {
|
|
807
|
+
var res = await fetch(_api('/v1/setup/keys'), {
|
|
808
|
+
method: 'POST',
|
|
809
|
+
headers: { 'Content-Type': 'application/json' },
|
|
810
|
+
body: JSON.stringify(keys)
|
|
811
|
+
});
|
|
812
|
+
var result = await res.json();
|
|
813
|
+
if (result.ok) { status.className = 'status-ok'; status.textContent = t('savedMsg'); }
|
|
814
|
+
else { status.className = 'status-err'; status.textContent = result.error || t('saveErr'); }
|
|
815
|
+
} catch (e) { status.className = 'status-err'; status.textContent = e.message; }
|
|
165
816
|
}
|
|
166
817
|
|
|
818
|
+
applyLang();
|
|
167
819
|
refresh();
|
|
168
820
|
setInterval(refresh, 5000);
|
|
821
|
+
setInterval(function () { _modelsFetchedAt = 0; fetchModels(); }, 30000);
|
|
822
|
+
// модели: подгружаем рекомендации один раз после первой загрузки
|
|
823
|
+
var _isEn = _lang === 'en';
|
|
169
824
|
</script>
|
|
170
825
|
</body>
|
|
171
826
|
</html>`;
|