xiaoyukkkk commited on
Commit
77f05e1
·
verified ·
1 Parent(s): cbb6522

Upload 2 files

Browse files
templates/public/logs.html CHANGED
@@ -0,0 +1,453 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>服务状态</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ html, body { height: 100%; overflow: hidden; }
10
+ body {
11
+ font-family: 'Consolas', 'Monaco', monospace;
12
+ background: #fafaf9;
13
+ display: flex;
14
+ align-items: center;
15
+ justify-content: center;
16
+ padding: 15px;
17
+ }
18
+ .container {
19
+ width: 100%;
20
+ max-width: 1200px;
21
+ height: calc(100vh - 30px);
22
+ background: white;
23
+ border-radius: 16px;
24
+ padding: 30px;
25
+ box-shadow: 0 2px 8px rgba(0,0,0,0.08);
26
+ display: flex;
27
+ flex-direction: column;
28
+ }
29
+ h1 {
30
+ color: #1a1a1a;
31
+ font-size: 22px;
32
+ font-weight: 600;
33
+ margin-bottom: 20px;
34
+ display: flex;
35
+ align-items: center;
36
+ justify-content: center;
37
+ gap: 12px;
38
+ }
39
+ h1 img {
40
+ width: 32px;
41
+ height: 32px;
42
+ border-radius: 8px;
43
+ }
44
+ .info-bar {
45
+ background: #f9f9f9;
46
+ border: 1px solid #e5e5e5;
47
+ border-radius: 8px;
48
+ padding: 12px 16px;
49
+ margin-bottom: 16px;
50
+ display: flex;
51
+ align-items: center;
52
+ justify-content: space-between;
53
+ flex-wrap: wrap;
54
+ gap: 12px;
55
+ }
56
+ .info-item {
57
+ display: flex;
58
+ align-items: center;
59
+ gap: 6px;
60
+ font-size: 13px;
61
+ color: #6b6b6b;
62
+ }
63
+ .info-item strong { color: #1a1a1a; }
64
+ .info-item a {
65
+ color: #1a73e8;
66
+ text-decoration: none;
67
+ font-weight: 500;
68
+ }
69
+ .info-item a:hover { text-decoration: underline; }
70
+ .stats {
71
+ display: grid;
72
+ grid-template-columns: repeat(4, 1fr);
73
+ gap: 12px;
74
+ margin-bottom: 16px;
75
+ }
76
+ .stat {
77
+ background: #fafaf9;
78
+ padding: 12px;
79
+ border: 1px solid #e5e5e5;
80
+ border-radius: 8px;
81
+ text-align: center;
82
+ transition: all 0.15s ease;
83
+ }
84
+ .stat:hover { border-color: #d4d4d4; }
85
+ .stat-label { color: #6b6b6b; font-size: 11px; margin-bottom: 4px; }
86
+ .stat-value { color: #1a1a1a; font-size: 18px; font-weight: 600; }
87
+ .log-container {
88
+ flex: 1;
89
+ background: #fafaf9;
90
+ border: 1px solid #e5e5e5;
91
+ border-radius: 8px;
92
+ padding: 12px;
93
+ overflow-y: auto;
94
+ scrollbar-width: thin;
95
+ scrollbar-color: rgba(0,0,0,0.15) transparent;
96
+ }
97
+ .log-container::-webkit-scrollbar { width: 4px; }
98
+ .log-container::-webkit-scrollbar-track { background: transparent; }
99
+ .log-container::-webkit-scrollbar-thumb {
100
+ background: rgba(0,0,0,0.15);
101
+ border-radius: 2px;
102
+ }
103
+ .log-container::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.3); }
104
+ .log-group {
105
+ margin-bottom: 8px;
106
+ border: 1px solid #e5e5e5;
107
+ border-radius: 8px;
108
+ background: white;
109
+ }
110
+ .log-group-header {
111
+ padding: 10px 12px;
112
+ background: #f9f9f9;
113
+ border-radius: 8px 8px 0 0;
114
+ cursor: pointer;
115
+ display: flex;
116
+ align-items: center;
117
+ gap: 8px;
118
+ transition: background 0.15s ease;
119
+ }
120
+ .log-group-header:hover { background: #f0f0f0; }
121
+ .log-group-content { padding: 8px; }
122
+ .log-entry {
123
+ padding: 8px 10px;
124
+ margin-bottom: 4px;
125
+ background: white;
126
+ border: 1px solid #e5e5e5;
127
+ border-radius: 6px;
128
+ display: flex;
129
+ align-items: center;
130
+ gap: 10px;
131
+ font-size: 13px;
132
+ transition: all 0.15s ease;
133
+ }
134
+ .log-entry:hover { border-color: #d4d4d4; }
135
+ .log-time { color: #6b6b6b; font-size: 12px; min-width: 140px; }
136
+ .log-status {
137
+ padding: 2px 8px;
138
+ border-radius: 4px;
139
+ font-size: 11px;
140
+ font-weight: 600;
141
+ min-width: 60px;
142
+ text-align: center;
143
+ }
144
+ .status-success { background: #d1fae5; color: #065f46; }
145
+ .status-error { background: #fee2e2; color: #991b1b; }
146
+ .status-in_progress { background: #fef3c7; color: #92400e; }
147
+ .status-timeout { background: #fef3c7; color: #92400e; }
148
+ .log-info { flex: 1; color: #374151; }
149
+ .toggle-icon {
150
+ display: inline-block;
151
+ transition: transform 0.2s ease;
152
+ }
153
+ .toggle-icon.collapsed { transform: rotate(-90deg); }
154
+ .subtitle-public {
155
+ display: flex;
156
+ justify-content: center;
157
+ align-items: center;
158
+ gap: 8px;
159
+ flex-wrap: wrap;
160
+ }
161
+
162
+ @media (max-width: 768px) {
163
+ body { padding: 0; }
164
+ .container {
165
+ padding: 15px;
166
+ height: 100vh;
167
+ border-radius: 0;
168
+ max-width: 100%;
169
+ }
170
+ h1 { font-size: 18px; margin-bottom: 12px; }
171
+ .subtitle-public {
172
+ flex-direction: column;
173
+ gap: 6px;
174
+ }
175
+ .subtitle-public span {
176
+ font-size: 11px;
177
+ line-height: 1.6;
178
+ }
179
+ .subtitle-public a {
180
+ font-size: 12px;
181
+ font-weight: 600;
182
+ }
183
+ .info-bar {
184
+ padding: 10px 12px;
185
+ flex-direction: column;
186
+ align-items: flex-start;
187
+ gap: 8px;
188
+ }
189
+ .info-item { font-size: 12px; }
190
+ .stats {
191
+ grid-template-columns: repeat(2, 1fr);
192
+ gap: 8px;
193
+ margin-bottom: 12px;
194
+ }
195
+ .stat { padding: 8px; }
196
+ .stat-label { font-size: 10px; }
197
+ .stat-value { font-size: 16px; }
198
+ .log-container { padding: 8px; }
199
+ .log-group { margin-bottom: 6px; }
200
+ .log-group-header {
201
+ padding: 8px 10px;
202
+ font-size: 11px;
203
+ flex-wrap: wrap;
204
+ }
205
+ .log-group-header span { font-size: 10px !important; }
206
+ .log-entry {
207
+ padding: 6px 8px;
208
+ font-size: 11px;
209
+ flex-direction: column;
210
+ align-items: flex-start;
211
+ gap: 4px;
212
+ }
213
+ .log-time {
214
+ min-width: auto;
215
+ font-size: 10px;
216
+ }
217
+ .log-info {
218
+ font-size: 11px;
219
+ word-break: break-word;
220
+ }
221
+ }
222
+ </style>
223
+ </head>
224
+ <body>
225
+ <div class="container">
226
+ <h1>
227
+ {% if logo_url %}<img src="{{ logo_url }}" alt="Logo">{% endif %}
228
+ Gemini服务状态
229
+ </h1>
230
+ <div style="text-align: center; color: #999; font-size: 12px; margin-bottom: 16px;" class="subtitle-public">
231
+ <span>展示最近1000条对话日志 · 每5秒自动更新</span>
232
+ {% if chat_url %}<a href="{{ chat_url }}" target="_blank" style="color: #1a73e8; text-decoration: none;">开始对话</a>{% else %}<span style="color: #999;">开始对话</span>{% endif %}
233
+ </div>
234
+ <div class="stats">
235
+ <div class="stat">
236
+ <div class="stat-label">总访问</div>
237
+ <div class="stat-value" id="stat-visitors">0</div>
238
+ </div>
239
+ <div class="stat">
240
+ <div class="stat-label">每分钟请求</div>
241
+ <div class="stat-value" id="stat-load">0</div>
242
+ </div>
243
+ <div class="stat">
244
+ <div class="stat-label">平均响应</div>
245
+ <div class="stat-value" id="stat-avg-time">-</div>
246
+ </div>
247
+ <div class="stat">
248
+ <div class="stat-label">成功率</div>
249
+ <div class="stat-value" id="stat-success-rate" style="color: #10b981;">-</div>
250
+ </div>
251
+ <div class="stat">
252
+ <div class="stat-label">对话次数</div>
253
+ <div class="stat-value" id="stat-total">0</div>
254
+ </div>
255
+ <div class="stat">
256
+ <div class="stat-label">成功</div>
257
+ <div class="stat-value" id="stat-success" style="color: #10b981;">0</div>
258
+ </div>
259
+ <div class="stat">
260
+ <div class="stat-label">失败</div>
261
+ <div class="stat-value" id="stat-error" style="color: #ef4444;">0</div>
262
+ </div>
263
+ <div class="stat">
264
+ <div class="stat-label">更新时间</div>
265
+ <div class="stat-value" id="stat-update-time" style="font-size: 14px; color: #6b6b6b;">--:--</div>
266
+ </div>
267
+ </div>
268
+ <div class="log-container" id="log-container">
269
+ <div style="text-align: center; color: #999; padding: 20px;">加载中...</div>
270
+ </div>
271
+ </div>
272
+ <script>
273
+ async function loadData() {
274
+ try {
275
+ // 并行加载日志和统计数据
276
+ const [logsResponse, statsResponse] = await Promise.all([
277
+ fetch('/public/log?limit=1000'),
278
+ fetch('/public/stats')
279
+ ]);
280
+
281
+ const logsData = await logsResponse.json();
282
+ const statsData = await statsResponse.json();
283
+
284
+ displayLogs(logsData.logs);
285
+ updateStats(logsData.logs, statsData);
286
+ } catch (error) {
287
+ document.getElementById('log-container').innerHTML = '<div style="text-align: center; color: #f44336; padding: 20px;">加载失败: ' + error.message + '</div>';
288
+ }
289
+ }
290
+
291
+ function displayLogs(logs) {
292
+ const container = document.getElementById('log-container');
293
+ if (logs.length === 0) {
294
+ container.innerHTML = '<div style="text-align: center; color: #999; padding: 20px;">暂无日志</div>';
295
+ return;
296
+ }
297
+
298
+ // 读取折叠状态
299
+ const foldState = JSON.parse(localStorage.getItem('public-log-fold-state') || '{}');
300
+
301
+ let html = '';
302
+ logs.forEach(log => {
303
+ const reqId = log.request_id;
304
+
305
+ // 状态图标和颜色
306
+ let statusColor = '#ff9800';
307
+ let statusText = '进行中';
308
+
309
+ if (log.status === 'success') {
310
+ statusColor = '#4caf50';
311
+ statusText = '成功';
312
+ } else if (log.status === 'error') {
313
+ statusColor = '#f44336';
314
+ statusText = '失败';
315
+ } else if (log.status === 'timeout') {
316
+ statusColor = '#ffc107';
317
+ statusText = '超时';
318
+ }
319
+
320
+ // 检查折叠状态
321
+ const isCollapsed = foldState[reqId] === true;
322
+ const contentStyle = isCollapsed ? 'style="display: none;"' : '';
323
+ const iconClass = isCollapsed ? 'class="toggle-icon collapsed"' : 'class="toggle-icon"';
324
+
325
+ // 构建事件列表
326
+ let eventsHtml = '';
327
+ log.events.forEach(event => {
328
+ let eventClass = 'log-entry';
329
+ let eventLabel = '';
330
+
331
+ if (event.type === 'start') {
332
+ eventLabel = '<span style="color: #2563eb; font-weight: 600;">开始对话</span>';
333
+ } else if (event.type === 'select') {
334
+ eventLabel = '<span style="color: #8b5cf6; font-weight: 600;">选择</span>';
335
+ } else if (event.type === 'retry') {
336
+ eventLabel = '<span style="color: #f59e0b; font-weight: 600;">重试</span>';
337
+ } else if (event.type === 'switch') {
338
+ eventLabel = '<span style="color: #06b6d4; font-weight: 600;">切换</span>';
339
+ } else if (event.type === 'complete') {
340
+ if (event.status === 'success') {
341
+ eventLabel = '<span style="color: #10b981; font-weight: 600;">完成</span>';
342
+ } else if (event.status === 'error') {
343
+ eventLabel = '<span style="color: #ef4444; font-weight: 600;">失败</span>';
344
+ } else if (event.status === 'timeout') {
345
+ eventLabel = '<span style="color: #f59e0b; font-weight: 600;">超时</span>';
346
+ }
347
+ }
348
+
349
+ eventsHtml += `
350
+ <div class="${eventClass}">
351
+ <div class="log-time">${event.time}</div>
352
+ <div style="min-width: 60px;">${eventLabel}</div>
353
+ <div class="log-info">${event.content}</div>
354
+ </div>
355
+ `;
356
+ });
357
+
358
+ html += `
359
+ <div class="log-group" data-req-id="${reqId}">
360
+ <div class="log-group-header" onclick="toggleGroup('${reqId}')">
361
+ <span style="color: ${statusColor}; font-weight: 600; font-size: 11px;">⬤ ${statusText}</span>
362
+ <span style="color: #666; font-size: 11px; margin-left: 8px;">req_${reqId}</span>
363
+ <span style="color: #999; font-size: 11px; margin-left: 8px;">${log.events.length}条事件</span>
364
+ <span ${iconClass} style="margin-left: auto; color: #999;">▼</span>
365
+ </div>
366
+ <div class="log-group-content" ${contentStyle}>
367
+ ${eventsHtml}
368
+ </div>
369
+ </div>
370
+ `;
371
+ });
372
+
373
+ container.innerHTML = html;
374
+ }
375
+
376
+ function updateStats(logs, statsData) {
377
+ const total = logs.length;
378
+ const successLogs = logs.filter(log => log.status === 'success');
379
+ const success = successLogs.length;
380
+ const error = logs.filter(log => log.status === 'error').length;
381
+
382
+ // 计算平均响应时间
383
+ let avgTime = '-';
384
+ if (success > 0) {
385
+ let totalDuration = 0;
386
+ let count = 0;
387
+ successLogs.forEach(log => {
388
+ log.events.forEach(event => {
389
+ if (event.type === 'complete' && event.content.includes('耗时')) {
390
+ const match = event.content.match(/([\d.]+)s/);
391
+ if (match) {
392
+ totalDuration += parseFloat(match[1]);
393
+ count++;
394
+ }
395
+ }
396
+ });
397
+ });
398
+ if (count > 0) {
399
+ avgTime = (totalDuration / count).toFixed(1) + 's';
400
+ }
401
+ }
402
+
403
+ // 计算成功率
404
+ const totalCompleted = success + error;
405
+ const successRate = totalCompleted > 0 ? ((success / totalCompleted) * 100).toFixed(1) + '%' : '-';
406
+
407
+ // 更新日志统计
408
+ document.getElementById('stat-total').textContent = total;
409
+ document.getElementById('stat-success').textContent = success;
410
+ document.getElementById('stat-error').textContent = error;
411
+ document.getElementById('stat-success-rate').textContent = successRate;
412
+ document.getElementById('stat-avg-time').textContent = avgTime;
413
+
414
+ // 更新全局统计
415
+ document.getElementById('stat-visitors').textContent = statsData.total_visitors;
416
+
417
+ // 更新负载状态(带颜色)
418
+ const loadElement = document.getElementById('stat-load');
419
+ loadElement.textContent = statsData.requests_per_minute;
420
+ loadElement.style.color = statsData.load_color;
421
+
422
+ // 更新时间
423
+ document.getElementById('stat-update-time').textContent = new Date().toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit', second: '2-digit'});
424
+ }
425
+
426
+ function toggleGroup(reqId) {
427
+ const group = document.querySelector(`.log-group[data-req-id="${reqId}"]`);
428
+ const content = group.querySelector('.log-group-content');
429
+ const icon = group.querySelector('.toggle-icon');
430
+
431
+ const isCollapsed = content.style.display === 'none';
432
+ if (isCollapsed) {
433
+ content.style.display = 'block';
434
+ icon.classList.remove('collapsed');
435
+ } else {
436
+ content.style.display = 'none';
437
+ icon.classList.add('collapsed');
438
+ }
439
+
440
+ // 保存折叠状态
441
+ const foldState = JSON.parse(localStorage.getItem('public-log-fold-state') || '{}');
442
+ foldState[reqId] = !isCollapsed;
443
+ localStorage.setItem('public-log-fold-state', JSON.stringify(foldState));
444
+ }
445
+
446
+ // 初始加载
447
+ loadData();
448
+
449
+ // 自动刷新(每5秒)
450
+ setInterval(loadData, 5000);
451
+ </script>
452
+ </body>
453
+ </html>
templates/public/uptime.html ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Gemini Status</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ body {
10
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
11
+ background: #f5f5f7;
12
+ color: #1d1d1f;
13
+ min-height: 100vh;
14
+ padding: 20px;
15
+ }
16
+ .container { max-width: 1200px; margin: 0 auto; }
17
+ h1 {
18
+ font-size: 24px;
19
+ font-weight: 600;
20
+ margin-bottom: 8px;
21
+ color: #1d1d1f;
22
+ }
23
+ .subtitle { color: #86868b; font-size: 14px; margin-bottom: 24px; }
24
+ .update-time { color: #86868b; font-size: 12px; margin-bottom: 16px; }
25
+ .grid {
26
+ display: grid;
27
+ grid-template-columns: repeat(2, 1fr);
28
+ gap: 16px;
29
+ }
30
+ .card {
31
+ background: #fff;
32
+ border: 1px solid #e5e5e5;
33
+ border-radius: 12px;
34
+ padding: 16px;
35
+ box-shadow: 0 1px 3px rgba(0,0,0,0.04);
36
+ }
37
+ .card:hover { border-color: #d4d4d4; }
38
+ .card-header {
39
+ display: flex;
40
+ justify-content: space-between;
41
+ align-items: center;
42
+ margin-bottom: 12px;
43
+ }
44
+ .service-name { font-weight: 600; font-size: 14px; color: #1d1d1f; }
45
+ .status-badge {
46
+ padding: 2px 8px;
47
+ border-radius: 12px;
48
+ font-size: 11px;
49
+ font-weight: 600;
50
+ }
51
+ .status-up { background: #d1fae5; color: #065f46; }
52
+ .status-down { background: #fee2e2; color: #991b1b; }
53
+ .status-unknown { background: #f3f4f6; color: #6b7280; }
54
+ .stats {
55
+ display: flex;
56
+ gap: 16px;
57
+ margin-bottom: 12px;
58
+ font-size: 12px;
59
+ color: #86868b;
60
+ }
61
+ .stat-value { color: #1d1d1f; font-weight: 600; }
62
+ .heartbeat-bar {
63
+ display: flex;
64
+ gap: 2px;
65
+ height: 24px;
66
+ align-items: flex-end;
67
+ }
68
+ .beat {
69
+ flex: 1;
70
+ min-width: 4px;
71
+ max-width: 8px;
72
+ border-radius: 2px;
73
+ transition: all 0.2s;
74
+ position: relative;
75
+ }
76
+ .beat:hover { opacity: 0.8; transform: scaleY(1.1); }
77
+ .beat.up { background: #34c759; height: 100%; }
78
+ .beat.down { background: #ff3b30; height: 100%; }
79
+ .beat.empty { background: #e5e5ea; height: 40%; }
80
+ .beat .tooltip {
81
+ position: absolute;
82
+ bottom: 100%;
83
+ left: 50%;
84
+ transform: translateX(-50%);
85
+ background: #1d1d1f;
86
+ color: #fff;
87
+ padding: 6px 10px;
88
+ border-radius: 6px;
89
+ font-size: 11px;
90
+ white-space: nowrap;
91
+ opacity: 0;
92
+ pointer-events: none;
93
+ transition: opacity 0.15s;
94
+ margin-bottom: 6px;
95
+ z-index: 100;
96
+ }
97
+ .beat .tooltip::after {
98
+ content: '';
99
+ position: absolute;
100
+ top: 100%;
101
+ left: 50%;
102
+ transform: translateX(-50%);
103
+ border: 5px solid transparent;
104
+ border-top-color: #1d1d1f;
105
+ }
106
+ .beat:hover .tooltip { opacity: 1; }
107
+ @media (max-width: 768px) {
108
+ .grid { grid-template-columns: 1fr; }
109
+ .beat { min-width: 3px; max-width: 6px; }
110
+ }
111
+ </style>
112
+ </head>
113
+ <body>
114
+ <div class="container">
115
+ <h1>Gemini Status</h1>
116
+ <p class="subtitle">服务状态监控</p>
117
+ <p class="update-time" id="update-time">更新中...</p>
118
+ <div class="grid" id="services"></div>
119
+ </div>
120
+ <script>
121
+ async function loadStatus() {
122
+ try {
123
+ const res = await fetch('/public/uptime');
124
+ const data = await res.json();
125
+ renderServices(data);
126
+ document.getElementById('update-time').textContent = '更新于 ' + data.updated_at;
127
+ } catch (e) {
128
+ document.getElementById('services').innerHTML = '<div class="card">加载失败</div>';
129
+ }
130
+ }
131
+
132
+ function renderServices(data) {
133
+ const container = document.getElementById('services');
134
+ let html = '';
135
+ for (const [id, svc] of Object.entries(data.services)) {
136
+ const statusClass = svc.status === 'up' ? 'status-up' : svc.status === 'down' ? 'status-down' : 'status-unknown';
137
+ const statusText = svc.status === 'up' ? '正常' : svc.status === 'down' ? '故障' : '未知';
138
+
139
+ // 生成心跳条
140
+ let beats = '';
141
+ const maxBeats = 60;
142
+ const heartbeats = svc.heartbeats || [];
143
+ for (let i = 0; i < maxBeats; i++) {
144
+ if (i < heartbeats.length) {
145
+ const beat = heartbeats[i];
146
+ const status = beat.success ? '成功' : '失败';
147
+ beats += `<div class="beat ${beat.success ? 'up' : 'down'}"><span class="tooltip">${beat.time} · ${status}</span></div>`;
148
+ } else {
149
+ beats += '<div class="beat empty"></div>';
150
+ }
151
+ }
152
+
153
+ html += `
154
+ <div class="card">
155
+ <div class="card-header">
156
+ <span class="service-name">${svc.name}</span>
157
+ <span class="status-badge ${statusClass}">${statusText}</span>
158
+ </div>
159
+ <div class="stats">
160
+ <span>可用率 <span class="stat-value">${svc.uptime}%</span></span>
161
+ <span>请求 <span class="stat-value">${svc.total}</span></span>
162
+ <span>成功 <span class="stat-value">${svc.success}</span></span>
163
+ </div>
164
+ <div class="heartbeat-bar">${beats}</div>
165
+ </div>
166
+ `;
167
+ }
168
+ container.innerHTML = html;
169
+ }
170
+
171
+ loadStatus();
172
+ setInterval(loadStatus, 5000);
173
+ </script>
174
+ </body>
175
+ </html>