freegate 0.6.17 → 0.6.20

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
@@ -8,166 +8,296 @@ const HTML = `<!DOCTYPE html>
8
8
  <head>
9
9
  <meta charset="UTF-8">
10
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">
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='%23e8b553'/%3E%3C/svg%3E">
12
12
  <title>Freegate — Панель управления</title>
13
- <style>
13
+ <style>
14
+ /* ── Aesthetic: современный тёмный админ-дашборд (в стиле PolyCopy) ── */
14
15
  :root {
15
- --bg: #0d1117; --panel: #161b22; --panel2: #1c2330; --border: #232a3d;
16
- --accent: #00d4ff; --ok: #00ff88; --warn: #ffaa00; --err: #ff4444;
17
- --text: #e6e6e6; --muted: #8b949e;
16
+ --bg: #0d0d0f; --panel: #141416; --panel2: #1b1b1e; --border: #26262a;
17
+ --accent: #7c5cff; --accent-dim: #9d8bff; --accent-ink: #c9beff;
18
+ --ok: #4ade80; --warn: #fbbf24; --err: #f87171;
19
+ --text: #ececef; --muted: #9a9aa2;
20
+ --radius: 12px; --radius-sm: 8px;
21
+ /* тоновые полупрозрачные акценты (для мульти-тем) */
22
+ --t-log: rgba(124,92,255,.18); --t-glow: rgba(124,92,255,.2);
23
+ --t-row: rgba(124,92,255,.03); --t-chip: rgba(124,92,255,.16);
24
+ --t-bg-ok: rgba(74,222,128,.12); --t-bd-ok: rgba(74,222,128,.35); --t-dot-ok: rgba(74,222,128,.18);
25
+ --t-bg-warn: rgba(251,191,36,.12); --t-bd-warn: rgba(251,191,36,.4);
26
+ --t-bg-err: rgba(248,113,113,.12); --t-bd-err: rgba(248,113,113,.4);
27
+ --t-bg-muted: rgba(154,154,162,.14); --t-bg-muted2: rgba(154,154,162,.1);
28
+ }
29
+ body[data-theme="warm"] {
30
+ --bg: #131006; --panel: #1a1510; --panel2: #211c13; --border: #372e1c;
31
+ --accent: #e8b553; --accent-dim: #c8983f; --accent-ink: #ffd58a;
32
+ --ok: #8fbf6a; --warn: #e0a449; --err: #d97b6c;
33
+ --text: #e8dcc0; --muted: #8a7d5f;
34
+ --t-log: rgba(232,181,83,.18); --t-glow: rgba(232,181,83,.2);
35
+ --t-row: rgba(232,181,83,.03); --t-chip: rgba(232,181,83,.16);
36
+ --t-bg-ok: rgba(143,191,106,.12); --t-bd-ok: rgba(143,191,106,.35); --t-dot-ok: rgba(143,191,106,.18);
37
+ --t-bg-warn: rgba(224,164,73,.12); --t-bd-warn: rgba(224,164,73,.4);
38
+ --t-bg-err: rgba(217,123,108,.12); --t-bd-err: rgba(217,123,108,.4);
39
+ --t-bg-muted: rgba(154,143,117,.14); --t-bg-muted2: rgba(154,143,117,.1);
40
+ }
41
+ body[data-theme="cosmic"] {
42
+ --bg: #0a1018; --panel: #0f1720; --panel2: #141e29; --border: #1f2d3c;
43
+ --accent: #6fb6e0; --accent-dim: #4f8fb5; --accent-ink: #a9d8f5;
44
+ --ok: #6fbfa0; --warn: #d9b06a; --err: #d97b7b;
45
+ --text: #d7e2e8; --muted: #5d6d7d;
46
+ --t-log: rgba(111,182,224,.18); --t-glow: rgba(111,182,224,.2);
47
+ --t-row: rgba(111,182,224,.03); --t-chip: rgba(111,182,224,.16);
48
+ --t-bg-ok: rgba(111,191,160,.12); --t-bd-ok: rgba(111,191,160,.35); --t-dot-ok: rgba(111,191,160,.18);
49
+ --t-bg-warn: rgba(217,176,106,.12); --t-bd-warn: rgba(217,176,106,.4);
50
+ --t-bg-err: rgba(217,123,123,.12); --t-bd-err: rgba(217,123,123,.4);
51
+ --t-bg-muted: rgba(122,134,147,.14); --t-bg-muted2: rgba(122,134,147,.1);
52
+ }
53
+ body[data-theme="paper"] {
54
+ --bg: #f4f5f7; --panel: #ffffff; --panel2: #eef1f5; --border: #d7dce3;
55
+ --accent: #1f3a5f; --accent-dim: #2c5282; --accent-ink: #1f3a5f;
56
+ --ok: #2e7d52; --warn: #b26a00; --err: #c0392b;
57
+ --text: #1c2733; --muted: #6b7683;
58
+ --t-log: rgba(31,58,95,.18); --t-glow: rgba(31,58,95,.2);
59
+ --t-row: rgba(31,58,95,.03); --t-chip: rgba(31,58,95,.12);
60
+ --t-bg-ok: rgba(46,125,82,.1); --t-bd-ok: rgba(46,125,82,.35); --t-dot-ok: rgba(46,125,82,.18);
61
+ --t-bg-warn: rgba(178,106,0,.1); --t-bd-warn: rgba(178,106,0,.4);
62
+ --t-bg-err: rgba(192,57,43,.1); --t-bd-err: rgba(192,57,43,.4);
63
+ --t-bg-muted: rgba(107,118,131,.14); --t-bg-muted2: rgba(107,118,131,.1);
18
64
  }
19
65
  * { margin: 0; padding: 0; box-sizing: border-box; }
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; }
66
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
67
+ background: var(--bg); color: var(--text); padding-bottom: 40px;
68
+ -webkit-font-smoothing: antialiased; }
69
+ a { color: inherit; text-decoration: none; }
70
+
71
+ /* ── Раскладка: сайдбар + контент ── */
72
+ .layout { display: flex; min-height: 100vh; }
73
+ #sidebar { width: 240px; flex-shrink: 0; background: var(--panel); border-right: 1px solid var(--border);
74
+ position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column; padding: 20px 14px; }
75
+ .side-brand { display: flex; align-items: center; gap: 10px; margin-bottom: 26px; padding: 0 6px; }
76
+ .side-logo { width: 26px; height: 26px; border-radius: 7px; background: var(--accent); display: grid; place-items: center;
77
+ color: #fff; font-weight: 800; font-size: 14px; box-shadow: 0 0 0 3px var(--t-log); }
78
+ .side-brand .name { font-weight: 700; font-size: 16px; letter-spacing: .2px; }
79
+ .side-brand .name span { color: var(--muted); font-weight: 600; }
80
+
81
+ /* Сайдбар-навигация */
82
+ #tabs { display: flex; flex-direction: column; gap: 3px; }
83
+ .tab { padding: 10px 12px; border-radius: var(--radius-sm); cursor: pointer; color: var(--muted);
84
+ font-size: 13.5px; font-weight: 500; user-select: none; transition: all .15s ease; display: flex; align-items: center; gap: 10px;
85
+ border: none; background: none; text-align: left; position: relative; }
86
+ .tab::before { content: ''; width: 7px; height: 7px; border-radius: 2px; background: currentColor; opacity: .6; flex-shrink: 0; }
87
+ .tab:hover { color: var(--text); background: var(--panel2); }
88
+ .tab.active { color: var(--accent-ink); background: var(--panel2); }
89
+ .tab.active::before { background: var(--accent); opacity: 1; box-shadow: 0 0 0 3px var(--t-glow); }
90
+
91
+ .side-foot { margin-top: auto; padding: 12px 6px 2px; border-top: 1px solid var(--border); }
92
+ .side-user { display: flex; align-items: center; gap: 10px; padding: 8px 6px; }
93
+ .side-avatar { width: 30px; height: 30px; border-radius: 8px; background: var(--panel2); display: grid; place-items: center;
94
+ color: var(--accent-dim); font-weight: 700; font-size: 13px; border: 1px solid var(--border); }
95
+ .side-user .info .role { font-size: 11px; color: var(--muted); }
96
+ .side-user .info .email { font-size: 12.5px; font-weight: 600; }
97
+
98
+ /* ── Контент ── */
99
+ #main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
100
+ #topbar { position: sticky; top: 0; z-index: 10; background: color-mix(in srgb, var(--bg) 92%, transparent); backdrop-filter: blur(10px);
101
+ border-bottom: 1px solid var(--border); padding: 14px 28px; display: flex; align-items: center; gap: 14px; }
102
+ .brand { font-size: 15px; font-weight: 700; color: var(--text); display: flex; align-items: center; gap: 9px; }
103
+ .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; background: var(--muted); }
104
+ .dot.ok { background: var(--ok); box-shadow: 0 0 0 3px var(--t-dot-ok); }
105
+ .dot.bad { background: var(--err); box-shadow: 0 0 0 3px var(--t-bd-err); }
106
+
107
+ .lang-switch { margin-left: auto; display: flex; gap: 4px; background: var(--panel); border: 1px solid var(--border);
108
+ border-radius: 999px; padding: 3px; }
109
+ .lang-btn { padding: 5px 14px; border-radius: 999px; background: transparent; border: none;
110
+ color: var(--muted); font-size: 12px; font-weight: 600; cursor: pointer; transition: all .15s ease; }
111
+ .lang-btn.active { color: #fff; background: var(--accent); }
112
+ .lang-btn:hover:not(.active) { color: var(--text); }
113
+
114
+ /* ── Переключатель темы ── */
115
+ .theme-switch { display: flex; align-items: center; gap: 8px; padding: 4px 6px; border: 1px solid var(--border);
116
+ background: var(--panel); border-radius: 999px; }
117
+ .theme-swatch { width: 14px; height: 14px; border-radius: 4px; }
118
+ .theme-btn { padding: 4px 14px; border-radius: 999px; background: transparent; border: none;
119
+ color: var(--muted); font-size: 12px; font-weight: 600; cursor: pointer; transition: all .15s ease; }
120
+ .theme-btn:hover { color: var(--text); }
121
+
122
+ /* ── Главный заголовок ── */
123
+ #page-head { padding: 26px 28px 0; }
124
+ #page-head .crumb { color: var(--muted); font-size: 12px; margin-bottom: 4px; }
125
+ #page-head h1 { font-size: 20px; font-weight: 700; }
126
+ #page-head .sub { color: var(--muted); font-size: 13px; margin-top: 3px; }
127
+
128
+ /* ── KPI-карточки (крупные метрики) ── */
129
+ #kpiBar { padding: 20px 28px 4px; display: grid !important; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 14px; }
130
+ .kpi-chip { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 16px 13px; }
131
+ .kpi-label { display: block; color: var(--muted); font-size: 12px; font-weight: 500; }
132
+ .kpi-chip b { display: block; color: var(--text); font-size: 24px; font-weight: 700; margin-top: 8px; letter-spacing: -.5px; }
133
+ .kpi-chip.warn b { color: var(--warn); } .kpi-chip.err b { color: var(--err); }
134
+ .kpi-chip.warn, .kpi-chip.err { border-color: var(--t-bd-warn); }
135
+ .kpi-chip.err { border-color: var(--t-bd-err); }
136
+
137
+ /* ── Панели: общий перенос заголовка KPI полоски стал частью content ── */
138
+ main { padding: 0 28px; }
43
139
  .panel { display: none; padding-top: 16px; }
44
140
  .panel.active { display: block; }
45
141
 
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
142
  /* ── Карточки/сетки ── */
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; }
143
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; }
144
+ .card { background: var(--panel); border-radius: var(--radius); padding: 18px 20px; border: 1px solid var(--border); }
145
+ .card h2 { color: var(--muted); font-size: 11px; margin-bottom: 14px; text-transform: uppercase; letter-spacing: .8px;
146
+ display: flex; align-items: center; gap: 8px; font-weight: 600; }
147
+ .card h2::before { content: ''; width: 4px; height: 4px; border-radius: 50%; background: var(--accent); }
148
+ .stat { display: flex; justify-content: space-between; margin-bottom: 8px; gap: 10px; }
149
+ .stat-label { color: var(--muted); font-size: 13px; }
150
+ .stat-value { color: var(--text); font-weight: 600; text-align: right; font-size: 13px; }
60
151
 
61
152
  /* ── Алерты ── */
62
153
  #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; }
154
+ .alert { padding: 11px 16px; border-radius: var(--radius-sm); font-size: 13px; border: 1px solid; background: var(--panel); }
155
+ .alert.err { border-color: var(--t-bd-err); color: #fbc4c4; }
156
+ .alert.warn { border-color: var(--t-bd-warn); color: #fde9b8; }
157
+ .alert.ok { border-color: var(--t-bd-ok); color: #b7efcd; }
67
158
 
68
159
  /* ── Тулбар: поиск + фильтры ── */
69
160
  .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; }
161
+ .search { flex: 1; min-width: 180px; max-width: 320px; padding: 10px 14px; border-radius: var(--radius-sm);
162
+ border: 1px solid var(--border); background: var(--panel); color: var(--text); font-size: 13px; outline: none;
163
+ transition: border-color .15s ease; }
72
164
  .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; }
165
+ select.filter { padding: 10px 12px; border-radius: var(--radius-sm); border: 1px solid var(--border);
166
+ background: var(--panel); color: var(--text); font-size: 13px; outline: none; cursor: pointer; }
75
167
  .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); }
168
+ .chip { padding: 8px 14px; border-radius: 999px; background: var(--panel); border: 1px solid var(--border);
169
+ color: var(--muted); font-size: 12.5px; cursor: pointer; user-select: none; transition: all .15s ease; }
170
+ .chip.active { color: #fff; border-color: var(--accent); background: var(--t-chip); }
171
+ .chip:hover { color: var(--text); }
79
172
  .count-hint { color: var(--muted); font-size: 12px; margin-left: auto; }
80
173
 
81
174
  /* ── Таблицы ── */
82
- .table-wrap { overflow-x: auto; background: var(--panel); border-radius: 10px; border: 1px solid var(--border); }
175
+ .table-wrap { overflow-x: auto; background: var(--panel); border-radius: var(--radius); border: 1px solid var(--border); }
83
176
  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; }
177
+ th, td { text-align: left; padding: 12px 16px; border-bottom: 1px solid var(--border); font-size: 13px; white-space: nowrap; }
178
+ th { color: var(--muted); font-size: 11.5px; text-transform: uppercase; letter-spacing: .6px; cursor: pointer; user-select: none; font-weight: 600; }
179
+ th:hover { color: var(--text); }
180
+ th .arr { font-size: 9px; margin-left: 4px; }
88
181
  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; }
182
+ tbody tr:last-child td { border-bottom: none; }
183
+ tr:hover td { background: var(--t-row); }
184
+ .badge { padding: 3px 10px; border-radius: 999px; font-size: 11px; font-weight: 600; display: inline-block; }
185
+ .badge.up, .badge.active { background: var(--t-bg-ok); color: var(--ok); }
186
+ .badge.ratelimited { background: var(--t-bg-warn); color: var(--warn); }
187
+ .badge.error, .badge.dead { background: var(--t-bg-err); color: var(--err); }
188
+ .badge.unknown, .badge.untested { background: var(--t-bg-muted); color: var(--muted); }
189
+ .badge.disabled, .badge.user-disabled { background: var(--t-bg-muted2); color: var(--muted); }
190
+ .limit-bar { width: 110px; height: 6px; background: var(--panel2); border-radius: 4px; overflow: hidden; display: inline-block; vertical-align: middle; margin-right: 8px; }
97
191
  .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; }
192
+ .score { font-weight: 700; color: var(--text); }
193
+ .mono { font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 12px; }
100
194
 
101
195
  /* ── Действия над моделями ── */
102
196
  .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); }
197
+ .mini-btn { padding: 6px 11px; border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--panel2);
198
+ color: var(--text); font-size: 12px; cursor: pointer; transition: all .15s ease; font-weight: 500; }
199
+ .mini-btn:hover { border-color: var(--accent); color: var(--accent-ink); }
106
200
  .mini-btn.off { color: var(--warn); }
107
201
 
108
202
  /* ── Рекомендации ── */
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); }
203
+ .rec { font-size: 12.5px; margin-bottom: 7px; line-height: 1.6; }
204
+ .rec-badge { padding: 3px 10px; border-radius: 999px; font-size: 11px; font-weight: 600; margin-right: 6px; }
205
+ .rec-badge.ok { background: var(--t-bg-ok); color: var(--ok); }
206
+ .rec-badge.warn { background: var(--t-bg-warn); color: var(--warn); }
207
+ .rec-badge { background: var(--t-bg-muted); color: var(--muted); }
114
208
 
115
209
  /* ── Экономия ── */
116
- .savings .savings-usd { color: var(--ok); font-size: 24px; }
210
+ .savings .savings-usd { color: var(--ok); font-size: 28px; font-weight: 700; letter-spacing: -.5px; }
117
211
 
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); }
212
+ canvas { width: 100%; height: 180px; background: var(--panel); border-radius: var(--radius); border: 1px solid var(--border); }
213
+ .refresh { color: var(--muted); font-size: 12px; margin-top: 14px; }
214
+ .task-bar { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; font-size: 13px; }
215
+ .task-bar .name { width: 90px; color: var(--muted); text-transform: capitalize; }
122
216
  .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; }
217
+ .task-bar .fill { height: 100%; border-radius: 5px; }
218
+ .task-bar .num { width: 84px; text-align: right; color: var(--muted); }
125
219
 
126
220
  /* ── Setup ── */
127
221
  .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; }
222
+ .key-card { background: var(--panel); border-radius: var(--radius); padding: 18px 20px; border: 1px solid var(--border); }
223
+ .key-card h3 { color: var(--text); font-size: 15px; margin-bottom: 6px; font-weight: 700; }
224
+ .key-card .desc { color: var(--muted); font-size: 12.5px; margin-bottom: 10px; line-height: 1.5; }
225
+ .key-card ol { color: var(--muted); font-size: 12px; padding-left: 18px; margin-bottom: 10px; }
132
226
  .key-card li { margin-bottom: 4px; }
133
- .key-card a { color: var(--accent); text-decoration: none; }
227
+ .key-card a { color: var(--accent-dim); }
134
228
  .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; }
229
+ .key-input { flex: 1; padding: 10px; border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--bg); color: var(--text); font-size: 13px; font-family: monospace; }
230
+ .btn { padding: 10px 16px; border-radius: var(--radius-sm); border: none; cursor: pointer; font-size: 13px; font-weight: 600; transition: all .15s ease; }
231
+ .btn-validate { background: var(--panel2); color: var(--text); border: 1px solid var(--border); }
232
+ .btn-validate:hover { border-color: var(--accent); color: var(--accent-ink); }
233
+ .btn-save { background: var(--accent); color: #fff; }
234
+ .btn-save:hover { filter: brightness(1.08); }
235
+ .btn-test-all { background: var(--panel2); color: var(--ok); border: 1px solid var(--border); }
236
+ .status-ok { color: var(--ok); font-size: 12px; }
237
+ .status-err { color: var(--err); font-size: 12px; }
238
+ .status-wait { color: var(--muted); font-size: 12px; }
143
239
  .hidden { display: none; }
144
240
 
241
+ @media (max-width: 900px) {
242
+ #sidebar { width: 64px; padding: 20px 8px; }
243
+ .side-brand .name, .tab .label, .side-user .info { display: none; }
244
+ .tab { justify-content: center; }
245
+ .side-user { justify-content: center; }
246
+ }
145
247
  @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; }
248
+ #topbar, #page-head, main, #kpiBar { padding-left: 16px; padding-right: 16px; }
249
+ .kpi-chip b { font-size: 20px; }
149
250
  }
150
251
  </style>
151
252
  </head>
152
253
  <body>
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>
159
- </div>
160
- </header>
254
+ <div class="layout">
255
+ <aside id="sidebar">
256
+ <div class="side-brand">
257
+ <div class="side-logo">F</div>
258
+ <div class="name">free<strong>gate</strong></div>
259
+ </div>
260
+
261
+ <nav id="tabs">
262
+ <div class="tab active" data-i18n="tabOverview" onclick="showTab('overview')"></div>
263
+ <div class="tab" data-i18n="tabProviders" onclick="showTab('providers')"></div>
264
+ <div class="tab" data-i18n="tabModels" onclick="showTab('models')"></div>
265
+ <div class="tab" data-i18n="tabContext" onclick="showTab('context')"></div>
266
+ <div class="tab" data-i18n="tabSetup" onclick="showTab('setup')"></div>
267
+ </nav>
268
+
269
+ <div class="side-foot">
270
+ <div class="side-user">
271
+ <div class="side-avatar">F</div>
272
+ <div class="info">
273
+ <div class="email">Freegate</div>
274
+ <div class="role">Admin</div>
275
+ </div>
276
+ </div>
277
+ </div>
278
+ </aside>
279
+
280
+ <div id="main">
281
+ <header id="topbar">
282
+ <div class="brand"><span id="statusDot" class="dot"></span><span data-i18n="topTitle"></span></div>
283
+ <div class="theme-switch" title="">
284
+ <button class="theme-btn" id="themeBtn" onclick="cycleTheme()"></button>
285
+ </div>
286
+ <div class="lang-switch">
287
+ <button class="lang-btn" data-lang="ru" onclick="setLang('ru')">RU</button>
288
+ <button class="lang-btn" data-lang="en" onclick="setLang('en')">EN</button>
289
+ </div>
290
+ </header>
161
291
 
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>
292
+ <div id="page-head">
293
+ <div class="crumb" data-i18n="crumb"></div>
294
+ <h1 data-i18n="pageTitle"></h1>
295
+ <div class="sub" data-i18n="pageSub"></div>
296
+ </div>
297
+
298
+ <div class="kpi" id="kpiBar"></div>
169
299
 
170
- <main>
300
+ <main>
171
301
  <!-- ═══ ОБЗОР ═══ -->
172
302
  <section id="panel-overview" class="panel active">
173
303
  <div id="alerts"></div>
@@ -177,9 +307,21 @@ const HTML = `<!DOCTYPE html>
177
307
  <div class="card"><h2 data-i18n="cardPool"></h2><div id="pool" data-i18n="loading"></div></div>
