From d711b436c1ebcb60cf736aa28512d9732ca3e390 Mon Sep 17 00:00:00 2001 From: argszero Date: Sat, 12 Sep 2026 10:23:37 +0800 Subject: [PATCH] feat(ops): show service version and uptime on the operator overview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operator runtime overview had no way to read the deployed build's version, so confirming which build is live required curling /healthz by hand. - GET /api/ops/runtime now returns `version` (env!("CARGO_PKG_VERSION"), the same source /healthz uses) plus uptime_secs / uptime_days / uptime_hours / uptime_minutes / uptime_secs_rest. Carrying is done in one place, backend side (split_uptime), instead of in both language packs. - AppState gains `started_at` (std::time::Instant) to measure uptime on a monotonic clock. - The operator overview renders the two new stat cards, matching the prototype (docs/prototype/aitokenpool-console.html:1386). The version is read from the response, never hardcoded: the prototype's own "v0.7.20" was a literal that was already stale the day it was written. - New bilingual keys ops.stats.version{,.sub}, ops.stats.uptime{,.sub} and ops.uptime.{days,hours,minutes,seconds}; the i18n_pack gate's maintained counts move 775 -> 783 keys and 520 -> 531 call sites. The prototype's third card (错误率) is deliberately not built: the backend has no 5xx/timeout counter, so it could only be fabricated. --- src/i18n_pack.rs | 18 ++++++++--- src/routes/mod.rs | 27 +++++++++++++++- src/routes/ops.rs | 82 +++++++++++++++++++++++++++++++++++++++++++++++ ui/README.md | 2 +- ui/index.html | 10 +++--- ui/js/app.js | 18 +++++++++++ ui/js/i18n.js | 16 +++++++++ 7 files changed, 162 insertions(+), 11 deletions(-) diff --git a/src/i18n_pack.rs b/src/i18n_pack.rs index 331c47f..b95b4ac 100644 --- a/src/i18n_pack.rs +++ b/src/i18n_pack.rs @@ -31,12 +31,22 @@ const EN_END: &str = "\n };"; /// 阳性对照真值:**改动语言包/前端文案时应刻意更新这些数字**。 /// 它们的作用是把「提取器静默失真」与「语言包真的变了」区分开(见 `packs()`)。 -const ZH_KEY_COUNT: usize = 775; -const EN_KEY_COUNT: usize = 775; +/// +/// 本次更新(运营概览「服务版本 / 运行时长」两张卡):775 → 783 = **+8 键** +/// (`ops.stats.version{,.sub}` / `ops.stats.uptime{,.sub}` / `ops.uptime.{days,hours,minutes,seconds}`), +/// 520 → 531 / 418 → 426 = **+11 个 `T()` 字面量调用点 / +8 个去重键** +/// (两张卡各 1 个 + `fmtUptime` 的 8 处:name/sub 各 1,四个单位里 `days` 1 处、 +/// `hours`/`minutes`/`seconds` 各 2 处 ⇒ 11 处调用只有 8 个不同键)。 +/// `STATIC_ATTR_*` 不变:本次未改 `ui/index.html` 的静态属性。 +/// +/// ⚠️ 加数时**别口算**:C2013 我先把增量估成 +6(漏了 `fmtUptime` 里的重复调用), +/// 是这条阳性对照把 531 报了出来。改动后请以提取器的实际输出校准,再核对是否与 diff 相符。 +const ZH_KEY_COUNT: usize = 783; +const EN_KEY_COUNT: usize = 783; const STATIC_ATTR_COUNT: usize = 330; const STATIC_ATTR_DISTINCT: usize = 305; -const T_LITERAL_COUNT: usize = 520; -const T_LITERAL_DISTINCT: usize = 418; +const T_LITERAL_COUNT: usize = 531; +const T_LITERAL_DISTINCT: usize = 426; /// 切出语言包区段(起点标记 → 终点标记,含起点)。 fn pack_region<'a>(src: &'a str, start_mark: &str, end_mark: &str) -> &'a str { diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 175666b..e0b2adf 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -34,7 +34,7 @@ use crate::dao; use crate::gateway; use crate::router::RouterState; -/// 共享状态:数据库连接 + 配置 + 路由状态 + HTTP 客户端 + 密钥加密器 +/// 共享状态:数据库连接 + 配置 + 路由状态 + HTTP 客户端 + 密钥加密器 + 进程启动时刻 #[derive(Clone)] pub struct AppState { pub db: Arc>, @@ -42,6 +42,9 @@ pub struct AppState { pub router: Arc, pub http: reqwest::Client, pub crypto: Crypto, + /// 进程启动时刻(单调时钟):`/api/ops/runtime` 用它算运行时长。 + /// 取 `Instant` 而非墙上时间——运行时长不怕系统时钟被调整。 + pub started_at: std::time::Instant, } impl AppState { @@ -55,6 +58,7 @@ impl AppState { .build() .expect("reqwest client 构建失败"), crypto, + started_at: std::time::Instant::now(), } } } @@ -1846,6 +1850,27 @@ mod tests { let kh = v["key_health"].as_array().expect("key_health 数组"); assert!(!kh.is_empty(), "至少有 seed 的上游 key: {body}"); assert!(kh.iter().all(|k| k["off"] == 0), "seed key 均为 on: {body}"); + // 服务版本 / 运行时长(原型「服务版本」「运行时长」两张卡) + assert_eq!( + v["version"], + env!("CARGO_PKG_VERSION"), + "version 应与 /healthz 同源: {body}" + ); + let secs = v["uptime_secs"].as_u64().expect("uptime_secs: {body}"); + let d = v["uptime_days"].as_u64().expect("uptime_days"); + let h = v["uptime_hours"].as_u64().expect("uptime_hours"); + let m = v["uptime_minutes"].as_u64().expect("uptime_minutes"); + let s = v["uptime_secs_rest"].as_u64().expect("uptime_secs_rest"); + assert_eq!( + d * 86400 + h * 3600 + m * 60 + s, + secs, + "四位分解应能拼回 uptime_secs: {body}" + ); + assert!(h < 24 && m < 60 && s < 60, "各位未归一(进位漏了): {body}"); + assert!( + secs < 60, + "AppState 刚构造,运行时长应 < 60s,实得 {secs}s —— 取错了起点? {body}" + ); // users 列表(含余额) let (s, body) = get(st.clone(), "/api/ops/users", Some(&ops_bearer)).await; assert_eq!(s, StatusCode::OK); diff --git a/src/routes/ops.rs b/src/routes/ops.rs index e7d8840..778bb42 100644 --- a/src/routes/ops.rs +++ b/src/routes/ops.rs @@ -4,6 +4,8 @@ //! - GET /api/ops/runtime:平台运行概览(用户数 / 上架 key 数 / 本月调用 / 本月点数流水,全库聚合) //! + rant 2026-09-11T16:23:43(PR6):今日按小时调用量 today_hours(0-23 全量补零,UTC 小时) //! + 上游 key 健康 key_health(按厂商聚合 total/on/off) +//! + 服务版本 version(= `CARGO_PKG_VERSION`,与 /healthz 同源)与运行时长 +//! uptime_{secs,days,hours,minutes,secs_rest}(AppState.started_at 单调时钟) //! - POST /api/ops/credits {user_id, amount}:给任意用户充值(写 transactions counterpart='运营者') //! - GET /api/ops/users:全平台用户列表(含余额) //! - 权限:require_role(&["admin","ops"])——普通 user 403 @@ -34,12 +36,36 @@ pub struct OpsCreditReq { pub amount: f64, } +/// 一天的秒数(`uptime_days` / `uptime_hours` 的进位基数) +const DAY: u64 = 24 * 60 * 60; + +/// 把运行秒数分解成 `(整日, 整小时, 分钟, 秒)`。 +/// +/// 放在后端而不是前端:前端两份语言包都要拼「6 天 4 小时」,交给前端就得在 +/// JS 里再实现一遍进位(还要两语言各写一套),而这里只有一处。 +fn split_uptime(secs: u64) -> (u64, u64, u64, u64) { + ( + secs / DAY, + (secs % DAY) / 3600, + (secs % 3600) / 60, + secs % 60, + ) +} + /// GET /api/ops/runtime:平台运行概览(全库聚合) pub async fn runtime( State(st): State, auth: AuthUser, ) -> Result, ApiErr> { require_role(&auth)?; + + // 版本与运行时长:不查库,先算——它们与下面的聚合无关,且 uptime 越早取越准。 + // version 取 `env!("CARGO_PKG_VERSION")`,与 /healthz 同源(绝不在前端硬编码版本号: + // 原型里那张卡写死的 "v0.7.20" 从写下的一刻就已经过期了)。 + let uptime_secs = st.started_at.elapsed().as_secs(); + let (days, hours, minutes, secs) = split_uptime(uptime_secs); + let version = env!("CARGO_PKG_VERSION"); + let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; let users: i64 = conn .query_row("SELECT COUNT(*) FROM users", [], |r| r.get(0)) @@ -134,6 +160,13 @@ pub async fn runtime( } Ok(Json(serde_json::json!({ + // 版本与运行时长(原型「服务版本」/「运行时长」两张卡) + "version": version, + "uptime_secs": uptime_secs, + "uptime_days": days, + "uptime_hours": hours, + "uptime_minutes": minutes, + "uptime_secs_rest": secs, "users": users, "active_keys": active_keys, "month_calls": month_calls, @@ -233,3 +266,52 @@ pub async fn users( } Ok(Json(out)) } + +#[cfg(test)] +mod tests { + use super::*; + + /// `split_uptime` 的进位:**已知真值**断言,不是自证。 + /// + /// 每例都写明 `天:时:分:秒` 的期望值 —— 若把 `/` 与 `%` 写反、或漏掉某一位的归一, + /// 会在这里被点名,而不是在只断言「能拼回去」的地方悄悄溜过 + /// (拼回式断言对「天=总秒数、其余全 0」这种错法同样成立)。 + #[test] + fn split_uptime_carries_correctly() { + assert_eq!(split_uptime(0), (0, 0, 0, 0), "刚启动"); + assert_eq!(split_uptime(59), (0, 0, 0, 59)); + assert_eq!(split_uptime(60), (0, 0, 1, 0), "满 60 秒进 1 分"); + assert_eq!(split_uptime(3599), (0, 0, 59, 59)); + assert_eq!(split_uptime(3600), (0, 1, 0, 0), "满 60 分进 1 小时"); + assert_eq!(split_uptime(86399), (0, 23, 59, 59)); + assert_eq!(split_uptime(86400), (1, 0, 0, 0), "满 24 小时进 1 天"); + assert_eq!(split_uptime(86460), (1, 0, 1, 0)); + // 原型里那张卡的示例值:「6 天 4 小时」 + assert_eq!(split_uptime(6 * 86400 + 4 * 3600), (6, 4, 0, 0)); + // 长跑一年有余:不得溢出位宽 + assert_eq!( + split_uptime(400 * 86400 + 23 * 3600 + 59 * 60 + 59), + (400, 23, 59, 59) + ); + } + + /// 分解结果必须**无损**(拼回去等于原值)且各位**已归一**。 + /// + /// 与上面的真值例互补:真值例证明具体取值对,这一条覆盖一片区间, + /// 保证不存在「某个具体秒数进位不对」的漏网值。 + #[test] + fn split_uptime_is_lossless_and_normalized() { + for secs in (0..100_000u64).step_by(7) { + let (d, h, m, s) = split_uptime(secs); + assert_eq!( + d * 86400 + h * 3600 + m * 60 + s, + secs, + "拼不回原值: {secs}" + ); + assert!( + h < 24 && m < 60 && s < 60, + "位未归一: {secs} -> {h}/{m}/{s}" + ); + } + } +} diff --git a/ui/README.md b/ui/README.md index 7d1608b..1be3265 100644 --- a/ui/README.md +++ b/ui/README.md @@ -333,5 +333,5 @@ ui/ - **page-head crumb**:8 个视图统一 `.crumb`(`/ 设置` `/ 管理视图` `/ 运营视图`),与仪表盘/市场/共享/钱包/交易一致; - **管理视图**(4 tab 不变):成员管理 pane 增加 `#emp-search` 成员搜索(成员名 / 邮箱 / 部门,`hl()` 高亮)+ 表格「角色 / 部门 / 永久点数 / 赠送点数 / 可用」列;用量报表用 `.card-grid-3` 三栏;组织管理与模型管理的工具栏为「搜索 + `.grow` + 主按钮(`btn-sm`)」,与原型一致; -- **运营视图**(2 tab 不变):运行概览新增两张卡片——**今日调用量(按小时)** `.bar-list`(`/api/ops/runtime` 的 `today_hours`,服务端 0-23 全量补零;GROUP BY 会省略无调用的小时,不补零会让柱子整体左移,与交易页 `txTrendDays` 同款坑)与**上游 key 健康** `.mini-list`(`key_health` 按厂商聚合 total/on/off,三态 pill:健康 / N 个异常 / 全部失败);成员充值的搜索框移到卡片标题行右侧(原型 `.spread`); +- **运营视图**(2 tab 不变):运行概览新增四张卡片——**服务版本**(`/api/ops/runtime` 的 `version`,取自 `env!("CARGO_PKG_VERSION")`,与 `/healthz` 同源;前端**不得**写死版本号)与**运行时长**(`uptime_secs` / `uptime_days` / `uptime_hours` / `uptime_minutes` / `uptime_secs_rest`,进位在后端 `split_uptime` 完成,前端 `fmtUptime` 只挑「最高两个非零位」并取 `ops.uptime.{days,hours,minutes,seconds}` 单位)、**今日调用量(按小时)** `.bar-list`(`today_hours`,服务端 0-23 全量补零;GROUP BY 会省略无调用的小时,不补零会让柱子整体左移,与交易页 `txTrendDays` 同款坑)与**上游 key 健康** `.mini-list`(`key_health` 按厂商聚合 total/on/off,三态 pill:健康 / N 个异常 / 全部失败);成员充值的搜索框移到卡片标题行右侧(原型 `.spread`); - **零 mock 不破**:以上数据全部来自真实端点,加载失败仍走空态 + 重试(`.mini-item` / `.bar-row` 只在有真实数据时才渲染)。 diff --git a/ui/index.html b/ui/index.html index db5e79c..bef2fbb 100644 --- a/ui/index.html +++ b/ui/index.html @@ -5,7 +5,7 @@ AITokenPool - + @@ -842,9 +842,9 @@

