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/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: #1a1a2e; color: #e0e0e0; padding: 20px; }
11
- h1 { color: #00d4ff; margin-bottom: 20px; }
12
- .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; }
13
- .card { background: #16213e; border-radius: 8px; padding: 20px; border: 1px solid #0f3460; }
14
- .card h2 { color: #00d4ff; font-size: 14px; margin-bottom: 15px; text-transform: uppercase; }
15
- .stat { display: flex; justify-content: space-between; margin-bottom: 8px; }
16
- .stat-label { color: #888; }
17
- .stat-value { color: #fff; font-weight: bold; }
18
- .provider { display: flex; align-items: center; margin-bottom: 10px; }
19
- .status-dot { width: 10px; height: 10px; border-radius: 50%; margin-right: 10px; }
20
- .status-up { background: #00ff88; }
21
- .status-error { background: #ff4444; }
22
- .status-unknown { background: #888; }
23
- .latency { color: #888; margin-left: auto; }
24
- table { width: 100%; border-collapse: collapse; }
25
- th, td { text-align: left; padding: 8px; border-bottom: 1px solid #0f3460; }
26
- th { color: #00d4ff; font-size: 12px; }
27
- td { font-size: 12px; }
28
- .refresh { color: #888; font-size: 12px; margin-top: 10px; }
29
- canvas { width: 100%; height: 120px; background: #0f3460; border-radius: 4px; }
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
- <h1>Freegate — Панель управления</h1>
34
- <div class="grid">
35
- <div class="card"><h2>Провайдеры</h2><div id="providers">Загрузка...</div></div>
36
- <div class="card"><h2>Статистика</h2><div id="stats">Загрузка...</div></div>
37
- <div class="card"><h2>Кэш</h2><div id="cache">Загрузка...</div></div>
38
- <div class="card"><h2>Токены</h2><div id="tokens">Загрузка...</div></div>
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
- </div>
47
- <div class="refresh">Автообновление: 5 сек</div>
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
- async function refresh() {
55
- try {
56
- const [statsRes, recentRes, rpmRes] = await Promise.all([
57
- fetch(_api('/v1/stats')), fetch(_api('/v1/recent')), fetch(_api('/v1/rpm'))
58
- ]);
59
- const data = await statsRes.json();
60
- const recent = (await recentRes.json()).data;
61
- const rpmData = (await rpmRes.json()).data;
62
- renderProviders(data.health);
63
- renderStats(data);
64
- renderCache(data.cache);
65
- renderTokens(data.token_usage || {});
66
- renderPool(data.pool || {});
67
- renderLimits(data.limits || {});
68
- renderRpm(rpmData);
69
- renderRecent(recent);
70
- } catch (e) { console.error('Ошибка обновления:', e); }
71
- }
72
-
73
- function renderProviders(health) {
74
- let html = '';
75
- for (const [key, h] of Object.entries(health)) {
76
- const cls = h.status === 'up' ? 'status-up' : h.status === 'error' ? 'status-error' : 'status-unknown';
77
- const rel = h.reliability !== null && h.reliability !== undefined ? ' | стаб. ' + h.reliability + '%' : '';
78
- html += '<div class="provider"><div class="status-dot ' + cls + '"></div>';
79
- html += '<span>' + key + '</span><span class="latency">' + h.latency_ms + 'ms · ' + (h.reason || '—') + rel + '</span></div>';
80
- }
81
- document.getElementById('providers').innerHTML = html || '<div>Нет провайдеров</div>';
82
- }
83
-
84
- function renderStats(data) {
85
- document.getElementById('stats').innerHTML =
86
- '<div class="stat"><span class="stat-label">Запросов</span><span class="stat-value">' + data.total_requests + '</span></div>' +
87
- '<div class="stat"><span class="stat-label">Успешно</span><span class="stat-value">' + data.successful_requests + '</span></div>' +
88
- '<div class="stat"><span class="stat-label">Ошибок</span><span class="stat-value">' + data.failed_requests + '</span></div>' +
89
- '<div class="stat"><span class="stat-label">Время работы</span><span class="stat-value">' + Math.floor(data.uptime_seconds / 60) + ' мин</span></div>';
90
- }
91
-
92
- function renderCache(cache) {
93
- if (!cache) return;
94
- document.getElementById('cache').innerHTML =
95
- '<div class="stat"><span class="stat-label">Хиты</span><span class="stat-value">' + cache.hits + '</span></div>' +
96
- '<div class="stat"><span class="stat-label">Промахи</span><span class="stat-value">' + cache.misses + '</span></div>' +
97
- '<div class="stat"><span class="stat-label">Размер</span><span class="stat-value">' + cache.size + '/' + cache.maxSize + '</span></div>' +
98
- '<div class="stat"><span class="stat-label">Точность</span><span class="stat-value">' + cache.hitRate + '%</span></div>';
99
- }
100
-
101
- function renderTokens(tokens) {
102
- let html = '';
103
- for (const [key, t] of Object.entries(tokens)) {
104
- html += '<div class="stat"><span class="stat-label">' + key + '</span><span class="stat-value">' + t.totalTokens + ' ток.</span></div>';
105
- }
106
- document.getElementById('tokens').innerHTML = html || '<div>Нет данных</div>';
107
- }
108
-
109
- function renderPool(pool) {
110
- let html = '';
111
- let totalQueued = 0;
112
- for (const [key, s] of Object.entries(pool)) {
113
- totalQueued += s.queued || 0;
114
- if (s.queued > 0) {
115
- html += '<div class="stat"><span class="stat-label">' + key + '</span><span class="stat-value">активных ' + s.active + ', ждут ' + s.queued + '</span></div>';
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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 (!html) html = '<div class="stat"><span class="stat-label">Все провайдеры</span><span class="stat-value">без очереди</span></div>';
119
- document.getElementById('pool').innerHTML = html;
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
- function renderLimits(limits) {
123
- let html = '';
124
- const keys = Object.keys(limits).sort((a, b) => (limits[a].percent || 0) - (limits[b].percent || 0));
125
- for (const key of keys) {
126
- const l = limits[key];
127
- const color = l.percent > 90 ? '#ff4444' : l.percent > 60 ? '#ffaa00' : '#00ff88';
128
- html += '<div class="stat"><span class="stat-label">' + key + '</span>' +
129
- '<span class="stat-value" style="color:' + color + '">' + l.used + '/' + l.limit + ' (ост. ' + l.remaining + ', ' + l.percent + '%)</span></div>';
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
- const canvas = document.getElementById('rpmChart');
136
- const ctx = canvas.getContext('2d');
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
- ctx.clearRect(0, 0, canvas.width, canvas.height);
140
- if (!rpmData || rpmData.length === 0) return;
141
- const max = Math.max.apply(null, rpmData.map(r => r.count), 1);
142
- const w = canvas.width, h = canvas.height;
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.forEach((r, i) => {
147
- const x = w - (rpmData.length - 1 - i) * (w / 60);
148
- const y = h - (r.count / max) * (h - 20) - 10;
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
- const table = document.getElementById('recentTable');
156
- table.innerHTML = '<tr><th>Время</th><th>Модель</th><th>Провайдер</th><th>Статус</th><th>Задержка</th></tr>';
157
- recent.forEach(r => {
158
- const row = table.insertRow();
159
- row.insertCell().textContent = new Date(r.timestamp).toLocaleTimeString('ru-RU');
160
- row.insertCell().textContent = r.model || '-';
161
- row.insertCell().textContent = r.provider || '-';
162
- row.insertCell().textContent = r.status || '-';
163
- row.insertCell().textContent = (r.latency || 0) + 'ms';
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>`;