178
308
  <div class="card"><h2 data-i18n="cardSavings"></h2><div id="savings" data-i18n="loading"></div></div>
179
309
  </div>
180
- <div class="card" style="margin-top:16px;"><h2 data-i18n="cardRpm"></h2><canvas id="rpmChart"></canvas></div>
310
+ <div class="grid">
311
+ <div class="card"><h2 data-i18n="cardRpm"></h2><canvas id="rpmChart"></canvas></div>
312
+ <div class="card"><h2 data-i18n="cardSpark"></h2><canvas id="sparkline"></canvas></div>
313
+ </div>
181
314
  <div class="card" style="margin-top:16px;">
182
315
  <h2 data-i18n="cardRecent"></h2>
316
+ <div class="toolbar">
317
+ <input id="recentSearch" class="search" type="text" placeholder="" data-i18n-ph="searchRecent" oninput="renderRecent(_recent)">
318
+ <select id="recentStatusFilter" class="filter" onchange="renderRecent(_recent)">
319
+ <option value="all" data-i18n="filterAll"></option>
320
+ <option value="ok" data-i18n="recentOk"></option>
321
+ <option value="err" data-i18n="recentErr"></option>
322
+ </select>
323
+ <span class="count-hint" id="recentCount"></span>
324
+ </div>
183
325
  <div class="table-wrap"><table id="recentTable"></table></div>
