diff --git a/src/index.ts b/src/index.ts index ad74f06..1171b51 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,7 @@ import express from 'express'; import { getConfig, initConfigWatcher, stopConfigWatcher } from './config.js'; import { handleMessages, listModels, countTokens } from './handler.js'; import { handleOpenAIChatCompletions, handleOpenAIResponses } from './openai-handler.js'; -import { serveLogViewer, apiGetLogs, apiGetRequests, apiGetStats, apiGetPayload, apiLogsStream, serveLogViewerLogin, apiClearLogs, serveVueApp, apiGetRequestsMore } from './log-viewer.js'; +import { serveLogViewer, apiGetLogs, apiGetRequests, apiGetStats, apiGetVueStats, apiGetPayload, apiLogsStream, serveLogViewerLogin, apiClearLogs, serveVueApp, apiGetRequestsMore } from './log-viewer.js'; import { apiGetConfig, apiSaveConfig } from './config-api.js'; import { loadLogsFromFiles } from './logger.js'; import { initDb } from './logger-db.js'; @@ -72,6 +72,7 @@ app.get('/api/logs', logViewerAuth, apiGetLogs); app.get('/api/requests/more', logViewerAuth, apiGetRequestsMore); app.get('/api/requests', logViewerAuth, apiGetRequests); app.get('/api/stats', logViewerAuth, apiGetStats); +app.get('/api/vue/stats', logViewerAuth, apiGetVueStats); app.get('/api/payload/:requestId', logViewerAuth, apiGetPayload); app.get('/api/logs/stream', logViewerAuth, apiLogsStream); app.post('/api/logs/clear', logViewerAuth, apiClearLogs); diff --git a/src/log-viewer.ts b/src/log-viewer.ts index 7447c95..58ab569 100644 --- a/src/log-viewer.ts +++ b/src/log-viewer.ts @@ -8,7 +8,7 @@ import type { Request, Response } from 'express'; import { readFileSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; -import { getAllLogs, getRequestSummaries, getStats, getRequestPayload, subscribeToLogs, subscribeToSummaries, clearAllLogs, getRequestSummariesPage } from './logger.js'; +import { getAllLogs, getRequestSummaries, getStats, getVueStats, getRequestPayload, subscribeToLogs, subscribeToSummaries, clearAllLogs, getRequestSummariesPage } from './logger.js'; // ==================== 静态文件路径 ==================== @@ -35,10 +35,15 @@ export function apiGetRequests(req: Request, res: Response): void { res.json(getRequestSummaries(req.query.limit ? parseInt(req.query.limit as string) : 50)); } -export function apiGetStats(_req: Request, res: Response): void { +export function apiGetStats(req: Request, res: Response): void { res.json(getStats()); } +export function apiGetVueStats(req: Request, res: Response): void { + const since = req.query.since ? parseInt(req.query.since as string) : undefined; + res.json(getVueStats(since)); +} + /** GET /api/payload/:requestId - 获取请求的完整参数和响应 */ export function apiGetPayload(req: Request, res: Response): void { const payload = getRequestPayload(req.params.requestId as string); diff --git a/src/logger-db.ts b/src/logger-db.ts index 5c1703f..bf133b4 100644 --- a/src/logger-db.ts +++ b/src/logger-db.ts @@ -167,6 +167,51 @@ export function dbGetSummariesSince(cutoffTimestamp: number): DbRequestSummary[] }).filter((s): s is DbRequestSummary => s !== null); } +/** + * 聚合统计:通过 SQL 一次查询返回全量(或指定时间范围内)的 stats。 + * 仅在 db_enabled 时调用。 + */ +export function dbGetStats(since?: number): { + totalRequests: number; + successCount: number; + degradedCount: number; + errorCount: number; + interceptedCount: number; + processingCount: number; + avgResponseTime: number; + avgTTFT: number; +} { + const where = since !== undefined ? 'WHERE timestamp >= ?' : ''; + const params = since !== undefined ? [since] : []; + const row = getDb().prepare(` + SELECT + COUNT(*) as total, + SUM(CASE WHEN json_extract(summary_json,'$.status')='success' THEN 1 ELSE 0 END) as success, + SUM(CASE WHEN json_extract(summary_json,'$.status')='degraded' THEN 1 ELSE 0 END) as degraded, + SUM(CASE WHEN json_extract(summary_json,'$.status')='error' THEN 1 ELSE 0 END) as error, + SUM(CASE WHEN json_extract(summary_json,'$.status')='intercepted' THEN 1 ELSE 0 END) as intercepted, + SUM(CASE WHEN json_extract(summary_json,'$.status')='processing' THEN 1 ELSE 0 END) as processing, + AVG(CASE WHEN json_extract(summary_json,'$.endTime') IS NOT NULL + THEN json_extract(summary_json,'$.endTime') - timestamp END) as avgTime, + AVG(CASE WHEN json_extract(summary_json,'$.ttft') IS NOT NULL + THEN json_extract(summary_json,'$.ttft') END) as avgTTFT + FROM requests ${where} + `).get(...params) as { + total: number; success: number; degraded: number; error: number; + intercepted: number; processing: number; avgTime: number | null; avgTTFT: number | null; + }; + return { + totalRequests: row.total ?? 0, + successCount: row.success ?? 0, + degradedCount: row.degraded ?? 0, + errorCount: row.error ?? 0, + interceptedCount: row.intercepted ?? 0, + processingCount: row.processing ?? 0, + avgResponseTime: row.avgTime != null ? Math.round(row.avgTime) : 0, + avgTTFT: row.avgTTFT != null ? Math.round(row.avgTTFT) : 0, + }; +} + // ==================== 清空 ==================== export function dbClear(): void { diff --git a/src/logger.ts b/src/logger.ts index d0d28ad..fb7601e 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -17,7 +17,7 @@ import { EventEmitter } from 'events'; import { existsSync, mkdirSync, appendFileSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'fs'; import { join, basename } from 'path'; import { getConfig, onConfigReload } from './config.js'; -import { initDb, closeDb, isDbInitialized, dbInsertRequest, dbGetPayload, dbGetSummaries, dbCountSummaries, dbGetSummaryCount, dbGetStatusCounts, dbGetSummariesSince, dbClear } from './logger-db.js'; +import { initDb, closeDb, isDbInitialized, dbInsertRequest, dbGetPayload, dbGetSummaries, dbCountSummaries, dbGetSummaryCount, dbGetStatusCounts, dbGetSummariesSince, dbClear, dbGetStats } from './logger-db.js'; // ==================== 类型定义 ==================== @@ -705,6 +705,19 @@ export function getStats() { }; } +export function getVueStats(since?: number) { + const cfg = getConfig(); + if (cfg.logging?.db_enabled) { + try { + return { ...dbGetStats(since), totalLogEntries: logEntries.length }; + } catch (e) { + console.warn('[Logger] dbGetStats 失败,降级到内存:', e); + } + } + // 内存模式:since 参数忽略,数据本就有限,直接复用 getStats() + return getStats(); +} + // ==================== 核心 API ==================== export function createRequestLogger(opts: { diff --git a/vue-ui/src/App.vue b/vue-ui/src/App.vue index ff48f3b..d27202a 100644 --- a/vue-ui/src/App.vue +++ b/vue-ui/src/App.vue @@ -51,7 +51,7 @@ onMounted(async () => { history.replaceState(null, '', location.pathname); } try { - const res = await fetch('/api/stats', { + const res = await fetch('/api/vue/stats', { headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : {}, }); if (res.ok) { diff --git a/vue-ui/src/api.ts b/vue-ui/src/api.ts index 290d17e..37d4c30 100644 --- a/vue-ui/src/api.ts +++ b/vue-ui/src/api.ts @@ -30,8 +30,9 @@ export function fetchRequests(limit = 50): Promise { return apiFetch(`/api/requests?limit=${limit}`); } -export function fetchStats(): Promise { - return apiFetch('/api/stats'); +export function fetchStats(since?: number): Promise { + const qs = since !== undefined ? `?since=${since}` : ''; + return apiFetch(`/api/vue/stats${qs}`); } export function fetchPayload(requestId: string): Promise { diff --git a/vue-ui/src/components/AppHeader.vue b/vue-ui/src/components/AppHeader.vue index aa26107..83e5133 100644 --- a/vue-ui/src/components/AppHeader.vue +++ b/vue-ui/src/components/AppHeader.vue @@ -5,25 +5,25 @@
-
{{ stats.totalRequests }} 请求
-
{{ stats.successCount }}
-
!{{ stats.degradedCount }}
-
{{ stats.errorCount }}
-
{{ fmtMs(stats.avgResponseTime) }} 均耗
-
{{ fmtMs(stats.avgTTFT) }} TTFT
+
{{ stats.totalRequests }} 请求
+
{{ stats.successCount }}
+
⚠️ {{ stats.degradedCount }}
+
{{ stats.errorCount }}
+
{{ fmtMs(stats.avgResponseTime) }}
+
{{ fmtMs(stats.avgTTFT) }} TTFT
- - + - - + +
{{ connected ? '已连接' : '重连中…' }} @@ -56,7 +56,7 @@ async function onLogout() { authStore.clearToken(); // 检查无 token 时是否还能访问(open access 模式),能则不跳转登录页 try { - const res = await fetch('/api/stats'); + const res = await fetch('/api/vue/stats'); if (res.ok) { // 服务端不需要授权,保持登录状态 return; diff --git a/vue-ui/src/components/ConfigDrawer.vue b/vue-ui/src/components/ConfigDrawer.vue index b268354..41e61ba 100644 --- a/vue-ui/src/components/ConfigDrawer.vue +++ b/vue-ui/src/components/ConfigDrawer.vue @@ -37,8 +37,8 @@
+
@@ -19,6 +26,7 @@ :key="t.value" class="tb" :class="{ a: logsStore.timeFilter === t.value }" + :title="t.title" @click="logsStore.timeFilter = t.value" >{{ t.label }}
@@ -28,10 +36,13 @@ v-for="f in statusTabs" :key="f.value" class="fb" - :class="{ a: logsStore.statusFilter === f.value }" + :class="[{ a: logsStore.statusFilter === f.value }, f.value]" + :title="f.title" @click="logsStore.statusFilter = f.value" > - {{ f.label }}{{ counts[f.value] }} + {{ f.icon }} + 全部 + {{ counts[f.value] }}
@@ -52,26 +63,26 @@ {{ req.title || shortModel(req.model) }}
- 耗时 {{ fmtMs(req.endTime - req.startTime) }} - ⚡️{{ fmtMs(req.ttft) }} + 耗时 {{ fmtMs(req.endTime - req.startTime) }} + ⚡️{{ fmtMs(req.ttft) }} {{ fmtDate(req.startTime) }}
- {{ req.requestId.slice(0, 8) }} - {{ req.apiFormat }} - {{ fmtN(req.responseChars) }} chars - ↑{{ fmtN(req.inputTokens) }}↓{{ fmtN(req.outputTokens ?? 0) }} tok + {{ req.requestId.slice(0, 8) }} + {{ req.apiFormat }} + {{ fmtN(req.responseChars) }} chars + ↑{{ fmtN(req.inputTokens) }}↓{{ fmtN(req.outputTokens ?? 0) }} tok
- Stream - T:{{ req.toolCount }} - C:{{ req.toolCallsDetected }} - R:{{ req.retryCount }} - +{{ req.continuationCount }} - 🤔 {{ fmtN(req.thinkingChars) }} chars - DEGRADED - ERR - INTERCEPT + Stream + T:{{ req.toolCount }} + C:{{ req.toolCallsDetected }} + R:{{ req.retryCount }} + +{{ req.continuationCount }} + 🤔 {{ fmtN(req.thinkingChars) }} chars + DEGRADED + ERR + INTERCEPT
{{ req.error }}
@@ -87,7 +98,7 @@