diff --git "a/NEWAPI_MONITOR_\345\211\215\347\253\257\346\216\245\345\205\245\344\270\216\351\205\215\347\275\256\350\257\264\346\230\216.md" "b/NEWAPI_MONITOR_\345\211\215\347\253\257\346\216\245\345\205\245\344\270\216\351\205\215\347\275\256\350\257\264\346\230\216.md" new file mode 100644 index 00000000..af77e342 --- /dev/null +++ "b/NEWAPI_MONITOR_\345\211\215\347\253\257\346\216\245\345\205\245\344\270\216\351\205\215\347\275\256\350\257\264\346\230\216.md" @@ -0,0 +1,675 @@ +# NewAPI 监控功能前端接入与配置说明 + +## 1. 功能简介 + +本功能为 VCP 管理面板新增了一组面向前端展示的 NewAPI 用量监控接口,用于给网页前端、管理面板页面、桌面挂件等场景提供统计数据。 + +当前实现严格遵循最小可用原则,只提供前端已经明确需要的能力: + +- 总请求数统计 +- 总 Token 数统计 +- 总 Quota 统计 +- 当前实时 RPM / TPM +- 按时间范围的趋势数据 +- 按模型维度的聚合数据 +- 按模型筛选 summary / trend + +当前**不包含**以下能力: + +- 区分 `/v1/chat/completions`、`/v1/responses` 等端点级统计 +- 用户排行 +- 渠道排行 +- Token 名排行 +- WebSocket 实时推送 +- 其他前端未明确需要的占位接口 + +--- + +## 2. 后端接入位置 + +本功能对应的核心文件如下: + +- `routes/admin/newapiMonitor.js` +- `routes/adminPanelRoutes.js` +- `config.env.example` + +其中: + +### 2.1 `routes/admin/newapiMonitor.js` +负责: + +- 解析查询参数 +- 连接 NewAPI 管理员接口 +- 管理 session 或账号密码登录 +- 拉取统计数据 +- 聚合并输出给前端 + +### 2.2 `routes/adminPanelRoutes.js` +负责把监控路由挂载到 VCP 管理接口下。 + +### 2.3 `config.env.example` +负责提供配置示例,说明如何填写 NewAPI 监控功能所需环境变量。 + +--- + +## 3. 前端应该请求哪个地址 + +前端**不要直接请求 NewAPI**,而是请求 VCP 的管理接口。 + +可用接口如下: + +- `GET /admin_api/newapi-monitor/summary` +- `GET /admin_api/newapi-monitor/trend` +- `GET /admin_api/newapi-monitor/models` + +这三个接口都由 VCP 后端代持 NewAPI 的管理员鉴权信息。 + +也就是说: + +- 前端不需要持有 NewAPI 的 session cookie +- 前端不需要自己拼 `New-Api-User` +- 前端不需要自己处理 NewAPI 登录逻辑 + +--- + +## 4. 配置说明 + +需要在 VCP 的运行配置中填写以下项目。 + +参考配置写在 `config.env.example` 中。 + +### 4.1 必填配置 + +```env +NEWAPI_MONITOR_BASE_URL=http://127.0.0.1:3000 +``` + +含义: + +- 目标 NewAPI 后台地址 + +### 4.2 可选配置:请求超时 + +```env +NEWAPI_MONITOR_TIMEOUT_MS=15000 +``` + +含义: + +- VCP 调用 NewAPI 时的超时时间,单位毫秒 + +### 4.3 鉴权方式二选一 + +#### 方式 A:直接填写管理员 session cookie(推荐) + +适用于: + +- 开启验证码 +- 开启 2FA +- 不方便让后端自动登录的实例 + +```env +NEWAPI_MONITOR_SESSION_COOKIE= +``` + +#### 方式 B:填写管理员用户名密码 + +适用于: + +- 允许后端直接调用登录接口 +- 无验证码或交互式校验阻碍 + +```env +NEWAPI_MONITOR_USERNAME= +NEWAPI_MONITOR_PASSWORD= +``` + +### 4.4 特殊兼容配置(可选) + +某些经过定制的 NewAPI 实例,除了 session 外,还会额外要求 `New-Api-User` 请求头。 + +这类实例可以补充: + +```env +NEWAPI_MONITOR_API_USER_ID= +``` + +说明: + +- 只有当目标实例明确要求 `New-Api-User` 时才需要填写 +- 普通标准实例可以留空 + +--- + +## 5. 数据来源说明 + +VCP 侧会使用以下策略获取数据: + +### 5.1 优先使用 `/api/data/` +优先从 NewAPI 的聚合数据接口拉取: + +- 请求数 +- token_used +- quota +- created_at +- model_name + +优点: + +- 已按小时聚合 +- 性能更好 +- 更适合前端统计 + +### 5.2 使用 `/api/log/stat` 获取实时值 +用于获取: + +- 当前 RPM +- 当前 TPM + +### 5.3 `/api/data/` 无数据时自动回退到 `/api/log/` +如果目标实例没有可用的 quota_data,则 VCP 会自动回退到消费日志分页拉取,再在本地聚合。 + +因此本功能兼容两类 NewAPI: + +- 已启用聚合数据导出的实例 +- 仅有日志数据的实例 + +--- + +## 6. 接口说明 + +## 6.1 summary 接口 + +### 请求地址 + +```text +GET /admin_api/newapi-monitor/summary +``` + +### 支持参数 + +| 参数 | 是否必填 | 说明 | +|---|---:|---| +| `start_timestamp` | 否 | 开始时间,默认最近 24 小时 | +| `end_timestamp` | 否 | 结束时间,默认当前时间 | +| `model_name` | 否 | 按模型筛选 | + +### 返回内容 + +- 总请求数 +- 总 Token 数 +- 总 Quota +- 当前 RPM +- 当前 TPM + +### 示例响应 + +```json +{ + "success": true, + "data": { + "source": "quota_data", + "start_timestamp": 1711584000, + "end_timestamp": 1711670400, + "model_name": null, + "total_requests": 123, + "total_tokens": 456789, + "total_quota": 987654, + "current_rpm": 12, + "current_tpm": 34567 + } +} +``` + +--- + +## 6.2 trend 接口 + +### 请求地址 + +```text +GET /admin_api/newapi-monitor/trend +``` + +### 支持参数 + +| 参数 | 是否必填 | 说明 | +|---|---:|---| +| `start_timestamp` | 否 | 开始时间,默认最近 24 小时 | +| `end_timestamp` | 否 | 结束时间,默认当前时间 | +| `model_name` | 否 | 按模型筛选 | + +### 返回内容 + +返回趋势数组,每个时间桶包含: + +- `created_at` +- `requests` +- `token_used` +- `quota` + +### 示例响应 + +```json +{ + "success": true, + "data": { + "source": "quota_data", + "start_timestamp": 1711584000, + "end_timestamp": 1711670400, + "model_name": null, + "items": [ + { + "created_at": 1711584000, + "requests": 10, + "token_used": 20000, + "quota": 30000 + } + ] + } +} +``` + +--- + +## 6.3 models 接口 + +### 请求地址 + +```text +GET /admin_api/newapi-monitor/models +``` + +### 支持参数 + +| 参数 | 是否必填 | 说明 | +|---|---:|---| +| `start_timestamp` | 否 | 开始时间,默认最近 24 小时 | +| `end_timestamp` | 否 | 结束时间,默认当前时间 | + +### 返回内容 + +返回按模型聚合后的统计数据: + +- `model_name` +- `requests` +- `token_used` +- `quota` + +### 示例响应 + +```json +{ + "success": true, + "data": { + "source": "quota_data", + "start_timestamp": 1711584000, + "end_timestamp": 1711670400, + "items": [ + { + "model_name": "gpt-4o", + "requests": 100, + "token_used": 123456, + "quota": 789012 + } + ] + } +} +``` + +--- + +## 7. 前端如何调用 + +## 7.1 基础原则 + +前端应该: + +- 只调用 VCP 的 `/admin_api/newapi-monitor/*` +- 尽量使用同源请求 +- 使用浏览器原生 `fetch` +- 不直接连接 NewAPI 后台接口 +- 不在前端保存 NewAPI 管理员鉴权信息 + +--- + +## 7.2 推荐封装一个通用请求函数 + +```js +async function requestMonitorJson(url) { + const response = await fetch(url, { + method: 'GET', + credentials: 'same-origin' + }); + + const result = await response.json(); + + if (!response.ok || result.success === false) { + throw new Error(result.error || result.message || '请求失败'); + } + + return result.data; +} +``` + +--- + +## 7.3 获取 summary + +```js +async function fetchSummary({ startTimestamp, endTimestamp, modelName }) { + const params = new URLSearchParams(); + + if (startTimestamp) params.set('start_timestamp', String(startTimestamp)); + if (endTimestamp) params.set('end_timestamp', String(endTimestamp)); + if (modelName) params.set('model_name', modelName); + + return requestMonitorJson(`/admin_api/newapi-monitor/summary?${params.toString()}`); +} +``` + +--- + +## 7.4 获取 trend + +```js +async function fetchTrend({ startTimestamp, endTimestamp, modelName }) { + const params = new URLSearchParams(); + + if (startTimestamp) params.set('start_timestamp', String(startTimestamp)); + if (endTimestamp) params.set('end_timestamp', String(endTimestamp)); + if (modelName) params.set('model_name', modelName); + + return requestMonitorJson(`/admin_api/newapi-monitor/trend?${params.toString()}`); +} +``` + +--- + +## 7.5 获取 models + +```js +async function fetchModels({ startTimestamp, endTimestamp }) { + const params = new URLSearchParams(); + + if (startTimestamp) params.set('start_timestamp', String(startTimestamp)); + if (endTimestamp) params.set('end_timestamp', String(endTimestamp)); + + return requestMonitorJson(`/admin_api/newapi-monitor/models?${params.toString()}`); +} +``` + +--- + +## 8. 前端页面建议结构 + +建议页面分成三块: + +### 8.1 筛选区域 +建议包含: + +- 时间范围选择器 +- 模型选择器 +- 手动刷新按钮 + +### 8.2 顶部统计卡片 +建议展示: + +- 总请求数 +- 总 Token +- 总 Quota +- 当前 RPM +- 当前 TPM + +### 8.3 趋势与模型排行区域 +建议展示: + +- 趋势图 +- 模型排行表格 + +--- + +## 9. 页面初始化建议 + +推荐初始化流程如下: + +1. 计算默认时间范围(最近 24 小时) +2. 请求 `models` +3. 请求 `summary` +4. 请求 `trend` +5. 渲染下拉框、卡片、图表和表格 + +示例: + +```js +async function initMonitorPage() { + const endTimestamp = Math.floor(Date.now() / 1000); + const startTimestamp = endTimestamp - 24 * 60 * 60; + + const [modelsData, summaryData, trendData] = await Promise.all([ + fetchModels({ startTimestamp, endTimestamp }), + fetchSummary({ startTimestamp, endTimestamp }), + fetchTrend({ startTimestamp, endTimestamp }) + ]); + + renderModelOptions(modelsData.items); + renderSummaryCards(summaryData); + renderTrendChart(trendData.items); + renderModelTable(modelsData.items); +} +``` + +--- + +## 10. 一个最小可用前端示例 + +```html +
+ + + +
+ +
+