184
326
  </div>
185
327
  </section>
@@ -235,9 +377,11 @@ const HTML = `<!DOCTYPE html>
235
377
  </div>
236
378
  <div id="setup-grid" class="setup-grid" data-i18n="loading"></div>
237
379
  </section>
238
- </main>
380
+ </main>
239
381
 
240
- <div class="refresh" style="padding: 0 20px;" data-i18n="autoRefresh"></div>
382
+ <div class="refresh" style="padding: 0 28px;" data-i18n="autoRefresh"></div>
383
+ </div>
384
+ </div>
241
385
 
242
386
  <script>
243
387
  // Carry the ?key= param from the dashboard URL to API calls
@@ -251,17 +395,19 @@ const HTML = `<!DOCTYPE html>
251
395
  ru: {
252
396
  tabOverview: 'Обзор', tabProviders: 'Провайдеры', tabModels: 'Модели',
253
397
  tabContext: 'Контекст', tabSetup: 'Настройки (ключи)',
398
+ crumb: 'Админ', pageTitle: 'Панель управления', pageSub: 'Мониторинг и управление Freegate', topTitle: 'Панель управления',
254
399
  kpiProviders: 'Провайдеры', kpiRequests: 'Запросы', kpiRpm: 'RPM', kpiCache: 'Кэш',
255
400
  kpiToday: 'Успех сегодня', kpiLimit: 'Лимит', kpiModels: 'Модели', kpiUptime: 'Аптайм',
256
401
  cardSavings: 'Экономия (против платных API)', savings: 'Сэкономлено', tokens: 'токенов',
257
402
  savingsBasis: 'расчёт: input $3/M, output $15/M (репрезентативно)',
258
403
  cardCache: 'Кэш', cardTokens: 'Токены', cardPool: 'Очередь',
259
- cardRpm: 'Запросов в минуту', cardRecent: 'Недавние запросы',
404
+ cardRpm: 'Запросов в минуту', cardRecent: 'Недавние запросы', cardSpark: 'Успех сегодня (по часам)',
260
405
  cardCtx: 'Контекст (узкое место)', cardTasks: 'Категории задач (24ч)',
261
406
  loading: 'Загрузка...', noData: 'Нет данных',
262
407
  cacheHits: 'Хиты', cacheMisses: 'Промахи', cacheSize: 'Размер', cacheRate: 'Точность',
263
408
  poolAll: 'без очереди', poolActive: 'активных', poolWait: 'ждут',
264
- searchProvider: 'Поиск провайдера...', searchModel: 'Поиск модели...',
409
+ searchProvider: 'Поиск провайдера...', searchModel: 'Поиск модели...', searchRecent: 'Поиск по модели/провайдеру...',
410
+ recentOk: 'OK', recentErr: 'Ошибки',
265
411
  filterAll: 'Все', filterUp: 'Up', filterLimit: 'Лимит', filterError: 'Ошибка', filterUnknown: 'Неизвестно',
266
412
  thStatus: 'Статус', thProvider: 'Провайдер', thLatency: 'Задержка', thReliability: 'Стабильность',
267
413
  thLimit: 'Лимит', thReason: 'Причина', dot: '—',
@@ -287,17 +433,19 @@ const HTML = `<!DOCTYPE html>
287
433
  en: {
288
434
  tabOverview: 'Overview', tabProviders: 'Providers', tabModels: 'Models',
289
435
  tabContext: 'Context', tabSetup: 'Settings (keys)',
436
+ crumb: 'Admin', pageTitle: 'Control Panel', pageSub: 'Freegate monitoring & management', topTitle: 'Control Panel',
290
437
  kpiProviders: 'Providers', kpiRequests: 'Requests', kpiRpm: 'RPM', kpiCache: 'Cache',
291
438
  kpiToday: 'Success today', kpiLimit: 'Limit', kpiModels: 'Models', kpiUptime: 'Uptime',
292
439
  cardSavings: 'Savings (vs paid APIs)', savings: 'Saved', tokens: 'tokens',
293
440
  savingsBasis: 'estimate: input $3/M, output $15/M (representative)',
294
441
  cardCache: 'Cache', cardTokens: 'Tokens', cardPool: 'Queue',
295
- cardRpm: 'Requests per minute', cardRecent: 'Recent requests',
442
+ cardRpm: 'Requests per minute', cardRecent: 'Recent requests', cardSpark: 'Success today (hourly)',
296
443
  cardCtx: 'Context (bottleneck)', cardTasks: 'Task categories (24h)',
297
444
  loading: 'Loading...', noData: 'No data',
298
445
  cacheHits: 'Hits', cacheMisses: 'Misses', cacheSize: 'Size', cacheRate: 'Rate',
299
446
  poolAll: 'no queue', poolActive: 'active', poolWait: 'waiting',
300
- searchProvider: 'Search provider...', searchModel: 'Search model...',
447
+ searchProvider: 'Search provider...', searchModel: 'Search model...', searchRecent: 'Search by model/provider...',
448
+ recentOk: 'OK', recentErr: 'Errors',
301
449
  filterAll: 'All', filterUp: 'Up', filterLimit: 'Limit', filterError: 'Error', filterUnknown: 'Unknown',
302
450
  thStatus: 'Status', thProvider: 'Provider', thLatency: 'Latency', thReliability: 'Reliability',
303
451
  thLimit: 'Limit', thReason: 'Reason', dot: '—',
@@ -331,9 +479,31 @@ const HTML = `<!DOCTYPE html>
331
479
  btns.forEach(function (b) { b.classList.toggle('active', b.getAttribute('data-lang') === _lang); });
332
480
  }
