Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions changelog/unreleased/overview-drilldown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
### English

- **Console** · Overview rankings drill down: clicking a model, account or error kind opens the logs page filtered to it over the overview's current window, and drag-selecting a range on the traffic chart opens the logs for that range. The logs page filters now live in the URL and stay in sync with its controls.
- **Console** · The overview time window adds 30d and a custom from/to range. Stats refresh every 30s in the background (no skeleton), with a pause toggle, and skip refreshes while the tab is hidden.

### 中文

- **界面** · 概览排行支持下钻:点击模型、账号或错误类型,会按概览当前时间窗口打开已筛选的日志页;在流量图上拖选时间段,可直接查看该时段的日志。日志页筛选条件写入 URL,并与筛选控件双向同步。
- **界面** · 概览时间窗口新增 30 天与自定义起止时间;统计每 30 秒在后台刷新(不显示骨架屏),可暂停,标签页隐藏时不刷新。
11 changes: 11 additions & 0 deletions changelog/unreleased/overview-stats.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
### English

- **Console** · The overview's third ranking card now shows request outcomes (ok / incomplete / error / canceled) as a stacked bar together with the error mix and the busiest accounts; new metric cards show cache hit rate (cache read / prompt tokens), streaming share and canceled requests.
- **Console** · The account pool card lists each cooling account with its reason, a live countdown and restart backoff level, and shows the session-affinity hit rate.
- **Console** · Cards and icon tiles get a subtle solid surface (top highlight, close and soft lift shadows); live status dots pulse gently (off under reduced motion); metric numbers roll like an odometer.

### 中文

- **界面** · 概览第三个排行卡片同时展示请求结果构成(成功 / 未完成 / 失败 / 取消,堆叠条)、错误构成与热门账号;新增缓存命中率(缓存读取 / 输入 token)、流式占比与已取消请求指标卡片。
- **界面** · 账号池卡片列出每个冷却中账号的原因、实时倒计时与重启退避等级,并显示会话亲和命中率。
- **界面** · 卡片与图标块采用更细腻的实体表面(顶部高光、贴近阴影与柔和抬升阴影);在线状态点轻微脉动(减少动态效果时关闭);指标数字以滚轮方式滚动。
299 changes: 247 additions & 52 deletions frontend/src/components/overview/CountUp.tsx
Original file line number Diff line number Diff line change
@@ -1,53 +1,248 @@
import { useLayoutEffect, useRef } from 'react'
import { formatCountKind } from '@/lib/format'
import { duration, ease, gsap } from '@/lib/motion'

