diff --git a/README.md b/README.md index cf5088bf..02591828 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ before the client runs them. - The overview for the last seven days: what needs attention (failed requests, a cut tool call, unpriced requests); six cards for tokens, cost, cache hits, requests, first token and traffic, each with the change against the seven days before and a chart; and the statistics by model below + The overview for the last seven days: tokens, cost, requests, cache hits, first token and traffic, each with the change against the seven days before, over a chart of the selected one; below, where the tokens went by model and the quotas and balances the upstreams report ## Sponsors diff --git a/README.zh-CN.md b/README.zh-CN.md index a5409dc9..865b98ba 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -23,7 +23,7 @@ Claude Code、Codex 等 AI 客户端的本地网关,支持 macOS、Windows 与 - 最近 7 天的概览:需要处理的事项(失败的请求、被切断的工具调用、无法计价的请求);token、费用、缓存命中、请求、首 token 与流量六张卡片,各带与前 7 天的对比和趋势图;下方是按模型的统计 + 最近 7 天的概览:token、费用、请求、缓存命中、首 token 与流量六个指标,各带与前 7 天的对比,下面是所选指标的曲线;再往下是按模型的去向,以及上游报告的额度与余额 ## 赞助商 diff --git a/docs/screenshots/en/menubar-menu-dark.png b/docs/screenshots/en/menubar-menu-dark.png index 095240fb..3e0afbb8 100644 Binary files a/docs/screenshots/en/menubar-menu-dark.png and b/docs/screenshots/en/menubar-menu-dark.png differ diff --git a/docs/screenshots/en/menubar-menu-light.png b/docs/screenshots/en/menubar-menu-light.png index 3170ba97..6db4dce3 100644 Binary files a/docs/screenshots/en/menubar-menu-light.png and b/docs/screenshots/en/menubar-menu-light.png differ diff --git a/docs/screenshots/en/overview-dark.png b/docs/screenshots/en/overview-dark.png index 05a1537a..85e13590 100644 Binary files a/docs/screenshots/en/overview-dark.png and b/docs/screenshots/en/overview-dark.png differ diff --git a/docs/screenshots/en/overview-light.png b/docs/screenshots/en/overview-light.png index acec57bb..bc05269f 100644 Binary files a/docs/screenshots/en/overview-light.png and b/docs/screenshots/en/overview-light.png differ diff --git a/docs/screenshots/menubar-dark.png b/docs/screenshots/menubar-dark.png index 6a94e353..51b560ff 100644 Binary files a/docs/screenshots/menubar-dark.png and b/docs/screenshots/menubar-dark.png differ diff --git a/docs/screenshots/menubar-light.png b/docs/screenshots/menubar-light.png index 29091a90..14e7c0ea 100644 Binary files a/docs/screenshots/menubar-light.png and b/docs/screenshots/menubar-light.png differ diff --git a/docs/screenshots/web/menubar-menu-en.webp b/docs/screenshots/web/menubar-menu-en.webp index 2844e372..ffc92b99 100644 Binary files a/docs/screenshots/web/menubar-menu-en.webp and b/docs/screenshots/web/menubar-menu-en.webp differ diff --git a/docs/screenshots/web/menubar-menu.webp b/docs/screenshots/web/menubar-menu.webp index b53655ef..e832385f 100644 Binary files a/docs/screenshots/web/menubar-menu.webp and b/docs/screenshots/web/menubar-menu.webp differ diff --git a/docs/screenshots/web/menubar.png b/docs/screenshots/web/menubar.png index 093f4eba..8f79970e 100644 Binary files a/docs/screenshots/web/menubar.png and b/docs/screenshots/web/menubar.png differ diff --git a/docs/screenshots/web/overview-en.webp b/docs/screenshots/web/overview-en.webp index 5fb7ae0e..e3010919 100644 Binary files a/docs/screenshots/web/overview-en.webp and b/docs/screenshots/web/overview-en.webp differ diff --git a/docs/screenshots/web/overview.webp b/docs/screenshots/web/overview.webp index 695f634f..c2215d73 100644 Binary files a/docs/screenshots/web/overview.webp and b/docs/screenshots/web/overview.webp differ diff --git a/docs/screenshots/zh/menubar-menu-dark.png b/docs/screenshots/zh/menubar-menu-dark.png index 95061788..79c1b07e 100644 Binary files a/docs/screenshots/zh/menubar-menu-dark.png and b/docs/screenshots/zh/menubar-menu-dark.png differ diff --git a/docs/screenshots/zh/menubar-menu-light.png b/docs/screenshots/zh/menubar-menu-light.png index bb22ab9b..c87d06f3 100644 Binary files a/docs/screenshots/zh/menubar-menu-light.png and b/docs/screenshots/zh/menubar-menu-light.png differ diff --git a/docs/screenshots/zh/overview-dark.png b/docs/screenshots/zh/overview-dark.png index 0a3b8b82..0acdc302 100644 Binary files a/docs/screenshots/zh/overview-dark.png and b/docs/screenshots/zh/overview-dark.png differ diff --git a/docs/screenshots/zh/overview-light.png b/docs/screenshots/zh/overview-light.png index 1ddce548..95e9083e 100644 Binary files a/docs/screenshots/zh/overview-light.png and b/docs/screenshots/zh/overview-light.png differ diff --git a/package.json b/package.json index 0a0104e5..0688f9e1 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "thinkwatch-lite", "private": true, "description": "Desktop app for a local AI API gateway on macOS, Windows and Linux", - "version": "2026.10.32", + "version": "2026.10.33", "type": "module", "packageManager": "pnpm@11.13.0", "scripts": { diff --git a/release-notes/2026.10.33.md b/release-notes/2026.10.33.md new file mode 100644 index 00000000..1afb9959 --- /dev/null +++ b/release-notes/2026.10.33.md @@ -0,0 +1,16 @@ +**Upgrade notes:** +- Core stays at 0.84.0 and the control-plane protocol at 55. Nothing is cleared. + +**Overview:** +- What used to sit at the top of the overview now lives in the notices (the bell). Failed requests and models without a price are new notices there, shown in the app only; a cut-off tool call and an unavailable upstream were notices already. Clicking the failed-requests notice shows only the failed requests in Traffic. +- The usage chart draws lines instead of bars. Tokens, cost, requests and traffic are shaded areas; failures and downloads are a second, thin line. The period still in progress is dashed, and periods from before the first request record are marked "Not recorded" instead of being drawn as zero. +- The chart's scale fits the data more closely: a peak of 3.9M now reads 0-5M instead of 0-8M, and axis labels drop the trailing ".0". +- Clicking a day on the past-year heatmap, or changing the time range further down the page, no longer jumps back to the top. The heatmap stays where it was. +- Hovering the chart no longer lights up the border of the whole card. +- Distribution and Breakdown follow one choice of model, upstream or key. Distribution has its title and a slim bar per row, and Breakdown sits in its own titled card. +- The past-year heatmap uses calmer colors, its totals are a size smaller than the metrics above, and days before recording started are faint cells instead of dashed boxes. +- When there is no previous period to compare with, the metric tiles leave the change line empty instead of showing a dash. +- The overview opens on the last 24 hours until a time range has been chosen. + +**Fixes:** +- Token counts of a billion or more read like "1.81B" instead of "1809.8M". diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8d8061de..c808636c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4877,7 +4877,7 @@ dependencies = [ [[package]] name = "thinkwatch-lite" -version = "2026.10.32" +version = "2026.10.33" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 988543dc..74ff585e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "thinkwatch-lite" -version = "2026.10.32" +version = "2026.10.33" edition = "2024" # **这个数决定依赖能升到哪一版。**edition 2024 的解析器只挑声明的 Rust # 版本编得动的依赖:写 1.85 的时候,`cargo update` 一直停在旧的 time 和 diff --git a/src-tauri/src/gateway.rs b/src-tauri/src/gateway.rs index 51e64df7..4f84a40c 100644 --- a/src-tauri/src/gateway.rs +++ b/src-tauri/src/gateway.rs @@ -477,6 +477,19 @@ pub(crate) async fn bridge_events(app: tauri::AppHandle) { if matches!(ev, tw_api::Event::EventsDropped { .. }) { reconcile_notices(&a); } + // **算不出费用的请求落地了**:哪个模型没有价格,事件里没有,按现状对一次账 + // (`/pricing` 说得出是哪家上游的哪个模型)。一分钟最多一次:一个没定价的 + // 模型每个请求都会来一条 + if matches!( + ev, + tw_api::Event::RequestPriced { + cost_micros: None, + .. + } + ) && unpriced_due() + { + reconcile_notices(&a); + } // **安全模式下换进了一份能用的配置**(界面里改好保存、一键修复、回滚、在 // 编辑器里改好):按正常模式重启,网关自己恢复,不用再去点「重新启动」 if matches!(ev, tw_api::Event::ConfigReloaded { .. }) @@ -560,6 +573,20 @@ async fn until_switched( } } +/// 无法计价触发的对账,上一次是什么时候(毫秒)。见事件流里 `RequestPriced` 那一处 +static UNPRICED_AT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// 离上一次无法计价触发的对账过了一分钟没有;过了就记下这一次 +fn unpriced_due() -> bool { + use std::sync::atomic::Ordering; + let now = notices::now_ms(); + let last = UNPRICED_AT.load(Ordering::Relaxed); + now.saturating_sub(last) >= 60_000 + && UNPRICED_AT + .compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() +} + /// 提醒按 core 的现状对账(见 [`notices::Notices::reconcile`])。问不到就算了: /// 下一次接上还会再问,事件流上来的照常处理。 pub(crate) fn reconcile_notices(app: &tauri::AppHandle) { @@ -573,12 +600,15 @@ pub(crate) fn reconcile_notices(app: &tauri::AppHandle) { }; let asked_at = notices::now_ms(); let c = &st.control; - let (status, overview, quotas) = tokio::join!( + let (status, overview, quotas, pricing) = tokio::join!( c.status(), c.call::(&[], &()), - c.call::(&[], &()) + c.call::(&[], &()), + c.call::(&[], &()) ); - let (Ok(status), Ok(overview), Ok(quotas)) = (status, overview, quotas) else { + let (Ok(status), Ok(overview), Ok(quotas), Ok(pricing)) = + (status, overview, quotas, pricing) + else { tracing::debug!("提醒对账:没问到 core 的现状"); return; }; @@ -586,6 +616,7 @@ pub(crate) fn reconcile_notices(app: &tauri::AppHandle) { status: &status, overview: &overview, quotas: "as, + pricing: &pricing, }; n.reconcile(notices::rules::from_snapshot(&snap, asked_at), asked_at); }); diff --git a/src-tauri/src/notices/rules.rs b/src-tauri/src/notices/rules.rs index 4a75a41a..68398bf5 100644 --- a/src-tauri/src/notices/rules.rs +++ b/src-tauri/src/notices/rules.rs @@ -49,6 +49,8 @@ const SECURITY: &str = "security"; const MCP: &str = "mcp"; const PLUGINS: &str = "plugins"; const KEYS: &str = "keys"; +/// 流量页,只看失败的那一批 +const FAILED_REQUESTS: &str = "requests:failed"; const SETTINGS: &str = "settings"; /// 设置页的「网关监听」一节(`settings:<节>`,界面滚到那一节) const LISTEN_SETTINGS: &str = "settings:listen"; @@ -62,6 +64,9 @@ pub fn default_view(key: &str) -> &'static str { "scan" => MCP, "plugin" => PLUGINS, "keylimit" => KEYS, + "failed" => FAILED_REQUESTS, + // 价格设在上游页(每家上游的价目表) + "unpriced" => UPSTREAMS, // 同步要处理的事在设置的「同步与备份」;密钥改写不成的在客户端页 "sync" => crate::sync::SYNC_VIEW, "keysync" => "clients", @@ -412,6 +417,27 @@ pub fn from_event(ev: &Event) -> Vec { .filter_map(|i| watched("redact", provider, &i.rule, i.custom, &i.detail)) .collect(), Event::KeyLimitAlert { .. } => key_limit(ev), + // **请求失败只进应用内**(概览上原来那条「需要处理的事」挪到这里):多数失败是上游 + // 一时出错,客户端会重试,为它弹系统通知不值得;一阵子接连失败攒成一条,次数照实记。 + // 界面上中止的不算 —— 那是用户自己叫停的 + Event::RequestFailed { + model, + source, + message, + .. + } if *source != "aborted" => { + let why = core_text::clause(message); + vec![ + Signal::raised("failed", Level::Info, tr!("请求失败", "Request Failed")) + .body(tr!( + format!("「{model}」:{why}。"), + format!("“{model}”: {why}.") + )) + .view(FAILED_REQUESTS) + .event() + .gathered(), + ] + } Event::PluginFailed { plugin_id, plugin_name, @@ -673,6 +699,8 @@ pub struct Snapshot<'a> { pub status: &'a tw_api::Status, pub overview: &'a tw_api::Overview, pub quotas: &'a [tw_api::ProviderQuota], + /// 默认价目表的现状:最近 7 天里哪些上游的哪些模型有请求无法计价 + pub pricing: &'a tw_api::PricingStatus, } /// 对账时认得的那几类键:**现状查得到的**。快照里没有、而列表里还开着的这几类, @@ -688,6 +716,7 @@ pub const RECONCILED: &[&str] = &[ "quota:", "config", "listen", + "unpriced:", ]; /// 这个键是不是对账管的那几类 @@ -798,9 +827,35 @@ pub fn from_snapshot(s: &Snapshot<'_>, now_ms: u64) -> Vec { .iter() .flat_map(from_event) .filter(|sig| sig.change == Change::Raised) + .chain(s.pricing.unpriced_models.iter().map(unpriced)) .collect() } +/// 一个模型最近 7 天里有请求无法计价(概览上原来那条「需要处理的事」挪到这里)。 +/// +/// **按现状说,没有对应的事件**:`request_priced` 不带模型,而无法计价是一个持续的状态 —— +/// 价目表里没有它,就一直算不出来。每家上游的每个模型一条,点开落到上游页去设价格; +/// 7 天里不再有算不出来的请求,对账时自己收起。只进应用内:请求照常转发,只是费用没计入 +fn unpriced(m: &tw_api::UnpricedModel) -> Signal { + let (provider, model, n) = (&m.provider, &m.model, m.requests); + Signal::raised( + format!("unpriced:{provider}:{model}"), + Level::Info, + tr!( + format!("模型「{model}」未定价"), + format!("No Price for “{model}”") + ), + ) + .body(tr!( + format!("最近 7 天经「{provider}」的 {n} 次请求无法计价,费用未计入。在上游页为它设定价格。"), + format!( + "{n} {} through “{provider}” in the last 7 days could not be priced, so their cost is not counted. Set a price for it on the Upstreams page.", + if n == 1 { "request" } else { "requests" } + ) + )) + .view(UPSTREAMS) +} + /// 「仅记录」的命中:规则的动作就是仅记录,或者防护处于「监控」(那时每条规则都按仅记录 /// 处理)。**只认 core 说的结局**(`outcome_detail` 是 `recorded`),不自己按档位和动作推。 /// diff --git a/src-tauri/src/notices/tests.rs b/src-tauri/src/notices/tests.rs index 73a20446..1b53594c 100644 --- a/src-tauri/src/notices/tests.rs +++ b/src-tauri/src/notices/tests.rs @@ -1707,8 +1707,14 @@ fn chinese_notices_say_core_reasons_in_chinese() { // ---------------------------------------------------------------- 对账 /// 一份真的现状:隔离实例里凭据在启动时就失效(令牌端点回 invalid_grant)、配置文件 -/// 刚被外部改坏;额度那一段是手写的(一个窗口用完、一个没有) -fn snapshot() -> (tw_api::Status, tw_api::Overview, Vec) { +/// 刚被外部改坏;额度那一段是手写的(一个窗口用完、一个没有);价目表那一段也是手写的 +/// (一个模型最近有 13 次请求无法计价) +fn snapshot() -> ( + tw_api::Status, + tw_api::Overview, + Vec, + tw_api::PricingStatus, +) { #[derive(serde::Deserialize)] struct F { status: tw_api::Status, @@ -1716,15 +1722,25 @@ fn snapshot() -> (tw_api::Status, tw_api::Overview, Vec) quotas: Vec, } let f: F = serde_json::from_str(include_str!("fixtures/snapshot.json")).unwrap(); - (f.status, f.overview, f.quotas) + let pricing = serde_json::from_value(serde_json::json!({ + "date": "2026-10-28", + "source": "fetched", + "models": 2400, + "auto_update": true, + "unpriced_recent": 13, + "unpriced_models": [{ "provider": "openrouter", "model": "qwen/qwen3-coder-plus", "requests": 13 }], + })) + .unwrap(); + (f.status, f.overview, f.quotas, pricing) } fn snapshot_keys(now: u64) -> Vec { - let (status, overview, quotas) = snapshot(); + let (status, overview, quotas, pricing) = snapshot(); let snap = rules::Snapshot { status: &status, overview: &overview, quotas: "as, + pricing: &pricing, }; let mut keys: Vec = rules::from_snapshot(&snap, now) .into_iter() @@ -1739,19 +1755,29 @@ fn snapshot_keys(now: u64) -> Vec { fn the_snapshot_says_what_the_events_would_have_said() { assert_eq!( snapshot_keys(T0), - ["config", "credential:官方", "quota:chatgpt:primary"] + [ + "config", + "credential:官方", + "quota:chatgpt:primary", + "unpriced:openrouter:qwen/qwen3-coder-plus" + ] ); // 到了重置的时刻,那个窗口就不算用完了 assert_eq!( snapshot_keys(4_102_444_800_001), - ["config", "credential:官方"] + [ + "config", + "credential:官方", + "unpriced:openrouter:qwen/qwen3-coder-plus" + ] ); with_lang(Lang::Zh, || { - let (status, overview, quotas) = snapshot(); + let (status, overview, quotas, pricing) = snapshot(); let snap = rules::Snapshot { status: &status, overview: &overview, quotas: "as, + pricing: &pricing, }; let s = rules::from_snapshot(&snap, T0); let cred = s.iter().find(|s| s.key == "credential:官方").unwrap(); @@ -1761,9 +1787,40 @@ fn the_snapshot_says_what_the_events_would_have_said() { config.body, "网关密钥名称「default」重复。上一版配置仍在服务。" ); + let unpriced = s.iter().find(|s| s.key.starts_with("unpriced:")).unwrap(); + assert_eq!(unpriced.title, "模型「qwen/qwen3-coder-plus」未定价"); + assert_eq!( + unpriced.body, + "最近 7 天经「openrouter」的 13 次请求无法计价,费用未计入。在上游页为它设定价格。" + ); + assert_eq!(unpriced.level, Level::Info); }); } +/// 请求失败只进应用内、攒成一条(概览上那条「需要处理的事」挪到了提醒里);界面上中止的不算 +#[test] +fn a_failed_request_is_an_in_app_notice_and_an_abort_is_not() { + let failed = |source: tw_api::FailureSource| { + rules::from_event(&tw_api::Event::RequestFailed { + id: 9, + model: "claude-sonnet-5".into(), + source, + message: msg("gw.upstream.status", "upstream returned 529"), + sent_bytes: None, + received_bytes: None, + duration_ms: None, + usage: None, + answered_model: None, + }) + }; + let s = failed(tw_api::FailureSource::Upstream); + assert_eq!(s.len(), 1); + assert_eq!(s[0].key, "failed"); + assert_eq!(s[0].level, Level::Info); + assert_eq!(rules::default_view("failed"), "requests:failed"); + assert!(failed(tw_api::FailureSource::Aborted).is_empty()); +} + /// 对账:**开着的不动**(不涨次数、看过的还是看过的、不再弹),没开的补上,现状里 /// 已经好了的收起来 —— 但只收对账管的那几类、只收问现状之前就开着的 #[tokio::test] @@ -1794,11 +1851,12 @@ async fn reconciling_fills_in_what_was_missed_without_saying_anything_twice() { ); let shown_before = b.titles().len(); - let (status, overview, quotas) = snapshot(); + let (status, overview, quotas, pricing) = snapshot(); let snap = rules::Snapshot { status: &status, overview: &overview, quotas: "as, + pricing: &pricing, }; let asked_at = T0 + 1_000; b.bus diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 49f12bbd..8edc3f80 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ThinkWatch Lite", - "version": "2026.10.32", + "version": "2026.10.33", "identifier": "app.thinkwatch.lite", "build": { "beforeDevCommand": "pnpm dev", diff --git a/src/format.test.ts b/src/format.test.ts index 2b5d2756..1e96c081 100644 --- a/src/format.test.ts +++ b/src/format.test.ts @@ -154,6 +154,15 @@ describe("token", () => { expect(compact(200_000)).toBe("200k"); expect(compact(2_698_000)).toBe("2.7M"); }); + + it("十亿以上写 B,三位有效数字;先取整再定单位", () => { + expect(compact(1_809_800_000)).toBe("1.81B"); + expect(compact(18_100_000_000)).toBe("18.1B"); + expect(compact(181_400_000_000)).toBe("181B"); + expect(compact(999_940_000)).toBe("999.9M"); + expect(compact(999_960_000)).toBe("1.00B"); + expect(compact(9_996_000_000)).toBe("10.0B"); + }); }); describe("一行的金额", () => { diff --git a/src/format.ts b/src/format.ts index 28e69c9d..82eb162d 100644 --- a/src/format.ts +++ b/src/format.ts @@ -116,6 +116,9 @@ function sameDay(a: Date, b: Date): boolean { * **先按这一档的精度取整,再看它还在不在这一档。**按取整之前的值挑单位的话, * 9_960 落在「一位小数的 k」那一档,写出来是「10.0k」;999_600 落在「整数 k」 * 那一档,写出来是「1000k」。取整之后进了位的,交给下一档去写(「10k」「1.0M」)。 + * + * 十亿以上写 B,三位有效数字(「1.81B」「18.1B」「181B」):一个月的用量过十亿很常见, + * 写成「1809.8M」是六位数加一位小数,比账本上的数还难掂量。 */ export function compact(n: number): string { if (n < 1000) return String(n); @@ -123,7 +126,10 @@ export function compact(n: number): string { if (Number(tenths) < 10) return `${tenths}k`; const k = Math.round(n / 1000); if (k < 1000) return `${k}k`; - return `${(n / 1_000_000).toFixed(1)}M`; + const m = (n / 1_000_000).toFixed(1); + if (Number(m) < 1000) return `${m}M`; + const b = n / 1_000_000_000; + return `${b >= 999.5 ? Math.round(b) : b.toPrecision(3)}B`; } /** diff --git a/src/index.css b/src/index.css index 4c7f65ba..fdf43f07 100644 --- a/src/index.css +++ b/src/index.css @@ -201,8 +201,8 @@ --data-wash: 0.16; /* 指标卡的面:比窗口底亮一档,卡片才从底上浮出来 */ --panel: #ffffff; - /* 品牌的青→品红(官网 logo 的两色,浅色底上换深一档)。**只做点缀**:悬停卡片的 - * 边、联动的竖线、提示框顶上那一道、「进行中」的点。不碰数据颜色 */ + /* 品牌的青→品红(官网 logo 的两色,浅色底上换深一档)。**只做点缀**:概览页头 + * 「进行中」的点。不碰数据颜色,也不随悬停把整张卡片的边点亮 */ --brand-from: #14c3d3; --brand-to: #d94fc9; /* 路由图上在途请求的光点:**每个请求一种颜色**,按先后轮着取。同一档明度和饱和度的五个 @@ -581,31 +581,6 @@ transition: height var(--motion-fast) var(--motion-ease); } -/* 品牌渐变的点缀(概览指标卡)。**只用在交互上**:指着的那张卡片的边、提示框顶上那一道。 - * 渐变边是两层背景:里层铺卡片的面(padding-box),外层铺渐变(border-box),边框本身 - * 透明 —— `border-image` 画不出圆角 */ -@utility brand-border { - border-color: transparent; - background: - linear-gradient(var(--panel), var(--panel)) padding-box, - linear-gradient(135deg, var(--brand-from), var(--brand-to)) border-box; - box-shadow: 0 6px 20px color-mix(in srgb, var(--brand-from) 8%, transparent); - @media (prefers-color-scheme: dark) { - box-shadow: - 0 0 0 1px color-mix(in srgb, var(--brand-from) 5%, transparent), - 0 8px 28px color-mix(in srgb, var(--brand-to) 10%, transparent); - } -} -@utility brand-edge { - &::before { - content: ""; - position: absolute; - inset: 0 0 auto 0; - height: 2px; - background: linear-gradient(90deg, var(--brand-from), var(--brand-to)); - } -} - @media (prefers-reduced-motion: reduce) { .motion-page, .motion-fade, @@ -730,6 +705,15 @@ body { font-variant-numeric: normal; font-feature-settings: normal; } +/* 概览「最近一年」的统计数字:比指标卡的大数低一级(20px),同一种字。展示级只留给指标卡, + * 别的大数用这一级,页面才分得出主次 */ +@utility tw-figure { + font-family: "Geist", var(--font-ui); + font-size: 20px; + font-weight: 600; + line-height: 26px; + letter-spacing: -0.01em; +} @utility tw-title { font-size: var(--fs-title); font-weight: 600; diff --git a/src/overview/Attention.tsx b/src/overview/Attention.tsx deleted file mode 100644 index 238ba348..00000000 --- a/src/overview/Attention.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import type { ReactNode } from "react"; -import { ArrowRightIcon, TriangleAlertIcon } from "lucide-react"; -import { useIgnored } from "@/ignore"; -import { IconGuard, IconServer } from "@/ui/icons"; -import { uiText } from "@/ui/ui.i18n"; -import { presetRange, type Range } from "@/ui/range"; -import { rangeText } from "@/ui/range.i18n"; -import { useNav } from "@/nav"; -import type { Dashboard, Overview } from "@/types"; -import { useText } from "@/i18n"; -import { attention, attentionCovered, attentionMark, type Attention as Item } from "./series"; -import { LinkText } from "./parts"; -import { overviewText } from "./overview.i18n"; - -/** - * 顶上那条「需要处理的事」:请求失败、被切断的工具调用、用不了的上游、无法计价的请求。 - * 每一件一句话,行尾是去处理它的那一页。 - * - * **什么都不用处理时整条不出现** —— 没问题的时候不该占地方。日常的脱敏次数不在这里: - * 那是防护在正常工作。 - * - * 每一行都能「忽略」:失败、无法计价是已经发生的事,在区间里会一直算着,没法处理掉, - * 只能看过算数;忽略之后有新情况(又失败了、又一家上游不可用)再出现(`attentionMark`)。 - * - * 实时档的数按 24 小时算(和缓存命中那几张卡片一样),句子前面标出来。 - */ -export function AttentionStrip({ d, ov, range }: { d: Dashboard; ov: Overview | null; range: Range }) { - const t = useText(overviewText); - const rt = useText(rangeText); - const nav = useNav(); - const items = attention(d, ov?.providers); - const live = range.live === true; - const scoped = (text: string) => (live ? t.scoped(rt.preset["1d"], text) : text); - // 实时档的计数按 24 小时算(见 `windowStart`),日志也按 24 小时看 - const logRange = live ? presetRange("1d") : range; - - const row = (item: Item): { icon: ReactNode; text: string; go: string; open: () => void } => { - switch (item.kind) { - case "failed": - return { - icon: , - text: scoped(t.attnFailed(item.n, item.top)), - go: t.viewInTraffic, - open: () => nav.open("requests", { grouped: false, filter: { failedOnly: true } }), - }; - case "toolCut": - return { - icon: , - text: scoped(t.attnToolCut(item.n)), - go: t.showLog, - open: () => nav.open("security", { focus: { range: logRange, at: Date.now() } }), - }; - case "upstreams": { - const first = item.down[0]; - const why = { open: t.whyOpen, auth: t.whyAuth, login: t.whyLogin }; - return { - icon: , - text: - item.down.length === 1 && first - ? t.attnUpstream(first.name, why[first.why]) - : t.attnUpstreams(item.down.length, item.down.map((x) => x.name)), - go: t.showUpstreams, - open: () => nav.open("upstreams", { upstream: first?.name }), - }; - } - case "unpriced": - return { - icon: , - text: scoped(t.attnUnpriced(item.n)), - go: t.viewInTraffic, - open: () => nav.open("requests", { grouped: false, filter: { unpricedOnly: true } }), - }; - } - }; - - return ; -} - -/** 一行一件,忽略过的不画;全忽略了整条不出现 */ -function Strip({ - items, - buckets, - row, - label, -}: { - items: Item[]; - buckets: Dashboard["buckets"]; - row: (item: Item) => { icon: ReactNode; text: string; go: string; open: () => void }; - label: string; -}) { - // 四种各一行,钩子的次数固定 - const kinds = ["failed", "toolCut", "upstreams", "unpriced"] as const; - const rows = kinds.map((kind) => { - const item = items.find((i) => i.kind === kind); - // eslint-disable-next-line react-hooks/rules-of-hooks - const ig = useIgnored(`attention:${kind}`, item ? attentionMark(item, buckets) : null, attentionCovered); - return item && !ig.ignored ? { item, ignore: ig.ignore } : null; - }); - const shown = rows.filter((r) => r !== null); - if (shown.length === 0) return null; - return ( -
- {shown.map(({ item, ignore }) => ( - - ))} -
- ); -} - -function Line({ - icon, - text, - go, - open, - onIgnore, -}: { - icon: ReactNode; - text: string; - go: string; - open: () => void; - onIgnore: () => void; -}) { - const u = useText(uiText); - const link = "inline-flex shrink-0 items-center gap-1 tw-label whitespace-nowrap text-muted-foreground hover:text-foreground"; - return ( -
- {icon} - {text} - - {go} - - - - {u.ignore} - -
- ); -} diff --git a/src/overview/Breakdown.tsx b/src/overview/Breakdown.tsx index 45f8f8ab..ee9e49ab 100644 --- a/src/overview/Breakdown.tsx +++ b/src/overview/Breakdown.tsx @@ -46,8 +46,9 @@ const COLS = "grid grid-cols-[minmax(0,1fr)_3.5rem_4rem_4.5rem_4rem_4rem_4rem] gap-x-3 @min-[760px]:grid-cols-[minmax(0,1fr)_4.5rem_repeat(5,5.25rem)] @min-[760px]:gap-x-4"; /** - * 指标卡下面的明细表:按模型、上游、密钥分,每一行是这段时间的请求、token、费用、 - * 缓存命中、首 token 和生成速度。请求多的在前,先列几行,其余的写出有几项、点开再看。 + * 页面最下面的明细表(一张卡片,标题「明细」,和上面几块同一副外框):按模型、上游、密钥 + * 分,每一行是这段时间的请求、token、费用、缓存命中、首 token 和生成速度。请求多的在前, + * 先列几行,其余的写出有几项、点开再看。 * * 点一行落到流量页,筛好那一个模型、上游或密钥。没有名字的那一行(没到上游的请求、 * 认不出的密钥)只说不点 —— 没有东西可筛。 @@ -56,12 +57,26 @@ const COLS = * * 名称格前面是标志(`ItemLogo`)。`ov` 给了,上游按地址认、密钥认得出是哪个客户端的。 */ -export function Breakdown({ d, range, ov }: { d: Dashboard; range: Range; ov?: Overview | null }) { +export function Breakdown({ + d, + range, + ov, + dim, + onDim, +}: { + d: Dashboard; + range: Range; + ov?: Overview | null; + /** 按什么分。**和「去向」是同一个选择**(放在页面那一层) */ + dim: Dim; + onDim: (d: Dim) => void; +}) { const t = useText(overviewText); const rt = useText(rangeText); const nav = useNav(); - const [dim, setDim] = useState("model"); - const [all, setAll] = useState(false); + // 展开记在是哪一页上:换了按什么分(这里或「去向」那边切的),回到只列几行 + const [open, setOpen] = useState(null); + const all = open === dim; const groups: CostBucketGroup[] | null = dim === "model" ? d.buckets_by_model : dim === "provider" ? d.buckets_by_provider : d.buckets_by_client; const rows = groups ? breakdown(groups) : []; @@ -82,8 +97,13 @@ export function Breakdown({ d, range, ov }: { d: Dashboard; range: Range; ov?: O const unnamed = { model: t.unknownModel, provider: t.noUpstream, client: t.unknownKey }[dim]; return ( -
-
+
+
+

{t.tableLabel}

label={t.tableLabel} value={dim} @@ -92,16 +112,13 @@ export function Breakdown({ d, range, ov }: { d: Dashboard; range: Range; ov?: O { id: "provider", label: t.tabUpstreams }, { id: "client", label: t.tabKeys }, ]} - onChange={(v) => { - setDim(v); - setAll(false); - }} + onChange={onDim} /> {range.live && {rt.preset["1d"]}} {rows.length > ROWS && (
); } - const { grid, top } = spec; + const { grid, ticks } = spec; + const top = ticks[ticks.length - 1] ?? 1; const n = grid.at.length; const phase = (i: number) => slotPhase(grid, i, now); const pctOf = (v: number) => Math.min(100, Math.max(0, (v / top) * 100)); - const ticks = yTicks(top); const xs = n > 0 ? xTicks(grid, Math.max(1, Math.floor(w / TICK_W)), { today: t.today, now }) : []; // 贴着右边的标签放不下就不写:标签往右排开,右边没有地方 const shownX = w > 0 ? xs.filter((x) => x.f * w + 36 <= w) : xs; @@ -113,7 +116,22 @@ export function Chart({ }; const center = (i: number) => ((i + 0.5) / n) * 100; - const tip = cursor !== null ? spec.tip(cursor) : null; + const blank = Math.min(unrecorded, n); + const tip = + cursor === null ? null : cursor < blank ? { main: t.unrecorded, rows: [], rows2: [] } : spec.tip(cursor); + /* + 历史档:线只画到上一格为止,正在进行的那一格(还没过完,数还会涨)用一段虚线接上, + 此刻之后的不画;还没有记录的那几格不画 —— 那里没有数,不是零 + */ + const lines = spec.lines.flatMap((l): ChartLine[] => { + const known = l.values.map((v, i) => (i < blank ? null : v)); + if (live) return [{ ...l, values: known }]; + const solid = known.map((v, i) => (phase(i) === "past" ? v : null)); + const k = known.findIndex((_, i) => phase(i) === "ongoing"); + if (k <= blank) return [{ ...l, values: solid }]; + const tail = known.map((v, i) => (i === k || i === k - 1 ? v : null)); + return [{ ...l, values: solid }, { ...l, values: tail, wash: false, dash: true }]; + }); return (
@@ -155,58 +173,22 @@ export function Chart({ /> ))} - {spec.lines && } + {blank > 0 && ( +
+ {(blank / n) * w >= 56 && ( + {t.unrecorded} + )} +
+ )} -
- {grid.at.map((at, i) => { - const ph = phase(i); - const hot = i === cursor; - let bars: ReactNode = null; - if (spec.bars && ph !== "future") { - const a = spec.bars.a[i] ?? 0; - const b = spec.bars.b?.[i] ?? 0; - // 有量的那一段至少看得见 - const ah = a > 0 ? Math.max(1.2, pctOf(a)) : 0; - const bh = b > 0 ? Math.max(1.2, pctOf(b)) : 0; - const bar = cn( - "absolute bottom-0 left-1/2 w-4 max-w-[70%] -translate-x-1/2", - !live && "motion-bar", - ph === "ongoing" && "opacity-50", - ); - bars = ( - <> - {ah > 0 && ( - - )} - {bh > 0 && ( - 0 ? `calc(${ah}% + 2px)` : 0, - background: spec.bars.colorB, - }} - /> - )} - - ); - } - return ( -
- {bars} -
- ); - })} -
+ {/* 线:指着的那一格画竖线,每条线上一个点 */} - {spec.lines && cursor !== null && ( + {cursor !== null && ( <> {spec.lines.map((l, k) => { - const v = l.values[cursor]; + const v = cursor < blank ? null : l.values[cursor]; if (v == null) return null; return ( ; + lines: ChartLine[]; n: number; top: number; }) { @@ -312,6 +294,8 @@ function Lines({ fill="none" stroke={l.color} strokeWidth={2} + strokeDasharray={l.dash ? "3 4" : undefined} + opacity={l.dash ? 0.7 : undefined} strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" @@ -342,7 +326,8 @@ function Lines({ function TipBox({ id, title, body, x, right }: { id: string; title: string; body: TipBody; x: number; right: boolean }) { const row = (r: TipRow, k: number) => (
- + {/* 图里对得上的那一样才有图例;没有的不留位置,和标题左对齐 */} + {r.key && } {r.v} {r.k}
diff --git a/src/overview/OverviewPage.tsx b/src/overview/OverviewPage.tsx index 2e5a24bf..ba76a220 100644 --- a/src/overview/OverviewPage.tsx +++ b/src/overview/OverviewPage.tsx @@ -11,17 +11,17 @@ import type { Dashboard, Overview } from "@/types"; import { useText } from "@/i18n"; import { FirstRequestHint } from "@/guide/FirstRequestHint"; import { SetupGuide } from "@/guide/SetupGuide"; -import { bucketOf, useOverview, type Shown } from "./useOverview"; +import { bucketOf, rangeId, useOverview, type Shown } from "./useOverview"; import { OverviewStatus } from "./OverviewStatus"; import { OverviewSkeleton } from "./OverviewSkeleton"; -import { AttentionStrip } from "./Attention"; import { Hero } from "./Hero"; import { Spend } from "./Spend"; import { Quota, hasQuota } from "./Quota"; -import { Year } from "./Year"; -import { Breakdown } from "./Breakdown"; +import { Year, useUsageHistory } from "./Year"; +import { Breakdown, type Dim } from "./Breakdown"; import type { Metric } from "./metric"; import type { YearMetric } from "./yearGrid"; +import { useHold } from "./useHold"; import { overviewText } from "./overview.i18n"; /** @@ -43,9 +43,9 @@ function neverUsed(d: Dashboard): boolean { * 这一页的每一个数字都受那条约束:**绝不让估算值混进精确数字里假装准确。**所以 * 金额旁边永远跟着它的限定词。 * - * 版面:页头(状态一行、时间范围);需要处理的事(没有就不出现);用量(六个指标并排, + * 版面:页头(状态一行、时间范围);用量(六个指标并排, * 选中的那个画成下面的大图);去向和额度与余额并排;最近一年的热力图;按模型、上游、 - * 密钥分的明细表。 + * 密钥分的明细表。去向和明细按什么分是同一个选择,切一处两处一起换。 * * **跟着时间范围走的放在一起**:用量、去向、明细。额度与余额是此刻的状态,最近一年固定 * 一年,各自标着自己的口径。 @@ -67,10 +67,20 @@ export default function OverviewPage({ const t = useText(overviewText); const [range, setRange] = useRange(); const o = useOverview(range, tick); - // 选中的指标放在这一层:换时间范围时下面整块重挂,选择不该跟着丢 + // 选中的指标、按什么分放在这一层:几块卡片共用一个选择 const [metric, setMetric] = useState("tokens"); const [yearMetric, setYearMetric] = useState("tokens"); - const pickDay = useCallback((day: Date) => setRange(dayRange(day)), [setRange]); + const [dim, setDim] = useState("model"); + const history = useUsageHistory(tick); + const hold = useHold(o.shown?.id); + const pickDay = useCallback( + (day: Date, from: HTMLElement) => { + const r = dayRange(day); + hold.start(rangeId(r), from); + setRange(r); + }, + [setRange, hold.start], + ); const landed = useRef(onLanded); landed.current = onLanded; @@ -94,12 +104,15 @@ export default function OverviewPage({ shown={o.shown} ov={ov} switching={o.switching} - tick={tick} metric={metric} onMetric={setMetric} yearMetric={yearMetric} onYearMetric={setYearMetric} onPickDay={pickDay} + dim={dim} + onDim={setDim} + history={history} + pad={hold.pad} /> )} @@ -110,23 +123,30 @@ function Body({ shown, ov, switching, - tick, metric, onMetric, yearMetric, onYearMetric, onPickDay, + dim, + onDim, + history, + pad, }: { shown: Shown; ov: Overview | null; /** 选中的区间还在取,画的是上一个区间的 */ switching: boolean; - tick: number; metric: Metric; onMetric: (m: Metric) => void; yearMetric: YearMetric; onYearMetric: (m: YearMetric) => void; - onPickDay: (day: Date) => void; + onPickDay: (day: Date, from: HTMLElement) => void; + dim: Dim; + onDim: (d: Dim) => void; + history: ReturnType; + /** 页尾临时补的空白(`useHold`) */ + pad: number; }) { const t = useText(overviewText); const { data: d, range, id } = shown; @@ -137,10 +157,10 @@ function Body({ return (
) : ( <> - - +
- +
- - + + + {pad > 0 &&
} )}
); } + diff --git a/src/overview/OverviewSkeleton.tsx b/src/overview/OverviewSkeleton.tsx index 0011afe5..90e134ba 100644 --- a/src/overview/OverviewSkeleton.tsx +++ b/src/overview/OverviewSkeleton.tsx @@ -85,12 +85,12 @@ export function OverviewSkeleton() {
- {/* 明细表:切换 28、8、表头 28、行 31 */} -
+ {/* 明细:和上面几块同一副外框。标题行 28、8、表头 28、行 31 */} +
{[0.9, 0.6, 0.4, 0.25].map((w, i) => ( -
+
{[0, 1, 2, 3, 4, 5].map((j) => ( diff --git a/src/overview/Quota.tsx b/src/overview/Quota.tsx index f558f570..0df6eea3 100644 --- a/src/overview/Quota.tsx +++ b/src/overview/Quota.tsx @@ -34,9 +34,9 @@ export function Quota({ d, ov }: { d: Dashboard; ov: Overview | null }) {
-
+

{t.title}

nav.open("upstreams")}> {t.showUpstreams} @@ -80,7 +80,7 @@ function Row({ r, now }: { r: QuotaRow; now: number }) { {t.resetAt(clock)} ) : null; return ( -
  • +
  • {p.name} diff --git a/src/overview/Spend.tsx b/src/overview/Spend.tsx index 6ee97619..93c6375b 100644 --- a/src/overview/Spend.tsx +++ b/src/overview/Spend.tsx @@ -1,4 +1,4 @@ -import { useState, type ReactNode } from "react"; +import type { ReactNode } from "react"; import { cn } from "@/lib/utils"; import { Segmented } from "@/ui/segmented"; import { rangeText } from "@/ui/range.i18n"; @@ -18,19 +18,33 @@ import { overviewText } from "./overview.i18n"; /** * 去向:按模型、上游、密钥排出所选指标(「用量」那张卡片上选中的那一个)。 * - * 每一行是标志、名字、值、能加起来的指标再带占比;后面一条淡色的条,长度按最大的那一项 - * 归一。token、费用、请求、流量是各项的合计,缓存命中是各项自己的命中率,首 token 是各项 + * 每一行是标志、名字、值、一根细进度条(长度按最大的那一项归一,和右边额度卡的进度条 + * 同一形制,颜色是大图的蓝:它跟着上面选中的指标),能加起来的指标再带占比。token、费用、请求、流量是各项的合计,缓存命中是各项自己的命中率,首 token 是各项 * 的 P50。先列五行,其余的写出有几项。底下一行是合计(或整体命中率、整体 P50)。 * * 点一行落到流量页,筛好那一项(和明细表一样)。没有名字的那一项只说不点。 * * 实时档和明细表一样按 24 小时算,右上角的口径写着。 */ -export function Spend({ d, range, metric, ov }: { d: Dashboard; range: Range; metric: Metric; ov?: Overview | null }) { +export function Spend({ + d, + range, + metric, + ov, + dim, + onDim, +}: { + d: Dashboard; + range: Range; + metric: Metric; + ov?: Overview | null; + /** 按什么分。**和明细表是同一个选择**(放在页面那一层),切一处两处一起换 */ + dim: Dim; + onDim: (d: Dim) => void; +}) { const t = useText(spendText); const rt = useText(rangeText); const nav = useNav(); - const [dim, setDim] = useState("model"); const groups: CostBucketGroup[] | null = dim === "model" ? d.buckets_by_model : dim === "provider" ? d.buckets_by_provider : d.buckets_by_client; const latency = new Map( @@ -49,9 +63,10 @@ export function Spend({ d, range, metric, ov }: { d: Dashboard; range: Range; me
    -
    +
    +

    {t.label}

    label={t.label} value={dim} @@ -60,17 +75,17 @@ export function Spend({ d, range, metric, ov }: { d: Dashboard; range: Range; me { id: "provider", label: t.dims.provider }, { id: "client", label: t.dims.client }, ]} - onChange={setDim} + onChange={onDim} /> {caption}
    {groups === null ? ( -

    {t.unavailable}

    +

    {t.unavailable}

    ) : rows.length === 0 ? ( -

    {t.empty}

    +

    {t.empty}

    ) : ( -
      +
        {shown.map((r) => (
      1. 0 && ( -
        + // 合计紧跟着列表。卡片和右边的额度卡等高,多出来的空在最底下,不夹在行和合计之间 +
        {rows.length > SPEND_ROWS && {t.more[dim](rows.length - SPEND_ROWS)}}
        @@ -101,8 +117,10 @@ export function Spend({ d, range, metric, ov }: { d: Dashboard; range: Range; me } /** - * 一行。整行能点(读屏读作链接,Enter 和空格都能打开);悬停压暗一档。条铺在整行底下, - * 从左往右,`--data-1` 按 `--data-wash` 的浓度。 + * 一行。整行能点(读屏读作链接,Enter 和空格都能打开);悬停压暗一档。悬停的底色往两边各 + * 伸出 10px(列表 `-mx-2.5`),行里的标志和卡片标题对齐。 + * + * **条不铺在整行底下**:那样最大的一项永远是满满一整行,看上去像被选中的那一行。 */ function SpendLine({ r, @@ -142,13 +160,6 @@ function SpendLine({ "cursor-pointer outline-none transition-colors duration-(--motion-fast) ease-(--motion-ease) hover:bg-foreground/[0.045] focus-visible:bg-foreground/[0.045] focus-visible:ring-2 focus-visible:ring-ring/40", )} > - {r.bar > 0 && ( - - )} {logo} + + {/* 有量的至少看得见一小截 */} + 0 ? Math.max(4, r.bar * 100) : 0}%` }} + /> + {additive(metric) && ( {r.share === null ? "" : shareText(r.share)} diff --git a/src/overview/Year.tsx b/src/overview/Year.tsx index 2af47c8d..f988e56d 100644 --- a/src/overview/Year.tsx +++ b/src/overview/Year.tsx @@ -3,8 +3,8 @@ import { cn } from "@/lib/utils"; import { call } from "@/control"; import { compact } from "@/format"; import { useText } from "@/i18n"; -import { useResource } from "@/lib/resource"; -import { usd } from "@/types"; +import { useResource, type Resource } from "@/lib/resource"; +import { usd, type UsageHistory } from "@/types"; import { Segmented } from "@/ui/segmented"; import { Skeleton } from "@/ui/skeleton"; import { Tip } from "@/ui/tip"; @@ -37,50 +37,68 @@ import { yearText } from "./Year.i18n"; type Text = (typeof yearText)["zh"]; type OverviewText = (typeof overviewText)["zh"]; -/** 五级的颜色:0 是空格子的灰,1–4 是 `--chart-*` 那一条蓝由浅到深(暗色下由暗到亮) */ -const LEVEL = ["bg-surface", "bg-chart-5", "bg-chart-3", "bg-chart-2", "bg-chart-1"] as const; +/** + * 五级的颜色:0 是空格子的灰,1–4 是大图那个蓝(`--data-1`)按 16 / 32 / 52 / 78% 混进格子的底色。 + * **最深一档也不比 `--data-1` 本身亮**:一年三百多格,最高一档要是接近正文的白,热力图就成了 + * 整页最亮的一块,把视线从上面的用量拉走 + */ +const LEVEL = [ + "bg-surface", + "bg-[color-mix(in_oklab,var(--data-1)_16%,var(--surface))]", + "bg-[color-mix(in_oklab,var(--data-1)_32%,var(--surface))]", + "bg-[color-mix(in_oklab,var(--data-1)_52%,var(--surface))]", + "bg-[color-mix(in_oklab,var(--data-1)_78%,var(--surface))]", +] as const; const METRICS: YearMetric[] = ["tokens", "cost", "requests"]; +/** + * 最近一年的用量历史(`UsageHistory`),从网格第一天的本地零点起、一天一格。**放在页面那一层 + * 取**:热力图画它,大图用它的 `detail_since_ms` 标出还没有逐条记录的那几格。`tick` 变了重取, + * 过了零点网格往前走一格。 + */ +export function useUsageHistory(tick: number): { r: Resource; today: number } { + const today = useStartOf(DAY_MS); + const from = gridStart(today); + const r = useResource("usage-history", () => call("UsageHistory", { from_ms: from, to_ms: null, bucket_ms: DAY_MS }), { + deps: [tick, from], + }); + return { r, today }; +} + /** * 最近一年:一天一格的热力图,固定一年,不跟时间范围走;所选时间范围在格子上描边。 * * 53 列(周)× 7 行(周一在上),最右一列是本周,今天之后的不画;格子随卡片宽度伸缩。 - * 开始记录之前的日子画成虚线框「未记录」—— **不是「没有用量」**,那时还没在记。 + * 开始记录之前的日子画成更淡的实心格「未记录」—— **不是「没有用量」**,那时还没在记。不画 + * 虚线框:两个月的空框连成一片,比有数的格子还扎眼。 * * 颜色按「有用量的日子」的四分位分五级。四个统计只算记录里的日子。费用的限定词(含估算、 * 无法计价)一直写在统计下面,全文在悬停里。 * - * 数据:`UsageHistory`,从网格第一天的本地零点起、一天一格;`tick` 变了重取,重取时上一份 - * 照样画着。 + * 数据:页面那一层的 `useUsageHistory`,重取时上一份照样画着。 */ export function Year({ + history, range, - tick, metric, onMetric: setMetric, onPickDay, }: { + /** `useUsageHistory` 取到的 */ + history: { r: Resource; today: number }; range: Range; - /** 库里多了请求、手动刷新:重取 */ - tick: number; /** - * 选中的指标。**放在页面那一层**:换时间范围时概览整块重挂(点一个格子就是换范围), - * 放在这里的话选了「费用」再点一天就弹回了 Token + * 选中的指标。放在页面那一层,和用量那六个指标的选择一样 */ metric: YearMetric; onMetric: (m: YearMetric) => void; /** 点了某一天:时间范围切到那一天 */ - onPickDay: (day: Date) => void; + onPickDay: (day: Date, cell: HTMLElement) => void; }) { const t = useText(yearText); const ot = useText(overviewText); - // 今天的零点:过了零点就换,网格往前走一格 - const today = useStartOf(DAY_MS); - const from = gridStart(today); - const r = useResource("usage-history", () => call("UsageHistory", { from_ms: from, to_ms: null, bucket_ms: DAY_MS }), { - deps: [tick, from], - }); + const { r, today } = history; const h = r.data ?? null; const days = useMemo(() => layout(today, h), [today, h]); const failed = h === null && !r.loading && r.error !== undefined; @@ -93,7 +111,7 @@ export function Year({ return (

        @@ -190,7 +208,7 @@ function Body({ /** 逐条记录从哪一刻起有。比它早的日子点不开:概览在那几天按逐条记录算出来全是零 */ detailSince: number | null; range: Range; - onPickDay: (day: Date) => void; + onPickDay: (day: Date, cell: HTMLElement) => void; t: Text; ot: OverviewText; }) { @@ -234,7 +252,7 @@ function Body({ {stats.map((x) => (
        {x.label}
        -
        {x.value}
        +
        {x.value}
        {x.tip && x.tip.length > 0 ? ( }>
        @@ -263,13 +281,13 @@ function Body({
        {anyInRange && ( - + {t.legendRange} )} {anyUnrecorded && ( - + {t.unrecorded} )} @@ -303,7 +321,7 @@ function Grid({ /** 点得开的日子:有记录,而且逐条记录还在 */ openable: (d: Day) => boolean; metric: YearMetric; - onPickDay: (day: Date) => void; + onPickDay: (day: Date, cell: HTMLElement) => void; t: Text; ot: OverviewText; }) { @@ -322,14 +340,15 @@ function Grid({ const cells = useRef(new Map()); const wrap = useRef(null); - const pick = (d: Day) => { - if (openable(d)) onPickDay(new Date(d.at)); + // 点的那一格交出去:换了区间之后页面按它把滚动位置补回来,格子留在指针底下 + const pick = (d: Day, cell: HTMLElement) => { + if (openable(d)) onPickDay(new Date(d.at), cell); }; const onKey = (e: KeyboardEvent, i: number) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); - pick(days[i]!); + pick(days[i]!, e.currentTarget as HTMLElement); return; } const j = step(days, i, e.key); @@ -397,9 +416,9 @@ function Grid({ tabIndex={i === cur ? 0 : -1} aria-label={t.cell(t.dateFull(new Date(d.at)), mainText(d, metric, t, ot))} aria-disabled={!openable(d) || undefined} - onClick={() => { + onClick={(e) => { setActive(i); - pick(d); + pick(d, e.currentTarget); }} onKeyDown={(e) => onKey(e, i)} onFocus={(e) => { @@ -409,7 +428,7 @@ function Grid({ onBlur={() => setFocused((f) => (f === i ? null : f))} className={cn( "relative aspect-square rounded-[2.5px] outline-none", - unrec ? "border border-dashed border-input" : [LEVEL[lv], openable(d) && "cursor-pointer"], + unrec ? "bg-surface/55" : [LEVEL[lv], openable(d) && "cursor-pointer"], shown === i ? "ring-2 ring-foreground" : inRange(d) && "ring-[1.5px] ring-foreground/70", )} > diff --git a/src/overview/overview.i18n.ts b/src/overview/overview.i18n.ts index ed61740f..096299e0 100644 --- a/src/overview/overview.i18n.ts +++ b/src/overview/overview.i18n.ts @@ -17,23 +17,7 @@ export const overviewText = messages( noUpstreams: "尚未添加上游", showUpstreams: "在上游页中查看", - // 需要处理的事。一件一句,陈述式 - attentionLabel: "需要处理的事", - /** 失败过半集中在一个上游时点出它;全在一个上游时说「均」 */ - attnFailed: (n: number, top: { name: string; n: number } | null) => - `${n} 次请求失败` + (top ? (top.n === n ? `,均发生在 ${top.name}` : `,其中 ${top.name} ${top.n} 次`) : ""), - attnToolCut: (n: number) => `危险命令防护切断了 ${n} 个可疑工具调用`, - attnUpstream: (name: string, why: string) => `${name} ${why}`, - attnUpstreams: (n: number, names: string[]) => - `${n} 个上游不可用:${names.slice(0, 3).join("、")}${names.length > 3 ? " 等" : ""}`, - whyOpen: "熔断中", - whyAuth: "凭据被拒", - whyLogin: "需要重新登录", - attnUnpriced: (n: number) => `${n} 条请求无法计价:所用模型未定价,费用未计入`, - /** 实时档:这几句的数按 24 小时算 */ - scoped: (period: string, text: string) => `${period}内${sp(text)}${text}`, viewInTraffic: "在流量中查看", - showLog: "在安全日志中查看", // 用量:六个指标的名目 usageLabel: "用量", @@ -53,7 +37,6 @@ export const overviewText = messages( */ deltaTip: (period: string, before: string, now: string) => `较上一个${sp(period)}${period}:${before} → ${now}`, /** 上一个区间没有数据时,幅度那一格(「—」)的悬停说明 */ - noPrior: (period: string) => `上一个${sp(period)}${period}无记录`, // 大图 /** 悬停抬头里还没过完的那一格止于「现在」 */ @@ -62,6 +45,7 @@ export const overviewText = messages( today: "今天", /** 那一样读取失败:**不是没有** */ chartUnavailable: "暂时取不到", + unrecorded: "未记录", /** 图例右边:一格多宽 */ perSlot: (width: string) => `每格 ${width}`, hours: (n: number) => `${n} 小时`, @@ -176,25 +160,7 @@ export const overviewText = messages( noUpstreams: "No upstreams yet", showUpstreams: "View in Upstreams", - attentionLabel: "Needs attention", - attnFailed: (n: number, top: { name: string; n: number } | null) => - (n === 1 ? "1 request failed" : `${n} requests failed`) + - (top ? (top.n === n ? (n === 1 ? ` on ${top.name}` : `, all on ${top.name}`) : `, ${top.n} of them on ${top.name}`) : ""), - attnToolCut: (n: number) => - n === 1 ? "Dangerous-command protection cut off 1 suspicious tool call" : `Dangerous-command protection cut off ${n} suspicious tool calls`, - attnUpstream: (name: string, why: string) => `${name}: ${why}`, - attnUpstreams: (n: number, names: string[]) => - `${n} upstreams unavailable: ${names.slice(0, 3).join(", ")}${names.length > 3 ? ", …" : ""}`, - whyOpen: "circuit open", - whyAuth: "credentials rejected", - whyLogin: "sign-in required", - attnUnpriced: (n: number) => - n === 1 - ? "1 request unpriced: its model has no price, so its cost is not included" - : `${n} requests unpriced: their models have no price, so their cost is not included`, - scoped: (period: string, text: string) => `Last ${period}: ${text}`, viewInTraffic: "View in Traffic", - showLog: "View in the security log", usageLabel: "Usage", metricsLabel: "Metrics", @@ -207,11 +173,11 @@ export const overviewText = messages( chartOf: (name: string) => `${name} over time`, deltaTip: (period: string, before: string, now: string) => `vs. prior ${period}: ${before} → ${now}`, - noPrior: (period: string) => `No data for the prior ${period}`, now: "Now", today: "Today", chartUnavailable: "Unavailable right now", + unrecorded: "Not recorded", perSlot: (width: string) => `${width} per bar`, hours: (n: number) => (n === 1 ? "1 hour" : `${n} hours`), days: (n: number) => (n === 1 ? "1 day" : `${n} days`), diff --git a/src/overview/series.test.ts b/src/overview/series.test.ts index f285772a..baee3f5e 100644 --- a/src/overview/series.test.ts +++ b/src/overview/series.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { CostBucket, CostBucketGroup, Dashboard, ProviderView, Summary } from "@/types"; +import type { CostBucket, CostBucketGroup, Dashboard, Summary } from "@/types"; import { - attention, - attentionCovered, - attentionMark, breakdown, cacheOf, delta, @@ -15,7 +12,6 @@ import { liveModelsAt, rankCost, slotTitle, - topFailure, type Grid, } from "./series"; import { overviewText } from "./overview.i18n"; @@ -421,94 +417,6 @@ describe("明细表", () => { }); }); -describe("失败集中在哪个上游", () => { - const at = 0; - const g = (name: string, failed: number) => group(at, name, 0, { failed, requests: failed }); - - it("过半才点名;没到上游的(空名字)不归给谁", () => { - expect(topFailure([g("openrouter", 11), g("anthropic", 4), g("", 3)], 18)).toEqual({ name: "openrouter", n: 11 }); - expect(topFailure([g("openrouter", 9), g("anthropic", 4), g("", 5)], 18)).toBeNull(); - expect(topFailure([g("", 18)], 18)).toBeNull(); - }); - - it("跨格加起来", () => { - expect(topFailure([g("a", 2), { ...g("a", 2), at_ms: HOUR }, g("b", 1)], 5)).toEqual({ name: "a", n: 4 }); - }); - - it("取不到按上游分的那份:不点名", () => { - expect(topFailure(null, 5)).toBeNull(); - }); -}); - -describe("需要处理的事", () => { - const provider = (name: string, over: Partial = {}) => ({ name, disabled: false, health: "closed", ...over }) as ProviderView; - - it("什么都不用处理:空的,那一条整个不出现", () => { - expect(attention(dashboard(0, []), [provider("a")])).toEqual([]); - }); - - it("失败、切断的工具调用、用不了的上游、无法计价,按这个次序", () => { - const d = dashboard(0, [], { - summary: summary({ - failed: 18, - unpriced_requests: 3, - security: { ...summary().security, tool_calls: 2, tool_calls_cut: 1, secrets: 40 }, - }), - buckets_by_provider: [group(0, "openrouter", 0, { failed: 11 })], - }); - const items = attention(d, [ - provider("a"), - provider("b", { health: "open" }), - provider("c", { disabled: true, health: "open" }), - ]); - expect(items).toEqual([ - { kind: "failed", n: 18, top: { name: "openrouter", n: 11 } }, - { kind: "toolCut", n: 1 }, - { kind: "upstreams", down: [{ name: "b", why: "open" }] }, - { kind: "unpriced", n: 3 }, - ]); - }); - - describe("忽略之后什么时候再出现", () => { - const bucket = (at: number, over: Partial) => - ({ at_ms: at, requests: 1, failed: 0, unpriced_requests: 0, ...over }) as CostBucket; - - it("失败:看最近一次落在哪一格、那格几条;旧的滚出区间不算新情况", () => { - const seen = attentionMark({ kind: "failed", n: 5, top: null }, [bucket(1, { failed: 3 }), bucket(2, { failed: 2 })]); - expect(seen).toEqual({ at: 2, n: 2 }); - // 旧的那一格滚走了 - expect(attentionCovered(seen, attentionMark({ kind: "failed", n: 2, top: null }, [bucket(2, { failed: 2 })]))).toBe(true); - // 同一格又多了一条 - expect(attentionCovered(seen, attentionMark({ kind: "failed", n: 3, top: null }, [bucket(2, { failed: 3 })]))).toBe(false); - // 后面的格子又有了 - expect(attentionCovered(seen, attentionMark({ kind: "failed", n: 3, top: null }, [bucket(2, { failed: 2 }), bucket(3, { failed: 1 })]))).toBe(false); - }); - - it("取不到格子时按总数:没变多就不出现", () => { - const seen = attentionMark({ kind: "failed", n: 5, top: null }, null); - expect(seen).toBe(5); - expect(attentionCovered(seen, 4)).toBe(true); - expect(attentionCovered(seen, 6)).toBe(false); - // 一次没取到格子、一次取到了:对不上,宁可再提醒一次 - expect(attentionCovered(seen, { at: 1, n: 1 })).toBe(false); - }); - - it("上游:多一家、换了原因才是新情况,少一家不是", () => { - const seen = attentionMark({ kind: "upstreams", down: [{ name: "b", why: "open" }, { name: "a", why: "auth" }] }, null); - expect(seen).toEqual(["a:auth", "b:open"]); - expect(attentionCovered(seen, ["b:open"])).toBe(true); - expect(attentionCovered(seen, ["a:auth", "b:open", "c:login"])).toBe(false); - expect(attentionCovered(seen, ["b:login"])).toBe(false); - }); - }); - - /** 日常的脱敏是防护在正常工作,不用处理;记录档里看见但没切断的工具调用也不算 */ - it("只有脱敏、只记录了的工具调用:不出现", () => { - const d = dashboard(0, [], { summary: summary({ security: { ...summary().security, secrets: 12, secrets_replaced: 12, tool_calls: 3 } }) }); - expect(attention(d, [])).toEqual([]); - }); -}); - describe("一格里排前几的模型", () => { const at = new Date(2026, 8, 24, 6).getTime(); diff --git a/src/overview/series.ts b/src/overview/series.ts index 03d3c209..1e2cca03 100644 --- a/src/overview/series.ts +++ b/src/overview/series.ts @@ -1,5 +1,4 @@ import { densify, monthDay } from "@/format"; -import { noMore, noNew, type Mark } from "@/ignore"; import { usd, type CostBucket, type CostBucketGroup, type Dashboard, type ProviderView, type Summary } from "@/types"; import { LIVE_BUCKET_MS, LIVE_REACH_MS, liveRate, type LiveEnd, type LiveSample } from "./useLive"; import type { overviewText } from "./overview.i18n"; @@ -497,7 +496,7 @@ export function breakdown(groups: readonly CostBucketGroup[]): BreakdownRow[] { ); } -// ───────────────────────────────────────────── 需要处理的事 +// ───────────────────────────────────────────── 上游可不可用 /** 上游现在为什么用不了。和上游页那一行上的标记同一套判断 */ export type DownWhy = "open" | "auth" | "login"; @@ -512,106 +511,3 @@ export function downWhy(p: ProviderView): DownWhy | null { if (p.health === "open") return "open"; return null; } - -/** 顶上那条「需要处理的事」里的一行 */ -export type Attention = - | { - kind: "failed"; - n: number; - /** 失败**过半**集中在一个上游时,是那个上游和它的次数 */ - top: { name: string; n: number } | null; - } - | { kind: "toolCut"; n: number } - | { kind: "upstreams"; down: { name: string; why: DownWhy }[] } - | { kind: "unpriced"; n: number }; - -/** - * 需要人去处理的事。**没有就是空的**,那一条整个不出现:一排「0 次失败」「未发现」 - * 不构成安心,只是占地方。日常的脱敏次数不在这里 —— 那是防护在正常工作,不用处理。 - * - * · 请求失败了。失败过半集中在一个上游时说出是哪个:那是最先要去看的地方。 - * · 工具调用审查切断了可疑的工具调用:客户端那边的一步没执行,要知道是哪一步。 - * · 有上游用不了。 - * · 有请求无法计价:模型未定价,费用没算进去,补一个价就好了。 - */ -export function attention(d: Dashboard, providers: readonly ProviderView[] | undefined): Attention[] { - const s = d.summary; - const out: Attention[] = []; - if (s.failed > 0) out.push({ kind: "failed", n: s.failed, top: topFailure(d.buckets_by_provider, s.failed) }); - if (s.security.tool_calls_cut > 0) out.push({ kind: "toolCut", n: s.security.tool_calls_cut }); - const down = (providers ?? []).flatMap((p) => { - const why = downWhy(p); - return why ? [{ name: p.name, why }] : []; - }); - if (down.length > 0) out.push({ kind: "upstreams", down }); - if (s.unpriced_requests > 0) out.push({ kind: "unpriced", n: s.unpriced_requests }); - return out; -} - -/** - * 一条「需要处理的事」此刻说的是什么(`@/ignore` 的印记),和记下的印记盖不盖得住它。 - * - * · 失败、无法计价:**最近一次落在哪个格子、那格有几条**。拿总数做印记的话,旧的滚出 - * 区间、数变少了也算「变了」,忽略过的又冒出来;拿最近的那一格,旧的滚走不动它,新来 - * 一条才会往后挪或变多。取不到格子时退回总数。 - * · 切断的工具调用:格子里没有它,只能拿总数。 - * · 上游:哪几家、为什么。多一家、换了原因才算新情况;少一家不算。 - */ -export function attentionMark(item: Attention, buckets: readonly CostBucket[] | null | undefined): Mark { - switch (item.kind) { - case "failed": - return latest(buckets, (b) => b.failed) ?? item.n; - case "unpriced": - return latest(buckets, (b) => b.unpriced_requests) ?? item.n; - case "toolCut": - return item.n; - case "upstreams": - return item.down.map((x) => `${x.name}:${x.why}`).sort(); - } -} - -export function attentionCovered(seen: Mark, current: Mark): boolean { - if (typeof seen === "number" && typeof current === "number") return noMore(seen, current); - if (Array.isArray(seen) && Array.isArray(current)) { - return noNew(seen.filter(isString), current.filter(isString)); - } - if (isLatest(seen) && isLatest(current)) { - return current.at < seen.at || (current.at === seen.at && current.n <= seen.n); - } - // 两种印记对不上(一次取到格子、一次没有):当作没盖住,宁可多提醒一次 - return false; -} - -const isString = (x: unknown): x is string => typeof x === "string"; -const isLatest = (x: Mark): x is { at: number; n: number } => - x !== null && typeof x === "object" && !Array.isArray(x) && typeof x.at === "number" && typeof x.n === "number"; - -/** 最后一个 `of` 不为零的格子:起点和那一格的数 */ -function latest( - buckets: readonly CostBucket[] | null | undefined, - of: (b: CostBucket) => number, -): { at: number; n: number } | null { - if (!buckets) return null; - let hit: { at: number; n: number } | null = null; - for (const b of buckets) { - const n = of(b); - if (n > 0 && (!hit || b.at_ms > hit.at)) hit = { at: b.at_ms, n }; - } - return hit; -} - -/** - * 失败最多的那个上游,**只在它占了一半以上时**才算:十次失败散在五个上游里,点名其中 - * 一个只会把人带偏。没到上游的失败(规则拒绝、没有可用的上游,名字是空串)不归给谁。 - */ -export function topFailure( - groups: readonly CostBucketGroup[] | null | undefined, - total: number, -): { name: string; n: number } | null { - if (!groups || total <= 0) return null; - const by = new Map(); - for (const g of groups) if (g.name && g.failed > 0) by.set(g.name, (by.get(g.name) ?? 0) + g.failed); - let top: { name: string; n: number } | null = null; - for (const [name, n] of by) if (!top || n > top.n || (n === top.n && name < top.name)) top = { name, n }; - return top && top.n * 2 > total ? top : null; -} diff --git a/src/overview/useHold.test.ts b/src/overview/useHold.test.ts new file mode 100644 index 00000000..e6689c33 --- /dev/null +++ b/src/overview/useHold.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { holdTarget } from "./useHold"; + +/** 页面滚动层 682 高;热力图换区间前在视口 300 处 */ +const view = { clientHeight: 682 }; + +describe("点热力图之后热力图留在原处", () => { + it("上面那几块变矮了:往回滚同样的距离", () => { + // 上面的卡片矮了一截,热力图往上挪了 110 + expect(holdTarget({ ...view, scrollTop: 759, scrollHeight: 1458, top: 190 }, 300)).toEqual({ scrollTop: 649, short: 0 }); + }); + + it("上面那几块变高了:往下滚", () => { + expect(holdTarget({ ...view, scrollTop: 400, scrollHeight: 1600, top: 380 }, 300)).toEqual({ scrollTop: 480, short: 0 }); + }); + + it("页面短了、滚不到那么下面:差的那一截要在页尾垫出来", () => { + // 想滚到 759,最多只能到 1194 - 682 = 512 + expect(holdTarget({ ...view, scrollTop: 512, scrollHeight: 1194, top: 547 }, 300)).toEqual({ scrollTop: 759, short: 247 }); + }); + + it("没动:原地", () => { + expect(holdTarget({ ...view, scrollTop: 300, scrollHeight: 1400, top: 300 }, 300)).toEqual({ scrollTop: 300, short: 0 }); + }); +}); diff --git a/src/overview/useHold.ts b/src/overview/useHold.ts new file mode 100644 index 00000000..e33c4f1f --- /dev/null +++ b/src/overview/useHold.ts @@ -0,0 +1,59 @@ +import { useCallback, useLayoutEffect, useRef, useState } from "react"; + +/** 往上找滚动的那一层(页面自己的滚动层,见 shell/Pages.tsx) */ +function scrollParent(el: HTMLElement): HTMLElement | null { + for (let p = el.parentElement; p; p = p.parentElement) { + const o = getComputedStyle(p).overflowY; + if (o === "auto" || o === "scroll") return p; + } + return null; +} + +/** + * 换区间之后滚动位置补多少:让 `el` 回到它原来在屏幕上的高度(`was`,视口坐标)。内容变短、 + * 滚不到那么下面时,差的那一截(`short`)要在页尾临时补出来。 + */ +export function holdTarget( + m: { scrollTop: number; scrollHeight: number; clientHeight: number; top: number }, + was: number, +): { scrollTop: number; short: number } { + const want = m.scrollTop + (m.top - was); + const max = m.scrollHeight - m.clientHeight; + return { scrollTop: Math.max(0, want), short: Math.max(0, Math.ceil(want - max)) }; +} + +/** + * 在热力图上点了一天:时间范围换成那一天,**热力图留在指针底下。** + * + * 换了区间,上面几块的高度可能会变(去向、额度两张卡片按行数撑高),下面的明细表也可能 + * 短一截。新数据画出来之后按热力图移动的距离把滚动位置补回去;页面短了、补不回 + * 时在页尾临时垫一段空白,到下一次换区间为止。 + */ +export function useHold(shownId: string | undefined) { + const hold = useRef<{ id: string; el: HTMLElement; was: number } | null>(null); + const [pad, setPad] = useState<{ id: string; px: number } | null>(null); + const start = useCallback((id: string, el: HTMLElement) => { + hold.current = { id, el, was: el.getBoundingClientRect().top }; + }, []); + useLayoutEffect(() => { + const h = hold.current; + if (!h || h.id !== shownId || !h.el.isConnected) return; + const sc = scrollParent(h.el); + if (!sc) { + hold.current = null; + return; + } + const at = holdTarget( + { scrollTop: sc.scrollTop, scrollHeight: sc.scrollHeight, clientHeight: sc.clientHeight, top: h.el.getBoundingClientRect().top }, + h.was, + ); + if (at.short > 0) { + // 先把空白垫上,下一次画完再补滚动位置 + setPad((p) => ({ id: h.id, px: (p && p.id === h.id ? p.px : 0) + at.short })); + return; + } + sc.scrollTop = at.scrollTop; + hold.current = null; + }); + return { start, pad: pad && pad.id === shownId ? pad.px : 0 }; +} diff --git a/src/shell/Workspace.tsx b/src/shell/Workspace.tsx index 92555bb0..0d0ad108 100644 --- a/src/shell/Workspace.tsx +++ b/src/shell/Workspace.tsx @@ -231,8 +231,10 @@ export default memo(function Workspace({ return; } if (!(SURFACES as readonly string[]).includes(page ?? "")) return; + // `requests:failed`:流量页只看失败的那一批(「请求失败」那条提醒) + if (page === "requests" && id === "failed") openRef.current("requests", { grouped: false, filter: { failedOnly: true } }); // `requests:42`:打开流量页并展开那一条(菜单栏里点了一个进行中的请求) - if (page === "requests" && id) openRef.current("requests", { request: Number(id) }); + else if (page === "requests" && id) openRef.current("requests", { request: Number(id) }); // `settings:listen`:设置页并滚到那一节(「监听设置未生效」) else if (page === "settings" && id) openRef.current("settings", { section: id }); else openRef.current(page as Surface); diff --git a/src/ui/README.md b/src/ui/README.md index a273b386..66b7815a 100644 --- a/src/ui/README.md +++ b/src/ui/README.md @@ -102,6 +102,12 @@ export default function KeysPage() { is 4px below it. - Toolbar-level page actions (Config file / Backups) are the shell's; pages do not repeat them. +- **Cards** (Overview's blocks): `rounded-[10px] border border-border bg-panel`, 16px sides + (`px-4`), 12px top. The first row is 28px (`h-7`): title (`tw-head`, muted) first, then the + block's controls, then its scope or link at the right end. Every block on a page uses the + same inset, so the content of all blocks starts on one vertical line. Rows with a hover + background extend it 10px past that line on each side (the list gets `-mx-2.5`, each row + `px-2.5`), so their icons stay on the line. A card's border never changes on hover. ### Spacing scale @@ -120,12 +126,13 @@ Table rows are 36px (`h-9`), toolbar controls 28px, form fields 32px. ### Type scale -Five levels, named by purpose. Never use `text-xs`/`text-sm`/`text-[12px]` outside +Six levels, named by purpose. Never use `text-xs`/`text-sm`/`text-[12px]` outside `src/ui` (`copy.test.ts` enforces it). | Class | Size | Use | | --- | --- | --- | | `tw-display` | 26px/600 Geist | The big figures on the Overview metric cards. Nowhere else. Geist is bundled (`@fontsource/geist`, latin 600), never fetched at runtime. | +| `tw-figure` | 20px/600 Geist | Secondary big figures under the metric cards (Overview's past-year totals). | | `tw-title` | 15px/600 | Dialog title, standalone-window title. One per screen. | | `tw-head` | 13px/500 | Page name in the toolbar, section titles, table headers, emphasised labels. | | `tw-body` | 13px | Body text, table cells. | @@ -230,16 +237,21 @@ import { Sparkline, sparklineWidth } from "@/ui/sparkline"; for its skeleton); with `tip` each slot is a 4px hover cell. Keep `bars` and `tip` referentially stable where the parent re-renders often (it is memoised). - **Overview usage chart** (`src/overview/HeroChart.tsx`, page-specific): one large chart for - the metric picked in the usage card's tiles. Left axis with 1 / 2 / 2.5 / 5 x 10^n ticks - (`HeroAxis.ts`), hairline grid, date or time labels at slot boundaries. Quantities are bars - (at most 16px wide, 4px rounded top, two stacked parts with a 2px gap: failures in - `--data-fail`, download in `--data-2`); the hit rate and first-token P50 / P95 are 2px lines - (`null` = no samples, bridged by a faint dashed connector, never a made-up value). The slot - still in progress is drawn faded. Hovering or keyboard focus shows one tooltip per slot - (value first, line keys) and gives the card the brand border. -- **Past-year heatmap** (`src/overview/Year.tsx`): one cell per day, `--chart-5` to `--chart-1` - by quartile of the days with usage, `--surface` for none, dashed `--input` border for days - before recording started; the selected range is outlined. + the metric picked in the usage card's tiles. Left axis with 1 / 2 / 2.5 / 5 x 10^n steps, + four or five of them, whichever tops out lower (`niceTicks` in `HeroAxis.ts`), hairline + grid, date or time labels at slot boundaries. **No bars.** Every series is a 2px monotone + line; quantities get a wash under the line (`--data-1`), and a second part is a thin line + without a wash (failures in `--data-fail`, download in `--data-2`); the hit rate and + first-token P50 / P95 are plain lines (`null` = no samples, bridged by a faint dashed + connector, never a made-up value). The slot still in progress joins with a dashed segment. + Slots older than the first request record (`UsageHistory.detail_since_ms`) get a faint + band labelled "not recorded" and no line: they are unknown, not zero. Hovering or keyboard + focus shows a guide line, a dot per line and one tooltip per slot (value first; only rows + that match a line carry a key). +- **Past-year heatmap** (`src/overview/Year.tsx`): one cell per day, `--data-1` mixed into + `--surface` at 16 / 32 / 52 / 78% by quartile of the days with usage (never brighter than + `--data-1` itself), `--surface` for none, `--surface` at 55% for days before recording + started; the selected range is outlined. - Hand-roll a bar or a chart only when none of these fits, and then follow the same rules. --- diff --git a/src/ui/range.tsx b/src/ui/range.tsx index 775306b3..a87eff67 100644 --- a/src/ui/range.tsx +++ b/src/ui/range.tsx @@ -195,8 +195,8 @@ export const LIVE_RANGE: Range = { * 的跨度也是按「到现在」算出来的,存下来隔一阵就不对了。所以只存一个 * 标识,回来时重新构造。 * - * 出厂停在实时档 —— 第一次打开时,「现在有没有在跑」比「过去一天用了 - * 多少」更是个问题。 + * 出厂停在 24 小时。概览一页的数大多按所选区间算,实时档里六个大数一半是十分钟、一半是 + * 24 小时,加上额度(此刻)和最近一年,一屏四种口径;「现在有没有在跑」页头那一行一直写着。 * * `key` 让每一页各记各的:概览看的是「今天花了多少」,流量翻的是 * 「上周二那阵子」—— 把两者绑在一起,去流量里查一次就会把概览也拨走。 @@ -204,12 +204,10 @@ export const LIVE_RANGE: Range = { export function useRange( key = "tw-range", /** - * 没存过的时候停在哪一档。 - * - * **概览停实时,流量不行** —— 实时只有十分钟,一张请求表开局只剩十分钟 - * 的内容,看起来像什么都没有。翻历史的页面该从一天起步。 + * 没存过的时候停在哪一档。实时只有十分钟,一张表、一页汇总开局只剩十分钟的内容, + * 看起来像什么都没有 —— 都从一天起步。 */ - fallback: "live" | "1d" | "7d" | "30d" = "live", + fallback: "live" | "1d" | "7d" | "30d" = "1d", ): [Range, (r: Range) => void] { const [range, set] = useState(() => { try {