diff --git a/scripts/shots/mock/commands.ts b/scripts/shots/mock/commands.ts index f5527ad6..cd58a7d0 100644 --- a/scripts/shots/mock/commands.ts +++ b/scripts/shots/mock/commands.ts @@ -118,7 +118,8 @@ const COMMANDS: Table = { dashboard: (a) => { const { sinceMs, bucketMs } = need("dashboard", a, "sinceMs", "bucketMs"); - return dashboard(sinceMs, bucketMs); + // 订阅额度是此刻的状态,和上游页同一份(dashboard.rs 同样问 `/quota`) + return { ...dashboard(sinceMs, bucketMs), quotas: [{ provider: "chatgpt", windows: CHATGPT_WINDOWS() }] }; }, upstream_stats: (a) => { const { sinceMs, bucketMs, egressSinceMs } = need("upstream_stats", a, "sinceMs", "bucketMs", "egressSinceMs"); diff --git a/scripts/shots/mock/core.ts b/scripts/shots/mock/core.ts index b288d91f..54f9158f 100644 --- a/scripts/shots/mock/core.ts +++ b/scripts/shots/mock/core.ts @@ -35,6 +35,7 @@ import { unpricedModels, upstreamHealth, } from "./traffic"; +import { usageHistory } from "./history"; import { DAY, HOUR, NOW, clone, msg } from "./util"; type Handler = (req: Endpoints[N]["req"], params: string[]) => Endpoints[N]["res"]; @@ -185,6 +186,8 @@ export const CORE: { [N in WebviewEndpoint]: Handler } = { // 没给时间窗就是最近 7 天(`UpstreamHealth` 的默认) UpstreamHealth: (req) => upstreamHealth(req.from_ms ?? NOW - 7 * DAY, req.to_ms ?? Date.now()), RouteStats: (req) => routeStats(req.from_ms ?? new Date(NOW).setHours(0, 0, 0, 0), req.to_ms ?? Date.now()), + // 概览「最近一年」:一年左右的逐日用量,记录从十个月前开始 + UsageHistory: (req) => usageHistory(req), Pricing: () => pricing(unpricedModels()), RefreshPricing: refuse, diff --git a/scripts/shots/mock/history.ts b/scripts/shots/mock/history.ts new file mode 100644 index 00000000..f03ac4bd --- /dev/null +++ b/scripts/shots/mock/history.ts @@ -0,0 +1,65 @@ +// 用量历史(`GET /summary/history`):概览「最近一年」那张热力图。 +// +// 一年左右的逐日用量:工作日多、周末少、偶尔几天没用,越近用得越多;费用有一小部分是估算的, +// 偶尔几条无法计价。记录从十个月前开始。格子按 core 的分法排:`from + k × bucket`,稀疏。 +import type { BucketQuery, CostBucket, UsageHistory } from "@/types"; +import { DAY, NOW, rng } from "./util"; + +/** 记录从哪一刻起:十个月前 */ +const SINCE = NOW - 304 * DAY; +/** 逐条记录(请求库)只有最近 30 天:更早的日子在热力图上点不开 */ +const DETAIL_SINCE = () => NOW - 30 * DAY; + +export function usageHistory(req: BucketQuery, sinceMs: number = SINCE): UsageHistory { + const now = Date.now(); + const bucket = req.bucket_ms ?? DAY; + const from = req.from_ms ?? now - 365 * DAY; + const to = Math.min(req.to_ms ?? now, now); + const r = rng(20261028); + const buckets: CostBucket[] = []; + for (let k = 0; from + k * bucket < to; k++) { + const at = from + k * bucket; + const rolls = [r(), r(), r(), r()] as const; + // 记录之前没有数据;格子按 `at + 12h` 那一刻对本地日期,这里同样按它判断工作日 + if (at + bucket <= sinceMs) continue; + const mid = new Date(at + bucket / 2); + const weekend = mid.getDay() === 0 || mid.getDay() === 6; + if (rolls[0] < (weekend ? 0.35 : 0.05)) continue; + const age = (now - at) / DAY; + const grow = 0.35 + 0.65 * Math.pow(Math.max(0, 1 - age / 365), 1.5); + const spike = rolls[3] < 0.04 ? 2.4 : 1; + // 今天只过了一部分 + const part = Math.min(1, (to - at) / bucket); + const tokens = Math.round(2.6e6 * grow * (weekend ? 0.35 : 1) * spike * (0.45 + rolls[1] * 1.1) * part); + if (tokens <= 0) continue; + const output = Math.round(tokens * (0.05 + rolls[1] * 0.04)); + const cacheRead = Math.round(tokens * (0.52 + rolls[0] * 0.14)); + const cacheWrite = Math.round(tokens * 0.04); + const input = tokens - output - cacheRead - cacheWrite; + const requests = Math.max(1, Math.round(tokens / (21_000 + rolls[2] * 14_000))); + const cost = Math.round(tokens * (1.25 + rolls[2] * 0.5)); + // 大约每七天有一天含估算(那一天费用的一成多),每二十天有一天几条无法计价 + const estimated = rolls[2] < 0.15 ? Math.round(cost * 0.12) : 0; + const unpriced = rolls[3] > 0.95 ? 1 + Math.floor(rolls[1] * 3) : 0; + buckets.push({ + at_ms: at, + requests, + failed: Math.floor(requests * 0.02 * rolls[0]), + cost_micros_exact: cost - estimated, + cost_micros_estimated: estimated, + unpriced_requests: unpriced, + no_usage_requests: 0, + sent_bytes: Math.round(tokens * 3.6), + received_bytes: Math.round(output * 5.6), + input_tokens: input, + output_tokens: output, + cache_read_tokens: cacheRead, + cache_write_tokens: cacheWrite, + // 用量历史不记首 token + ttft_p50_ms: null, + ttft_p95_ms: null, + ttft_samples: 0, + }); + } + return { since_ms: sinceMs, detail_since_ms: DETAIL_SINCE(), buckets }; +} diff --git a/scripts/shots/mock/traffic.ts b/scripts/shots/mock/traffic.ts index 9ba0b582..ee2d13b1 100644 --- a/scripts/shots/mock/traffic.ts +++ b/scripts/shots/mock/traffic.ts @@ -754,7 +754,8 @@ function tokenRate(rows: HistoryRow[], key: (h: HistoryRow) => string): TokenRat }); } -export function dashboard(sinceMs: number, bucketMs: number): Dashboard { +/** 订阅额度不在这里:`commands.ts` 和上游页那份一起给 */ +export function dashboard(sinceMs: number, bucketMs: number): Omit { const rows = rowsBetween(sinceMs).filter((h) => !h.local); const buckets = new Map(); const byModel = new Map(); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5f8a5d18..e666f473 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5383,8 +5383,8 @@ dependencies = [ [[package]] name = "tw-api" -version = "0.83.2" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.83.2#350173912af43ffca3de9978698b4ec16371af64" +version = "0.84.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.84.0#dcde84ed1da155303574ad430c0cafade50e3c8b" dependencies = [ "serde", "serde_json", @@ -5395,8 +5395,8 @@ dependencies = [ [[package]] name = "tw-dialect" -version = "0.83.2" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.83.2#350173912af43ffca3de9978698b4ec16371af64" +version = "0.84.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.84.0#dcde84ed1da155303574ad430c0cafade50e3c8b" dependencies = [ "memchr", "serde", @@ -5405,8 +5405,8 @@ dependencies = [ [[package]] name = "tw-guard" -version = "0.83.2" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.83.2#350173912af43ffca3de9978698b4ec16371af64" +version = "0.84.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.84.0#dcde84ed1da155303574ad430c0cafade50e3c8b" dependencies = [ "aho-corasick", "base64 0.22.1", @@ -5424,8 +5424,8 @@ dependencies = [ [[package]] name = "tw-link" -version = "0.83.2" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.83.2#350173912af43ffca3de9978698b4ec16371af64" +version = "0.84.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.84.0#dcde84ed1da155303574ad430c0cafade50e3c8b" dependencies = [ "serde", "serde_json", @@ -5451,8 +5451,8 @@ dependencies = [ [[package]] name = "tw-types" -version = "0.83.2" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.83.2#350173912af43ffca3de9978698b4ec16371af64" +version = "0.84.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.84.0#dcde84ed1da155303574ad430c0cafade50e3c8b" dependencies = [ "serde", "ts-rs", @@ -5460,8 +5460,8 @@ dependencies = [ [[package]] name = "tw-watch" -version = "0.83.2" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.83.2#350173912af43ffca3de9978698b4ec16371af64" +version = "0.84.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.84.0#dcde84ed1da155303574ad430c0cafade50e3c8b" dependencies = [ "libc", "notify", @@ -5471,8 +5471,8 @@ dependencies = [ [[package]] name = "tw-yaml" -version = "0.83.2" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.83.2#350173912af43ffca3de9978698b4ec16371af64" +version = "0.84.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.84.0#dcde84ed1da155303574ad430c0cafade50e3c8b" dependencies = [ "saphyr-parser", "thiserror 2.0.21", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2ca58be1..3be72e86 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,12 +26,12 @@ license = "MIT" # twcore 二进制必须和这里编译进去的协议镜像来自同一个 core 版本 —— 打包脚本正是 # 从 tw-api 锁到的 tag 去取二进制的(见 scripts/fetch-core.sh)。 [workspace.dependencies] -tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.83.2" } -tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.83.2" } -tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.83.2" } -tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.83.2" } -tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.83.2" } -tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.83.2" } +tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.84.0" } +tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.84.0" } +tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.84.0" } +tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.84.0" } +tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.84.0" } +tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.84.0" } [lib] name = "thinkwatch_lite_lib" diff --git a/src-tauri/src/call.rs b/src-tauri/src/call.rs index c319dcaa..95e30745 100644 --- a/src-tauri/src/call.rs +++ b/src-tauri/src/call.rs @@ -131,6 +131,8 @@ webview_endpoints![ AliasUsage, // 各条路由、各条规则命中了多少:路由图按它给线加权、标出从没命中过的规则 RouteStats, + // 用量历史:概览上「最近一年」的热力图,一天一格 + UsageHistory, // 上游体检:各家上游的事实和样本数,界面按它写成自己的话 UpstreamHealth, // 价目表 diff --git a/src-tauri/src/dashboard.rs b/src-tauri/src/dashboard.rs index 2407f8d2..90901cec 100644 --- a/src-tauri/src/dashboard.rs +++ b/src-tauri/src/dashboard.rs @@ -28,6 +28,9 @@ pub async fn dashboard( // **没有兜底。**这一层替界面挑一个时间窗(原来是「最近 24 小时」),就又有一处 // 不按本地时钟定的窗口;缺参数是界面的错,该在那边报出来。 since_ms: i64, + // 时间窗的终点(不含)。**只有「某一天」那种区间给**(界面的 `dayRange`):别的区间 + // 都到现在为止。终点还在将来(点的是今天)时按到现在算 + until_ms: Option, bucket_ms: i64, ) -> Out { let now = std::time::SystemTime::now() @@ -35,24 +38,27 @@ pub async fn dashboard( .map(|d| d.as_millis() as i64) .unwrap_or(0); let since = since_ms.min(now); + let until = until_ms.filter(|&u| u > since && u < now); + // 这一段有多长:上一个等长区间从它往前量 + let end = until.unwrap_or(now); let bucket = bucket_ms.max(60_000); - // **十四样同时问,不排队。**每一问都是一条新连接、一次握手:排着队问,连着远程时 - // 十四问的来回一个接一个叠起来,而这一页是默认打开的那一页、每个请求结束都要重取。 + // **十五样同时问,不排队。**每一问都是一条新连接、一次握手:排着队问,连着远程时 + // 十五问的来回一个接一个叠起来,而这一页是默认打开的那一页、每个请求结束都要重取。 // 钥匙只读一次(`pin`) let c = state.control.pin().map_err(text)?; // 延迟、生成速度和汇总**同一个时间窗**。不给窗口的话 core 按「今天零点至今」算, // 而界面上它们和上面的数字摆在一起,读的人会当成同一段时间 - let win = window(Some(since), None); + let win = window(Some(since), until); let buckets = tw_api::BucketQuery { from_ms: Some(since), - to_ms: None, + to_ms: until, bucket_ms: Some(bucket), }; // 同样的格子按三个维度分:模型(趋势图的 token 合计、明细表的「模型」)、上游和 // 密钥(明细表的另两页)。**格宽和模型那份一样**,三份的格子对得上 let by = |dim| tw_api::BucketGroupQuery { from_ms: Some(since), - to_ms: None, + to_ms: until, bucket_ms: Some(bucket), dim, }; @@ -63,18 +69,18 @@ pub async fn dashboard( ); // **上一个等长区间。**一个没有参照系的金额只能读,不能判断 // ——「$4.05」是多还是少,只有和上一个七天比过才知道 - let before = window(Some(since - (now - since)), Some(since)); + let before = window(Some(since - (end - since)), Some(since)); // **整段时间的首 token 分位**:问一个宽过整个时间窗的格子,整段落在第 0 格。分位数不能 // 从每一格(或每个模型)的分位合出来 —— 中位数不能这么合 —— 只能让 core 拿整段的样本 - // 求。终点和汇总一样不给(到 core 的「现在」),两边数的是同一批请求 + // 求。终点和汇总一样(某一天到那天末尾,别的到 core 的「现在」),两边数的是同一批请求 let whole = |from_ms, to_ms| tw_api::BucketQuery { from_ms: Some(from_ms), to_ms, bucket_ms: Some(WHOLE_MS), }; let (whole_now, whole_before) = ( - whole(since, None), - whole(since - (now - since), Some(since)), + whole(since, until), + whole(since - (end - since), Some(since)), ); let ( summary, @@ -91,6 +97,7 @@ pub async fn dashboard( prev, ttft, prev_ttft, + quotas, ) = tokio::join!( c.call::(&[], &win), c.call::(&[], &win), @@ -106,6 +113,7 @@ pub async fn dashboard( c.call::(&[], &before), c.call::(&[], &whole_now), c.call::(&[], &whole_before), + c.call::(&[], &()), ); Ok(Dashboard { summary: summary.map_err(text)?, @@ -126,6 +134,8 @@ pub async fn dashboard( prev: prev.ok(), ttft: ttft.ok().map(|b| Percentiles::of(&b)), prev_ttft: prev_ttft.ok().map(|b| Percentiles::of(&b)), + // 此刻的订阅额度,不跟时间窗走。拿不到同样是 `None`:那一块不画,不是「没有额度」 + quotas: quotas.ok(), since_ms: since, }) } @@ -163,6 +173,9 @@ pub struct Dashboard { ttft: Option, /// 上一个等长区间的,首 token 那张卡片的环比用 prev_ttft: Option, + /// 订阅的额度窗口(`GET /quota`),每个上游一份:概览「额度与余额」那一块。**此刻的 + /// 状态**,不按时间窗取。拿不到是 `None` + quotas: Option>, /// 实际用上的时间窗起点。**原样回传** —— 界面补空桶要从它数起,而界面送来的 /// 起点在未来时(时钟被往回拨过)这里截到了现在 since_ms: i64, diff --git a/src/control.ts b/src/control.ts index 21baaf6c..495de758 100644 --- a/src/control.ts +++ b/src/control.ts @@ -88,6 +88,7 @@ export const WEBVIEW_ENDPOINTS = [ "PreviewAlias", "AliasUsage", "RouteStats", + "UsageHistory", "UpstreamHealth", "Pricing", "RefreshPricing", diff --git a/src/generated/tw-api.ts b/src/generated/tw-api.ts index 4aa74cd2..5d821901 100644 --- a/src/generated/tw-api.ts +++ b/src/generated/tw-api.ts @@ -1,6 +1,6 @@ // Generated by tw-api (`tw_api::ts::export_all`). Do not edit by hand. -export const CONTROL_API_VERSION = 54; +export const CONTROL_API_VERSION = 55; /** * 手动中止(`POST /request/{id}/abort`、`POST /sessions/{id}/abort`)叫停了哪几个请求。 @@ -6257,6 +6257,38 @@ covered_since_ms: number | null, */ upstreams: Array, }; +/** + * 很长一段时间里的用量,按格合计(`GET /summary/history`)。概览上「最近一年」那张热力图用。 + * + * **不从请求记录里数**:请求记录只留保留期那么多天,请求库换版本时还会整个重建。用量另按 + * UTC 整点累加存着,这两样都不丢;口径和 [`Summary`] 一样(本地应答不算,费用三态分开)。 + * 一个请求在它结束时加进它开始的那个整点,所以还在跑的不在里面。 + */ +export type UsageHistory = { +/** + * 从什么时候起有记录,Unix 毫秒:这一刻之前的格子是「没有记录」,不是「没有用量」。 + * 开始记用量历史时请求记录里已有的行补进来了,所以是那时最老那一行的时刻;那时一行都没有 + * 就是开始记的那一刻。从没记过是 None。 + * + * **说的是用量历史,不是逐条记录**:它定下之后不再变,请求记录过了保留期被删、请求库重建都 + * 不动它。逐条记录从哪一刻起有看 [`UsageHistory::detail_since_ms`] + */ +since_ms: number | null, +/** + * 逐条记录从哪一刻起有,Unix 毫秒:请求记录里最老那一行开始的时刻(本地应答也算,口径同 + * [`RouteStats::covered_since_ms`])。请求记录是空的是 None。 + * + * **概览、流量页按逐条记录算**:这一刻之前的日子在用量历史里有数,在那些页上却是零 —— + * 记录过了保留期被删了、请求库重建过。它随保留期往后挪 + */ +detail_since_ms: number | null, +/** + * 按给的格宽分的格子,**稀疏**(没有用量的格子不在里面),分格算法同 `/summary/buckets` + * (格子的起点是 `from + k × bucket`)。累加的单位是整点:起点不在整点上时,一个整点整个 + * 落在它开始的那一格。首 token 的三项不记(None、None、0) + */ +buckets: Array, }; + /** * 一次调用的用量。 */ @@ -6385,6 +6417,7 @@ export const ENDPOINTS = { CostBuckets: { method: "GET", path: "/summary/buckets", params: [], format: "json" }, CostBucketsBy: { method: "GET", path: "/summary/buckets/by", params: [], format: "json" }, CostBy: { method: "GET", path: "/summary/by", params: [], format: "json" }, + UsageHistory: { method: "GET", path: "/summary/history", params: [], format: "json" }, RouteStats: { method: "GET", path: "/summary/routes", params: [], format: "json" }, History: { method: "GET", path: "/history", params: [], format: "json" }, HistorySearch: { method: "POST", path: "/history/search", params: [], format: "json" }, @@ -6537,6 +6570,7 @@ export type Endpoints = { CostBuckets: { req: BucketQuery; res: Array }; CostBucketsBy: { req: BucketGroupQuery; res: Array }; CostBy: { req: GroupQuery; res: Array }; + UsageHistory: { req: BucketQuery; res: UsageHistory }; RouteStats: { req: Window; res: RouteStats }; History: { req: ListQuery; res: Array }; HistorySearch: { req: HistorySearchQuery; res: HistorySearchPage }; diff --git a/src/logos.test.ts b/src/logos.test.ts index 8115995b..8ef07d98 100644 --- a/src/logos.test.ts +++ b/src/logos.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; -import { clientGlyph, upstreamGlyph } from "./ui/logos"; +import { clientGlyph, modelGlyph, upstreamGlyph } from "./ui/logos"; import { GLYPHS } from "./ui/logo-data"; describe("上游和客户端的标志", () => { @@ -51,3 +51,39 @@ describe("上游和客户端的标志", () => { for (const g of Object.values(GLYPHS)) expect(g.paths.length).toBeGreaterThan(0); }); }); + +describe("模型的标志", () => { + it("按模型名认出是哪一家", () => { + expect(modelGlyph("claude-sonnet-5")).toBe("claude"); + expect(modelGlyph("gpt-5.5-codex")).toBe("openai"); + expect(modelGlyph("o3")).toBe("openai"); + expect(modelGlyph("o4-mini")).toBe("openai"); + expect(modelGlyph("gemini-3-pro")).toBe("gemini"); + expect(modelGlyph("glm-4.6")).toBe("zhipu"); + expect(modelGlyph("deepseek-chat")).toBe("deepseek"); + expect(modelGlyph("kimi-k2-turbo")).toBe("kimi"); + expect(modelGlyph("moonshot-v1-8k")).toBe("moonshot"); + expect(modelGlyph("qwen3-coder:30b")).toBe("qwen"); + expect(modelGlyph("grok-4")).toBe("xai"); + expect(modelGlyph("codestral-latest")).toBe("mistral"); + expect(modelGlyph("devstral-small")).toBe("mistral"); + }); + + it("带厂商前缀、Bedrock 的区域前缀也认得", () => { + expect(modelGlyph("anthropic/claude-sonnet-5")).toBe("claude"); + expect(modelGlyph("us.anthropic.claude-haiku-4-5-20251001-v1:0")).toBe("claude"); + expect(modelGlyph("moonshotai/kimi-k2")).toBe("kimi"); + expect(modelGlyph("qwen/qwen3-coder")).toBe("qwen"); + expect(modelGlyph("openai/gpt-oss-120b")).toBe("openai"); + expect(modelGlyph("openai/o3")).toBe("openai"); + }); + + it("认不出来是 null:画首字母方块,不硬套一家", () => { + expect(modelGlyph("llama-3.3-70b")).toBeNull(); + expect(modelGlyph("my-finetune")).toBeNull(); + // 名字里碰巧带个 o 加数字的不算 OpenAI + expect(modelGlyph("solo3")).toBeNull(); + expect(modelGlyph("")).toBeNull(); + expect(modelGlyph(null)).toBeNull(); + }); +}); diff --git a/src/overview/Breakdown.tsx b/src/overview/Breakdown.tsx index 81fa5cb5..45f8f8ab 100644 --- a/src/overview/Breakdown.tsx +++ b/src/overview/Breakdown.tsx @@ -1,21 +1,45 @@ import { useState } from "react"; -import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"; +import { ChevronDownIcon, ChevronRightIcon, KeyRoundIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { Segmented } from "@/ui/segmented"; import { Tip } from "@/ui/tip"; +import { ClientLogo, ModelLogo, UpstreamLogo, clientGlyph } from "@/ui/logos"; import { rangeText } from "@/ui/range.i18n"; import type { Range } from "@/ui/range"; import { useNav } from "@/nav"; import type { Filter } from "@/requestTable"; import { compact, msShort } from "@/format"; -import { usd, type CostBucketGroup, type Dashboard, type LatencyView, type TokenRateView } from "@/types"; +import { usd, type CostBucketGroup, type Dashboard, type LatencyView, type Overview, type TokenRateView } from "@/types"; import { useText } from "@/i18n"; import { breakdown, rankCost, ROWS, type BreakdownRow } from "./series"; import { LinkText } from "./parts"; import { overviewText } from "./overview.i18n"; -/** 明细表按什么分 */ -type Dim = "model" | "provider" | "client"; +/** 明细表(和「去向」)按什么分 */ +export type Dim = "model" | "provider" | "client"; + +/** + * 一项前面的标志:模型按出它的那一家(`modelGlyph`),上游按地址(`UpstreamLogo`,地址从 + * `ov` 里找;没有 `ov` 时只按名字认),密钥按它是为哪个客户端生成的(`ClientView.client`; + * 名字本身就是客户端 id 的也认),对不上客户端的画一把钥匙。认不出来的画首字母方块。 + * + * 没有名字的那一项(未知模型、未到上游、未知密钥)不画标志,只留出位置,名字才对得齐。 + */ +export function ItemLogo({ dim, name, ov }: { dim: Dim; name: string; ov?: Overview | null }) { + const cls = "text-muted-foreground"; + if (!name) return ; + if (dim === "model") return ; + if (dim === "provider") { + const p = ov?.providers.find((x) => x.name === name); + return ; + } + const client = ov?.clients.find((k) => k.name === name)?.client ?? (clientGlyph(name) ? name : null); + return client ? ( + + ) : ( + + ); +} /** 一行里名字以外那几列。**表头和每一行用同一套**,列才对得齐 */ const COLS = @@ -29,8 +53,10 @@ const COLS = * 认不出的密钥)只说不点 —— 没有东西可筛。 * * 实时档按 24 小时算:十分钟里撑不起一张有意义的表。 + * + * 名称格前面是标志(`ItemLogo`)。`ov` 给了,上游按地址认、密钥认得出是哪个客户端的。 */ -export function Breakdown({ d, range }: { d: Dashboard; range: Range }) { +export function Breakdown({ d, range, ov }: { d: Dashboard; range: Range; ov?: Overview | null }) { const t = useText(overviewText); const rt = useText(rangeText); const nav = useNav(); @@ -107,6 +133,7 @@ export function Breakdown({ d, range }: { d: Dashboard; range: Range }) { key={`row:${r.name}`} r={r} label={r.name || unnamed} + logo={} mono={dim === "model" && r.name !== ""} latency={latency.get(r.name)} speed={speed.get(r.name)} @@ -128,6 +155,7 @@ export function Breakdown({ d, range }: { d: Dashboard; range: Range }) { function Row({ r, label, + logo, mono, latency, speed, @@ -136,6 +164,7 @@ function Row({ }: { r: BreakdownRow; label: string; + logo: React.ReactNode; mono: boolean; latency?: LatencyView; speed?: TokenRateView; @@ -169,6 +198,7 @@ function Row({ )} > + {logo} {label} @@ -213,8 +243,9 @@ function Row({ /** * 费用那一格,写法见 `rankCost`。有说明的写在悬停里、金额带虚线下划线;「无法计价」是 * 琥珀色,**可以点**:落到流量页这一行里没算出费用的那一批 —— 看到它之后要做的就是去补价。 + * 「去向」那张卡片也用它(不给 `unpriced`:那一整行本来就能点)。 */ -function CostCell({ +export function CostCell({ c, amount, unpriced, diff --git a/src/overview/Cards.tsx b/src/overview/Cards.tsx deleted file mode 100644 index 89857b85..00000000 --- a/src/overview/Cards.tsx +++ /dev/null @@ -1,677 +0,0 @@ -import { useCallback, useEffect, useState, type ReactNode } from "react"; -import { ArrowDownRightIcon, ArrowRightIcon, ArrowUpRightIcon } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { AnimatedNumber } from "@/ui/motion"; -import { Tip } from "@/ui/tip"; -import type { Range } from "@/ui/range"; -import { rangeText } from "@/ui/range.i18n"; -import { useNav } from "@/nav"; -import { compact, monthDay, msShort, traffic } from "@/format"; -import { usd, type Dashboard, type Percentiles } from "@/types"; -import { useText } from "@/i18n"; -import { LIVE_REACH_MS, useLiveWindow } from "./useLive"; -import { - cacheOf, - delta, - historySeries, - liveSeries, - rankCost, - slotOf, - slotTitle, - totalCost, - totalTokens, - type Delta, - type Grid, -} from "./series"; -import { BarChart, LineChart, SPARK_H } from "./Spark"; -import { LinkText } from "./parts"; -import { overviewText } from "./overview.i18n"; - -/** 六张卡片 */ -type CardId = "tokens" | "cost" | "cache" | "requests" | "latency" | "traffic"; - -/** 联动的竖线:指着哪张卡片、哪一刻 */ -type Hover = { card: CardId; at: number } | null; - -const fmtTokens = (n: number) => compact(Math.round(n)); -const fmtCount = (n: number) => Math.round(n).toLocaleString(); -const pct = (n: number) => `${Math.round(n)}%`; - -/** - * 概览的指标卡:Token · 费用 · 缓存命中 / 请求 · 首 token · 流量,三列两行(窄窗口下两列)。 - * - * 每张卡片一个大数、一句注解、一张小图。**大数和图是同一段时间**: - * - * · 历史档(24 小时、7 天、30 天、自定义)六张都是所选的区间,和上一个等长区间比。 - * · 实时档 token、费用、请求三张是最近十分钟,跟着事件流走;十分钟里的样本撑不起有 - * 意义的命中率和分位,缓存命中、首 token、流量三张按 24 小时算,标签旁边标着。 - * - * 实时档的事件流订阅在这一层:每半秒往左挪一步时只重画卡片,页面上别的部分不跟着 - * 每一帧重画。 - */ -export function CardGrid({ - d, - range, - scope, - step, -}: { - d: Dashboard; - range: Range; - /** 数据属于哪个区间(`rangeId`):换了区间,数字直接落到新值 */ - scope: string; - /** 历史档的格宽(`bucketOf`)。实时档是 24 小时一小时一格的那一份 */ - step: number; -}) { - const t = useText(overviewText); - const rt = useText(rangeText); - const live = range.live === true; - // 多留一个核宽的样本:最左边那一格的鼓包有一半来自图外 - const win = useLiveWindow(live, range.ms + LIVE_REACH_MS); - const now = Date.now(); - const hist = historySeries(d, now, step); - const lv = live ? liveSeries(win.samples, win.ends, now, range.ms) : null; - /** token、费用、请求三张卡片的那一份:实时档是十分钟,别的档是所选区间 */ - const main = lv ?? hist; - const s = d.summary; - const p = d.prev; - const day = rt.preset["1d"]; - // 实时档里按 24 小时算的那几张卡片,标签旁边标出口径 - const dayScope = live ? day : undefined; - const period = range.compare; - - const [hover, setHover] = useState(null); - const clear = useCallback(() => setHover(null), []); - useHoverOff(hover !== null, clear); - - /** 一张图的联动:哪一格亮着、指针在它上面时交出哪一刻,提示框开没开,首尾两头写什么 */ - const sync = (card: CardId, grid: Grid): Sync => ({ - n: grid.at.length, - cursor: hover ? slotOf(grid, hover.at) : null, - onPoint: (i: number | null) => { - const at = i === null ? undefined : grid.at[i]; - setHover(at === undefined ? null : { card, at }); - }, - title: (i: number) => slotTitle(grid, i, now, t.now), - tipOn: hover?.card === card, - ends: [ - grid.kind === "instant" - ? t.liveAgo - : live - ? t.ago(day) - : range.custom - ? monthDay(d.since_ms) - : t.ago(range.label), - t.now, - ], - }); - - // ── token - const tokens = lv ? lv.totals.tokens : totalTokens(s); - const output = lv ? lv.totals.output : s.output_tokens; - // ── 费用 - const costRow = lv - ? { - cost: lv.totals.cost, - estimated: lv.totals.estimated, - unpriced: lv.totals.unpriced, - noUsage: 0, - pending: lv.totals.pending, - } - : { - cost: totalCost(s), - estimated: s.cost_micros_estimated, - unpriced: s.unpriced_requests, - noUsage: s.no_usage_requests, - }; - const cost = rankCost(costRow, t); - // ── 请求 - const requests = lv ? lv.totals.requests : s.requests; - const failed = lv ? lv.totals.failed : s.failed; - // ── 缓存:实时档也按 24 小时 - const cache = cacheOf(s); - const prevCache = p ? cacheOf(p) : null; - // ── 首 token、流量:实时档也按 24 小时(`hist`) - const ttftNow = d.ttft?.p50_ms ?? null; - - const tok = sync("tokens", main.grid); - const money = sync("cost", main.grid); - const req = sync("requests", main.grid); - - return ( -
-
- - ) - } - value={} - sub={t.inOut(fmtTokens(tokens - output), fmtTokens(output))} - ends={tok.ends} - > - {main.tokens ? ( - { - const v = main.tokens?.[i] ?? 0; - return live ? t.tokenRate(fmtTokens(v)) : fmtTokens(v); - })} - /> - ) : ( - - )} - - - - ) - } - value={} - sub={ - cost.kind === "unpriced" - ? t.unpricedCell - : cost.kind === "noUsage" - ? t.noUsageCell - : requests > 0 - ? t.avgCost(cost.prefix + usd(Math.round(costRow.cost / requests))) - : t.noRequestsShort - } - ends={money.ends} - > - {main.cost ? ( - { - const v = Math.round(main.cost?.[i] ?? 0); - return live ? t.costRate(usd(v)) : usd(v); - })} - /> - ) : ( - - )} - - - - ) - } - value={cache.hit === null ? "—" : } - sub={ - cache.ctx === 0 - ? t.noTokens - : s.cache_saved_micros < 0 - ? t.netCost(usd(-s.cache_saved_micros)) - : t.saved(usd(s.cache_saved_micros)) - } - > - - - - - ) - } - value={} - sub={} - ends={req.ends} - > - {main.requests && main.failed ? ( - t.reqTip(main.requests?.[i] ?? 0, main.failed?.[i] ?? 0))} - /> - ) : ( - - )} - - - - ) - } - scopeTag={dayScope} - /> - - - ) - } - scopeTag={dayScope} - /> -
-
- ); -} - -/** - * 指针离开的方式不止「移到旁边去」一种,而只有那一种会送来 mouseleave:切走应用、截图 - * 工具接管指针、把鼠标甩到另一块屏幕上,竖线和提示框都会永远停在原地。这几种在这里收。 - * `mouseout` 且 `relatedTarget` 为空,说的正是「指针去了文档外面」。 - */ -function useHoverOff(on: boolean, off: () => void) { - useEffect(() => { - if (!on) return; - const out = (e: MouseEvent) => { - if (!e.relatedTarget) off(); - }; - window.addEventListener("blur", off); - document.addEventListener("mouseleave", off); - document.addEventListener("mouseout", out); - document.addEventListener("visibilitychange", off); - return () => { - window.removeEventListener("blur", off); - document.removeEventListener("mouseleave", off); - document.removeEventListener("mouseout", out); - document.removeEventListener("visibilitychange", off); - }; - }, [on, off]); -} - -/** 一张小图的联动:交给图的三样,外加提示框开没开、抬头怎么写、首尾两头写什么 */ -type Sync = { - n: number; - cursor: number | null; - onPoint: (i: number | null) => void; - title: (i: number) => string; - tipOn: boolean; - ends: [string, string]; -}; - -/** 交给小图的那三样 */ -function chartOf(s: Sync) { - return { n: s.n, cursor: s.cursor, onPoint: s.onPoint }; -} - -/** 提示框:只在指着的那张图上、指着某一格时有 */ -function tipAt(s: Sync, value: (i: number) => string): { title: string; value: string } | null { - return s.tipOn && s.cursor !== null ? { title: s.title(s.cursor), value: value(s.cursor) } : null; -} - -/** - * 一张卡片:名目(实时档里按 24 小时算的旁边标着)、环比、大数、一句注解、一张小图和 - * 它首尾两头的时刻。指着它的小图时,边换成品牌的青→品红。 - */ -function Card({ - label, - scopeTag, - pill, - value, - sub, - hovered = false, - ends, - children, -}: { - label: string; - scopeTag?: string; - pill?: ReactNode; - value: ReactNode; - sub: ReactNode; - hovered?: boolean; - /** 小图下面首尾两头写什么。没有小图的卡片(缓存命中)不给 */ - ends?: [string, string]; - children: ReactNode; -}) { - return ( -
-
-

{label}

- {scopeTag && {scopeTag}} - {pill} -
-

{value}

-
- {sub} -
- {children} - {ends && ( -
- {ends[0]} - {ends[1]} -
- )} -
- ); -} - -/** - * 环比:方向和幅度,悬停写出和什么比、从多少到多少。 - * - * **没有参照系的数字只能读,不能判断。**只有好坏分明的那几样上色:费用少了、命中率高了、 - * 首 token 快了。用量和请求数两个方向都不上色 —— 多用掉一些 token 不是问题,少用也不是 - * 成绩。不到百分之一算持平,箭头放平。上一个区间是零时写「—」,取不到时整格不画。 - */ -function DeltaPill({ - d, - good, - period, - before, - now, -}: { - d: Delta | null; - good?: "up" | "down"; - /** 「24 小时」「7 天」「等长区间」 */ - period: string; - before: string; - now: string; -}) { - const t = useText(overviewText); - if (!d) return null; - if (d.kind === "noPrior") - return ( - - - — - - - ); - const shown = Math.round(Math.abs(d.ratio * 100)); - const flat = shown === 0; - const better = !flat && ((good === "down" && d.ratio < 0) || (good === "up" && d.ratio > 0)); - const Icon = flat ? ArrowRightIcon : d.ratio < 0 ? ArrowDownRightIcon : ArrowUpRightIcon; - const tip = t.deltaTip(period, before, now); - return ( - - - - {shown.toLocaleString()}% - - - ); -} - -/** - * 费用那个大数。写什么见 `rankCost`:有算不出来的请求时是下限(「≥」),含估算的带「~」, - * 说明写在悬停里、数字带虚线下划线。一条都没算出来时不写 $0,写「—」,注解那一行说为什么。 - */ -function CostValue({ c, amount, scope }: { c: ReturnType; amount: number; scope: string }) { - if (c.kind !== "amount") return <>—; - const n = c.prefix + usd(Math.round(v))} scope={scope} />; - if (c.notes.length === 0) return n; - return ( - - {c.notes.map((l) => ( -

{l}

- ))} - - } - > - {n} -
- ); -} - -/** 请求卡片的注解:失败几次(红的,点进去是流量页里失败的那一批)和失败率;没有失败就说没有 */ -function RequestsSub({ requests, failed }: { requests: number; failed: number }) { - const t = useText(overviewText); - const nav = useNav(); - if (failed === 0) return <>{requests > 0 ? t.noFailures : t.noRequestsShort}; - return ( - <> - - nav.open("requests", { grouped: false, filter: { failedOnly: true } })} - > - {t.failedSub(failed)} - - - {t.rateSuffix(((failed / Math.max(1, requests)) * 100).toFixed(1))} - - ); -} - -/** - * 缓存的构成:一根条,三段按单价从低到高排(读缓存最便宜、写缓存最贵),亮的那一段越长 - * 越省;下面三行写出各是多少。**这根条就是命中率的公式本身**。 - */ -function CacheParts({ read, plain, write }: { read: number; plain: number; write: number }) { - const t = useText(overviewText); - const ctx = read + plain + write; - const parts = [ - { key: "read", color: "bg-data-1", name: t.cacheReads, n: read }, - { key: "plain", color: "bg-data-rest", name: t.uncachedInput, n: plain }, - { key: "write", color: "bg-data-2", name: t.cacheWrites, n: write }, - ]; - return ( - <> -
- {ctx > 0 && - parts.map((p) => - p.n > 0 ? : null, - )} -
-
- {parts.map((p) => ( - -
- - {p.name} - {fmtTokens(p.n)} -
-
- ))} -
- - ); -} - -/** 图取不到(那一样读取失败):**不是没有**,不画一条贴底的线 */ -function Unavailable() { - const t = useText(overviewText); - return ( -
- {t.chartUnavailable} -
- ); -} - -/** - * 首 token:第一个 token 到的时刻,P50 一条线、P95 一条线(只有流式请求有样本)。用分位 - * 不用平均值:AI 的延迟是长尾的,平均值会被几个极端值拉偏。 - * - * 大数是**整段时间**的 P50(core 拿整段的样本求的;各格、各模型的分位合不出整体的), - * 注解兼作图例、写整段的 P95。没有样本的格子线断开,用虚线跨过去。 - */ -function LatencyCard({ - ttft, - series, - pill, - scopeTag, -}: { - /** 整段时间的分位。取不到是 `null`,没有样本是 `samples` 为 0 */ - ttft: Percentiles | null; - series: { p50: (number | null)[]; p95: (number | null)[]; sync: Sync } | null; - /** 环比(`DeltaPill`,`good="down"`):比上一个区间的整体 P50 */ - pill?: ReactNode; - scopeTag?: string; -}) { - const t = useText(overviewText); - const p50 = ttft?.p50_ms ?? null; - const p95 = ttft?.p95_ms ?? null; - const sub = - p50 !== null && p95 !== null ? ( - - - P50 · - - {t.p95(msShort(p95))} - - ) : ttft === null ? ( - t.chartUnavailable - ) : ( - t.noSamples - ); - return ( - - {series ? ( - { - const a = series.p50[i]; - const b = series.p95[i]; - return a == null || b == null ? t.noSamples : t.latTip(msShort(a), msShort(b)); - })} - /> - ) : ( - - )} - - ); -} - -/** - * 流量:网关和上游之间的请求体与回答体(线上的样子:网关压缩之后、解压之前),上传一条线、 - * 下载一条线。故障转移试过的每一跳都算;本地应答的没有流量。 - */ -function TrafficCard({ - sent, - received, - series, - pill, - scopeTag, -}: { - sent: number; - received: number; - series: { sent: number[]; received: number[]; sync: Sync } | null; - /** 环比(`DeltaPill`):比上一个区间的上传加下载 */ - pill?: ReactNode; - scopeTag?: string; -}) { - const t = useText(overviewText); - const s = series; - return ( - - - {t.upload(traffic(sent))} · - - {t.download(traffic(received))} -
- } - hovered={s?.sync.tipOn} - ends={s?.sync.ends} - > - {s ? ( - t.trafficTip(traffic(s.sent[i] ?? 0), traffic(s.received[i] ?? 0)))} - /> - ) : ( - - )} - - ); -} - -/** 注解里的色块:两条线的卡片,注解那一行兼作图例 */ -function Swatch({ color, className }: { color: string; className?: string }) { - return ; -} diff --git a/src/overview/Hero.test.tsx b/src/overview/Hero.test.tsx new file mode 100644 index 00000000..6f04e6b2 --- /dev/null +++ b/src/overview/Hero.test.tsx @@ -0,0 +1,153 @@ +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import type { CostBucket, Dashboard, Summary } from "@/types"; +import { LIVE_RANGE, dayRange, presetRange, type Range } from "@/ui/range"; +import { bucketOf } from "./useOverview"; +import { Hero } from "./Hero"; +import { METRICS, type Metric } from "./metric"; +import { overviewText } from "./overview.i18n"; + +// 悬停提示要挂在 TooltipProvider 里;这里只看画出来的字 +vi.mock("@/ui/tip", () => ({ Tip: ({ children }: { children: ReactNode }) => <>{children} })); +vi.mock("@/nav", () => ({ useNav: () => ({ open: () => {} }) })); + +const t = overviewText.zh; +const HOUR = 3_600_000; + +function summary(over: Partial = {}): Summary { + return { + requests: 12, + failed: 2, + locally_answered: 0, + input_tokens: 4_000, + output_tokens: 1_000, + cache_read_tokens: 6_000, + cache_write_tokens: 500, + cost_micros_exact: 30_000, + cost_micros_estimated: 2_000, + unpriced_requests: 0, + no_usage_requests: 0, + sent_bytes: 40_000, + received_bytes: 9_000, + cache_saved_micros: 1_200, + security: { + secrets: 0, + secrets_replaced: 0, + tool_calls: 0, + tool_calls_cut: 0, + content: 0, + content_blocked: 0, + content_stripped: 0, + content_replaced: 0, + }, + pricing_date: "2026-10-20", + ...over, + }; +} + +function bucket(at_ms: number): CostBucket { + return { + at_ms, + requests: 3, + failed: 1, + cost_micros_exact: 7_000, + cost_micros_estimated: 500, + unpriced_requests: 0, + no_usage_requests: 0, + sent_bytes: 10_000, + received_bytes: 2_000, + input_tokens: 1_000, + output_tokens: 250, + cache_read_tokens: 1_500, + cache_write_tokens: 100, + ttft_p50_ms: 800, + ttft_p95_ms: 2_400, + ttft_samples: 3, + }; +} + +function dashboard(range: Range, over: Partial = {}): Dashboard { + const since = range.from ?? Date.now() - range.ms; + const at = since - (since % HOUR); + return { + summary: summary(), + latency: [], + latency_by_provider: [], + latency_by_client: [], + token_rate: [], + token_rate_by_provider: [], + storage: null, + quotas: null, + buckets: [bucket(at)], + buckets_by_model: [], + buckets_by_provider: [], + buckets_by_client: [], + prev: summary({ requests: 10, cost_micros_exact: 40_000, cost_micros_estimated: 0 }), + ttft: { p50_ms: 800, p95_ms: 2_400, samples: 9 }, + prev_ttft: { p50_ms: 900, p95_ms: 2_000, samples: 7 }, + since_ms: at, + ...over, + }; +} + +function render(range: Range, metric: Metric, over: Partial = {}) { + return renderToStaticMarkup( + {}} />, + ); +} + +describe("用量", () => { + it("每个区间、每个指标都画得出来:六个指标、选中的那个、它的细项", () => { + for (const range of [presetRange("7d"), presetRange("1d"), dayRange(new Date(2026, 9, 20)), LIVE_RANGE]) { + for (const m of METRICS) { + const html = render(range, m); + expect(html.match(/role="tab"/g)).toHaveLength(6); + expect(html.match(/aria-selected="true"/g)).toHaveLength(1); + expect(html).toContain('role="tabpanel"'); + } + } + }); + + /** 估算不能冒充实测:限定词一直写着,不只在悬停里 */ + it("费用的限定词一直看得见:没选中费用时在指标上,选中时细项里逐条写着", () => { + const range = presetRange("7d"); + const unpriced = { summary: summary({ unpriced_requests: 3 }) }; + expect(render(range, "tokens")).toContain(t.tagEstimated); + expect(render(range, "tokens", unpriced)).toContain(t.tagUnpriced); + const cost = render(range, "cost", unpriced); + expect(cost).toContain(t.estimated("$0.0020")); + expect(cost).toContain(t.shortUnpriced(3)); + // 限定符缩小写在金额前面(单独一段),金额照常 + expect(cost).toMatch(/>≥~<\/span>\$0\.032 { + const html = render(presetRange("7d"), "tokens", { + summary: summary({ cost_micros_exact: 0, cost_micros_estimated: 0, unpriced_requests: 4 }), + }); + expect(html).toContain(t.unpricedCell); + expect(html).not.toContain(">$0<"); + }); + + it("实时档:按 24 小时算的三个名目旁边标着", () => { + const html = render(LIVE_RANGE, "cache"); + expect(html.match(/>24 小时24 小时<"); + }); + + it("取不到那一样:写「暂时取不到」,不画成零", () => { + const html = render(presetRange("7d"), "tokens", { buckets: null }); + expect(html).toContain(t.chartUnavailable); + expect(render(presetRange("7d"), "ttft", { ttft: null, buckets: null })).toContain(t.chartUnavailable); + }); + + it("细项:缓存的三段和省下的费用,请求的失败次数", () => { + const cache = render(presetRange("7d"), "cache"); + expect(cache).toContain(t.cacheReads); + expect(cache).toContain(t.uncachedInput); + expect(cache).toContain(t.cacheWrites); + expect(cache).toContain(t.saved("$0.0012")); + expect(render(presetRange("7d"), "requests")).toContain(t.failedSub(2)); + }); +}); diff --git a/src/overview/Hero.tsx b/src/overview/Hero.tsx new file mode 100644 index 00000000..fb5e3168 --- /dev/null +++ b/src/overview/Hero.tsx @@ -0,0 +1,868 @@ +import { useCallback, useEffect, useId, useState, type KeyboardEvent, type ReactNode } from "react"; +import { ArrowDownRightIcon, ArrowRightIcon, ArrowUpRightIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { AnimatedNumber } from "@/ui/motion"; +import { Tip } from "@/ui/tip"; +import type { Range } from "@/ui/range"; +import { rangeText } from "@/ui/range.i18n"; +import { useNav } from "@/nav"; +import { compact, msShort, traffic } from "@/format"; +import { usd, type Dashboard } from "@/types"; +import { useText } from "@/i18n"; +import { LIVE_REACH_MS, useLiveWindow } from "./useLive"; +import { + cacheOf, + cellModels, + costTag, + costText, + delta, + historySeries, + liveModelsAt, + liveSeries, + rankCost, + totalCost, + totalTokens, + type Delta, + type TopModels, +} from "./series"; +import { byteUnit, niceTop } from "./HeroAxis"; +import { Chart, type ChartSpec, type TipRow } from "./HeroChart"; +import { METRICS, type Metric } from "./metric"; +import { LinkText } from "./parts"; +import { overviewText } from "./overview.i18n"; + +type Text = (typeof overviewText)["zh"]; + +const HOUR = 3_600_000; +const DAY = 24 * HOUR; + +const fmtTokens = (n: number) => compact(Math.round(n)); +const fmtCount = (n: number) => Math.round(n).toLocaleString(); +const pct = (n: number) => `${Math.round(n)}%`; + +// 数据颜色只取 `--data-*` +const C1 = "var(--data-1)"; +const C2 = "var(--data-2)"; +const CREST = "var(--data-rest)"; +const CFAIL = "var(--data-fail)"; + +/** + * 用量:六个指标并排,选中的那个画成下面带坐标轴的大图。 + * + * 每个指标是名目、大数、环比。**大数和图是同一段时间**: + * + * · 历史档(24 小时、7 天、30 天、自定义、某一天)六个都是所选的区间,和上一个等长区间比。 + * · 实时档 token、费用、请求三个是最近十分钟,图跟着事件流走;十分钟里的样本撑不起有意义 + * 的命中率和分位,缓存命中、首 token、流量三个按 24 小时算,名目旁边标着。 + * + * 大图:可加的画柱(token、费用、请求、流量;两段的叠起来),比率和延迟画线(缓存命中、 + * 首 token)。实时档 token、费用读的是速率,画线。图上方一行是选中指标的细项,费用的 + * 限定词在那里一直写着。指着(或键盘聚焦)大图时逐格出提示,卡片换品牌渐变边。 + * + * 实时档的事件流订阅在这一层:每半秒往左挪一步时只重画这一块,页面上别的部分不跟着 + * 每一帧重画。 + */ +export function Hero({ + d, + range, + scope, + step, + metric, + onMetric, +}: { + d: Dashboard; + range: Range; + /** 数据属于哪个区间(`rangeId`):换了区间,数字直接落到新值 */ + scope: string; + /** 历史档的格宽(`bucketOf`) */ + step: number; + metric: Metric; + onMetric: (m: Metric) => void; +}) { + const t = useText(overviewText); + const rt = useText(rangeText); + const uid = useId(); + const live = range.live === true; + // 多留一个核宽的样本:最左边那一格的鼓包有一半来自图外 + const win = useLiveWindow(live, range.ms + LIVE_REACH_MS); + const now = Date.now(); + // **图的右端是 `range.to ?? 现在`**:「某一天」画到那一天的终点 + const hist = historySeries(d, range.to ?? now, step); + const lv = live ? liveSeries(win.samples, win.ends, now, range.ms) : null; + const s = d.summary; + const p = d.prev; + // 实时档里按 24 小时算的那几个,名目旁边标出口径 + const dayScope = live ? rt.preset["1d"] : undefined; + const period = range.compare; + + // ── 六个大数 + const tokens = lv ? lv.totals.tokens : totalTokens(s); + const output = lv ? lv.totals.output : s.output_tokens; + const costRow = lv + ? { + cost: lv.totals.cost, + estimated: lv.totals.estimated, + unpriced: lv.totals.unpriced, + noUsage: 0, + pending: lv.totals.pending, + } + : { + cost: totalCost(s), + estimated: s.cost_micros_estimated, + unpriced: s.unpriced_requests, + noUsage: s.no_usage_requests, + pending: 0, + }; + const cost = rankCost(costRow, t); + const requests = lv ? lv.totals.requests : s.requests; + const failed = lv ? lv.totals.failed : s.failed; + const cache = cacheOf(s); + const prevCache = p ? cacheOf(p) : null; + const ttftNow = d.ttft?.p50_ms ?? null; + + const tiles: Record = { + tokens: { + label: t.kpiTokens, + value: , + delta: !live && p && ( + + ), + }, + cost: { + label: t.kpiCost, + value: , + delta: !live && p && ( + + ), + tag: , + }, + requests: { + label: t.kpiRequests, + value: , + delta: !live && p && ( + + ), + }, + cache: { + label: t.kpiCache, + scopeTag: dayScope, + value: cache.hit === null ? "—" : , + delta: cache.hit !== null && prevCache && ( + + ), + }, + ttft: { + label: t.kpiLatency, + scopeTag: dayScope, + value: ttftNow === null ? "—" : msShort(ttftNow), + delta: ttftNow !== null && d.prev_ttft && ( + + ), + }, + traffic: { + label: t.kpiTraffic, + scopeTag: dayScope, + value: traffic(s.sent_bytes + s.received_bytes), + delta: p && ( + + ), + }, + }; + + // ── 选中指标的细项(图上方那一行) + const detail: Record = { + tokens: t.inOut(fmtTokens(tokens - output), fmtTokens(output)), + cost: , + requests: , + cache: , + ttft: + d.ttft === null + ? t.chartUnavailable + : d.ttft.p50_ms == null || d.ttft.p95_ms == null + ? t.noSamples + : t.latDetail(msShort(d.ttft.p50_ms), msShort(d.ttft.p95_ms)), + traffic: `${t.upload(traffic(s.sent_bytes))} · ${t.download(traffic(s.received_bytes))}`, + }; + + const spec = chartSpec(metric, { d, hist, lv, samples: win.samples, step, t }); + + // 指着哪一格。记着是哪个指标的:换了指标(格子可能不一样多)就不再指着 + const [hover, setHover] = useState<{ metric: Metric; i: number } | null>(null); + const n = spec?.grid.at.length ?? 0; + const cursor = hover && hover.metric === metric && hover.i < n ? hover.i : null; + const point = useCallback((i: number | null) => setHover(i === null ? null : { metric, i }), [metric]); + const clear = useCallback(() => setHover(null), []); + useHoverOff(cursor !== null, clear); + + const tabId = (m: Metric) => `${uid}-tab-${m}`; + const panelId = `${uid}-panel`; + const onTabKey = (e: KeyboardEvent) => { + const k = METRICS.indexOf(metric); + const next = + e.key === "ArrowRight" ? (k + 1) % METRICS.length + : e.key === "ArrowLeft" ? (k - 1 + METRICS.length) % METRICS.length + : e.key === "Home" ? 0 + : e.key === "End" ? METRICS.length - 1 + : null; + if (next === null) return; + e.preventDefault(); + const m = METRICS[next]!; + onMetric(m); + document.getElementById(tabId(m))?.focus(); + }; + + return ( +
+
+ {METRICS.map((m, i) => ( + onMetric(m)} + /> + ))} +
+
+
+
{detail[metric]}
+
+ {spec?.legend.map((l) => ( + + + {l.label} + + ))} + {spec?.note && {spec.note}} +
+
+ +
+
+ ); +} + +/** 一个指标:名目(实时档里按 24 小时算的旁边标着)、大数、环比,费用还有它的限定词 */ +interface TileSpec { + label: string; + scopeTag?: string; + value: ReactNode; + delta?: ReactNode; + tag?: ReactNode; +} + +/** + * 六个指标里的一个。宽的时候六个一排,窄于 760px 排成 3 × 2:格子之间的分隔线、四个角 + * 的圆角跟着换。选中的那个底色升一档、下沿一道 `--data-1`。 + */ +function Tile({ + id, + panel, + on, + index, + spec, + onPick, +}: { + id: string; + panel: string; + on: boolean; + index: number; + spec: TileSpec; + onPick: () => void; +}) { + return ( + + ); +} + +/** + * 环比:方向和幅度,悬停写出和什么比、从多少到多少。 + * + * **没有参照系的数字只能读,不能判断。**只有好坏分明的那几样上色:费用少了、命中率高了、 + * 首 token 快了。用量和请求数两个方向都不上色 —— 多用掉一些 token 不是问题,少用也不是 + * 成绩。不到百分之一算持平,箭头放平。上一个区间是零时写「—」,取不到时整格不画。 + */ +function Change({ + d, + good, + period, + before, + now, +}: { + d: Delta | null; + good?: "up" | "down"; + /** 「24 小时」「7 天」「等长区间」 */ + period: string; + before: string; + now: string; +}) { + const t = useText(overviewText); + if (!d) return null; + if (d.kind === "noPrior") + return ( + + — + + ); + const shown = Math.round(Math.abs(d.ratio * 100)); + const flat = shown === 0; + const better = !flat && ((good === "down" && d.ratio < 0) || (good === "up" && d.ratio > 0)); + const Icon = flat ? ArrowRightIcon : d.ratio < 0 ? ArrowDownRightIcon : ArrowUpRightIcon; + const tip = t.deltaTip(period, before, now); + return ( + + + + {shown.toLocaleString()}% + + + ); +} + +/** + * 费用那个大数。写什么见 `rankCost`:有算不出来的请求时是下限(「≥」),含估算的带「~」。 + * 一条都没算出来时不写 $0,写「—」,限定词写着为什么。 + */ +function CostValue({ c, amount, scope }: { c: ReturnType; amount: number; scope: string }) { + if (c.kind !== "amount") return <>—; + /* + 指标只有一百多像素宽:「≥~」两个限定符按大数的字号写,「≥~$52.57」就放不下了。限定符 + 缩小写在前面,意思不变;一千美元以上不写分,写满几位的整数 + */ + return ( + <> + {c.prefix && {c.prefix}} + + + ); +} + +/** 指标上的费用:一千美元以上不写分 */ +function tileUsd(micros: number): string { + const v = Math.round(micros); + return v >= 1e9 ? `$${Math.round(v / 1e6).toLocaleString("en-US")}` : usd(v); +} + +/** + * 费用指标上**一直看得见**的限定词(`costTag`)。完整的几句在悬停里,选中费用时细项那一行 + * 也逐条写着。 + */ +function CostTag({ tag, notes }: { tag: string | null; notes: string[] }) { + if (!tag) return null; + const word = {tag}; + if (notes.length === 0) return word; + return ( + + {notes.map((l) => ( +

{l}

+ ))} + + } + > + {word} +
+ ); +} + +/** 一段细项之间的「 · 」 */ +function Dots({ items }: { items: ReactNode[] }) { + return ( + <> + {items.map((x, i) => ( + + {i > 0 && " · "} + {x} + + ))} + + ); +} + +/** + * 费用的细项:平均每次请求多少,和**金额之外的那几样**(含估算多少、几条无法计价、几条 + * 没有用量、实时档几条尚在计算),各写一段。无法计价的点进去是流量页里那一批。 + */ +function CostDetail({ + row, + kind, + prefix, + requests, +}: { + row: { cost: number; estimated: number; unpriced: number; noUsage: number; pending: number }; + kind: "amount" | "unpriced" | "noUsage"; + prefix: string; + requests: number; +}) { + const t = useText(overviewText); + const nav = useNav(); + const items: ReactNode[] = []; + if (kind === "amount") + items.push(requests > 0 ? t.avgCost(prefix + usd(Math.round(row.cost / requests))) : t.noRequestsShort); + if (row.estimated > 0) items.push(t.estimated(usd(row.estimated))); + if (row.unpriced > 0) + items.push( + + nav.open("requests", { grouped: false, filter: { unpricedOnly: true } })}> + {t.shortUnpriced(row.unpriced)} + {t.showUnpriced} + + , + ); + if (row.noUsage > 0) + items.push( + + {t.shortNoUsage(row.noUsage)} + , + ); + if (row.pending > 0) + items.push( + + {t.shortPending(row.pending)} + , + ); + return ; +} + +/** 请求的细项:失败几次(红的,点进去是流量页里失败的那一批)和失败率;没有失败就说没有 */ +function RequestsDetail({ requests, failed }: { requests: number; failed: number }) { + const t = useText(overviewText); + const nav = useNav(); + if (failed === 0) return <>{requests > 0 ? t.noFailures : t.noRequestsShort}; + return ( + <> + + nav.open("requests", { grouped: false, filter: { failedOnly: true } })} + > + {t.failedSub(failed)} + + + {t.rateSuffix(((failed / Math.max(1, requests)) * 100).toFixed(1))} + + ); +} + +/** + * 缓存的细项:一根小条,三段按单价从低到高排(读缓存最便宜、写缓存最贵),亮的那一段越长 + * 越省 —— **这根条就是命中率的公式本身**;后面写出三段各是多少,和省下(或净增)的费用。 + */ +function CacheDetail({ read, plain, write, saved }: { read: number; plain: number; write: number; saved: number }) { + const t = useText(overviewText); + const ctx = read + plain + write; + if (ctx === 0) return <>{t.noTokens}; + const parts = [ + { key: "read", color: "bg-data-1", name: t.cacheReads, n: read }, + { key: "plain", color: "bg-data-rest", name: t.uncachedInput, n: plain }, + { key: "write", color: "bg-data-2", name: t.cacheWrites, n: write }, + ]; + return ( + + + {parts.map((p) => + p.n > 0 ? : null, + )} + + {parts.map((p) => ( + + + + {p.name} + {fmtTokens(p.n)} + + + ))} + {saved < 0 ? t.netCost(usd(-saved)) : t.saved(usd(saved))} + + ); +} + +// ───────────────────────────────────────────── 大图:选中的指标画成什么 + +/** 一格多宽,写成「6 小时」「1 天」 */ +function widthOf(step: number, t: Text): string { + return step >= DAY ? t.days(Math.round(step / DAY)) : t.hours(Math.round(step / HOUR)); +} + +/** 纵轴上的钱:整齐的刻度写几位就几位,不补零 */ +const axisMoney = (v: number) => `$${(v / 1e6).toLocaleString("en-US", { maximumFractionDigits: 6 })}`; +/** 纵轴上的毫秒:一秒以内写 ms,以上写 s */ +const axisMs = (v: number) => (v < 1000 ? `${Math.round(v)}ms` : `${Number((v / 1000).toPrecision(4))}s`); +const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB"]; + +/** 一格里排前几的模型,写成提示里的几行 */ +function modelRows(m: TopModels | null, by: "tokens" | "cost", t: Text): TipRow[] { + if (!m) return []; + const v = (r: TopModels["top"][number]) => (by === "tokens" ? fmtTokens(r.tokens) : costText(r, t)); + return [ + ...m.top.map((r) => ({ v: v(r), k: r.name || t.unknownModel })), + ...(m.other ? [{ v: v(m.other), k: t.otherModels(m.other.count) }] : []), + ]; +} + +const peak = (...xs: readonly (readonly (number | null)[])[]) => { + let m = 0; + for (const x of xs) for (const v of x) if (v != null && v > m) m = v; + return m; +}; + +/** + * 选中的指标画成什么。那一样取不到(读取失败)时是 `null`:写「暂时取不到」,不画成零。 + */ +function chartSpec( + metric: Metric, + { + d, + hist, + lv, + samples, + step, + t, + }: { + d: Dashboard; + hist: ReturnType; + lv: ReturnType | null; + samples: Parameters[0]; + step: number; + t: Text; + }, +): ChartSpec | null { + const cellAt = (i: number) => hist.cells[i]; + const slot = t.perSlot(widthOf(step, t)); + const hourly = t.perSlot(widthOf(HOUR, t)); + switch (metric) { + case "tokens": { + if (lv) { + const v = lv.tokens!; + return { + grid: lv.grid, + bars: null, + lines: [{ values: v, color: C1, wash: true }], + top: niceTop(peak(v)), + axis: (x) => compact(x), + tip: (i) => ({ + main: t.tokenRate(fmtTokens(v[i] ?? 0)), + rows: [], + rows2: modelRows(liveModelsAt(samples, lv.grid.at[i] ?? 0, "tokens"), "tokens", t), + }), + legend: [], + note: t.tokenUnit, + }; + } + const v = hist.tokens; + const out = hist.output; + if (!v || !out) return null; + return { + grid: hist.grid, + bars: { a: v, colorA: C1 }, + lines: null, + top: niceTop(peak(v), { integer: true }), + axis: (x) => compact(x), + tip: (i) => { + const n = v[i] ?? 0; + if (n === 0) return { main: t.tokens("0", 0), rows: [], rows2: [] }; + const o = out[i] ?? 0; + return { + main: t.tokens(fmtTokens(n), n), + rows: [ + { v: fmtTokens(n - o), k: t.input }, + { v: fmtTokens(o), k: t.output }, + ], + rows2: modelRows(cellModels(d.buckets_by_model, hist.grid.at[i] ?? 0, "tokens"), "tokens", t), + }; + }, + legend: [], + note: slot, + }; + } + case "cost": { + if (lv) { + const v = lv.cost!; + return { + grid: lv.grid, + bars: null, + lines: [{ values: v, color: C1, wash: true }], + top: niceTop(peak(v) || 10_000), + axis: axisMoney, + tip: (i) => ({ + main: t.costRate(usd(Math.round(v[i] ?? 0))), + rows: modelRows(liveModelsAt(samples, lv.grid.at[i] ?? 0, "cost"), "cost", t), + rows2: [], + }), + legend: [], + note: t.costUnit, + }; + } + const v = hist.cost; + if (!v) return null; + return { + grid: hist.grid, + bars: { a: v, colorA: C1 }, + lines: null, + // 一分钱都没有的图,纵轴写到 $0.04,不写到百万分之四 + top: niceTop(peak(v) || 10_000), + axis: axisMoney, + tip: (i) => { + const c = cellAt(i); + if (!c || c.requests === 0) return { main: t.noRequestsShort, rows: [], rows2: [] }; + return { + main: costText( + { + cost: c.cost_micros_exact + c.cost_micros_estimated, + estimated: c.cost_micros_estimated, + unpriced: c.unpriced_requests, + noUsage: c.no_usage_requests, + }, + t, + ), + rows: modelRows(cellModels(d.buckets_by_model, c.at_ms, "cost"), "cost", t), + rows2: [], + }; + }, + legend: [], + note: slot, + }; + } + case "requests": { + const src = lv ?? hist; + const total = src.requests; + const bad = src.failed; + if (!total || !bad) return null; + const ok = total.map((n, i) => Math.max(0, n - (bad[i] ?? 0))); + return { + grid: src.grid, + bars: { a: ok, b: bad, colorA: C1, colorB: CFAIL }, + lines: null, + top: niceTop(peak(total), { integer: true }), + axis: fmtCount, + tip: (i) => { + const n = total[i] ?? 0; + if (n === 0) return { main: t.noRequestsShort, rows: [], rows2: [] }; + return { + main: t.reqCount(fmtCount(n), n), + rows: [ + { v: fmtCount(ok[i] ?? 0), k: t.succeeded, key: C1 }, + { v: fmtCount(bad[i] ?? 0), k: t.failed, key: CFAIL }, + ], + rows2: [], + }; + }, + legend: [ + { label: t.succeeded, color: C1, shape: "rect" }, + { label: t.failed, color: CFAIL, shape: "rect" }, + ], + note: lv ? t.perSlot(t.seconds(Math.round(lv.grid.step / 1000))) : slot, + }; + } + case "cache": { + const v = hist.hit; + if (!v) return null; + return { + grid: hist.grid, + bars: null, + lines: [{ values: v, color: C1, wash: true }], + top: 1, + axis: (x) => pct(x * 100), + tip: (i) => { + const c = cellAt(i); + const h = v[i]; + if (!c || h == null) return { main: t.noTokens, rows: [], rows2: [] }; + return { + main: t.hitRate(pct(h * 100)), + rows: [ + { v: fmtTokens(c.cache_read_tokens), k: t.cacheReads, key: C1 }, + { v: fmtTokens(c.input_tokens), k: t.uncachedInput, key: CREST }, + { v: fmtTokens(c.cache_write_tokens), k: t.cacheWrites, key: C2 }, + ], + rows2: [], + }; + }, + legend: [], + note: lv ? hourly : slot, + }; + } + case "ttft": { + const a = hist.p50; + const b = hist.p95; + if (!a || !b) return null; + return { + grid: hist.grid, + bars: null, + // 分位不铺面:延迟没有「多少」 + lines: [ + { values: a, color: C1 }, + { values: b, color: C2 }, + ], + top: niceTop(peak(a, b) || 1000), + axis: axisMs, + tip: (i) => { + const x = a[i]; + const y = b[i]; + if (x == null || y == null) return { main: t.noSamples, rows: [], rows2: [] }; + return { + main: msShort(x), + rows: [ + { v: msShort(x), k: "P50", key: C1 }, + { v: msShort(y), k: "P95", key: C2 }, + ], + rows2: [], + }; + }, + legend: [ + { label: "P50", color: C1, shape: "stroke" }, + { label: "P95", color: C2, shape: "stroke" }, + ], + note: lv ? hourly : slot, + }; + } + case "traffic": { + const up = hist.sent; + const down = hist.received; + if (!up || !down) return null; + const max = peak(up.map((x, i) => x + (down[i] ?? 0))); + const { unit, level } = byteUnit(max); + return { + grid: hist.grid, + bars: { a: up, b: down, colorA: C1, colorB: C2 }, + lines: null, + top: niceTop(max, { integer: level === 0, unit }), + axis: (x) => `${Number((x / unit).toPrecision(4))} ${BYTE_UNITS[level]}`, + tip: (i) => { + const u = up[i] ?? 0; + const w = down[i] ?? 0; + if (u + w === 0) return { main: traffic(0), rows: [], rows2: [] }; + return { + main: traffic(u + w), + rows: [ + { v: traffic(u), k: t.sent, key: C1 }, + { v: traffic(w), k: t.received, key: C2 }, + ], + rows2: [], + }; + }, + legend: [ + { label: t.sent, color: C1, shape: "rect" }, + { label: t.received, color: C2, shape: "rect" }, + ], + note: lv ? hourly : slot, + }; + } + } +} + +/** + * 指针离开的方式不止「移到旁边去」一种,而只有那一种会送来 mouseleave:切走应用、截图 + * 工具接管指针、把鼠标甩到另一块屏幕上,竖线和提示框都会永远停在原地。这几种在这里收。 + * `mouseout` 且 `relatedTarget` 为空,说的正是「指针去了文档外面」。 + */ +function useHoverOff(on: boolean, off: () => void) { + useEffect(() => { + if (!on) return; + const out = (e: MouseEvent) => { + if (!e.relatedTarget) off(); + }; + window.addEventListener("blur", off); + document.addEventListener("mouseleave", off); + document.addEventListener("mouseout", out); + document.addEventListener("visibilitychange", off); + return () => { + window.removeEventListener("blur", off); + document.removeEventListener("mouseleave", off); + document.removeEventListener("mouseout", out); + document.removeEventListener("visibilitychange", off); + }; + }, [on, off]); +} diff --git a/src/overview/HeroAxis.test.ts b/src/overview/HeroAxis.test.ts new file mode 100644 index 00000000..9b3e0783 --- /dev/null +++ b/src/overview/HeroAxis.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; +import { byteUnit, niceTop, slotPhase, xTicks, yTicks } from "./HeroAxis"; +import { liveSeries, type Grid } from "./series"; + +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; +const opts = (now: number) => ({ today: "今天", now }); + +describe("纵轴的刻度", () => { + it("四格,每格是 1 / 2 / 2.5 / 5 乘 10 的幂,顶上留一点空", () => { + expect(niceTop(100)).toBe(200); // 一格 26 → 50 + expect(niceTop(96)).toBe(100); // 一格 24.96 → 25 + expect(niceTop(7_600)).toBe(8_000); // 一格 1976 → 2000 + expect(niceTop(9_000)).toBe(10_000); // 一格 2340 → 2500 + expect(niceTop(0.9)).toBe(1); // 一格 0.234 → 0.25 + expect(yTicks(niceTop(0.9))).toEqual([0, 0.25, 0.5, 0.75, 1]); + }); + + it("刚好顶到一档时也留出空:最高的柱子不贴着最上面那条线", () => { + expect(niceTop(400)).toBe(800); + }); + + it("个数:每格是整数,没有 2.5 次;只有一两次时纵轴是 0–4", () => { + expect(niceTop(1, { integer: true })).toBe(4); + expect(niceTop(9, { integer: true })).toBe(20); // 2.5 跳过,取 5 + expect(niceTop(9)).toBe(10); + expect(niceTop(0, { integer: true })).toBe(4); + expect(yTicks(niceTop(3, { integer: true }))).toEqual([0, 1, 2, 3, 4]); + }); + + it("全是零:最小的一档,不是 NaN", () => { + expect(niceTop(0)).toBe(1); + expect(niceTop(Number.NaN)).toBe(1); + }); + + it("字节按 1024 进位取整:刻度是整齐的 KB、MB", () => { + const max = 300 * 1024 * 1024; + const { unit, level } = byteUnit(max); + expect(level).toBe(2); + expect(niceTop(max, { unit })).toBe(400 * 1024 * 1024); + expect(byteUnit(800)).toEqual({ unit: 1, level: 0 }); + expect(byteUnit(1024)).toEqual({ unit: 1024, level: 1 }); + }); + + it("浮点误差抹掉", () => { + expect(yTicks(0.4)).toEqual([0, 0.1, 0.2, 0.3, 0.4]); + }); +}); + +describe("哪一格还在进行", () => { + const since = new Date(2026, 9, 27).getTime(); + const g: Grid = { at: Array.from({ length: 24 }, (_, i) => since + i * HOUR), step: HOUR, kind: "bucket" }; + + it("包着此刻的那一格在进行;之前的过去了,之后的还没到(「某一天」选的是今天)", () => { + const now = since + 9 * HOUR + 20 * MINUTE; + expect(slotPhase(g, 8, now)).toBe("past"); + expect(slotPhase(g, 9, now)).toBe("ongoing"); + expect(slotPhase(g, 10, now)).toBe("future"); + }); + + it("画到现在的图:最右一格就是进行中的那一格", () => { + const now = since + 23 * HOUR + 1; + expect(slotPhase(g, 23, now)).toBe("ongoing"); + }); + + it("过去的某一天:没有进行中的", () => { + const now = since + 3 * DAY; + expect(g.at.map((_, i) => slotPhase(g, i, now)).every((p) => p === "past")).toBe(true); + }); + + it("实时档的格子是一个个时刻,没有进行中", () => { + const live = liveSeries([], [], 10_000_000, 10 * MINUTE).grid; + expect(slotPhase(live, live.at.length - 1, 10_000_000)).toBe("past"); + }); +}); + +describe("横轴的标签", () => { + it("24 小时:每 6 小时一个整点,跨过的零点写日期", () => { + const since = new Date(2026, 9, 27, 15).getTime(); + const now = since + 24 * HOUR - 10 * MINUTE; + const g: Grid = { at: Array.from({ length: 24 }, (_, i) => since + i * HOUR), step: HOUR, kind: "bucket" }; + const ticks = xTicks(g, 6, opts(now)); + expect(ticks.map((x) => x.label)).toEqual(["18:00", "今天", "06:00", "12:00"]); + // 标签写在那一格的起点上 + expect(ticks[0]?.f).toBeCloseTo(3 / 24); + }); + + it("地方宽就标得密:每 3 小时一个", () => { + const since = new Date(2026, 9, 27, 15).getTime(); + const g: Grid = { at: Array.from({ length: 24 }, (_, i) => since + i * HOUR), step: HOUR, kind: "bucket" }; + expect(xTicks(g, 9, opts(since + DAY)).map((x) => x.label)).toHaveLength(8); + }); + + it("「某一天」:第一格就是那一天,写日期", () => { + const day = new Date(2026, 9, 20).getTime(); + const g: Grid = { at: Array.from({ length: 24 }, (_, i) => day + i * HOUR), step: HOUR, kind: "bucket" }; + const ticks = xTicks(g, 6, opts(day + 8 * DAY)); + expect(ticks[0]).toEqual({ f: 0, label: "10-20" }); + expect(ticks.map((x) => x.label)).toEqual(["10-20", "06:00", "12:00", "18:00"]); + }); + + it("7 天、6 小时一格:只标零点,一天一个;今天写「今天」", () => { + const since = new Date(2026, 9, 21, 18).getTime(); + const g: Grid = { at: Array.from({ length: 28 }, (_, i) => since + i * 6 * HOUR), step: 6 * HOUR, kind: "bucket" }; + const now = new Date(2026, 9, 28, 20).getTime(); + const ticks = xTicks(g, 10, opts(now)); + expect(ticks.map((x) => x.label)).toEqual(["10-22", "10-23", "10-24", "10-25", "10-26", "10-27", "今天"]); + expect(ticks[0]?.f).toBeCloseTo(1 / 28); + }); + + it("零点也排不下:隔几天一个,从最近的那一天往回数", () => { + const since = new Date(2026, 9, 12).getTime(); + const g: Grid = { at: Array.from({ length: 32 }, (_, i) => since + i * 12 * HOUR), step: 12 * HOUR, kind: "bucket" }; + const ticks = xTicks(g, 6, opts(since + 30 * DAY)); + expect(ticks.length).toBeLessThanOrEqual(6); + expect(ticks.at(-1)?.label).toBe("10-27"); + }); + + it("30 天、一天一格:隔几天一个日期,最右边那一天一定标着", () => { + const since = new Date(2026, 8, 29).getTime(); + const g: Grid = { at: Array.from({ length: 31 }, (_, i) => since + i * DAY), step: DAY, kind: "bucket" }; + const now = new Date(2026, 9, 29, 10).getTime(); + const ticks = xTicks(g, 8, opts(now)); + expect(ticks.length).toBeLessThanOrEqual(8); + expect(ticks.at(-1)).toEqual({ f: 30 / 31, label: "今天" }); + // 隔开的距离一样 + const gaps = ticks.slice(1).map((x, i) => x.f - ticks[i]!.f); + for (const gap of gaps) expect(gap).toBeCloseTo(gaps[0]!); + }); + + it("按天的格子过了夏令时切换(起点落在前一天 23 点):写离起点最近的那一天", () => { + const at = new Date(2026, 9, 27).getTime() - HOUR; + const g: Grid = { at: [at], step: DAY, kind: "bucket" }; + expect(xTicks(g, 4, opts(at + 10 * DAY))[0]?.label).toBe("10-27"); + }); + + it("实时档:整分钟的时刻,位置按时刻算,跟着时间往左走", () => { + const now = new Date(2026, 9, 28, 14, 31, 40).getTime(); + const g = liveSeries([], [], now, 10 * MINUTE).grid; + const ticks = xTicks(g, 6, opts(now)); + expect(ticks.map((x) => x.label)).toEqual(["14:22", "14:24", "14:26", "14:28", "14:30"]); + const later = liveSeries([], [], now + 5_000, 10 * MINUTE).grid; + const moved = xTicks(later, 6, opts(now + 5_000)); + expect(moved[0]!.f).toBeLessThan(ticks[0]!.f); + }); + + it("一个都放不下或者没有格子:不标", () => { + const g: Grid = { at: [], step: HOUR, kind: "bucket" }; + expect(xTicks(g, 5, opts(0))).toEqual([]); + const h: Grid = { at: [0, HOUR], step: HOUR, kind: "bucket" }; + expect(xTicks(h, 0, opts(0))).toEqual([]); + }); +}); diff --git a/src/overview/HeroAxis.ts b/src/overview/HeroAxis.ts new file mode 100644 index 00000000..47fa9b38 --- /dev/null +++ b/src/overview/HeroAxis.ts @@ -0,0 +1,158 @@ +import { monthDay } from "@/format"; +import type { Grid } from "./series"; + +/** + * 用量大图的坐标轴:纵轴的刻度、横轴标在哪几格、哪一格还在进行。 + * + * 和 `Hero.tsx` 分开,是为了能测:刻度整不整齐、标签落在哪一格、夏令时那天对不对得上, + * 写在 JSX 里没有东西会回答。 + */ + +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +/** 大图的绘图区多高(骨架照它留地方) */ +export const CHART_H = 184; + +/** 纵轴分几格:四格五条线 */ +export const Y_STEPS = 4; + +/** + * 纵轴的顶:四格,每格是 1 / 2 / 2.5 / 5 乘 10 的幂(乘 `unit`),顶上留出一点空,最高的 + * 那根柱子不顶着最上面那条线。 + * + * · `integer`:数的是个数(请求、token、字节),每格必须是整数 —— 没有「2.5 次请求」。 + * 一格是 2.5 的那一档跳过,不到 1 的按 1 算:只有一两次请求的图,纵轴是 0–4。 + * · `unit`:先换算到这个单位再取整齐的数。字节按 1024 进位,取整要在 KB、MB 里取, + * 不然刻度是「97.7 KB」。 + * · 全是零(或者没有数)时给最小的那一档:个数是 0–4,别的是 0–1 个单位。 + */ +export function niceTop(max: number, { integer = false, unit = 1 }: { integer?: boolean; unit?: number } = {}): number { + const m = max / unit; + if (!(m > 0) || !Number.isFinite(m)) return (integer ? Y_STEPS : 1) * unit; + const raw = (m * 1.04) / Y_STEPS; + let e = 10 ** Math.floor(Math.log10(raw)); + for (;;) { + for (const f of [1, 2, 2.5, 5]) { + // 1e-9:10 的负几次幂乘出来差一点点,不能因此跳到下一档 + const step = Number((f * e).toPrecision(12)); + if (step < raw * (1 - 1e-9)) continue; + if (integer && (step < 1 || !Number.isInteger(step))) continue; + return step * Y_STEPS * unit; + } + e *= 10; + } +} + +/** 纵轴上那五个数:0 到顶,四等分。浮点误差抹掉(「0.30000000000000004」) */ +export function yTicks(top: number): number[] { + return Array.from({ length: Y_STEPS + 1 }, (_, k) => Number(((top * k) / Y_STEPS).toPrecision(12))); +} + +/** + * 字节的纵轴按哪个单位取整:让峰值落在 1–1024 之间的那一级(B、KB、MB…)。返回那一级的 + * 字节数和第几级。 + */ +export function byteUnit(max: number): { unit: number; level: number } { + let level = 0; + while (level < 4 && max >= 1024 ** (level + 1)) level += 1; + return { unit: 1024 ** level, level }; +} + +/** 横轴上的一个标签:落在第几格的起点(`f` 是 0–1 的位置),写什么 */ +export interface XTick { + f: number; + label: string; +} + +/** + * 这一格是过去的、正在进行的(包着此刻、还没过完),还是将来的(「某一天」选的是今天: + * 图画到今晚零点,后面那几格还没到)。实时档的格子是一个个时刻,没有「进行中」。 + */ +export function slotPhase(g: Grid, i: number, now: number): "past" | "ongoing" | "future" { + const at = g.at[i]; + if (at === undefined || g.kind === "instant") return "past"; + if (at > now) return "future"; + return now < at + g.step ? "ongoing" : "past"; +} + +const pad = (n: number) => String(n).padStart(2, "0"); +const sameDay = (a: Date, b: Date) => + a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); + +/** + * 横轴标在哪几格、写什么。标签写在那一格的起点上,往右排开;**最多 `max` 个**(按图的宽度 + * 算,挤不下就隔得更开)。 + * + * · 实时档:整分钟的时刻(每 1 / 2 / 5 分钟一个),写「14:32」。格子跟着时间走,标签也跟着 + * 往左走,位置按时刻算,不按第几格。 + * · 几小时一格:本地的整点(每 1 / 2 / 3 / 6 / 12 小时一个,是格宽的整数倍)。**零点写日期** + * (今天写「今天」):24 小时的图跨过半夜,那一刻要看得出换了一天;「某一天」的第一格 + * 就是那一天的日期。整点排不下时退到按天标。 + * · 一天以上一格:每隔几格标一个日期。**从最右边那一格往回数**:最近的那一天一定标着。 + */ +export function xTicks(g: Grid, max: number, { today, now }: { today: string; now: number }): XTick[] { + const n = g.at.length; + const first = g.at[0]; + if (first === undefined || max < 1) return []; + const nowDate = new Date(now); + const dateOf = (at: number) => (sameDay(new Date(at), nowDate) ? today : monthDay(at)); + + if (g.kind === "instant") { + const last = g.at[n - 1] ?? first; + for (const every of [1, 2, 5, 10].map((m) => m * MINUTE)) { + const out: XTick[] = []; + for (let t = Math.ceil(first / every) * every; t <= last; t += every) { + const d = new Date(t); + // 点画在每一格的正中(见大图),时刻 t 落在两点之间按比例插 + out.push({ f: ((t - first) / g.step + 0.5) / n, label: `${pad(d.getHours())}:${pad(d.getMinutes())}` }); + } + if (out.length <= max) return out; + } + return []; + } + + const at = (i: number) => g.at[i] ?? first; + const tick = (i: number, label: string): XTick => ({ f: i / n, label }); + + if (g.step < DAY) { + const stepH = Math.max(1, Math.round(g.step / HOUR)); + for (const every of [1, 2, 3, 6, 12, 24]) { + if (every < stepH || every % stepH !== 0) continue; + const out: XTick[] = []; + for (let i = 0; i < n; i++) { + const d = new Date(at(i)); + if (d.getMinutes() !== 0 || d.getHours() % every !== 0) continue; + out.push(tick(i, d.getHours() === 0 ? dateOf(at(i)) : `${pad(d.getHours())}:00`)); + } + if (out.length <= max) return out; + } + // 整点排不下:只标零点,再隔几天一个 + const midnights: number[] = []; + for (let i = 0; i < n; i++) { + const d = new Date(at(i)); + if (d.getHours() === 0 && d.getMinutes() === 0) midnights.push(i); + } + return everyFew(midnights, max).map((i) => tick(i, dateOf(at(i)))); + } + + // 一天以上一格。按天的格子从本地零点数固定毫秒,过了夏令时切换会落在前一天 23 点: + // 写离起点最近的那一天 + const all = Array.from({ length: n }, (_, i) => i); + return everyFew(all, max).map((i) => tick(i, dateOf(at(i) + 12 * HOUR))); +} + +/** + * 从一排格子里每隔几个挑一个,挑出来的不超过 `max` 个。**从最后一个往回数**,最后一个 + * 总在里面。隔几个按 1 / 2 / 3 / 5 / 7 / 14 / 28 … 挑,读起来是整齐的间距。 + */ +function everyFew(idx: readonly number[], max: number): number[] { + if (idx.length === 0 || max < 1) return []; + const strides = [1, 2, 3, 5, 7, 14, 28]; + let stride = strides.find((s) => Math.ceil(idx.length / s) <= max); + if (stride === undefined) stride = Math.ceil(idx.length / max); + const out: number[] = []; + for (let k = idx.length - 1; k >= 0; k -= stride) out.push(idx[k]!); + return out.reverse(); +} diff --git a/src/overview/HeroChart.tsx b/src/overview/HeroChart.tsx new file mode 100644 index 00000000..1b2e1941 --- /dev/null +++ b/src/overview/HeroChart.tsx @@ -0,0 +1,369 @@ +import { useId, useLayoutEffect, useState, type KeyboardEvent, type ReactNode } from "react"; +import { cn } from "@/lib/utils"; +import { useText } from "@/i18n"; +import { slotTitle, type Grid } from "./series"; +import { CHART_H, slotPhase, xTicks, yTicks } from "./HeroAxis"; +import { lineGeometry } from "./geometry"; +import { overviewText } from "./overview.i18n"; + +/** + * 用量的大图:坐标轴、柱或线、逐格的提示。画什么由 `Hero` 按选中的指标算好交进来 + * (`ChartSpec`),这里只管画和指。 + */ + +/** 提示里的一行:值在前、名目在后,`key` 是前面那一小段线的颜色(图里对得上的那一样才有) */ +export type TipRow = { v: string; k: string; key?: string }; +export type TipBody = { main: string; rows: TipRow[]; rows2: TipRow[] }; + +export interface ChartSpec { + grid: Grid; + /** 柱:`a` 在下,`b` 叠在上面(失败、下载),中间留 2px */ + bars: { a: readonly number[]; b?: readonly number[]; colorA: string; colorB?: string } | null; + /** 线:第一条是主要的。`wash`:线下面铺一片淡色 */ + lines: { values: readonly (number | null)[]; color: string; wash?: boolean }[] | null; + /** 纵轴的顶 */ + top: number; + axis: (v: number) => string; + tip: (i: number) => TipBody; + legend: { label: string; color: string; shape: "rect" | "stroke" }[]; + /** 图例右边:每格多宽,实时档的速率写单位 */ + note: string | null; +} + +/** 容器有多宽。在画出来之前量(布局阶段),第一帧就是对的宽度 */ +function useWidth(el: HTMLElement | null): number { + const [w, setW] = useState(0); + useLayoutEffect(() => { + if (!el) return; + setW(el.getBoundingClientRect().width); + const ro = new ResizeObserver(() => setW(el.getBoundingClientRect().width)); + ro.observe(el); + return () => ro.disconnect(); + }, [el]); + return w; +} + +/** 横轴上一个标签大约占多宽(「10-27」「14:00」「今天」),按它决定最多标几个 */ +const TICK_W = 64; + +/** + * 大图:左边纵轴四格,横向网格线,柱或线,下面横轴的日期、时刻。指着(或键盘的左右键) + * 逐格出提示:柱的那一格底色升一档,线的那一格画竖线和点。正在进行的那一格(到现在为止, + * 还没过完)画淡一些;「某一天」选的是今天时,此刻之后的格子空着,指不上。 + * + * 柱子最宽 16px、不超过格宽的七成,顶上 4px 圆角,叠起来的两段之间空 2px;有量的那一段 + * 至少看得见(约 2px)。线 2px,没有样本的格子断开、用一段淡的虚线接上(`lineGeometry`)。 + * 没有入场动画;换区间时柱高走过渡(`motion-bar`),实时档不走 —— 格子每十秒挪一次。 + */ +export function Chart({ + spec, + cursor, + onPoint, + now, + live, + label, +}: { + spec: ChartSpec | null; + cursor: number | null; + onPoint: (i: number | null) => void; + now: number; + live: boolean; + label: string; +}) { + const t = useText(overviewText); + const tipId = useId(); + const [plot, setPlot] = useState(null); + const w = useWidth(plot); + if (!spec) { + return ( +
+ {t.chartUnavailable} +
+ ); + } + const { grid, top } = spec; + 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; + + const slotAt = (clientX: number, box: DOMRect): number | null => { + if (n === 0 || box.width <= 0) return null; + const i = Math.min(n - 1, Math.max(0, Math.floor(((clientX - box.left) / box.width) * n))); + return phase(i) === "future" ? null : i; + }; + /** 键盘能走到的最后一格:此刻之后的不算 */ + let lastLive = n - 1; + while (lastLive > 0 && phase(lastLive) === "future") lastLive -= 1; + const onKey = (e: KeyboardEvent) => { + const from = cursor ?? lastLive + 1; + const next = + e.key === "ArrowLeft" ? Math.max(0, from - 1) + : e.key === "ArrowRight" ? Math.min(lastLive, cursor === null ? lastLive : from + 1) + : e.key === "Home" ? 0 + : e.key === "End" ? lastLive + : e.key === "Escape" ? null + : undefined; + if (next === undefined) return; + e.preventDefault(); + onPoint(next); + }; + + const center = (i: number) => ((i + 0.5) / n) * 100; + const tip = cursor !== null ? spec.tip(cursor) : null; + + return ( +
+
+ {ticks.map((v, k) => ( + + {spec.axis(v)} + + ))} +
+
+
{ + const i = slotAt(e.clientX, e.currentTarget.getBoundingClientRect()); + if (i !== cursor) onPoint(i); + }} + onMouseLeave={() => onPoint(null)} + onKeyDown={onKey} + onBlur={() => onPoint(null)} + > + {/* 网格线:最下面那条是基线,深一档 */} + {ticks.map((_, k) => ( + + ))} + + {spec.lines && } + +
+ {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 && ( + <> + + {spec.lines.map((l, k) => { + const v = l.values[cursor]; + if (v == null) return null; + return ( + + ); + })} + + )} + + {tip && cursor !== null && ( + + )} +
+
+ {shownX.map((x) => ( + + {x.label} + + ))} +
+
+
+ ); +} + +/** + * 线(缓存命中率、首 token 的两条分位、实时档的速率)。画在 1000 × 100 的坐标里横向拉伸, + * 线宽不跟着拉(`non-scaling-stroke`)。点在每一格的正中。前后都没有邻居的孤点画成小圆点 + * (用 HTML 画,SVG 里拉伸会变成椭圆)。 + */ +function Lines({ + lines, + n, + top, +}: { + lines: NonNullable; + n: number; + top: number; +}) { + const gid = useId().replace(/:/g, ""); + const xs = Array.from({ length: n }, (_, i) => ((i + 0.5) / n) * 1000); + const y = (v: number) => 100 - Math.min(1, Math.max(0, v / top)) * 100; + // 后画的在上面:对照的那条先画,主要的那条压在它上面 + const drawn = lines.map((l, i) => ({ l, i, geo: lineGeometry(l.values, xs, y, 100) })).reverse(); + return ( + <> + + + {lines.map((l, i) => + l.wash ? ( + + + + + ) : null, + )} + + {drawn.map(({ l, i, geo }) => ( + + {l.wash && geo.area && } + {geo.gap && ( + + )} + {geo.line && ( + + )} + + ))} + + {drawn.flatMap(({ l, i, geo }) => + geo.dots.map((p) => ( + + )), + )} + + ); +} + +/** + * 逐格的提示:哪一段时间,大数,下面几行「值 名目」(图里对得上的那几样前面一小段线做 + * 图例),token 和费用再隔一道线列出这一格排前几的模型。放在那一格的右边(指着右半边时 + * 放左边),贴着图的顶。 + */ +function TipBox({ id, title, body, x, right }: { id: string; title: string; body: TipBody; x: number; right: boolean }) { + const row = (r: TipRow, k: number) => ( +
+ + {r.v} + {r.k} +
+ ); + return ( +