333
481
 
482
+ // ── темы: data-theme на body + переключатель ──
483
+ var THEME_LABELS = { poly: 'PolyCopy', warm: 'Тёплый', cosmic: 'Космос', paper: 'Бумага' };
484
+ var THEME_ORDER = ['poly', 'warm', 'cosmic', 'paper'];
485
+ var _theme = 'poly';
486
+ function applyTheme() {
487
+ document.body.setAttribute('data-theme', _theme);
488
+ var btn = document.getElementById('themeBtn');
489
+ if (btn) btn.textContent = (_lang === 'en' ? 'Theme' : 'Тема') + ': ' + (THEME_LABELS[_theme] || _theme);
490
+ }
491
+ function cycleTheme() {
492
+ var i = THEME_ORDER.indexOf(_theme);
493
+ _theme = THEME_ORDER[(i + 1) % THEME_ORDER.length];
494
+ localStorage.setItem('fg_theme', _theme);
495
+ applyTheme();
496
+ }
497
+ function setTheme(name) {
498
+ if (THEME_ORDER.indexOf(name) < 0) name = 'poly';
499
+ _theme = name; localStorage.setItem('fg_theme', name); applyTheme();
500
+ }
501
+ _theme = (new URLSearchParams(location.search).get('theme')) || localStorage.getItem('fg_theme') || 'poly';
502
+ if (THEME_ORDER.indexOf(_theme) < 0) _theme = 'poly';
503
+
334
504
  // ── состояние клиента ──