使用模型

- - - - + + + + diff --git a/ui/js/app.js b/ui/js/app.js index e7e75b3..74ab071 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -2546,6 +2546,10 @@ const rt = Live.opsRuntime; $("#ops-stats").innerHTML = [ stat(T("ops.stats.status"), '' + T("common.online") + "", T("ops.stats.status.sub")), + // 服务版本 / 运行时长:版本来自后端 env!("CARGO_PKG_VERSION")(与 /healthz 同源), + // 绝不在前端写死——原型那张卡里的 "v0.7.20" 是一写就过期的字面量。 + stat(T("ops.stats.version"), "v" + esc(rt.version || ""), T("ops.stats.version.sub")), + stat(T("ops.stats.uptime"), esc(fmtUptime(rt)), T("ops.stats.uptime.sub")), stat(T("ops.stats.users"), T("cnt.people", { n: rt.users }), T("ops.stats.users.sub")), stat(T("ops.stats.keys"), T("cnt.keys", { n: rt.active_keys }), T("ops.stats.keys.sub.on")), stat(T("ops.stats.calls"), T("cnt.calls", { n: rt.month_calls }), T("ops.stats.calls.sub")), @@ -2698,6 +2702,20 @@ return full; } + // 运行时长(运营概览「运行时长」卡):后端给分解好的 days/hours/minutes/secs_rest + // (进位在后端做,前端只挑单位——两语言各写一遍进位没必要)。 + // 取最高两个非零位:天+小时 / 小时+分 / 分+秒 / 秒(刚启动)。 + function fmtUptime(rt) { + const d = rt.uptime_days || 0; + const h = rt.uptime_hours || 0; + const m = rt.uptime_minutes || 0; + const s = rt.uptime_secs_rest || 0; + if (d > 0) return T("ops.uptime.days", { n: d }) + (h > 0 ? " " + T("ops.uptime.hours", { n: h }) : ""); + if (h > 0) return T("ops.uptime.hours", { n: h }) + (m > 0 ? " " + T("ops.uptime.minutes", { n: m }) : ""); + if (m > 0) return T("ops.uptime.minutes", { n: m }) + (s > 0 ? " " + T("ops.uptime.seconds", { n: s }) : ""); + return T("ops.uptime.seconds", { n: s }); + } + // 精确本地时间(YYYY-MM-DD HH:MM:SS,到秒)——交易列表时间列(rant 2026-08-24T12:38:44) function fmtPrecise(s) { if (!s) return ""; diff --git a/ui/js/i18n.js b/ui/js/i18n.js index 9ae2764..9b7a3a5 100644 --- a/ui/js/i18n.js +++ b/ui/js/i18n.js @@ -676,6 +676,14 @@ "ops.keys.empty": "尚未上架任何上游 key", "ops.stats.status": "运行状态", "ops.stats.status.sub": "平台服务正常", + "ops.stats.version": "服务版本", + "ops.stats.version.sub": "单二进制运行中", + "ops.stats.uptime": "运行时长", + "ops.stats.uptime.sub": "SQLite · 无外部依赖", + "ops.uptime.days": "{n} 天", + "ops.uptime.hours": "{n} 小时", + "ops.uptime.minutes": "{n} 分", + "ops.uptime.seconds": "{n} 秒", "ops.stats.users": "用户数", "ops.stats.users.sub": "全平台注册用户", "ops.stats.keys": "上架 key 数", @@ -1476,6 +1484,14 @@ "ops.keys.empty": "No upstream keys listed yet", "ops.stats.status": "Status", "ops.stats.status.sub": "platform service OK", + "ops.stats.version": "Service version", + "ops.stats.version.sub": "single binary, running", + "ops.stats.uptime": "Uptime", + "ops.stats.uptime.sub": "SQLite · no external deps", + "ops.uptime.days": "{n} d", + "ops.uptime.hours": "{n} h", + "ops.uptime.minutes": "{n} min", + "ops.uptime.seconds": "{n} s", "ops.stats.users": "Users", "ops.stats.users.sub": "all registered users", "ops.stats.keys": "Active keys",