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/.env.example +13 -1
- package/README.md +19 -7
- package/README.ru.md +21 -7
- package/assets/dashboard-en.gif +0 -0
- package/assets/dashboard-en.png +0 -0
- package/assets/dashboard-models-en.png +0 -0
- package/assets/dashboard-models.png +0 -0
- package/assets/dashboard.gif +0 -0
- package/assets/dashboard.png +0 -0
- package/bin/freegate.js +122 -0
- package/config.example.json +39 -0
- package/lib/cache.js +19 -4
- package/lib/compress.js +85 -0
- package/lib/contextstats.js +2 -2
- package/lib/dashboard.js +378 -135
- package/lib/doctor.js +106 -0
- package/lib/health.js +48 -0
- package/lib/methodology.js +23 -2
- package/lib/modelscan.js +7 -2
- package/lib/onboarding.js +79 -0
- package/lib/setup.js +136 -0
- package/lib/strategy.js +57 -0
- package/lib/taskclassify.js +44 -3
- package/lib/vetting.js +91 -0
- package/lib/websearch.js +75 -0
- package/package.json +1 -1
- package/providers.json +264 -0
- package/server.js +127 -9
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='%
|
|
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
|
-
|
|
13
|
+
<style>
|
|
14
|
+
/* ── Aesthetic: современный тёмный админ-дашборд (в стиле PolyCopy) ── */
|
|
14
15
|
:root {
|
|
15
|
-
--bg: #
|
|
16
|
-
--accent: #
|
|
17
|
-
--
|
|
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',
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
.
|
|
30
|
-
.
|
|
31
|
-
|
|
32
|
-
.
|
|
33
|
-
.
|
|
34
|
-
|
|
35
|
-
/*
|
|
36
|
-
#tabs { display: flex;
|
|
37
|
-
.tab { padding:
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
.tab
|
|
41
|
-
|
|
42
|
-
|
|
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(
|
|
55
|
-
.card { background: var(--panel); border-radius:
|
|
56
|
-
.card h2 { color: var(--
|
|
57
|
-
|
|
58
|
-
.
|
|
59
|
-
.stat
|
|
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:
|
|
64
|
-
.alert.err {
|
|
65
|
-
.alert.warn {
|
|
66
|
-
.alert.ok {
|
|
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:
|
|
71
|
-
border: 1px solid var(--border); background: var(--panel); color:
|
|
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:
|
|
74
|
-
background: var(--panel); color: var(--text); font-size:
|
|
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:
|
|
77
|
-
color: var(--muted); font-size:
|
|
78
|
-
.chip.active { 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:
|
|
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:
|
|
85
|
-
th { color: var(--
|
|
86
|
-
th:hover { color:
|
|
87
|
-
th .arr { font-size: 9px; margin-left:
|
|
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:
|
|
90
|
-
|
|
91
|
-
.badge
|
|
92
|
-
.badge.
|
|
93
|
-
.badge.
|
|
94
|
-
.badge.
|
|
95
|
-
.badge.
|
|
96
|
-
.
|
|
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:
|
|
99
|
-
.mono { font-family: ui-monospace, Menlo, monospace; font-size:
|
|
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:
|
|
104
|
-
color: var(--text); font-size:
|
|
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:
|
|
110
|
-
.rec-badge { padding:
|
|
111
|
-
.rec-badge.ok { background:
|
|
112
|
-
.rec-badge.warn { background:
|
|
113
|
-
.rec-badge { background:
|
|
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:
|
|
210
|
+
.savings .savings-usd { color: var(--ok); font-size: 28px; font-weight: 700; letter-spacing: -.5px; }
|
|
117
211
|
|
|
118
|
-
canvas { width: 100%; height:
|
|
119
|
-
.refresh { color: var(--muted); font-size:
|
|
120
|
-
.task-bar { display: flex; align-items: center; gap: 10px; margin-bottom:
|
|
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%;
|
|
124
|
-
.task-bar .num { width:
|
|
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:
|
|
129
|
-
.key-card h3 { color: var(--
|
|
130
|
-
.key-card .desc { color: var(--muted); font-size:
|
|
131
|
-
.key-card ol { color:
|
|
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);
|
|
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:
|
|
136
|
-
.btn { padding:
|
|
137
|
-
.btn-validate { background: var(--panel2); color: var(--
|
|
138
|
-
.btn-
|
|
139
|
-
.btn-
|
|
140
|
-
.
|
|
141
|
-
.
|
|
142
|
-
.status-
|
|
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:
|
|
147
|
-
|
|
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
|
-
<
|
|
154
|
-
<
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
380
|
+
</main>
|
|
239
381
|
|
|
240
|
-
|
|
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') + '
|
|
414
|
-
html += '<div class="kpi-chip">' + t('kpiRequests') + '
|
|
415
|
-
html += '<div class="kpi-chip">' + t('kpiRpm') + '
|
|
416
|
-
html += '<div class="kpi-chip">' + t('kpiCache') + '
|
|
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') + '
|
|
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) + '
|
|
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') + '
|
|
428
|
-
html += '<div class="kpi-chip">' + t('kpiUptime') + '
|
|
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 = '#
|
|
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 = '#
|
|
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
|
-
|
|
532
|
-
var sc = r.status === 200 ? '
|
|
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: '#
|
|
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>
|