335
505
  var _health = {}, _limits = {}, _cache = null, _pool = {}, _statsData = null, _ctxSummary = null;
336
- var _rpmData = [], _recent = [];
506
+ var _rpmData = [], _recent = [], _hourly = [];
337
507
  var _models = [], _modelStats = null, _modelManager = null, _modelsFetchedAt = 0;
338
508
  var _savings = null;
339
509
  var _providerFilter = 'all', _providerQuery = '';
@@ -349,6 +519,8 @@ const HTML = `<!DOCTYPE html>
349
519
  });
350
520
  document.querySelectorAll('.panel').forEach(function (p) { p.classList.remove('active'); });
351
521
  document.getElementById('panel-' + tab).classList.add('active');
522
+ // Шаринг вкладки по хэшу (#models и т.д.)
523
+ if (location.hash !== '#' + tab) { try { history.replaceState(null, '', '#' + tab); } catch (e) {} }
352
524
  if (tab === 'setup' && !_setupData) loadSetup();
353
525
  if (tab === 'models') fetchModels();
354
526
  }
@@ -388,10 +560,12 @@ const HTML = `<!DOCTYPE html>
388
560
  _pool = _statsData.pool || {};
389
561
  _ctxSummary = _statsData.context_summary || null;
390
562
  _savings = _statsData.savings || null;
