From 91bbb2d425bcb1012ca157ace863c085c53a064c Mon Sep 17 00:00:00 2001 From: Zyy Date: Wed, 24 Jun 2026 18:14:34 +0800 Subject: [PATCH] fix: update dashboard data and layout Co-authored-by: Codex --- docs/codex-proxy-rs.pen | 985 ++++++++++++++++++ src/admin/accounts/service/lifecycle.rs | 29 +- src/admin/accounts/service/mod.rs | 4 +- src/admin/accounts/service/types.rs | 32 - src/admin/auth/mod.rs | 1 - src/admin/auth/routes.rs | 114 -- src/admin/auth/session.rs | 35 + src/admin/monitoring/dashboard.rs | 743 +++++++++++++ src/admin/monitoring/diagnostics_routes.rs | 47 - src/admin/monitoring/mod.rs | 4 +- src/admin/router.rs | 12 +- src/web/assets.rs | 19 +- web/package.json | 16 +- web/pnpm-lock.yaml | 10 + web/src/App.vue | 2 +- web/src/api/index.ts | 4 +- web/src/api/modules/auth.ts | 15 +- web/src/api/modules/dashboard.ts | 123 +++ web/src/api/modules/diagnostics.ts | 49 - web/src/api/modules/logs.ts | 22 +- web/src/api/request.ts | 22 +- web/src/api/types.ts | 2 +- web/src/components/base/BaseBadge.vue | 17 +- web/src/components/base/BaseButton.vue | 75 +- web/src/components/base/BaseCard.vue | 48 +- web/src/components/base/BaseEmpty.vue | 38 +- web/src/components/base/BaseIconButton.vue | 43 +- web/src/components/base/BaseInput.vue | 72 +- web/src/components/base/BaseModal.vue | 82 +- web/src/components/base/BaseScrollbar.vue | 38 +- web/src/components/base/BaseSegmented.vue | 10 +- web/src/components/base/BaseSelect.vue | 51 +- web/src/components/base/BaseSpinner.vue | 5 +- web/src/components/base/BaseTable.vue | 64 +- web/src/components/base/BaseToast.vue | 115 -- .../components/base/BaseToast/BaseToast.vue | 115 ++ web/src/components/base/BaseToast/index.ts | 3 + web/src/components/base/BaseToast/toast.ts | 81 ++ web/src/components/charts/BaseChart.vue | 87 +- web/src/composables/useECharts.ts | 53 - web/src/directives/loading.ts | 144 +++ web/src/layout/components/AppSidebar.vue | 71 +- web/src/layout/components/AppTopbar.vue | 163 +-- web/src/layout/index.vue | 2 +- web/src/main.ts | 3 +- web/src/plugins/echarts.ts | 7 +- web/src/router/index.ts | 15 +- web/src/stores/index.ts | 1 - web/src/stores/modules/auth.ts | 24 +- web/src/stores/modules/toast.ts | 61 -- web/src/styles/base.css | 10 +- web/src/styles/index.css | 8 +- web/src/views/accounts/AccountsView.vue | 132 +-- web/src/views/api-keys/ApiKeysView.vue | 150 +-- web/src/views/dashboard/DashboardView.vue | 56 +- .../components/AccountOverviewCard.vue | 200 +++- .../dashboard/components/EventLogCard.vue | 64 +- .../views/dashboard/components/MetricCard.vue | 69 +- .../dashboard/components/RequestTrendCard.vue | 258 +++-- .../components/ServiceStatusCard.vue | 67 +- .../dashboard/composables/useDashboard.ts | 501 +++++---- web/src/views/dashboard/types.ts | 13 +- web/src/views/login/LoginView.vue | 95 +- web/src/views/logs/LogsView.vue | 105 +- web/src/views/settings/SettingsView.vue | 66 +- web/tsconfig.app.json | 1 - web/tsconfig.json | 5 +- 67 files changed, 4022 insertions(+), 1556 deletions(-) delete mode 100644 src/admin/auth/routes.rs create mode 100644 src/admin/monitoring/dashboard.rs delete mode 100644 src/admin/monitoring/diagnostics_routes.rs create mode 100644 web/src/api/modules/dashboard.ts delete mode 100644 web/src/api/modules/diagnostics.ts delete mode 100644 web/src/components/base/BaseToast.vue create mode 100644 web/src/components/base/BaseToast/BaseToast.vue create mode 100644 web/src/components/base/BaseToast/index.ts create mode 100644 web/src/components/base/BaseToast/toast.ts delete mode 100644 web/src/composables/useECharts.ts create mode 100644 web/src/directives/loading.ts delete mode 100644 web/src/stores/modules/toast.ts diff --git a/docs/codex-proxy-rs.pen b/docs/codex-proxy-rs.pen index 34202bc99..6168e08ec 100644 --- a/docs/codex-proxy-rs.pen +++ b/docs/codex-proxy-rs.pen @@ -25396,6 +25396,991 @@ ] } ] + }, + { + "type": "frame", + "id": "e0NBl", + "x": 0, + "y": 1700, + "name": "Dashboard 顶部四卡融合方案 V8 - 今日 Token 主值", + "clip": true, + "width": 1920, + "height": 360, + "fill": "$cp-bg-page", + "layout": "none", + "children": [ + { + "type": "frame", + "id": "R8KGf", + "x": 0, + "y": 0, + "name": "V8 左侧导航简版", + "width": 280, + "height": 360, + "fill": "$cp-bg-surface", + "layout": "none", + "children": [ + { + "type": "frame", + "id": "JUDnP", + "x": 28, + "y": 31, + "name": "V8 品牌图标", + "width": 46, + "height": 46, + "fill": "$cp-text-strong", + "cornerRadius": 13, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "$cp-shadow-card-color", + "offset": { + "x": 0, + "y": 8 + }, + "blur": 18, + "spread": -16 + }, + "layout": "none" + }, + { + "type": "text", + "id": "xvMza", + "x": 84, + "y": 34, + "name": "V8 品牌名", + "fill": "$cp-text-strong", + "content": "Codex", + "lineHeight": 1.1, + "fontFamily": "Inter", + "fontSize": 17, + "fontWeight": "720" + }, + { + "type": "frame", + "id": "v4VXc", + "x": 24, + "y": 112, + "name": "V8 导航 概览", + "width": 232, + "height": 46, + "fill": "$cp-bg-nav-active", + "cornerRadius": "$cp-radius-md", + "layout": "none", + "children": [ + { + "type": "icon", + "id": "jRytg", + "x": 14, + "y": 14, + "name": "V8 导航 概览 图标", + "width": 18, + "height": 18, + "icon": "layout-dashboard", + "library": "lucide", + "fill": "$cp-info" + }, + { + "type": "text", + "id": "vlfMg", + "x": 44, + "y": 14, + "name": "V8 导航 概览 文字", + "fill": "$cp-text-primary", + "content": "概览", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "g1s2k9", + "x": 24, + "y": 170, + "name": "V8 导航 账号池", + "width": 232, + "height": 46, + "fill": "$cp-transparent", + "cornerRadius": "$cp-radius-md", + "layout": "none", + "children": [ + { + "type": "icon", + "id": "H9GuT", + "x": 14, + "y": 14, + "name": "V8 导航 账号池 图标", + "width": 18, + "height": 18, + "icon": "users", + "library": "lucide", + "fill": "$cp-text-secondary" + }, + { + "type": "text", + "id": "i8o2LE", + "x": 44, + "y": 14, + "name": "V8 导航 账号池 文字", + "fill": "$cp-text-secondary", + "content": "账号池", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "i9kBP", + "x": 24, + "y": 228, + "name": "V8 导航 API Keys", + "width": 232, + "height": 46, + "fill": "$cp-transparent", + "cornerRadius": "$cp-radius-md", + "layout": "none", + "children": [ + { + "type": "icon", + "id": "y9inH", + "x": 14, + "y": 14, + "name": "V8 导航 API Keys 图标", + "width": 18, + "height": 18, + "icon": "key-round", + "library": "lucide", + "fill": "$cp-text-secondary" + }, + { + "type": "text", + "id": "c2OiOP", + "x": 44, + "y": 14, + "name": "V8 导航 API Keys 文字", + "fill": "$cp-text-secondary", + "content": "API Keys", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "q3jFK", + "x": 24, + "y": 286, + "name": "V8 导航 日志", + "width": 232, + "height": 46, + "fill": "$cp-transparent", + "cornerRadius": "$cp-radius-md", + "layout": "none", + "children": [ + { + "type": "icon", + "id": "KRGYq", + "x": 14, + "y": 14, + "name": "V8 导航 日志 图标", + "width": 18, + "height": 18, + "icon": "list", + "library": "lucide", + "fill": "$cp-text-secondary" + }, + { + "type": "text", + "id": "BXnmz", + "x": 44, + "y": 14, + "name": "V8 导航 日志 文字", + "fill": "$cp-text-secondary", + "content": "日志", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "text", + "id": "lLDQO", + "x": 308, + "y": 34, + "name": "V8 页面标题", + "fill": "$cp-text-primary", + "content": "系统概览", + "lineHeight": 1.15, + "fontFamily": "Inter", + "fontSize": 34, + "fontWeight": "800" + }, + { + "type": "text", + "id": "EsB6L", + "x": 308, + "y": 78, + "name": "V8 页面副标题", + "fill": "$cp-text-secondary", + "content": "生产环境 · 14:32 更新 · 自动刷新 30s", + "lineHeight": 1.15, + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "zj6sT", + "x": 308, + "y": 126, + "name": "V8 指标卡 账号", + "width": 378, + "height": 154, + "fill": "$cp-bg-surface", + "cornerRadius": "$cp-radius-lg", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "$cp-shadow-card-color", + "offset": { + "x": 0, + "y": 10 + }, + "blur": 22, + "spread": -18 + }, + "layout": "none", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$cp-radius-sm", + "id": "sCWEO", + "x": 24, + "y": 20, + "name": "V8 指标卡 账号 图标底", + "fill": "$cp-normal-bg", + "width": 34, + "height": 34 + }, + { + "type": "icon", + "id": "aX2A4", + "x": 32, + "y": 28, + "name": "V8 指标卡 账号 图标", + "width": 18, + "height": 18, + "icon": "users", + "library": "lucide", + "fill": "$cp-normal" + }, + { + "type": "text", + "id": "LPEOG", + "x": 70, + "y": 24, + "name": "V8 指标卡 账号 标题", + "fill": "$cp-text-secondary", + "content": "账号", + "lineHeight": 1.15, + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "650" + }, + { + "type": "text", + "id": "DmFQq", + "x": 24, + "y": 67, + "name": "V8 指标卡 账号 主值", + "fill": "$cp-text-primary", + "textGrowth": "fixed-width", + "width": 170, + "content": "1", + "lineHeight": 1.05, + "fontFamily": "JetBrains Mono", + "fontSize": 29, + "fontWeight": "780" + }, + { + "type": "text", + "id": "wnLgY", + "x": 198, + "y": 70, + "name": "V8 指标卡 账号 主值标签", + "fill": "$cp-text-secondary", + "textGrowth": "fixed-width", + "width": 156, + "lineHeight": 1.15, + "textAlign": "right", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "o0fsEl", + "x": 24, + "y": 110, + "name": "V8 指标卡 账号 明细带", + "width": 330, + "height": 30, + "fill": "$cp-bg-subtle", + "cornerRadius": "$cp-radius-sm", + "layout": "none", + "children": [ + { + "type": "text", + "id": "lXuVO", + "x": 12, + "y": 0, + "name": "V8 指标卡 账号 明细左标签", + "fill": "$cp-text-muted", + "textGrowth": "fixed-width-height", + "width": 28, + "height": 30, + "content": "启用", + "lineHeight": 1.15, + "textAlignVertical": "middle", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "650" + }, + { + "type": "text", + "id": "q50dGO", + "x": 48, + "y": 0, + "name": "V8 指标卡 账号 明细左值", + "fill": "$cp-normal", + "textGrowth": "fixed-width-height", + "width": 72, + "height": 30, + "content": "1", + "lineHeight": 1.15, + "textAlignVertical": "middle", + "fontFamily": "JetBrains Mono", + "fontSize": 12, + "fontWeight": "700" + }, + { + "type": "text", + "id": "fvp0B", + "x": 178, + "y": 0, + "name": "V8 指标卡 账号 明细右标签", + "fill": "$cp-text-muted", + "textGrowth": "fixed-width-height", + "width": 28, + "height": 30, + "content": "异常", + "lineHeight": 1.15, + "textAlignVertical": "middle", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "650" + }, + { + "type": "text", + "id": "zQn2U", + "x": 214, + "y": 0, + "name": "V8 指标卡 账号 明细右值", + "fill": "$cp-normal-text", + "textGrowth": "fixed-width-height", + "width": 74, + "height": 30, + "content": "0", + "lineHeight": 1.15, + "textAlignVertical": "middle", + "fontFamily": "JetBrains Mono", + "fontSize": 12, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "PF09r", + "x": 710, + "y": 126, + "name": "V8 指标卡 流量", + "width": 378, + "height": 154, + "fill": "$cp-bg-surface", + "cornerRadius": "$cp-radius-lg", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "$cp-shadow-card-color", + "offset": { + "x": 0, + "y": 10 + }, + "blur": 22, + "spread": -18 + }, + "layout": "none", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$cp-radius-sm", + "id": "P7fkTT", + "x": 24, + "y": 20, + "name": "V8 指标卡 流量 图标底", + "fill": "$cp-info-bg", + "width": 34, + "height": 34 + }, + { + "type": "icon", + "id": "yvbAG", + "x": 32, + "y": 28, + "name": "V8 指标卡 流量 图标", + "width": 18, + "height": 18, + "icon": "activity", + "library": "lucide", + "fill": "$cp-info" + }, + { + "type": "text", + "id": "AKGO0", + "x": 70, + "y": 24, + "name": "V8 指标卡 流量 标题", + "fill": "$cp-text-secondary", + "content": "今日流量", + "lineHeight": 1.15, + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "650" + }, + { + "type": "text", + "id": "HTnki", + "x": 24, + "y": 67, + "name": "V8 指标卡 流量 主值", + "fill": "$cp-text-primary", + "textGrowth": "fixed-width", + "width": 170, + "content": "0", + "lineHeight": 1.05, + "fontFamily": "JetBrains Mono", + "fontSize": 29, + "fontWeight": "780" + }, + { + "type": "text", + "id": "De6kV", + "x": 198, + "y": 70, + "name": "V8 指标卡 流量 主值标签", + "fill": "$cp-text-secondary", + "textGrowth": "fixed-width", + "width": 156, + "lineHeight": 1.15, + "textAlign": "right", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "HZn6h", + "x": 24, + "y": 110, + "name": "V8 指标卡 流量 明细带", + "width": 330, + "height": 30, + "fill": "$cp-bg-subtle", + "cornerRadius": "$cp-radius-sm", + "layout": "none", + "children": [ + { + "type": "text", + "id": "DvaDw", + "x": 12, + "y": 0, + "name": "V8 指标卡 流量 明细左标签", + "fill": "$cp-text-muted", + "textGrowth": "fixed-width-height", + "width": 52, + "height": 30, + "content": "总请求", + "lineHeight": 1.15, + "textAlignVertical": "middle", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "650" + }, + { + "type": "text", + "id": "M3woR8", + "x": 70, + "y": 0, + "name": "V8 指标卡 流量 明细左值", + "fill": "$cp-info", + "textGrowth": "fixed-width-height", + "width": 72, + "height": 30, + "content": "95", + "lineHeight": 1.15, + "textAlignVertical": "middle", + "fontFamily": "JetBrains Mono", + "fontSize": 12, + "fontWeight": "700" + }, + { + "type": "text", + "id": "y2pDQI", + "x": 164, + "y": 0, + "name": "V8 指标卡 流量 明细右标签", + "fill": "$cp-text-muted", + "textGrowth": "fixed-width-height", + "width": 66, + "height": 30, + "content": "RPM / TPM", + "lineHeight": 1.15, + "textAlignVertical": "middle", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "650" + }, + { + "type": "text", + "id": "Z1NXvl", + "x": 236, + "y": 0, + "name": "V8 指标卡 流量 明细右值", + "fill": "$cp-info-text", + "textGrowth": "fixed-width-height", + "width": 72, + "height": 30, + "content": "0 / 0", + "lineHeight": 1.15, + "textAlignVertical": "middle", + "fontFamily": "JetBrains Mono", + "fontSize": 12, + "fontWeight": "700" + } + ] + }, + { + "type": "path", + "id": "U7tno", + "x": 50, + "y": 82, + "name": "V8 指标卡 流量 昨日趋势", + "geometry": "M8 14l-6-6h4v-6h4v6h4z", + "viewBox": [ + 0, + 0, + 16, + 16 + ], + "fill": "#EF4444", + "width": 12, + "height": 12 + } + ] + }, + { + "type": "frame", + "id": "U0rMi", + "x": 1112, + "y": 126, + "name": "V8 指标卡 Token", + "width": 378, + "height": 154, + "fill": "$cp-bg-surface", + "cornerRadius": "$cp-radius-lg", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "$cp-shadow-card-color", + "offset": { + "x": 0, + "y": 10 + }, + "blur": 22, + "spread": -18 + }, + "layout": "none", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$cp-radius-sm", + "id": "C0vTR8", + "x": 24, + "y": 20, + "name": "V8 指标卡 Token 图标底", + "fill": "$cp-success-bg", + "width": 34, + "height": 34 + }, + { + "type": "icon", + "id": "wfEeV", + "x": 32, + "y": 28, + "name": "V8 指标卡 Token 图标", + "width": 18, + "height": 18, + "icon": "file-text", + "library": "lucide", + "fill": "$cp-success" + }, + { + "type": "text", + "id": "MeqZC", + "x": 70, + "y": 24, + "name": "V8 指标卡 Token 标题", + "fill": "$cp-text-secondary", + "content": "今日 Token", + "lineHeight": 1.15, + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "650" + }, + { + "type": "text", + "id": "EOcR4", + "x": 24, + "y": 67, + "name": "V8 指标卡 Token 主值", + "fill": "$cp-text-primary", + "textGrowth": "fixed-width", + "width": 170, + "content": "1M", + "lineHeight": 1.05, + "fontFamily": "JetBrains Mono", + "fontSize": 29, + "fontWeight": "780" + }, + { + "type": "text", + "id": "UsUdi", + "x": 82, + "y": 70, + "name": "V8 指标卡 Token 主值标签", + "fill": "#94A3B8", + "textGrowth": "fixed-width-height", + "width": 56, + "height": 14, + "lineHeight": 1.1, + "textAlignVertical": "middle", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "650" + }, + { + "type": "frame", + "id": "lsZ5K", + "x": 24, + "y": 110, + "name": "V8 指标卡 Token 明细带", + "width": 330, + "height": 30, + "fill": "$cp-bg-subtle", + "cornerRadius": "$cp-radius-sm", + "layout": "none", + "children": [ + { + "type": "text", + "id": "WBU0t", + "x": 12, + "y": 0, + "name": "V8 指标卡 Token 明细左标签", + "fill": "$cp-text-muted", + "textGrowth": "fixed-width-height", + "width": 52, + "height": 30, + "content": "总 Token", + "lineHeight": 1.15, + "textAlignVertical": "middle", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "650" + }, + { + "type": "text", + "id": "M5GnVY", + "x": 70, + "y": 0, + "name": "V8 指标卡 Token 明细左值", + "fill": "$cp-success", + "textGrowth": "fixed-width-height", + "width": 72, + "height": 30, + "content": "3.1M", + "lineHeight": 1.15, + "textAlignVertical": "middle", + "fontFamily": "JetBrains Mono", + "fontSize": 12, + "fontWeight": "700" + }, + { + "type": "text", + "id": "s1TME3", + "x": 158, + "y": 0, + "name": "V8 指标卡 Token 明细右标签", + "fill": "#94A3B8", + "textGrowth": "fixed-width-height", + "width": 28, + "height": 30, + "content": "计费", + "lineHeight": 1.15, + "textAlignVertical": "middle", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "650" + }, + { + "type": "text", + "id": "qjJSP", + "x": 194, + "y": 0, + "name": "V8 指标卡 Token 明细右值", + "fill": "#047857", + "textGrowth": "fixed-width-height", + "width": 124, + "height": 30, + "content": "$5.88 / $5.88", + "lineHeight": 1.15, + "textAlignVertical": "middle", + "fontFamily": "JetBrains Mono", + "fontSize": 12, + "fontWeight": "700" + } + ] + }, + { + "type": "path", + "id": "n0CNjV", + "x": 78, + "y": 82, + "name": "V8 指标卡 Token 昨日趋势", + "geometry": "M8 2l6 6h-4v6h-4v-6h-4z", + "viewBox": [ + 0, + 0, + 16, + 16 + ], + "fill": "#12B981", + "width": 12, + "height": 12 + } + ] + }, + { + "type": "frame", + "id": "iJsob", + "x": 1514, + "y": 126, + "name": "V8 指标卡 缓存", + "width": 378, + "height": 154, + "fill": "$cp-bg-surface", + "cornerRadius": "$cp-radius-lg", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "$cp-shadow-card-color", + "offset": { + "x": 0, + "y": 10 + }, + "blur": 22, + "spread": -18 + }, + "layout": "none", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$cp-radius-sm", + "id": "l6tR2", + "x": 24, + "y": 20, + "name": "V8 指标卡 缓存 图标底", + "fill": "$cp-warning-bg", + "width": 34, + "height": 34 + }, + { + "type": "icon", + "id": "EpH25", + "x": 32, + "y": 28, + "name": "V8 指标卡 缓存 图标", + "width": 18, + "height": 18, + "icon": "gauge", + "library": "lucide", + "fill": "$cp-warning" + }, + { + "type": "text", + "id": "lX9SA", + "x": 70, + "y": 24, + "name": "V8 指标卡 缓存 标题", + "fill": "$cp-text-secondary", + "content": "缓存命中", + "lineHeight": 1.15, + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "650" + }, + { + "type": "text", + "id": "QVzdr", + "x": 24, + "y": 67, + "name": "V8 指标卡 缓存 主值", + "fill": "$cp-text-primary", + "textGrowth": "fixed-width", + "width": 170, + "content": "0%", + "lineHeight": 1.05, + "fontFamily": "JetBrains Mono", + "fontSize": 29, + "fontWeight": "780" + }, + { + "type": "text", + "id": "Zf9da", + "x": 198, + "y": 70, + "name": "V8 指标卡 缓存 主值标签", + "fill": "$cp-text-secondary", + "textGrowth": "fixed-width", + "width": 156, + "lineHeight": 1.15, + "textAlign": "right", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "X6O5ty", + "x": 24, + "y": 110, + "name": "V8 指标卡 缓存 明细带", + "width": 330, + "height": 30, + "fill": "$cp-bg-subtle", + "cornerRadius": "$cp-radius-sm", + "layout": "none", + "children": [ + { + "type": "text", + "id": "DkvFX", + "x": 12, + "y": 0, + "name": "V8 指标卡 缓存 明细左标签", + "fill": "$cp-text-muted", + "textGrowth": "fixed-width-height", + "width": 72, + "height": 30, + "content": "总缓存命中", + "lineHeight": 1.15, + "textAlignVertical": "middle", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "650" + }, + { + "type": "text", + "id": "cfgcr", + "x": 90, + "y": 0, + "name": "V8 指标卡 缓存 明细左值", + "fill": "$cp-warning", + "textGrowth": "fixed-width-height", + "width": 72, + "height": 30, + "content": "94.7%", + "lineHeight": 1.15, + "textAlignVertical": "middle", + "fontFamily": "JetBrains Mono", + "fontSize": 12, + "fontWeight": "700" + }, + { + "type": "text", + "id": "I0s8rT", + "x": 176, + "y": 0, + "name": "V8 指标卡 缓存 明细右标签", + "fill": "$cp-text-muted", + "textGrowth": "fixed-width-height", + "width": 70, + "height": 30, + "content": "首字 / 完成", + "lineHeight": 1.15, + "textAlignVertical": "middle", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "650" + }, + { + "type": "text", + "id": "A0cX1j", + "x": 252, + "y": 0, + "name": "V8 指标卡 缓存 明细右值", + "fill": "$cp-warning-text", + "textGrowth": "fixed-width-height", + "width": 72, + "height": 30, + "content": "8.5s / -", + "lineHeight": 1.15, + "textAlignVertical": "middle", + "fontFamily": "JetBrains Mono", + "fontSize": 12, + "fontWeight": "700" + } + ] + }, + { + "type": "path", + "id": "IgWxp", + "x": 75, + "y": 82, + "name": "V8 指标卡 缓存 昨日趋势", + "geometry": "M8 14l-6-6h4v-6h4v6h4z", + "viewBox": [ + 0, + 0, + 16, + 16 + ], + "fill": "#EF4444", + "width": 12, + "height": 12 + } + ] + } + ] } ], "variables": { diff --git a/src/admin/accounts/service/lifecycle.rs b/src/admin/accounts/service/lifecycle.rs index 6891fc2b4..19aa2e242 100644 --- a/src/admin/accounts/service/lifecycle.rs +++ b/src/admin/accounts/service/lifecycle.rs @@ -1,7 +1,7 @@ use chrono::Utc; use secrecy::{ExposeSecret, SecretString}; -use crate::{infra::json::Page, upstream::accounts::model::AccountStatus as AcctStatus}; +use crate::infra::json::Page; use super::{types::*, AdminAccountService}; @@ -35,33 +35,6 @@ impl AdminAccountService { .map(|account| account.map(AdminAccountMetadata::from)) .map_err(|_| AdminAccountError::Inspect) } - pub async fn auth_status(&self) -> Result { - let mut cursor = None; - let mut pool = AdminAuthPoolStatus::default(); - let mut user = None; - loop { - let page = self - .store - .list_metadata(cursor, 200) - .await - .map_err(|_| AdminAccountError::List)?; - for account in page.items { - pool.record(account.status); - if user.is_none() && account.status == AcctStatus::Active { - user = Some(AdminAccountMetadata::from(account)); - } - } - if page.next_cursor.is_none() { - break; - } - cursor = page.next_cursor; - } - Ok(AdminAuthStatus { - authenticated: pool.total > 0, - user, - pool, - }) - } pub async fn create( &self, token: Option, diff --git a/src/admin/accounts/service/mod.rs b/src/admin/accounts/service/mod.rs index 9762af539..eeb2c094f 100644 --- a/src/admin/accounts/service/mod.rs +++ b/src/admin/accounts/service/mod.rs @@ -20,8 +20,8 @@ use crate::{ }; pub use types::{ - AdminAccountError, AdminAccountMetadata, AdminAuthPoolStatus, AdminAuthStatus, - BatchDeleteAccounts, BatchUpdateAccountStatus, ImportedAccounts, UpdatedAccountStatus, + AdminAccountError, AdminAccountMetadata, BatchDeleteAccounts, BatchUpdateAccountStatus, + ImportedAccounts, UpdatedAccountStatus, }; #[derive(Clone)] diff --git a/src/admin/accounts/service/types.rs b/src/admin/accounts/service/types.rs index e9aac2156..ab981ef93 100644 --- a/src/admin/accounts/service/types.rs +++ b/src/admin/accounts/service/types.rs @@ -110,38 +110,6 @@ pub enum AdminAccountError { Inactive(AcctStatus), } -#[derive(Debug, Clone)] -pub struct AdminAuthStatus { - pub authenticated: bool, - pub user: Option, - pub pool: AdminAuthPoolStatus, -} - -#[derive(Debug, Clone, Default)] -pub struct AdminAuthPoolStatus { - pub total: u32, - pub active: u32, - pub expired: u32, - pub quota_exhausted: u32, - pub refreshing: u32, - pub disabled: u32, - pub banned: u32, -} - -impl AdminAuthPoolStatus { - pub(super) fn record(&mut self, status: AcctStatus) { - self.total += 1; - match status { - AcctStatus::Active => self.active += 1, - AcctStatus::Expired => self.expired += 1, - AcctStatus::QuotaExhausted => self.quota_exhausted += 1, - AcctStatus::Refreshing => self.refreshing += 1, - AcctStatus::Disabled => self.disabled += 1, - AcctStatus::Banned => self.banned += 1, - } - } -} - #[derive(Debug, Clone)] pub struct UpdatedAccountStatus { pub id: String, diff --git a/src/admin/auth/mod.rs b/src/admin/auth/mod.rs index c70024735..87d8bfc98 100644 --- a/src/admin/auth/mod.rs +++ b/src/admin/auth/mod.rs @@ -1,5 +1,4 @@ //! 管理端认证与本地会话模块。 -pub mod routes; pub mod service; pub mod session; diff --git a/src/admin/auth/routes.rs b/src/admin/auth/routes.rs deleted file mode 100644 index ac2a02fae..000000000 --- a/src/admin/auth/routes.rs +++ /dev/null @@ -1,114 +0,0 @@ -//! 管理端账号认证状态 HTTP 处理器。 - -use axum::{ - extract::State, - http::{HeaderMap, StatusCode}, - response::IntoResponse, - Extension, -}; -use serde::Serialize; - -use crate::{ - admin::accounts::service::{AdminAccountError, AdminAuthStatus}, - admin::{ - accounts::routes::AdminAccountData, - auth::session::require_admin_session, - response::{AdminEnvelope, AdminError, AdminResponse}, - }, - http::middleware::request_id::RequestId, - runtime::state::AppState, -}; - -/// 管理端认证状态响应。 -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AdminAuthStatusData { - authenticated: bool, - user: Option, - pool: AdminAuthPoolData, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AdminAuthPoolData { - total: u32, - active: u32, - expired: u32, - quota_exhausted: u32, - refreshing: u32, - disabled: u32, - banned: u32, -} - -/// `GET /api/admin/accounts/auth-status` -pub async fn auth_status( - State(state): State, - Extension(request_id): Extension, - headers: HeaderMap, -) -> Result { - let request_id = request_id.as_str().to_string(); - require_admin_session(&state, &headers, &request_id).await?; - - let status = state - .services - .admin_accounts - .auth_status() - .await - .map_err(|error| account_error(error, request_id.clone()))?; - Ok(AdminResponse::new( - StatusCode::OK, - AdminEnvelope::ok(AdminAuthStatusData::from(status), request_id), - )) -} - -fn account_error(error: AdminAccountError, request_id: String) -> AdminError { - match error { - AdminAccountError::InvalidStatus(_) - | AdminAccountError::LabelTooLong - | AdminAccountError::EmptyIds - | AdminAccountError::NoImportableAccounts - | AdminAccountError::InvalidAccessTokenExpiresAt - | AdminAccountError::TokenRequired - | AdminAccountError::InvalidToken(_) - | AdminAccountError::RefreshTokenExchange(_) - | AdminAccountError::NoValidCookies => AdminError::new( - StatusCode::BAD_REQUEST, - 40001, - error.to_string(), - request_id, - ), - AdminAccountError::NotFound => AdminError::new( - StatusCode::NOT_FOUND, - 40401, - "Account not found", - request_id, - ), - AdminAccountError::Inactive(_) => { - AdminError::new(StatusCode::CONFLICT, 40901, error.to_string(), request_id) - } - _ => AdminError::new( - StatusCode::INTERNAL_SERVER_ERROR, - 50001, - error.to_string(), - request_id, - ), - } -} - -impl From for AdminAuthStatusData { - fn from(status: AdminAuthStatus) -> Self { - Self { - authenticated: status.authenticated, - user: status.user.map(AdminAccountData::from), - pool: AdminAuthPoolData { - total: status.pool.total, - active: status.pool.active, - expired: status.pool.expired, - quota_exhausted: status.pool.quota_exhausted, - refreshing: status.pool.refreshing, - disabled: status.pool.disabled, - banned: status.pool.banned, - }, - } - } -} diff --git a/src/admin/auth/session.rs b/src/admin/auth/session.rs index 2d32bf158..778582166 100644 --- a/src/admin/auth/session.rs +++ b/src/admin/auth/session.rs @@ -34,6 +34,14 @@ pub struct AdminLoginData { pub expires_at: String, } +/// 管理员会话状态响应。 +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminSessionStatusData { + /// 当前请求是否携带有效管理端会话。 + pub authenticated: bool, +} + /// 会话登录是否成功。 pub fn session_login_allowed() -> bool { true @@ -96,6 +104,33 @@ pub async fn login( Ok(response) } +/// `GET /api/admin/auth/status` +pub async fn session_status( + State(state): State, + Extension(request_id): Extension, + headers: HeaderMap, +) -> Result { + let request_id = request_id.as_str().to_string(); + let authenticated = state + .services + .admin_sessions + .validate(admin_session_cookie(&headers).as_deref()) + .await + .map_err(|_| { + AdminError::new( + StatusCode::INTERNAL_SERVER_ERROR, + 50001, + "Failed to validate admin session", + request_id.clone(), + ) + })?; + + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(AdminSessionStatusData { authenticated }, request_id), + )) +} + /// `POST /api/admin/logout` pub async fn logout( State(state): State, diff --git a/src/admin/monitoring/dashboard.rs b/src/admin/monitoring/dashboard.rs new file mode 100644 index 000000000..38140f127 --- /dev/null +++ b/src/admin/monitoring/dashboard.rs @@ -0,0 +1,743 @@ +//! 管理端 Dashboard 聚合视图。 + +use std::collections::HashMap; + +use axum::{ + extract::{Query, State}, + http::{HeaderMap, StatusCode}, + response::IntoResponse, + Extension, +}; +use chrono::{DateTime, Duration, Timelike, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::{ + admin::{ + auth::session::require_admin_session, + monitoring::{ + diagnostics::{ + fingerprint_diagnostics, AccountCapacityDiagnostics, AccountPoolDiagnostics, + }, + event_store::AdminLogFilter, + events::{EventLevel, EventLog}, + service::{AdminUsageRecord, AdminUsageSummary}, + }, + response::{AdminEnvelope, AdminError, AdminResponse}, + }, + http::middleware::request_id::RequestId, + runtime::state::AppState, + upstream::accounts::model::{Account, AccountStatus}, +}; + +const DASHBOARD_LOG_LIMIT: u32 = 1000; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DashboardTrendQuery { + pub kind: Option, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum DashboardTrendKind { + Usage, + Latency, + Errors, +} + +impl Default for DashboardTrendKind { + fn default() -> Self { + Self::Usage + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DashboardSummaryData { + pub cards: DashboardCardsData, + pub trend: DashboardTrendData, + pub account_usage: Vec, + pub service_statuses: Vec, + pub event_logs: Vec, + pub pool_summary: AccountPoolDiagnostics, + pub capacity_info: AccountCapacityDiagnostics, + pub rotation_strategy: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DashboardCardsData { + pub accounts: DashboardAccountsCardData, + pub traffic: DashboardTrafficCardData, + pub tokens: DashboardTokenCardData, + pub cache: DashboardCacheCardData, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DashboardAccountsCardData { + pub total: u64, + pub enabled: u64, + pub abnormal: u64, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DashboardTrafficCardData { + pub today_requests: u64, + pub yesterday_requests: u64, + pub total_requests: u64, + pub rpm: u64, + pub tpm: u64, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DashboardTokenCardData { + pub today_tokens: u64, + pub yesterday_tokens: u64, + pub total_tokens: u64, + pub today_cost_usd: Option, + pub total_cost_usd: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DashboardCacheCardData { + pub today_hit_rate: Option, + pub yesterday_hit_rate: Option, + pub total_hit_rate: Option, + pub total_cached_tokens: u64, + pub first_token_latency_ms: Option, + pub completion_latency_ms: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DashboardTrendData { + pub kind: DashboardTrendKind, + pub points: Vec, + pub summary: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DashboardTrendPointData { + pub time: String, + pub requests: u64, + pub input_tokens: u64, + pub output_tokens: u64, + pub cached_tokens: u64, + pub tokens: u64, + pub errors: u64, + pub latency: u64, + pub max_latency: u64, + pub min_latency: u64, + pub success_rate: f64, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DashboardTrendSummaryData { + pub label: String, + pub value: u64, + pub ratio: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DashboardAccountUsageData { + pub name: String, + pub email: String, + pub plan: String, + pub requests: u64, + pub tokens: u64, + pub quota_used_percent: Option, + pub last_used_at: Option, + pub status: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DashboardServiceStatusData { + pub label: String, + pub value: String, + pub detail: String, + pub tone: String, +} + +#[derive(Debug, Clone, Copy, Default)] +struct UsageWindow { + requests: u64, + input_tokens: u64, + output_tokens: u64, + cached_tokens: u64, + errors: u64, + latency_sum: u64, + latency_count: u64, + max_latency: u64, + min_latency: u64, +} + +impl UsageWindow { + fn tokens(self) -> u64 { + self.input_tokens + self.output_tokens + } + + fn cache_hit_rate(self) -> Option { + if self.input_tokens > 0 { + Some(self.cached_tokens as f64 / self.input_tokens as f64) + } else { + None + } + } + + fn avg_latency(self) -> Option { + if self.latency_count > 0 { + Some(self.latency_sum / self.latency_count) + } else { + None + } + } +} + +/// `GET /api/admin/dashboard/summary` +pub async fn dashboard_summary( + State(state): State, + Extension(request_id): Extension, + headers: HeaderMap, +) -> Result { + let request_id = request_id.as_str().to_string(); + require_admin_session(&state, &headers, &request_id).await?; + + let accounts = state + .services + .accounts + .list_pool_accounts() + .await + .unwrap_or_default(); + let capacity = state.services.account_pool.capacity_summary_now().await; + let summary = state + .services + .usage + .summary() + .await + .unwrap_or_else(|_| empty_usage_summary()); + let usage_records = state + .services + .usage + .list(None, 200) + .await + .map(|page| page.items) + .unwrap_or_default(); + let logs = state + .services + .logs + .list(None, DASHBOARD_LOG_LIMIT, AdminLogFilter::default()) + .await + .map(|page| page.items) + .unwrap_or_default(); + + let pool_summary = account_pool_summary(&accounts); + let dashboard_logs = logs.iter().take(10).cloned().collect(); + let trend = dashboard_trend_data(&logs, DashboardTrendKind::Usage); + let quota_used_by_account = account_quota_used_percent_by_id(&state, &usage_records).await; + let settings = state.services.settings.current(); + + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok( + DashboardSummaryData { + cards: dashboard_cards(&accounts, &summary, &logs), + trend, + account_usage: account_usage_data( + &accounts, + &usage_records, + "a_used_by_account, + ), + service_statuses: service_status_data(&state), + event_logs: dashboard_logs, + pool_summary, + capacity_info: AccountCapacityDiagnostics::from(capacity), + rotation_strategy: Some(settings.auth.rotation_strategy.clone()), + }, + request_id, + ), + )) +} + +/// `GET /api/admin/dashboard/trend?kind=usage|latency|errors` +pub async fn dashboard_trend( + State(state): State, + Extension(request_id): Extension, + headers: HeaderMap, + Query(query): Query, +) -> Result { + let request_id = request_id.as_str().to_string(); + require_admin_session(&state, &headers, &request_id).await?; + let logs = state + .services + .logs + .list(None, DASHBOARD_LOG_LIMIT, AdminLogFilter::default()) + .await + .map(|page| page.items) + .unwrap_or_default(); + + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok( + dashboard_trend_data(&logs, query.kind.unwrap_or_default()), + request_id, + ), + )) +} + +fn dashboard_cards( + accounts: &[Account], + summary: &AdminUsageSummary, + logs: &[EventLog], +) -> DashboardCardsData { + let now = Utc::now(); + let today_start = day_start(now); + let yesterday_start = today_start - Duration::days(1); + let hour_start = now - Duration::hours(1); + + let today = usage_window(logs, today_start, now); + let yesterday = usage_window(logs, yesterday_start, today_start); + let hour = usage_window(logs, hour_start, now); + + let total_input = summary.input_tokens.max(0) as u64; + let total_cached = summary.cached_tokens.max(0) as u64; + + DashboardCardsData { + accounts: DashboardAccountsCardData { + total: accounts.len() as u64, + enabled: accounts + .iter() + .filter(|account| account.status == AccountStatus::Active) + .count() as u64, + abnormal: accounts + .iter() + .filter(|account| { + matches!( + account.status, + AccountStatus::Expired + | AccountStatus::QuotaExhausted + | AccountStatus::Disabled + | AccountStatus::Banned + ) + }) + .count() as u64, + }, + traffic: DashboardTrafficCardData { + today_requests: today.requests, + yesterday_requests: yesterday.requests, + total_requests: summary.request_count.max(0) as u64, + rpm: hour.requests, + tpm: hour.tokens(), + }, + tokens: DashboardTokenCardData { + today_tokens: today.tokens(), + yesterday_tokens: yesterday.tokens(), + total_tokens: (summary.input_tokens + summary.output_tokens).max(0) as u64, + today_cost_usd: None, + total_cost_usd: None, + }, + cache: DashboardCacheCardData { + today_hit_rate: today.cache_hit_rate(), + yesterday_hit_rate: yesterday.cache_hit_rate(), + total_hit_rate: if total_input > 0 { + Some(total_cached as f64 / total_input as f64) + } else { + None + }, + total_cached_tokens: total_cached, + first_token_latency_ms: today.avg_latency(), + completion_latency_ms: None, + }, + } +} + +fn dashboard_trend_data(logs: &[EventLog], kind: DashboardTrendKind) -> DashboardTrendData { + let now = Utc::now(); + let current_hour = hour_start(now); + let start = current_hour - Duration::hours(23); + let mut buckets = (0..24) + .map(|index| { + let bucket_start = start + Duration::hours(index); + (bucket_start, UsageWindow::default()) + }) + .collect::>(); + + for log in logs { + if log.created_at < start || log.created_at > now { + continue; + } + let log_hour = hour_start(log.created_at); + if let Some((_, bucket)) = buckets + .iter_mut() + .find(|(bucket_start, _)| *bucket_start == log_hour) + { + apply_log(bucket, log); + } + } + + let points = buckets + .iter() + .map(|(bucket_start, bucket)| { + let latency = bucket.avg_latency().unwrap_or(0); + DashboardTrendPointData { + time: format!("{:02}", bucket_start.hour()), + requests: bucket.requests, + input_tokens: bucket.input_tokens, + output_tokens: bucket.output_tokens, + cached_tokens: bucket.cached_tokens, + tokens: bucket.tokens(), + errors: bucket.errors, + latency, + max_latency: bucket.max_latency, + min_latency: bucket.min_latency, + success_rate: if bucket.requests > 0 { + ((bucket.requests - bucket.errors) as f64 / bucket.requests as f64 * 1000.0) + .round() + / 10.0 + } else { + 0.0 + }, + } + }) + .collect::>(); + + DashboardTrendData { + kind, + summary: trend_summary(kind, &points), + points, + } +} + +fn trend_summary( + kind: DashboardTrendKind, + points: &[DashboardTrendPointData], +) -> Vec { + match kind { + DashboardTrendKind::Usage => vec![ + DashboardTrendSummaryData { + label: "输入".to_string(), + value: points.iter().map(|point| point.input_tokens).sum(), + ratio: None, + }, + DashboardTrendSummaryData { + label: "输出".to_string(), + value: points.iter().map(|point| point.output_tokens).sum(), + ratio: None, + }, + DashboardTrendSummaryData { + label: "缓存".to_string(), + value: points.iter().map(|point| point.cached_tokens).sum(), + ratio: None, + }, + ], + DashboardTrendKind::Latency => { + let samples = points + .iter() + .filter(|point| point.latency > 0) + .collect::>(); + let avg = if samples.is_empty() { + 0 + } else { + samples.iter().map(|point| point.latency).sum::() / samples.len() as u64 + }; + vec![ + DashboardTrendSummaryData { + label: "平均".to_string(), + value: avg, + ratio: None, + }, + DashboardTrendSummaryData { + label: "最高".to_string(), + value: samples + .iter() + .map(|point| point.max_latency) + .max() + .unwrap_or(0), + ratio: None, + }, + DashboardTrendSummaryData { + label: "最低".to_string(), + value: samples + .iter() + .filter_map(|point| (point.min_latency > 0).then_some(point.min_latency)) + .min() + .unwrap_or(0), + ratio: None, + }, + ] + } + DashboardTrendKind::Errors => { + let errors = points.iter().map(|point| point.errors).sum::(); + let requests = points.iter().map(|point| point.requests).sum::(); + let success_rate = if requests > 0 { + Some(((requests - errors) as f64 / requests as f64 * 1000.0).round() / 10.0) + } else { + None + }; + vec![ + DashboardTrendSummaryData { + label: "错误数".to_string(), + value: errors, + ratio: None, + }, + DashboardTrendSummaryData { + label: "成功率".to_string(), + value: 0, + ratio: success_rate, + }, + DashboardTrendSummaryData { + label: "总请求".to_string(), + value: requests, + ratio: None, + }, + ] + } + } +} + +fn usage_window(logs: &[EventLog], start: DateTime, end: DateTime) -> UsageWindow { + let mut window = UsageWindow::default(); + for log in logs { + if log.created_at >= start && log.created_at < end { + apply_log(&mut window, log); + } + } + window +} + +fn apply_log(window: &mut UsageWindow, log: &EventLog) { + window.requests += 1; + window.input_tokens += metadata_usage_number(&log.metadata, "inputTokens"); + window.output_tokens += metadata_usage_number(&log.metadata, "outputTokens"); + window.cached_tokens += metadata_usage_number(&log.metadata, "cachedTokens"); + if is_error_log(log) { + window.errors += 1; + } + if let Some(latency) = log + .latency_ms + .filter(|value| *value > 0) + .map(|value| value as u64) + { + window.latency_sum += latency; + window.latency_count += 1; + window.max_latency = window.max_latency.max(latency); + window.min_latency = if window.min_latency == 0 { + latency + } else { + window.min_latency.min(latency) + }; + } +} + +fn metadata_usage_number(metadata: &Value, field: &str) -> u64 { + metadata + .get("usage") + .and_then(|usage| usage.get(field)) + .or_else(|| metadata.get(field)) + .and_then(Value::as_u64) + .unwrap_or(0) +} + +fn is_error_log(log: &EventLog) -> bool { + log.level == EventLevel::Error || log.status_code.is_some_and(|status| status >= 400) +} + +fn account_pool_summary(accounts: &[Account]) -> AccountPoolDiagnostics { + let mut summary = AccountPoolDiagnostics { + total: accounts.len(), + ..AccountPoolDiagnostics::default() + }; + for account in accounts { + match account.status { + AccountStatus::Active => summary.active += 1, + AccountStatus::Expired => summary.expired += 1, + AccountStatus::QuotaExhausted => summary.quota_exhausted += 1, + AccountStatus::Refreshing => summary.refreshing += 1, + AccountStatus::Disabled => summary.disabled += 1, + AccountStatus::Banned => summary.banned += 1, + } + } + summary +} + +fn account_usage_data( + accounts: &[Account], + usage_records: &[AdminUsageRecord], + quota_used_by_account: &HashMap, +) -> Vec { + let account_by_id = accounts + .iter() + .map(|account| (account.id.as_str(), account)) + .collect::>(); + let mut rows = usage_records + .iter() + .map(|usage| { + let account = account_by_id.get(usage.account_id.as_str()).copied(); + let name = account + .and_then(|account| account.label.clone()) + .or_else(|| usage.label.clone()) + .or_else(|| { + usage + .email + .as_ref() + .and_then(|email| email.split('@').next().map(ToString::to_string)) + }) + .unwrap_or_else(|| usage.account_id.chars().take(8).collect()); + let status = account + .map(|account| account.status.to_string()) + .unwrap_or_else(|| "active".to_string()); + let quota_used_percent = quota_used_by_account + .get(&usage.account_id) + .copied() + .or_else(|| (status == "quota_exhausted").then_some(100.0)); + DashboardAccountUsageData { + name, + email: usage.email.clone().unwrap_or_else(|| "-".to_string()), + plan: usage + .plan_type + .clone() + .unwrap_or_else(|| "free".to_string()), + requests: usage.request_count.max(0) as u64, + tokens: (usage.input_tokens + usage.output_tokens).max(0) as u64, + quota_used_percent, + last_used_at: usage.last_used_at.map(|value| value.to_rfc3339()), + status, + } + }) + .collect::>(); + rows.sort_by(|a, b| b.requests.cmp(&a.requests)); + rows.truncate(4); + rows +} + +async fn account_quota_used_percent_by_id( + state: &AppState, + usage_records: &[AdminUsageRecord], +) -> HashMap { + let mut records = usage_records.iter().collect::>(); + records.sort_by(|a, b| b.request_count.cmp(&a.request_count)); + records.truncate(4); + + let mut quota_used_by_account = HashMap::with_capacity(records.len()); + for usage in records { + let Ok(Some(quota_json)) = state + .services + .accounts + .get_quota_json(&usage.account_id) + .await + else { + continue; + }; + if let Some(used_percent) = quota_used_percent("a_json) { + quota_used_by_account.insert(usage.account_id.clone(), used_percent); + } + } + quota_used_by_account +} + +fn quota_used_percent(quota_json: &str) -> Option { + let quota = serde_json::from_str::(quota_json).ok()?; + quota + .pointer("/rate_limit/used_percent") + .and_then(percent_value) +} + +fn percent_value(value: &Value) -> Option { + let percent = value + .as_f64() + .or_else(|| value.as_str().and_then(|value| value.parse::().ok()))?; + percent.is_finite().then_some(percent.clamp(0.0, 100.0)) +} + +fn service_status_data(state: &AppState) -> Vec { + let fingerprint = fingerprint_diagnostics(&state.services.fingerprint); + vec![ + DashboardServiceStatusData { + label: "客户端版本".to_string(), + value: empty_dash(fingerprint.app_version), + detail: if fingerprint.build_number.trim().is_empty() { + "-".to_string() + } else { + format!("Build {}", fingerprint.build_number) + }, + tone: "info".to_string(), + }, + DashboardServiceStatusData { + label: "平台架构".to_string(), + value: empty_dash(fingerprint.platform), + detail: empty_dash(fingerprint.arch), + tone: "info".to_string(), + }, + DashboardServiceStatusData { + label: "Chromium".to_string(), + value: if fingerprint.chromium_version.trim().is_empty() { + "-".to_string() + } else { + format!("v{}", fingerprint.chromium_version) + }, + detail: empty_dash(fingerprint.originator), + tone: "normal".to_string(), + }, + DashboardServiceStatusData { + label: "更新时间".to_string(), + value: fingerprint.updated_at.unwrap_or_else(|| "-".to_string()), + detail: String::new(), + tone: "normal".to_string(), + }, + DashboardServiceStatusData { + label: "User Agent".to_string(), + value: fingerprint.user_agent, + detail: String::new(), + tone: "normal".to_string(), + }, + ] +} + +fn empty_dash(value: String) -> String { + if value.trim().is_empty() { + "-".to_string() + } else { + value + } +} + +fn empty_usage_summary() -> AdminUsageSummary { + AdminUsageSummary { + account_count: 0, + request_count: 0, + empty_response_count: 0, + input_tokens: 0, + output_tokens: 0, + cached_tokens: 0, + reasoning_tokens: 0, + total_tokens: 0, + image_input_tokens: 0, + image_output_tokens: 0, + image_request_count: 0, + image_request_failed_count: 0, + } +} + +fn day_start(value: DateTime) -> DateTime { + value + .date_naive() + .and_hms_opt(0, 0, 0) + .expect("valid midnight") + .and_utc() +} + +fn hour_start(value: DateTime) -> DateTime { + value + .date_naive() + .and_hms_opt(value.hour(), 0, 0) + .expect("valid hour") + .and_utc() +} diff --git a/src/admin/monitoring/diagnostics_routes.rs b/src/admin/monitoring/diagnostics_routes.rs deleted file mode 100644 index d994fe8c4..000000000 --- a/src/admin/monitoring/diagnostics_routes.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! 管理端诊断处理器。 - -use axum::{ - extract::State, - http::{HeaderMap, StatusCode}, - response::IntoResponse, - Extension, -}; - -use crate::{ - admin::auth::session::require_admin_session, - admin::monitoring::diagnostics::{diagnostics_data, DiagnosticsInput}, - admin::response::{AdminEnvelope, AdminError, AdminResponse}, - http::middleware::request_id::RequestId, - runtime::state::AppState, -}; - -/// `GET /api/admin/diagnostics` -pub async fn diagnostics( - State(state): State, - Extension(request_id): Extension, - headers: HeaderMap, -) -> Result { - let request_id = request_id.as_str().to_string(); - require_admin_session(&state, &headers, &request_id).await?; - let config = state.services.settings.current(); - let accounts = state - .services - .accounts - .list_pool_accounts() - .await - .unwrap_or_default(); - let capacity = state.services.account_pool.capacity_summary_now().await; - - Ok(AdminResponse::new( - StatusCode::OK, - AdminEnvelope::ok( - diagnostics_data(DiagnosticsInput { - config: config.as_ref(), - accounts: &accounts, - capacity, - fingerprint: &state.services.fingerprint, - }), - request_id, - ), - )) -} diff --git a/src/admin/monitoring/mod.rs b/src/admin/monitoring/mod.rs index a362881ea..91e9d21d7 100644 --- a/src/admin/monitoring/mod.rs +++ b/src/admin/monitoring/mod.rs @@ -1,7 +1,7 @@ -//! 管理面监控模块:日志、用量统计和诊断。 +//! 管理面监控模块:日志、用量统计和 Dashboard 聚合。 +pub mod dashboard; pub mod diagnostics; -pub mod diagnostics_routes; pub mod event_store; pub mod events; pub mod logs; diff --git a/src/admin/router.rs b/src/admin/router.rs index 43a8664ac..a8bbf2f31 100644 --- a/src/admin/router.rs +++ b/src/admin/router.rs @@ -13,16 +13,13 @@ use super::{ get_account_cookies, health_check_accounts, import_accounts, quota_warnings, refresh_account, reset_account_usage, set_account_cookies, update_account, }, - auth::{ - routes::auth_status, - session::{login, logout}, - }, + auth::session::{login, logout, session_status}, keys::routes::{ api_keys, batch_delete_api_keys, create_api_key, export_api_keys, update_api_key, }, models::routes::refresh_models, monitoring::{ - diagnostics_routes::diagnostics, + dashboard::{dashboard_summary, dashboard_trend}, logs::{clear_logs, log_detail, logs, logs_state, update_logs_state}, usage::{usage_stats, usage_stats_summary}, }, @@ -33,10 +30,11 @@ use super::{ pub fn router() -> Router { Router::new() .route("/api/admin/login", post(login)) + .route("/api/admin/auth/status", get(session_status)) .route("/api/admin/logout", post(logout)) .route("/api/admin/settings", get(settings).post(update_settings)) - .route("/api/admin/diagnostics", get(diagnostics)) - .route("/api/admin/accounts/auth-status", get(auth_status)) + .route("/api/admin/dashboard/summary", get(dashboard_summary)) + .route("/api/admin/dashboard/trend", get(dashboard_trend)) .route("/api/admin/models/refresh", post(refresh_models)) .route("/api/admin/usage", get(usage_stats)) .route("/api/admin/usage/summary", get(usage_stats_summary)) diff --git a/src/web/assets.rs b/src/web/assets.rs index 0a5396f84..8bdcb0879 100644 --- a/src/web/assets.rs +++ b/src/web/assets.rs @@ -6,11 +6,12 @@ use std::{ use axum::{ body::Body, extract::{Path as AxumPath, State}, - http::{header, StatusCode}, + http::{header, StatusCode, Uri}, response::{IntoResponse, Response}, routing::get, - Router, + Json, Router, }; +use serde_json::json; use super::headers::{apply_static_headers, content_type_for_path}; @@ -35,7 +36,19 @@ async fn serve_index(State(state): State) -> Response { serve_file(&state.dist_dir.join("index.html"), "/").await } -async fn serve_spa_fallback(State(state): State) -> Response { +async fn serve_spa_fallback(State(state): State, uri: Uri) -> Response { + if uri.path() == "/api" || uri.path().starts_with("/api/") { + return ( + StatusCode::NOT_FOUND, + Json(json!({ + "code": 40401, + "message": "API route not found", + "data": null + })), + ) + .into_response(); + } + serve_file(&state.dist_dir.join("index.html"), "/").await } diff --git a/web/package.json b/web/package.json index 086fd8803..b0cd859a7 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,9 @@ "scripts": { "dev": "vite --host 0.0.0.0", "build": "vue-tsc -b && vite build", - "preview": "vite preview" + "preview": "vite preview", + "format": "prettier --write src \"*.{ts,json,html}\"", + "format:check": "prettier --check src \"*.{ts,json,html}\"" }, "dependencies": { "@fontsource-variable/inter": "^5.2.8", @@ -26,9 +28,21 @@ "@types/node": "^25.9.3", "@vitejs/plugin-vue": "^6.0.7", "@vue/tsconfig": "^0.9.1", + "prettier": "^3.8.4", "tailwindcss": "^4.3.1", "typescript": "^6.0.3", "vite": "^8.0.16", "vue-tsc": "^3.3.5" + }, + "prettier": { + "semi": false, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf" } } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 853aef20e..b0bba842c 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -51,6 +51,9 @@ importers: '@vue/tsconfig': specifier: ^0.9.1 version: 0.9.1(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)) + prettier: + specifier: ^3.8.4 + version: 3.8.4 tailwindcss: specifier: ^4.3.1 version: 4.3.1 @@ -798,6 +801,11 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + prettier@3.8.4: + resolution: {integrity: sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==} + engines: {node: '>=14'} + hasBin: true + proxy-from-env@2.1.0: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} @@ -1608,6 +1616,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prettier@3.8.4: {} + proxy-from-env@2.1.0: {} quansync@0.2.11: {} diff --git a/web/src/App.vue b/web/src/App.vue index d793cea84..1dfbad621 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -1,7 +1,7 @@