+

+
+
+```
+
+---
+
+## 11. 如何验证功能是否可用
+
+在前端正式接入前,建议先手动验证:
+
+1. 修改配置
+2. 重启 VCP
+3. 登录 VCP 管理面板
+4. 浏览器访问以下地址,确认返回 JSON:
+
+- `/admin_api/newapi-monitor/summary`
+- `/admin_api/newapi-monitor/trend`
+- `/admin_api/newapi-monitor/models`
+
+如果这三个接口都能正常返回数据,前端接入基本不会有问题。
+
+---
+
+## 12. 推荐刷新策略
+
+建议:
+
+- 页面初始化时加载全部数据
+- `summary` 每 30 秒自动刷新一次
+- `trend` 和 `models` 在筛选条件变化时刷新
+- 同时保留一个手动刷新按钮
+
+示例:
+
+```js
+let summaryTimer = null;
+
+function startSummaryAutoRefresh(getFilters) {
+  stopSummaryAutoRefresh();
+
+  summaryTimer = setInterval(async () => {
+    try {
+      const filters = getFilters();
+      const summaryData = await fetchSummary(filters);
+      renderSummaryCards(summaryData);
+    } catch (error) {
+      console.error('summary 自动刷新失败:', error);
+    }
+  }, 30000);
+}
+
+function stopSummaryAutoRefresh() {
+  if (summaryTimer) {
+    clearInterval(summaryTimer);
+    summaryTimer = null;
+  }
+}
+```
+
+---
+
+## 13. 前端错误处理建议
+
+后端错误通常返回:
+
+```json
+{
+  "success": false,
+  "error": "错误信息"
+}
+```
+
+前端建议:
+
+- 显示 loading 状态
+- 请求失败时显示错误提示
+- 提供重试按钮
+- 尽可能保留上一次成功的数据
+
+---
+
+## 14. 使用流程总结
+
+整个功能的典型使用流程如下:
+
+1. 在 VCP 中配置 NewAPI 监控所需环境变量
+2. 重启 VCP
+3. 用浏览器验证三个管理接口是否可返回 JSON
+4. 前端通过 `fetch` 请求 `/admin_api/newapi-monitor/*`
+5. 将 `summary` 渲染为统计卡片
+6. 将 `trend` 渲染为趋势图
+7. 将 `models` 渲染为模型排行表或模型下拉框
+
+---
+
+## 15. 维护注意事项
+
+- `NEWAPI_MONITOR_BASE_URL` 为必填项
+- 推荐优先使用 `NEWAPI_MONITOR_SESSION_COOKIE`
+- 用户名密码方式仅作备选
+- `NEWAPI_MONITOR_API_USER_ID` 只在某些特殊实例中需要
+- 前端不要根据 `source` 的单一取值写死逻辑
+- 若未来前端新增明确需求,再扩展接口,不要预埋无用能力
+
+---
+
+## 16. 一句话总结
+
+这套功能的核心原则只有一句话:
+
+**前端只请求 VCP 的 `/admin_api/newapi-monitor/*`,不要直接碰 NewAPI 管理员鉴权。**
\ No newline at end of file
diff --git a/config.env.example b/config.env.example
index 570b9a0e..a05afa3e 100644
--- a/config.env.example
+++ b/config.env.example
@@ -95,6 +95,24 @@ AdminPassword=YOUR_COMPLEX_PASSWORD_SUCH_AS_sd1iLm1xqSLfiI
 # 如果你将VCP部署在服务器上,需要将其中的 "localhost" 替换为你的服务器公网IP或域名。
 CALLBACK_BASE_URL="http://localhost:6005/plugin-callback"
 
+# -------------------------------------------------------------------
+# [NewAPI 用量监控]
+# -------------------------------------------------------------------
+# 该配置用于 VCP 管理面板中的 NewAPI 请求/Token 用量监控接口。
+# 只实现当前前端需要的 summary / trend / models 三个接口。
+#
+# 必填:NewAPI 后台地址
+NEWAPI_MONITOR_BASE_URL=http://127.0.0.1:3000
+#
+# 可选:请求超时(毫秒)
+NEWAPI_MONITOR_TIMEOUT_MS=15000
+#
+# 必填:NewAPI 系统访问令牌(在 NewAPI 个人设置 > 安全设置 > 系统访问令牌 中生成)
+NEWAPI_MONITOR_ACCESS_TOKEN=
+#
+# 必填:NewAPI 管理员用户 ID(与生成令牌的用户对应)
+NEWAPI_MONITOR_API_USER_ID=
+
 # -------------------------------------------------------------------
 # [模型路由]
 # -------------------------------------------------------------------
diff --git a/routes/admin/newapiMonitor.js b/routes/admin/newapiMonitor.js
new file mode 100644
index 00000000..b9cc6444
--- /dev/null
+++ b/routes/admin/newapiMonitor.js
@@ -0,0 +1,467 @@
+const express = require('express');
+const axios = require('axios');
+
+const DEFAULT_LOOKBACK_SECONDS = 24 * 60 * 60;
+const DEFAULT_TIMEOUT_MS = 15000;
+const MAX_LOG_PAGES = 200;
+const CONSUME_LOG_TYPE = 2;
+
+function safeNumber(value, fallback = 0) {
+    const parsed = Number(value);
+    return Number.isFinite(parsed) ? parsed : fallback;
+}
+
+function normalizeUnixTimestamp(value, fallback = 0) {
+    const parsed = safeNumber(value, fallback);
+    if (parsed > 100000000000) {
+        return Math.floor(parsed / 1000);
+    }
+    return Math.floor(parsed);
+}
+
+function normalizeBaseUrl(baseUrl) {
+    if (typeof baseUrl !== 'string') {
+        return '';
+    }
+    return baseUrl.trim().replace(/\/+$/, '');
+}
+
+function buildError(message, status = 500) {
+    const error = new Error(message);
+    error.status = status;
+    return error;
+}
+
+function getModelNameFromQuery(query) {
+    const value = query.model_name ?? query.model ?? '';
+    return typeof value === 'string' ? value.trim() : '';
+}
+
+function getTimeRangeFromQuery(query) {
+    const now = Math.floor(Date.now() / 1000);
+    const startValue = query.start_timestamp;
+    const endValue = query.end_timestamp;
+
+    let endTimestamp = endValue ? normalizeUnixTimestamp(endValue, now) : now;
+    let startTimestamp = startValue
+        ? normalizeUnixTimestamp(startValue, endTimestamp - DEFAULT_LOOKBACK_SECONDS)
+        : endTimestamp - DEFAULT_LOOKBACK_SECONDS;
+
+    if (!(endTimestamp > 0)) {
+        endTimestamp = now;
+    }
+    if (startTimestamp < 0) {
+        startTimestamp = 0;
+    }
+    if (startTimestamp > endTimestamp) {
+        throw buildError('start_timestamp 不能大于 end_timestamp。', 400);
+    }
+
+    return { startTimestamp, endTimestamp };
+}
+
+function normalizeQuotaItem(item = {}) {
+    return {
+        model_name: typeof item.model_name === 'string' ? item.model_name : '',
+        created_at: normalizeUnixTimestamp(item.created_at, 0),
+        requests: safeNumber(item.count, 0),
+        token_used: safeNumber(item.token_used, 0),
+        quota: safeNumber(item.quota, 0)
+    };
+}
+
+function normalizeLogItem(item = {}) {
+    return {
+        created_at: normalizeUnixTimestamp(item.created_at, 0),
+        model_name: typeof item.model_name === 'string' ? item.model_name : '',
+        prompt_tokens: safeNumber(item.prompt_tokens, 0),
+        completion_tokens: safeNumber(item.completion_tokens, 0),
+        quota: safeNumber(item.quota, 0)
+    };
+}
+
+function toHourTimestamp(unixSeconds) {
+    const value = normalizeUnixTimestamp(unixSeconds, 0);
+    return value - (value % 3600);
+}
+
+function sortByCreatedAtAsc(a, b) {
+    return a.created_at - b.created_at;
+}
+
+function sortModelItems(items) {
+    return items.sort((a, b) => {
+        if (b.requests !== a.requests) {
+            return b.requests - a.requests;
+        }
+        if (b.token_used !== a.token_used) {
+            return b.token_used - a.token_used;
+        }
+        if (b.quota !== a.quota) {
+            return b.quota - a.quota;
+        }
+        return a.model_name.localeCompare(b.model_name);
+    });
+}
+
+class NewApiMonitorClient {
+    constructor({ baseUrl, accessToken, timeoutMs, debugMode, apiUserId }) {
+        this.baseUrl = normalizeBaseUrl(baseUrl);
+        this.accessToken = typeof accessToken === 'string' ? accessToken.trim() : '';
+        this.timeoutMs = safeNumber(timeoutMs, DEFAULT_TIMEOUT_MS);
+        this.debugMode = Boolean(debugMode);
+        this.apiUserId = typeof apiUserId === 'string' ? apiUserId.trim() : '';
+    }
+
+    get isConfigured() {
+        return Boolean(this.baseUrl) && Boolean(this.accessToken) && Boolean(this.apiUserId);
+    }
+
+    debugLog(...args) {
+        if (this.debugMode) {
+            console.log('[NewApiMonitor]', ...args);
+        }
+    }
+
+    buildAuthHeaders() {
+        return {
+            'Authorization': this.accessToken,
+            'New-Api-User': this.apiUserId
+        };
+    }
+
+    async request(path, { method = 'GET', params = {} } = {}) {
+        if (!this.isConfigured) {
+            throw buildError('NewAPI 监控未配置。请设置 NEWAPI_MONITOR_BASE_URL、NEWAPI_MONITOR_ACCESS_TOKEN 和 NEWAPI_MONITOR_API_USER_ID。', 503);
+        }
+
+        this.debugLog('Request:', method, path);
+
+        const response = await axios({
+            url: `${this.baseUrl}${path}`,
+            method,
+            params,
+            timeout: this.timeoutMs,
+            headers: this.buildAuthHeaders(),
+            validateStatus: () => true
+        });
+
+        const responseBody = response.data || {};
+        const responseMessage = typeof responseBody.message === 'string' ? responseBody.message : '';
+
+        if (response.status >= 400) {
+            throw buildError(`请求 NewAPI 失败(${response.status}):${responseMessage || response.statusText || path}`, 502);
+        }
+        if (responseBody.success === false) {
+            throw buildError(`NewAPI 返回失败:${responseMessage || path}`, 502);
+        }
+
+        return responseBody;
+    }
+
+    async getQuotaData(startTimestamp, endTimestamp) {
+        return this.request('/api/data/', {
+            params: {
+                start_timestamp: startTimestamp,
+                end_timestamp: endTimestamp
+            }
+        });
+    }
+
+    async getLogStat(startTimestamp, endTimestamp, modelName) {
+        const params = {
+            type: CONSUME_LOG_TYPE,
+            start_timestamp: startTimestamp,
+            end_timestamp: endTimestamp
+        };
+        if (modelName) {
+            params.model_name = modelName;
+        }
+
+        return this.request('/api/log/stat', { params });
+    }
+
+    async getConsumeLogs(startTimestamp, endTimestamp, modelName, page) {
+        const params = {
+            type: CONSUME_LOG_TYPE,
+            start_timestamp: startTimestamp,
+            end_timestamp: endTimestamp,
+            p: page,
+            page_size: 100
+        };
+        if (modelName) {
+            params.model_name = modelName;
+        }
+
+        return this.request('/api/log/', { params });
+    }
+}
+
+function createMonitorClient(debugMode) {
+    return new NewApiMonitorClient({
+        baseUrl: process.env.NEWAPI_MONITOR_BASE_URL,
+        accessToken: process.env.NEWAPI_MONITOR_ACCESS_TOKEN,
+        timeoutMs: process.env.NEWAPI_MONITOR_TIMEOUT_MS,
+        debugMode,
+        apiUserId: process.env.NEWAPI_MONITOR_API_USER_ID
+    });
+}
+
+async function fetchAllConsumeLogs(client, { startTimestamp, endTimestamp, modelName }) {
+    const logItems = [];
+
+    for (let page = 1; ; page += 1) {
+        if (page > MAX_LOG_PAGES) {
+            break;
+        }
+
+        const responseBody = await client.getConsumeLogs(startTimestamp, endTimestamp, modelName, page);
+        const pageInfo = responseBody && responseBody.data ? responseBody.data : {};
+        const currentItems = Array.isArray(pageInfo.items) ? pageInfo.items.map(normalizeLogItem) : [];
+        const total = safeNumber(pageInfo.total, 0);
+
+        logItems.push(...currentItems);
+
+        if (currentItems.length === 0) {
+            break;
+        }
+        if (!(currentItems.length >= 100)) {
+            break;
+        }
+        if (total > 0 && logItems.length >= total) {
+            break;
+        }
+    }
+
+    return logItems;
+}
+
+async function fetchUsageDataset(client, { startTimestamp, endTimestamp, modelName }) {
+    const quotaResponseBody = await client.getQuotaData(startTimestamp, endTimestamp);
+    const quotaItems = Array.isArray(quotaResponseBody && quotaResponseBody.data)
+        ? quotaResponseBody.data.map(normalizeQuotaItem)
+        : [];
+
+    if (quotaItems.length > 0) {
+        return {
+            source: 'quota_data',
+            quotaItems,
+            logItems: []
+        };
+    }
+
+    const logItems = await fetchAllConsumeLogs(client, { startTimestamp, endTimestamp, modelName });
+    return {
+        source: 'consume_logs',
+        quotaItems: [],
+        logItems
+    };
+}
+
+function buildTrendItemsFromQuotaData(quotaItems, modelName) {
+    const trendMap = new Map();
+
+    for (const quotaItem of quotaItems) {
+        if (modelName && quotaItem.model_name !== modelName) {
+            continue;
+        }
+
+        const key = quotaItem.created_at;
+        if (!trendMap.has(key)) {
+            trendMap.set(key, {
+                created_at: key,
+                requests: 0,
+                token_used: 0,
+                quota: 0
+            });
+        }
+
+        const bucket = trendMap.get(key);
+        bucket.requests += quotaItem.requests;
+        bucket.token_used += quotaItem.token_used;
+        bucket.quota += quotaItem.quota;
+    }
+
+    return Array.from(trendMap.values()).sort(sortByCreatedAtAsc);
+}
+
+function buildTrendItemsFromLogs(logItems) {
+    const trendMap = new Map();
+
+    for (const logItem of logItems) {
+        const key = toHourTimestamp(logItem.created_at);
+        if (!trendMap.has(key)) {
+            trendMap.set(key, {
+                created_at: key,
+                requests: 0,
+                token_used: 0,
+                quota: 0
+            });
+        }
+
+        const bucket = trendMap.get(key);
+        bucket.requests += 1;
+        bucket.token_used += logItem.prompt_tokens + logItem.completion_tokens;
+        bucket.quota += logItem.quota;
+    }
+
+    return Array.from(trendMap.values()).sort(sortByCreatedAtAsc);
+}
+
+function buildModelItemsFromQuotaData(quotaItems) {
+    const modelMap = new Map();
+
+    for (const quotaItem of quotaItems) {
+        const modelName = quotaItem.model_name || '(unknown)';
+        if (!modelMap.has(modelName)) {
+            modelMap.set(modelName, {
+                model_name: modelName,
+                requests: 0,
+                token_used: 0,
+                quota: 0
+            });
+        }
+
+        const bucket = modelMap.get(modelName);
+        bucket.requests += quotaItem.requests;
+        bucket.token_used += quotaItem.token_used;
+        bucket.quota += quotaItem.quota;
+    }
+
+    return sortModelItems(Array.from(modelMap.values()));
+}
+
+function buildModelItemsFromLogs(logItems) {
+    const modelMap = new Map();
+
+    for (const logItem of logItems) {
+        const modelName = logItem.model_name || '(unknown)';
+        if (!modelMap.has(modelName)) {
+            modelMap.set(modelName, {
+                model_name: modelName,
+                requests: 0,
+                token_used: 0,
+                quota: 0
+            });
+        }
+
+        const bucket = modelMap.get(modelName);
+        bucket.requests += 1;
+        bucket.token_used += logItem.prompt_tokens + logItem.completion_tokens;
+        bucket.quota += logItem.quota;
+    }
+
+    return sortModelItems(Array.from(modelMap.values()));
+}
+
+function buildSummaryPayload(trendItems, realtimeStatBody) {
+    const totals = trendItems.reduce((accumulator, item) => {
+        accumulator.total_requests += item.requests;
+        accumulator.total_tokens += item.token_used;
+        accumulator.total_quota += item.quota;
+        return accumulator;
+    }, {
+        total_requests: 0,
+        total_tokens: 0,
+        total_quota: 0
+    });
+
+    const realtimeData = realtimeStatBody && realtimeStatBody.data ? realtimeStatBody.data : {};
+    return {
+        ...totals,
+        current_rpm: safeNumber(realtimeData.rpm, 0),
+        current_tpm: safeNumber(realtimeData.tpm, 0)
+    };
+}
+
+function handleRouteError(routeName, error, res) {
+    const status = safeNumber(error && error.status, 500);
+    const message = error && error.message ? error.message : 'Unknown error';
+    console.error(`[NewApiMonitor] ${routeName} failed:`, error);
+    res.status(status).json({
+        success: false,
+        error: message
+    });
+}
+
+module.exports = function newApiMonitorRoutes(options) {
+    const router = express.Router();
+    const debugMode = Boolean(options && options.DEBUG_MODE);
+
+    router.get('/newapi-monitor/summary', async (req, res) => {
+        try {
+            const { startTimestamp, endTimestamp } = getTimeRangeFromQuery(req.query);
+            const modelName = getModelNameFromQuery(req.query);
+            const client = createMonitorClient(debugMode);
+            const usageDataset = await fetchUsageDataset(client, { startTimestamp, endTimestamp, modelName });
+            const trendItems = usageDataset.source === 'quota_data'
+                ? buildTrendItemsFromQuotaData(usageDataset.quotaItems, modelName)
+                : buildTrendItemsFromLogs(usageDataset.logItems);
+            const realtimeStatBody = await client.getLogStat(startTimestamp, endTimestamp, modelName);
+            const summary = buildSummaryPayload(trendItems, realtimeStatBody);
+
+            res.json({
+                success: true,
+                data: {
+                    source: usageDataset.source,
+                    start_timestamp: startTimestamp,
+                    end_timestamp: endTimestamp,
+                    model_name: modelName || null,
+                    ...summary
+                }
+            });
+        } catch (error) {
+            handleRouteError('summary', error, res);
+        }
+    });
+
+    router.get('/newapi-monitor/trend', async (req, res) => {
+        try {
+            const { startTimestamp, endTimestamp } = getTimeRangeFromQuery(req.query);
+            const modelName = getModelNameFromQuery(req.query);
+            const client = createMonitorClient(debugMode);
+            const usageDataset = await fetchUsageDataset(client, { startTimestamp, endTimestamp, modelName });
+            const items = usageDataset.source === 'quota_data'
+                ? buildTrendItemsFromQuotaData(usageDataset.quotaItems, modelName)
+                : buildTrendItemsFromLogs(usageDataset.logItems);
+
+            res.json({
+                success: true,
+                data: {
+                    source: usageDataset.source,
+                    start_timestamp: startTimestamp,
+                    end_timestamp: endTimestamp,
+                    model_name: modelName || null,
+                    items
+                }
+            });
+        } catch (error) {
+            handleRouteError('trend', error, res);
+        }
+    });
+
+    router.get('/newapi-monitor/models', async (req, res) => {
+        try {
+            const { startTimestamp, endTimestamp } = getTimeRangeFromQuery(req.query);
+            const client = createMonitorClient(debugMode);
+            const usageDataset = await fetchUsageDataset(client, { startTimestamp, endTimestamp, modelName: '' });
+            const items = usageDataset.source === 'quota_data'
+                ? buildModelItemsFromQuotaData(usageDataset.quotaItems)
+                : buildModelItemsFromLogs(usageDataset.logItems);
+
+            res.json({
+                success: true,
+                data: {
+                    source: usageDataset.source,
+                    start_timestamp: startTimestamp,
+                    end_timestamp: endTimestamp,
+                    items
+                }
+            });
+        } catch (error) {
+            handleRouteError('models', error, res);
+        }
+    });
+
+    return router;
+};
\ No newline at end of file
diff --git a/routes/adminPanelRoutes.js b/routes/adminPanelRoutes.js
index 758b4559..5822042d 100644
--- a/routes/adminPanelRoutes.js
+++ b/routes/adminPanelRoutes.js
@@ -65,6 +65,7 @@ module.exports = function (DEBUG_MODE, dailyNoteRootPath, pluginManager, getCurr
     mount('/', 'toolListEditor');     // Handles /tool-list/*
     mount('/', 'dream');              // Handles /dream-logs/*, /dream-operation/*
     mount('/', 'dailyNotes');         // Wrapper for existing dailyNotesRoutes (Handles /dailynotes/*)
+    mount('/', 'newapiMonitor');      // Handles /newapi-monitor/*
 
     return adminApiRouter;
 };
\ No newline at end of file
diff --git a/server.js b/server.js
index dac11e7a..cc3d5521 100644
--- a/server.js
+++ b/server.js
@@ -395,6 +395,17 @@ const adminAuth = (req, res, next) => {
         // 验证登录的端点也需要特殊处理(允许无凭据时返回401而不是重定向)
         const isVerifyEndpoint = req.path === '/admin_api/verify-login';
 
+        // ========== 新增:只读仪表板接口白名单(不计入登录失败次数)==========
+        const readOnlyDashboardPaths = [
+            '/admin_api/system-monitor',
+            '/admin_api/newapi-monitor',
+            '/admin_api/server-log',
+            '/admin_api/user-auth-code',
+            '/admin_api/weather'
+        ];
+        const isReadOnlyPath = readOnlyDashboardPaths.some(path => req.path.startsWith(path));
+        // ========== 新增结束 ==========
+
         if (publicPaths.includes(req.path)) {
             return next(); // 直接放行登录页面相关资源
         }
@@ -420,9 +431,9 @@ const adminAuth = (req, res, next) => {
             return; // 停止进一步处理
         }
 
-        // 2. 检查IP是否被临时封禁
+        // 2. 检查IP是否被临时封禁(仅对非只读接口生效)
         const blockInfo = tempBlocks.get(clientIp);
-        if (blockInfo && Date.now() < blockInfo.expires) {
+        if (blockInfo && Date.now() < blockInfo.expires && !isReadOnlyPath) {
             console.warn(`[AdminAuth] Blocked login attempt from IP: ${clientIp}. Block expires at ${new Date(blockInfo.expires).toLocaleString()}.`);
             const timeLeft = Math.ceil((blockInfo.expires - Date.now()) / 1000 / 60);
             res.setHeader('Retry-After', Math.ceil((blockInfo.expires - Date.now()) / 1000)); // In seconds
@@ -463,8 +474,8 @@ const adminAuth = (req, res, next) => {
 
         // 4. 验证凭据
         if (!credentials || credentials.name !== ADMIN_USERNAME || credentials.pass !== ADMIN_PASSWORD) {
-            // 认证失败,处理登录尝试计数
-            if (clientIp) {
+            // 认证失败,处理登录尝试计数(仅对非只读接口计数)
+            if (clientIp && !isReadOnlyPath) {
                 const now = Date.now();
                 let attemptInfo = loginAttempts.get(clientIp) || { count: 0, firstAttempt: now };
 
@@ -1124,13 +1135,6 @@ app.post('/plugin-callback/:pluginName/:taskId', async (req, res) => {
         return res.status(404).json({ status: "error", message: "Plugin not found, but callback noted." });
     }
 
-    // 🚀 核心导出点:通过 pluginManager 广播回调数据
-    pluginManager.emit('plugin_async_callback', {
-        pluginName,
-        taskId,
-        data: callbackData
-    });
-
     // 2. WebSocket push (existing logic)
     if (pluginManifest.webSocketPush && pluginManifest.webSocketPush.enabled) {
         const targetClientType = pluginManifest.webSocketPush.targetClientType || null;