563
+ _hourly = _statsData.hourly || [];
391
564
  renderKpi();
392
565
  renderAlerts();
393
566
  renderOverviewCards();
394
567
  renderRpm(_rpmData);
568
+ renderSparkline(_hourly);
395
569
  renderRecent(_recent);
396
570
  renderProvidersTable();
397
571
  renderContext(_ctxSummary);
@@ -410,22 +584,22 @@ const HTML = `<!DOCTYPE html>
410
584
  if (p > riskPct) { riskPct = p; riskKey = lk; }
411
585
  }
412
586
  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>';
587
+ html += '<div class="kpi-chip"><span class="kpi-label">' + t('kpiProviders') + '</span><b>' + up + '/' + total + '</b></div>';
588
+ html += '<div class="kpi-chip"><span class="kpi-label">' + t('kpiRequests') + '</span><b>' + (_statsData ? _statsData.total_requests : 0) + '</b></div>';
589
+ html += '<div class="kpi-chip"><span class="kpi-label">' + t('kpiRpm') + '</span><b>' + rpm + '</b></div>';
590
+ html += '<div class="kpi-chip"><span class="kpi-label">' + t('kpiCache') + '</span><b>' + cacheRate + '%</b></div>';
417
591
  // Успех сегодня — главная метрика ценности. Зелёная при высоком, жёлтая/красная при деградации.
418
592
  if (_statsData && _statsData.today) {
419
593
  var td = _statsData.today;
420
594
  var tbCls = td.successRate >= 80 ? '' : td.successRate >= 50 ? ' warn' : ' err';
421
- html += '<div class="kpi-chip' + tbCls + '">' + t('kpiToday') + '<b>' + td.successRate + '%</b></div>';
595
+ html += '<div class="kpi-chip' + tbCls + '"><span class="kpi-label">' + t('kpiToday') + '</span><b>' + td.successRate + '%</b></div>';
422
596
  }
423
597
  if (riskKey) {
424
598
  var cls = riskPct > 90 ? ' err' : riskPct > 60 ? ' warn' : '';
425
- html += '<div class="kpi-chip' + cls + '">' + t('kpiLimit') + ': ' + esc(riskKey) + '<b>' + riskPct + '%</b></div>';
599
+ html += '<div class="kpi-chip' + cls + '"><span class="kpi-label">' + t('kpiLimit') + ': ' + esc(riskKey) + '</span><b>' + riskPct + '%</b></div>';
426
600
  }
427
- if (_modelStats) html += '<div class="kpi-chip">' + t('kpiModels') + '<b>' + (_modelStats.byStatus.active || 0) + '/' + _modelStats.total + '</b></div>';
428
- html += '<div class="kpi-chip">' + t('kpiUptime') + '<b>' + fmtUptime(_statsData ? _statsData.uptime_seconds : 0) + '</b></div>';
601
+ if (_modelStats) html += '<div class="kpi-chip"><span class="kpi-label">' + t('kpiModels') + '</span><b>' + (_modelStats.byStatus.active || 0) + '/' + _modelStats.total + '</b></div>';
602
+ html += '<div class="kpi-chip"><span class="kpi-label">' + t('kpiUptime') + '</span><b>' + fmtUptime(_statsData ? _statsData.uptime_seconds : 0) + '</b></div>';
429
603
  document.getElementById('kpiBar').innerHTML = html;
430
604
  var dot = document.getElementById('statusDot');
431
605
  dot.className = 'dot ' + (up > 0 ? 'ok' : 'bad');
@@ -499,7 +673,7 @@ const HTML = `<!DOCTYPE html>
499
673
  var w = canvas.width, h = canvas.height, padL = 34, padB = 16, padT = 8;