// Rolls from the previously shown value to the new one (from 0 on first mount),
// and nudges in from the side it moved toward, after arc's animated-counter /
// metric-card: a larger value rises from below, a smaller one drops from above.
export function CountUp({
value,
kind = 'int',
className,
}: {
value: number
kind?: 'int' | 'compact' | 'percent' | 'ms'
className?: string
}) {
const ref = useRef<HTMLSpanElement>(null)
const shown = useRef(0)
const mounted = useRef(false)

useLayoutEffect(() => {
const el = ref.current
if (!el) return
const from = shown.current
const isUpdate = mounted.current
mounted.current = true
const state = { value: from }
const context = gsap.context(() => {
const media = gsap.matchMedia()
media.add('(prefers-reduced-motion: reduce)', () => {
shown.current = value
el.textContent = formatCountKind(value, kind)
})
media.add('(prefers-reduced-motion: no-preference)', () => {
gsap.to(state, {
value,
duration: isUpdate ? duration.calm : duration.slow,
ease: ease.out,
onUpdate: () => {
shown.current = state.value
el.textContent = formatCountKind(state.value, kind)
},
})
if (isUpdate && value !== from) {
gsap.fromTo(el, { y: value > from ? 6 : -6, autoAlpha: 0.4 }, { y: 0, autoAlpha: 1, duration: duration.base, ease: ease.out })
}
})
}, el)
return () => context.revert()
}, [kind, value])

return <span ref={ref} className={`inline-block ${className ?? ''}`}>{formatCountKind(0, kind)}</span>
/*
* CountUp: odometer-style number roll for the Overview metric cards.
*
* Adapted from affectui "Number Ticker" (motion/number-ticker.tsx),
* https://github.com/flohoeller/affectui
*
* MIT License
* Copyright (c) 2026 Florian Höller (adfects)
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Local changes: takes the console's own formatter (formatCountKind) instead of
* Intl.NumberFormat so "4.2k" / "93%" / "6.4s" keep their existing shape;
* starts immediately (no IntersectionObserver / delay); new columns slide in
* without an opacity fade; exported as CountUp with the previous props.
*/
import { useEffect, useMemo, useRef } from "react";
import { formatCountKind } from "@/lib/format";

/*
* Number Ticker – animated numbers like an odometer, in a single self-contained file.
* Every digit is a small vertical column that rolls to its new value: up when the number grows, down when it
* shrinks. On first view it counts in from zero. Formatting comes from Intl.NumberFormat, so currency signs,
* separators and percent signs stay put while only the digits move. The styles are injected below.
*/

export type CountUpProps = {
/** The number to show */
value: number;
/** Console formatter kind */
kind?: CountKind;
/** Length of a roll in ms */
duration?: number;
className?: string;
};

type CountKind = "int" | "compact" | "percent" | "ms";

type Part = { key: string; digit: number } | { key: string; char: string };

/** "$48,240" → parts keyed from the right, so the ones column keeps its identity when the number grows */
function split(text: string): Part[] {
const chars = Array.from(text);
const parts: Part[] = [];
let d = 0;
let s = 0;
for (let i = chars.length - 1; i >= 0; i--) {
const c = chars[i];
if (c >= "0" && c <= "9") parts.unshift({ key: `d${d++}`, digit: c.charCodeAt(0) - 48 });
else parts.unshift({ key: `s${s++}`, char: c });
}
return parts;
}

const mod10 = (n: number) => ((n % 10) + 10) % 10;
const ease = (t: number) => 1 - Math.pow(1 - t, 4);
/** Position → transform; the stack holds 0–9 plus a trailing 0, so 9 → 0 rolls seamlessly */
const place = (el: HTMLElement, pos: number) => {
el.style.transform = `translateY(${(-mod10(pos) / 11) * 100}%)`;
};

type Roll = { pos: number; from: number; to: number };

export function CountUp({ value, kind = "int", duration = 900, className }: CountUpProps) {
const text = useMemo(() => formatCountKind(value, kind), [value, kind]);
const parts = useMemo(() => split(text), [text]);

const root = useRef<HTMLSpanElement>(null);
const rolls = useRef(new Map<string, Roll>());
const stacks = useRef(new Map<string, HTMLElement>());
const prev = useRef(0);
const up = useRef(true);
const mounted = useRef(false);
const entering = useRef(new Set<string>());
const seen = useRef(new Set<string>());

// Columns that show up after the first render slide in (e.g. 9,999 → 10,000)
const keysNow = new Set(parts.filter((p) => "digit" in p).map((p) => p.key));
for (const k of keysNow) if (mounted.current && !seen.current.has(k)) entering.current.add(k);
for (const k of entering.current) if (!keysNow.has(k)) entering.current.delete(k);
seen.current = keysNow;

useEffect(() => {
mounted.current = true;
}, []);

// Roll every digit column to its new value
useEffect(() => {
// Same value again (e.g. a format change) keeps the last direction
if (value !== prev.current) up.current = value > prev.current;
prev.current = value;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const keys = new Set<string>();

for (const p of parts) {
if (!("digit" in p)) continue;
keys.add(p.key);
const r = rolls.current.get(p.key) ?? { pos: 0, from: 0, to: 0 };
const delta = up.current ? mod10(p.digit - r.pos) : -mod10(r.pos - p.digit);
r.from = r.pos;
r.to = r.pos + delta;
if (reduce) r.pos = r.from = r.to;
rolls.current.set(p.key, r);
}
for (const k of rolls.current.keys()) if (!keys.has(k)) rolls.current.delete(k);

const apply = () => {
for (const [k, r] of rolls.current) {
const el = stacks.current.get(k);
if (el) place(el, r.pos);
}
};
if (reduce) return apply();

let raf = 0;
const t0 = performance.now();
const tick = (now: number) => {
const t = Math.min(1, (now - t0) / duration);
const e = ease(t);
for (const r of rolls.current.values()) r.pos = r.from + (r.to - r.from) * e;
apply();
if (t < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [parts, value, duration]);

return (
<>
<style href="ui-number-ticker" precedence="default">
{css}
</style>
<span ref={root} className={["ui-nt", className].filter(Boolean).join(" ")} aria-live="polite">
<span className="ui-nt__sr">{text}</span>
<span className="ui-nt__track" aria-hidden="true">
{parts.map((p) =>
"digit" in p ? (
<span key={p.key} className="ui-nt__col" data-enter={entering.current.has(p.key) || undefined}>
<span
className="ui-nt__stack"
ref={(el) => {
if (!el) return;
stacks.current.set(p.key, el);
place(el, rolls.current.get(p.key)?.pos ?? 0);
return () => {
if (stacks.current.get(p.key) === el) stacks.current.delete(p.key);
};
}}
>
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0].map((n, i) => (
<span key={i} className="ui-nt__cell">
{n}
</span>
))}
</span>
</span>
) : (
<span key={p.key} className="ui-nt__static">
{p.char}
</span>
),
)}
</span>
</span>
</>
);
}

/* ---------- Styles ---------- */

const css = /* css */ `
.ui-nt {
--ui-nt-lh: 1.15em;
--ui-nt-ease: cubic-bezier(0.22, 1, 0.36, 1);
position: relative;
display: inline-flex;
line-height: var(--ui-nt-lh);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.ui-nt__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.ui-nt__track {
display: inline-flex;
align-items: flex-start;
height: var(--ui-nt-lh);
line-height: var(--ui-nt-lh);
}
.ui-nt__col {
display: inline-block;
height: var(--ui-nt-lh);
overflow: hidden;
/* Soft top and bottom edges while digits pass through */
-webkit-mask-image: linear-gradient(transparent, #000 14%, #000 86%, transparent);
mask-image: linear-gradient(transparent, #000 14%, #000 86%, transparent);
}
.ui-nt__col[data-enter] { animation: ui-nt-in 450ms var(--ui-nt-ease) both; }
.ui-nt__stack {
display: flex;
flex-direction: column;
will-change: transform;
}
.ui-nt__cell,
.ui-nt__static {
display: block;
height: var(--ui-nt-lh);
text-align: center;
}
.ui-nt__static { white-space: pre; }

@keyframes ui-nt-in {
from { transform: translateY(-35%); }
}
@media (prefers-reduced-motion: reduce) {
.ui-nt__col[data-enter] { animation: none; }
}

/* Dark mode: follows a .dark class or data-theme="dark" on any ancestor, e.g. <html>.
The ticker inherits its color, so nothing needs to change here. */
:where(.dark, [data-theme="dark"]) .ui-nt {
--ui-nt-lh: 1.15em;
}
`;
Loading
Loading