From 337e78c93c40c689bb5ed95b6d20b3c3d9978c5e Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:30:09 +0800 Subject: [PATCH 1/7] Overview redesign: layout skeleton and day ranges Lay out the overview as usage, spend and quota side by side, the past year, then the breakdown table, with placeholder components for the new parts. The metric chosen in the usage card lives on the page so it survives range changes. A day range (from one local midnight to the next) lets the past-year heatmap open a single day; the dashboard command takes an optional end. Co-authored-by: Claude Opus 5.5 --- src-tauri/src/dashboard.rs | 20 ++++++++----- src/overview/Hero.tsx | 27 +++++++++++++++++ src/overview/OverviewPage.tsx | 55 ++++++++++++++++++++++++++--------- src/overview/Quota.tsx | 15 ++++++++++ src/overview/Spend.tsx | 12 ++++++++ src/overview/Year.tsx | 16 ++++++++++ src/overview/metric.ts | 14 +++++++++ src/overview/useOverview.ts | 8 ++++- src/ui/range.tsx | 37 ++++++++++++++++++++++- 9 files changed, 182 insertions(+), 22 deletions(-) create mode 100644 src/overview/Hero.tsx create mode 100644 src/overview/Quota.tsx create mode 100644 src/overview/Spend.tsx create mode 100644 src/overview/Year.tsx create mode 100644 src/overview/metric.ts diff --git a/src-tauri/src/dashboard.rs b/src-tauri/src/dashboard.rs index 2407f8d2..2e628d6f 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,6 +38,9 @@ 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); // **十四样同时问,不排队。**每一问都是一条新连接、一次握手:排着队问,连着远程时 // 十四问的来回一个接一个叠起来,而这一页是默认打开的那一页、每个请求结束都要重取。 @@ -42,17 +48,17 @@ pub async fn dashboard( 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, diff --git a/src/overview/Hero.tsx b/src/overview/Hero.tsx new file mode 100644 index 00000000..b87d5b05 --- /dev/null +++ b/src/overview/Hero.tsx @@ -0,0 +1,27 @@ +import type { Dashboard } from "@/types"; +import type { Range } from "@/ui/range"; +import { CardGrid } from "./Cards"; +import type { Metric } from "./metric"; + +/** + * 用量:六个指标并排,选中的那个画成下面带坐标轴的大图。 + * + * 占位:先沿用原来的六张指标卡,等这一块换上。 + */ +export function Hero({ + d, + range, + scope, + step, +}: { + d: Dashboard; + range: Range; + /** 数据属于哪个区间(`rangeId`):换了区间,数字直接落到新值 */ + scope: string; + /** 历史档的格宽(`bucketOf`) */ + step: number; + metric: Metric; + onMetric: (m: Metric) => void; +}) { + return ; +} diff --git a/src/overview/OverviewPage.tsx b/src/overview/OverviewPage.tsx index 188a015e..187a5c83 100644 --- a/src/overview/OverviewPage.tsx +++ b/src/overview/OverviewPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; // 大数的字(Geist),打包在应用里:离线也要是这个字。只在概览上用,跟着这一页载入 import "@fontsource/geist/latin-600.css"; import { cn } from "@/lib/utils"; @@ -6,7 +6,7 @@ import { Page, PageHeader } from "@/ui/page"; import { useIgnored } from "@/ignore"; import { Banner } from "@/ui/banner"; import { ErrorState } from "@/ui/states"; -import { RangePicker, useRange } from "@/ui/range"; +import { RangePicker, dayRange, useRange } from "@/ui/range"; import type { Dashboard, Overview } from "@/types"; import { useText } from "@/i18n"; import { FirstRequestHint } from "@/guide/FirstRequestHint"; @@ -15,8 +15,12 @@ import { bucketOf, useOverview, type Shown } from "./useOverview"; import { OverviewStatus } from "./OverviewStatus"; import { OverviewSkeleton } from "./OverviewSkeleton"; import { AttentionStrip } from "./Attention"; -import { CardGrid } from "./Cards"; +import { Hero } from "./Hero"; +import { Spend } from "./Spend"; +import { Quota, hasQuota } from "./Quota"; +import { Year } from "./Year"; import { Breakdown } from "./Breakdown"; +import type { Metric } from "./metric"; import { overviewText } from "./overview.i18n"; /** @@ -38,12 +42,15 @@ function neverUsed(d: Dashboard): boolean { * 这一页的每一个数字都受那条约束:**绝不让估算值混进精确数字里假装准确。**所以 * 金额旁边永远跟着它的限定词。 * - * 版面:页头(状态一行、时间范围);需要处理的事(没有就不出现);六张指标卡,每张一个 - * 大数和一张小图,指着任何一张,每一张都停在同一个时刻;下面一张按模型、上游、密钥分的 - * 明细表。 + * 版面:页头(状态一行、时间范围);需要处理的事(没有就不出现);用量(六个指标并排, + * 选中的那个画成下面的大图);去向和额度与余额并排;最近一年的热力图;按模型、上游、 + * 密钥分的明细表。 * - * 实时档里 token、费用、请求三张卡片是最近十分钟,跟着事件流走;缓存命中、首 token、 - * 流量和明细表按最近 24 小时算,旁边标着。 + * **跟着时间范围走的放在一起**:用量、去向、明细。额度与余额是此刻的状态,最近一年固定 + * 一年,各自标着自己的口径。 + * + * 实时档里 token、费用、请求三项是最近十分钟,跟着事件流走;缓存命中、首 token、流量、 + * 去向和明细表按最近 24 小时算,旁边标着。 */ export default function OverviewPage({ tick, @@ -59,6 +66,9 @@ export default function OverviewPage({ const t = useText(overviewText); const [range, setRange] = useRange(); const o = useOverview(range, tick); + // 选中的指标放在这一层:换时间范围时下面整块重挂,选择不该跟着丢 + const [metric, setMetric] = useState("tokens"); + const pickDay = useCallback((day: Date) => setRange(dayRange(day)), [setRange]); const landed = useRef(onLanded); landed.current = onLanded; @@ -68,7 +78,7 @@ export default function OverviewPage({ }, [settled]); return ( - + } actions={} @@ -78,7 +88,15 @@ export default function OverviewPage({ ) : o.shown === null ? ( ) : ( - + )} ); @@ -88,11 +106,19 @@ function Body({ shown, ov, switching, + tick, + metric, + onMetric, + onPickDay, }: { shown: Shown; ov: Overview | null; /** 选中的区间还在取,画的是上一个区间的 */ switching: boolean; + tick: number; + metric: Metric; + onMetric: (m: Metric) => void; + onPickDay: (day: Date) => void; }) { const t = useText(overviewText); const { data: d, range, id } = shown; @@ -132,10 +158,13 @@ function Body({ ) : ( <> - -
- + +
+ +
+ + )}
diff --git a/src/overview/Quota.tsx b/src/overview/Quota.tsx new file mode 100644 index 00000000..32540be0 --- /dev/null +++ b/src/overview/Quota.tsx @@ -0,0 +1,15 @@ +import type { Dashboard, Overview } from "@/types"; + +/** + * 额度与余额:订阅的额度窗口和各家余额,此刻的状态,不跟时间范围走。 + * + * 占位:等这一块换上。 + */ +export function Quota(_: { d: Dashboard; ov: Overview | null }) { + return null; +} + +/** 有没有一家上游说得出额度或余额。没有的话这一块不出现,「去向」占满一行 */ +export function hasQuota(_d: Dashboard, _ov: Overview | null): boolean { + return false; +} diff --git a/src/overview/Spend.tsx b/src/overview/Spend.tsx new file mode 100644 index 00000000..ee0b4f9b --- /dev/null +++ b/src/overview/Spend.tsx @@ -0,0 +1,12 @@ +import type { Dashboard } from "@/types"; +import type { Range } from "@/ui/range"; +import type { Metric } from "./metric"; + +/** + * 去向:按模型、上游、密钥排出所选指标的份额。 + * + * 占位:等这一块换上。 + */ +export function Spend(_: { d: Dashboard; range: Range; metric: Metric }) { + return null; +} diff --git a/src/overview/Year.tsx b/src/overview/Year.tsx new file mode 100644 index 00000000..915c7ec5 --- /dev/null +++ b/src/overview/Year.tsx @@ -0,0 +1,16 @@ +import type { Range } from "@/ui/range"; + +/** + * 最近一年:一天一格的热力图,固定一年,不跟时间范围走;所选时间范围在格子上描边。 + * + * 占位:等这一块换上。 + */ +export function Year(_: { + range: Range; + /** 库里多了请求、手动刷新:重取 */ + tick: number; + /** 点了某一天:时间范围切到那一天 */ + onPickDay: (day: Date) => void; +}) { + return null; +} diff --git a/src/overview/metric.ts b/src/overview/metric.ts new file mode 100644 index 00000000..48b970ce --- /dev/null +++ b/src/overview/metric.ts @@ -0,0 +1,14 @@ +/** + * 「用量」那张卡片上六个指标里选中的那一个。 + * + * **一个选择管两处**:大图画它,「去向」按它排。放在页面这一层(`OverviewPage`), + * 换时间范围时不丢。 + */ +export type Metric = "tokens" | "cost" | "requests" | "cache" | "ttft" | "traffic"; + +export const METRICS: readonly Metric[] = ["tokens", "cost", "requests", "cache", "ttft", "traffic"]; + +/** 能加起来的几样:去向写得出占比,热力图能按天合计 */ +export function additive(m: Metric): boolean { + return m === "tokens" || m === "cost" || m === "requests" || m === "traffic"; +} diff --git a/src/overview/useOverview.ts b/src/overview/useOverview.ts index 17f7e43c..2b8cb7a1 100644 --- a/src/overview/useOverview.ts +++ b/src/overview/useOverview.ts @@ -12,6 +12,7 @@ const HOUR = 3_600_000; */ export function rangeId(r: Range): string { if (r.live) return "live"; + if (r.to !== undefined) return `day:${r.from ?? r.to}`; if (r.custom) return `from:${new Date(r.from ?? Date.now() - r.ms).toDateString()}`; return `last:${r.ms}`; } @@ -46,7 +47,12 @@ export function useOverview(range: Range, tick: number) { const id = rangeId(range); const r = useResource( `overview:${id}`, - () => invoke("dashboard", { sinceMs: windowStart(range), bucketMs: bucketOf(range) }), + () => + invoke("dashboard", { + sinceMs: windowStart(range), + untilMs: range.to ?? null, + bucketMs: bucketOf(range), + }), { deps: [tick] }, ); const last = useRef(null); diff --git a/src/ui/range.tsx b/src/ui/range.tsx index 0ae28165..775306b3 100644 --- a/src/ui/range.tsx +++ b/src/ui/range.tsx @@ -54,6 +54,11 @@ export type Range = { * 往后挪,标签还写着「9/8 至今」,最早那一段却悄悄掉出了合计 */ from?: number; + /** + * 区间到哪一刻为止(不含)。**只有「某一天」有**(`dayRange`):别的区间都到现在为止。 + * 今天那一格的终点还在将来,窗口照样只取到现在 + */ + to?: number; }; /* @@ -136,6 +141,30 @@ export function customRange(d: Date): Range { }; } +/** + * 某一天:那一天的本地零点到第二天零点。概览上「最近一年」点一个格子时切到这里。 + * + * **和自定义区间不同,它有终点**(`to`):自定义区间是「从那一天到现在」,而点一个格子 + * 看的是那一天本身。跨度按两个零点量,夏令时切换的那一天是 23 或 25 小时。 + * 「较上一个」比的是它前面等长的那一段,也就是前一天。 + */ +export function dayRange(d: Date): Range { + const from = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + const to = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime(); + return { + ms: to - from, + from, + to, + get label() { + return new Date(from).toLocaleDateString(); + }, + get compare() { + return textOf(rangeText).preset["1d"]; + }, + custom: true, + }; +} + /** * 实时档的窗口:十分钟,五秒一格(见 `LIVE_BUCKET_MS`)。 * @@ -194,6 +223,10 @@ export function useRange( if (raw === "live") return LIVE_RANGE; const p = PRESETS.find((x) => x.id === raw); if (p) return fromPreset(p); + if (raw.startsWith("day:")) { + const d = new Date(raw.slice(4)); + if (!Number.isNaN(d.getTime()) && d.getTime() < Date.now()) return dayRange(d); + } if (raw.startsWith("from:")) { const d = new Date(raw.slice(5)); // 存坏了、或者那一天在将来(改过系统时间):当没存过 @@ -211,7 +244,9 @@ export function useRange( try { const id = r.live ? "live" - : r.custom + : r.to !== undefined && r.from !== undefined + ? `day:${new Date(r.from).toISOString()}` + : r.custom ? `from:${new Date(r.from ?? Date.now() - r.ms).toISOString()}` : (PRESETS.find((x) => x.ms === r.ms)?.id ?? ""); if (id) window.localStorage.setItem(key, id); From 1ce4046c79e2501b5ad1790d95e10340f54af625 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:01:11 +0800 Subject: [PATCH 2/7] Overview usage card: six metrics over one chart with axes Replace the six metric cards with one usage card. The six metrics sit side by side as tabs (three by two on a narrow page); each shows its figure and the change from the prior period with the same rules as before. The selected metric is drawn below as a large chart with a four-step axis, grid lines, date and time ticks, and a tooltip per slot that lists the top models for tokens and cost. Requests and traffic stack their two parts; cache hit rate and first-token percentiles are lines. The slot still in progress is drawn fainter, and a day range ends at that day's end rather than now. The cost qualifier (estimated, unpriced, no usage, still being priced) stays visible on the metric and in full in the detail line, not only on hover. The live range keeps tokens, cost and requests to ten minutes and labels the 24-hour metrics. The old card and sparkline components are removed; axis, tick and model ranking helpers are pure functions with tests. Co-authored-by: Claude Opus 5.5 --- src/overview/Cards.tsx | 677 ------------------------ src/overview/Hero.test.tsx | 151 ++++++ src/overview/Hero.tsx | 836 +++++++++++++++++++++++++++++- src/overview/HeroAxis.test.ts | 154 ++++++ src/overview/HeroAxis.ts | 158 ++++++ src/overview/HeroChart.tsx | 369 +++++++++++++ src/overview/OverviewSkeleton.tsx | 91 +++- src/overview/Spark.tsx | 279 ---------- src/overview/geometry.test.ts | 14 +- src/overview/geometry.ts | 17 +- src/overview/overview.i18n.ts | 81 ++- src/overview/series.test.ts | 148 ++++-- src/overview/series.ts | 180 ++++++- 13 files changed, 2073 insertions(+), 1082 deletions(-) delete mode 100644 src/overview/Cards.tsx create mode 100644 src/overview/Hero.test.tsx create mode 100644 src/overview/HeroAxis.test.ts create mode 100644 src/overview/HeroAxis.ts create mode 100644 src/overview/HeroChart.tsx delete mode 100644 src/overview/Spark.tsx 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..579b32df --- /dev/null +++ b/src/overview/Hero.test.tsx @@ -0,0 +1,151 @@ +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, + 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).toContain("≥~$0.032"); + }); + + it("一条都没算出费用:大数写「—」,旁边写「无法计价」,不写 $0", () => { + 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 index b87d5b05..d1c8df51 100644 --- a/src/overview/Hero.tsx +++ b/src/overview/Hero.tsx @@ -1,18 +1,74 @@ -import type { Dashboard } from "@/types"; +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 { CardGrid } from "./Cards"; -import type { Metric } from "./metric"; +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; @@ -23,5 +79,775 @@ export function Hero({ metric: Metric; onMetric: (m: Metric) => void; }) { - return ; + 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 <>—; + return c.prefix + usd(Math.round(v))} scope={scope} />; +} + +/** + * 费用指标上**一直看得见**的限定词(`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 ( +