500
674
  ctx.clearRect(0, 0, w, h);
501
675
  ctx.font = '10px monospace';
502
- ctx.fillStyle = '#8b949e';
676
+ ctx.fillStyle = '#5d6d7d';
503
677
  var max = 1;
504
678
  for (var i = 0; i < rpmData.length; i++) if (rpmData[i].count > max) max = rpmData[i].count;
505
679
  // сетка: 4 линии
@@ -510,8 +684,9 @@ const HTML = `<!DOCTYPE html>
510
684
  ctx.fillText(String(Math.round(max * g / 4)), 4, gy + 3);
511
685
  }
512
686
  if (!rpmData || rpmData.length === 0) { ctx.fillText('нет данных', padL + 10, h / 2); return; }
513
- ctx.strokeStyle = '#00d4ff';
687
+ ctx.strokeStyle = '#e8b553';
514
688
  ctx.lineWidth = 2;
689
+ ctx.shadowColor = '#e8b553'; ctx.shadowBlur = 4;
515
690
  ctx.beginPath();
516
691
  var step = (w - padL - 4) / Math.max(rpmData.length - 1, 1);
517
692
  rpmData.forEach(function (r, i) {
@@ -520,19 +695,83 @@ const HTML = `<!DOCTYPE html>
520
695
  if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
521
696
  });
522
697
  ctx.stroke();
698
+ ctx.shadowBlur = 0;
699
+ // неоновая заливка под линией
700
+ ctx.lineTo(w - 4, h - padB);
701
+ ctx.lineTo(padL, h - padB);
702
+ ctx.closePath();
703
+ var grad = ctx.createLinearGradient(0, padT, 0, h - padB);
704
+ grad.addColorStop(0, 'rgba(232,181,83,.20)'); grad.addColorStop(1, 'rgba(232,181,83,0)');
705
+ ctx.fillStyle = grad;
706
+ ctx.fill();
523
707
  ctx.fillText('-60м', padL, h - 3);
524
708
  ctx.fillText('сейчас', w - 44, h - 3);
525
709
  }
526
710
 
711
+ // ── Sparkline: успех/ошибки по часам (24ч) ──
712
+ function renderSparkline(hourly) {
713
+ var canvas = document.getElementById('sparkline');
714
+ if (!canvas) return;
715
+ var ctx = canvas.getContext('2d');
716
+ canvas.width = canvas.offsetWidth;
717
+ canvas.height = canvas.offsetHeight;
718
+ var w = canvas.width, h = canvas.height, padL = 8, padB = 18, padT = 8;
719
+ ctx.clearRect(0, 0, w, h);
720
+ if (!hourly || hourly.length === 0) {
721
+ ctx.fillStyle = '#5d6d7d'; ctx.font = '11px sans-serif';
722
+ ctx.fillText(t('noData'), padL + 4, h / 2);
723
+ return;
724
+ }
725
+ var data = hourly;
726
+ var maxVal = 1;
727
+ for (var i = 0; i < data.length; i++) if ((data[i].total || 0) > maxVal) maxVal = data[i].total;
728
+ var step = (w - padL - padB) / Math.max(data.length - 1, 1);
729
+ var barW = Math.max(2, Math.min(12, step - 3));
730
+ // колонки успеха (зелёные) + хвост ошибок (красный)
731
+ for (var i = 0; i < data.length; i++) {
732
+ var d = data[i];
733
+ var total = d.total || 0;
734
+ if (total === 0) continue;
735
+ var ok = d.ok || 0;
736
+ var fail = total - ok;
737
+ var x = padL + i * step;
738
+ var headH = (h - padT - padB);
739
+ var okH = (ok / maxVal) * headH;
740
+ var failH = (fail / maxVal) * headH;
741
+ var baseY = h - padB;
742
+ if (failH > 0) {
743
+ ctx.fillStyle = '#f87171';
744
+ ctx.fillRect(x, baseY - okH - failH, barW, failH);
745
+ }
746
+ ctx.fillStyle = '#4ade80';
747
+ ctx.fillRect(x, baseY - okH, barW, okH);
748
+ }
749
+ // подписи первого/последнего часа
750
+ ctx.fillStyle = '#5d6d7d'; ctx.font = '10px monospace';
751
+ ctx.fillText((data[0].hour || '').slice(8, 13), padL, h - 4);
752
+ var lastLbl = (data[data.length - 1].hour || '').slice(8, 13);
753
+ ctx.fillText(lastLbl, w - padB - 30, h - 4);
754
+ }
755
+
527
756
  // ── недавние запросы ──
528
757
  function renderRecent(recent) {
529
758
  var table = document.getElementById('recentTable');
759
+ var q = (document.getElementById('recentSearch') ? (document.getElementById('recentSearch').value || '') : '').toLowerCase();
760
+ var stF = document.getElementById('recentStatusFilter') ? document.getElementById('recentStatusFilter').value : 'all';
761
+ var rows = recent.filter(function (r) {
762
+ if (stF === 'ok' && r.status !== 200) return false;
763
+ if (stF === 'err' && (r.status === 200 || r.status === undefined)) return false;
764
+ if (q && ((r.model || '') + ' ' + (r.provider || '')).toLowerCase().indexOf(q) < 0) return false;
765
+ return true;
766
+ });
530
767
  var html = '<tr><th>' + t('thRecentTime') + '</th><th>' + t('thRecentModel') + '</th><th>' + t('thRecentProvider') + '</th><th>' + t('thRecentStatus') + '</th><th>' + t('thRecentLatency') + '</th></tr>';
531
- recent.forEach(function (r) {
532
- var sc = r.status === 200 ? 'var(--ok)' : 'var(--err)';
768
+ rows.forEach(function (r) {
769
+ var sc = r.status === 200 ? '#4ade80' : '#f87171';
533
770
  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>';
534
771
  });
535
772
  table.innerHTML = html;
773
+ var cnt = document.getElementById('recentCount');
774
+ if (cnt) cnt.textContent = rows.length + ' / ' + recent.length;
536
775
  }
537
776
 
538
777
  // ── ПРОВАЙДЕРЫ: фильтры/сортировка/таблица ──
@@ -735,7 +974,7 @@ const HTML = `<!DOCTYPE html>
735
974
  // категории задач — прогресс-бары
736
975
  var bars = '';
737
976
  if (sum.tasks && sum.taskTotal > 0) {
738
- var colors = { coding: '#00d4ff', reasoning: '#b388ff', search: '#00ff88', chat: '#ffaa00' };
977
+ var colors = { coding: '#e8b553', reasoning: '#c9a0f0', search: '#7cc4e8', chat: '#e0a449', design: '#ec8fae' };
739
978
  for (var tk in sum.tasks) {
740
979
  var n = sum.tasks[tk];
741
980
  if (n <= 0) continue;
@@ -825,9 +1064,13 @@ const HTML = `<!DOCTYPE html>
825
1064
  }
826
1065
 
827
1066
  applyLang();
1067
+ applyTheme();
828
1068
  refresh();
829
1069
  setInterval(refresh, 5000);
830
1070
  setInterval(function () { _modelsFetchedAt = 0; fetchModels(); }, 30000);
1071
+ // Открыть запрошенную вкладку из хэша (#models, #providers, ...)
1072
+ var initial = (location.hash || '').replace('#', '');
1073
+ if (initial && _tabByKey[initial]) showTab(initial);
831
1074
  // модели: подгружаем рекомендации один раз после первой загрузки
832
1075
  var _isEn = _lang === 'en';
833
1076
  </script>