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 秒在后台刷新(不显示骨架屏),可暂停,标签页隐藏时不刷新。
57 changes: 55 additions & 2 deletions frontend/src/components/overview/TrafficChart.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { useLayoutEffect, useMemo, useRef } from 'react'
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import gsap from 'gsap'
import {
Area,
CartesianGrid,
ComposedChart,
Line,
ReferenceArea,
ResponsiveContainer,
Tooltip,
XAxis,
Expand Down Expand Up @@ -79,13 +80,22 @@ export function TrafficChart({
emptyLabel,
okLabel,
errorLabel,
onSelectRange,
selectHint,
}: {
series: RequestStatsPoint[]
lang: 'en' | 'zh'
emptyLabel: string
okLabel: string
errorLabel: string
/** Called with an ISO [from, to) range after the user drag-selects buckets. */
onSelectRange?: (from: string, to: string) => void
selectHint?: string
}) {
const [dragStart, setDragStart] = useState<string | null>(null)
const [dragEnd, setDragEnd] = useState<string | null>(null)
// Draw the lines in once per mount; background refreshes must not replay it.
const playedRef = useRef(false)
const rootRef = useRef<HTMLDivElement>(null)
// 修复:成功/失败折线必须各自取自己的计数。
// 之前红色折线/面积用的是 requests(总请求数),与成功线在几乎无报错时重合,
Expand Down Expand Up @@ -117,6 +127,7 @@ export function TrafficChart({
const root = rootRef.current
if (!root || !hasTraffic) return

if (playedRef.current) return
let played = false
let observer: MutationObserver | null = null
const context = gsap.context(() => {
Expand Down Expand Up @@ -152,13 +163,15 @@ export function TrafficChart({

if (play()) {
played = true
playedRef.current = true
return
}

observer = new MutationObserver(() => {
if (played) return
if (play()) {
played = true
playedRef.current = true
observer?.disconnect()
}
})
Expand All @@ -171,6 +184,25 @@ export function TrafficChart({
}
}, [hasTraffic, signature])

const step = data.length > 1 ? Date.parse(data[1].at) - Date.parse(data[0].at) : 60 * 60 * 1000
const finishDrag = () => {
if (dragStart && dragEnd && onSelectRange) {
const a = Date.parse(dragStart)
const b = Date.parse(dragEnd)
if (Number.isFinite(a) && Number.isFinite(b)) {
const from = Math.min(a, b)
const to = Math.max(a, b) + step
onSelectRange(new Date(from).toISOString(), new Date(to).toISOString())
}
}
setDragStart(null)
setDragEnd(null)
}
const labelOf = (state: unknown) => {
const label = (state as { activeLabel?: string | number } | null)?.activeLabel
return label == null ? null : String(label)
}

if (!series.length || !hasTraffic) {
return <TrafficChartEmpty emptyLabel={emptyLabel} />
}
Expand All @@ -182,6 +214,21 @@ export function TrafficChart({
<ComposedChart
data={data}
margin={{ top: 8, right: 8, bottom: 0, left: 0 }}
style={onSelectRange ? { cursor: 'crosshair', userSelect: 'none' } : undefined}
onMouseDown={onSelectRange ? (state) => {
const label = labelOf(state)
if (label) {
setDragStart(label)
setDragEnd(label)
}
} : undefined}
onMouseMove={onSelectRange ? (state) => {
if (!dragStart) return
const label = labelOf(state)
if (label) setDragEnd(label)
} : undefined}
onMouseUp={onSelectRange ? finishDrag : undefined}
onMouseLeave={onSelectRange ? () => { setDragStart(null); setDragEnd(null) } : undefined}
>
<defs>
<linearGradient id="traffic-ok" x1="0" y1="0" x2="0" y2="1">
Expand Down Expand Up @@ -257,6 +304,9 @@ export function TrafficChart({
activeDot={{ r: 4, fill: 'var(--surface)', stroke: 'var(--danger)', strokeWidth: 1.5 }}
className="traffic-error-line"
/>
{dragStart && dragEnd ? (
<ReferenceArea x1={dragStart} x2={dragEnd} fill="var(--foreground)" fillOpacity={0.06} stroke="none" ifOverflow="visible" />
) : null}
</ComposedChart>
</ResponsiveContainer>
</div>
Expand All @@ -265,7 +315,10 @@ export function TrafficChart({
<span className="inline-flex items-center gap-1.5"><span className="size-1.5 rounded-full bg-success" />{okLabel}</span>
<span className="inline-flex items-center gap-1.5"><span className="size-1.5 rounded-full bg-danger" />{errorLabel}</span>
</div>
<span className="mono">{formatCompact(peak)} peak</span>
<span className="flex items-center gap-3">
{onSelectRange && selectHint ? <span>{selectHint}</span> : null}
<span className="mono">{formatCompact(peak)} peak</span>
</span>
</div>
</div>
)
Expand Down
151 changes: 151 additions & 0 deletions frontend/src/components/ui/TimeRangePicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { getLocalTimeZone, parseAbsoluteToLocal, toCalendarDateTime, type DateValue } from '@internationalized/date'
import {
DateField,
DateRangePicker,
Label,
RangeCalendar,
TimeField,
type TimeValue,
} from '@heroui/react'

export type DateRangeValue = { start: DateValue; end: DateValue }

/** DateValue (local wall time) → ISO instant; endOfMinute rounds the end up to :59.999. */
export function dateValueToISO(value: DateValue | null | undefined, endOfMinute = false) {
if (!value) return undefined
const date = value.toDate(getLocalTimeZone())
if (!Number.isFinite(date.getTime())) return undefined
if (endOfMinute) date.setSeconds(59, 999)
return date.toISOString()
}

/** ISO instant → local CalendarDateTime for the picker; null when unparsable. */
export function isoToDateValue(iso: string | null | undefined): DateValue | null {
if (!iso) return null
const ms = Date.parse(iso)
if (!Number.isFinite(ms)) return null
try {
return toCalendarDateTime(parseAbsoluteToLocal(new Date(ms).toISOString()))
} catch {
return null
}
}

/** Minute-granularity from/to range picker shared by the logs and overview pages. */
export function TimeRangePicker({
value,
onChange,
ariaLabel,
fromLabel,
toLabel,
className,
}: {
value: DateRangeValue | null
onChange: (next: DateRangeValue | null) => void
ariaLabel: string
fromLabel: string
toLabel: string
className?: string
}) {
return (
<DateRangePicker
className={className}
granularity="minute"
hourCycle={24}
shouldForceLeadingZeros
value={value}
onChange={(next) => onChange(next)}
aria-label={ariaLabel}
>
{({ state }) => (
<>
<DateField.Group className="w-full min-w-0" variant="secondary">
<DateField.Input className="min-w-0" slot="start" aria-label={fromLabel}>
{(segment) => <DateField.Segment segment={segment} />}
</DateField.Input>
<DateRangePicker.RangeSeparator />
<DateField.Input className="min-w-0" slot="end" aria-label={toLabel}>
{(segment) => <DateField.Segment segment={segment} />}
</DateField.Input>
<DateField.Suffix>
<DateRangePicker.Trigger>
<DateRangePicker.TriggerIndicator />
</DateRangePicker.Trigger>
</DateField.Suffix>
</DateField.Group>
<DateRangePicker.Popover className="flex w-[20.5rem] max-w-[calc(100vw-2rem)] flex-col gap-3 p-3">
<RangeCalendar aria-label={ariaLabel}>
<RangeCalendar.Header>
<RangeCalendar.YearPickerTrigger>
<RangeCalendar.YearPickerTriggerHeading />
<RangeCalendar.YearPickerTriggerIndicator />
</RangeCalendar.YearPickerTrigger>
<RangeCalendar.NavButton slot="previous" />
<RangeCalendar.NavButton slot="next" />
</RangeCalendar.Header>
<RangeCalendar.Grid>
<RangeCalendar.GridHeader>
{(day) => (
<RangeCalendar.HeaderCell>{day}</RangeCalendar.HeaderCell>
)}
</RangeCalendar.GridHeader>
<RangeCalendar.GridBody>
{(date) => <RangeCalendar.Cell date={date} />}
</RangeCalendar.GridBody>
</RangeCalendar.Grid>
<RangeCalendar.YearPickerGrid>
<RangeCalendar.YearPickerGridBody>
{({ year }) => <RangeCalendar.YearPickerCell year={year} />}
</RangeCalendar.YearPickerGridBody>
</RangeCalendar.YearPickerGrid>
</RangeCalendar>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<Label className="text-xs">{fromLabel}</Label>
<TimeField
aria-label={fromLabel}
granularity="minute"
hourCycle={24}
value={state.timeRange?.start ?? null}
onChange={(value) =>
state.setTimeRange({
start: value as TimeValue,
end: state.timeRange?.end as TimeValue,
})
}
>
<TimeField.Group className="w-[7.5rem]" variant="secondary">
<TimeField.Input>
{(segment) => <TimeField.Segment segment={segment} />}
</TimeField.Input>
</TimeField.Group>
</TimeField>
</div>
<div className="flex items-center justify-between gap-3">
<Label className="text-xs">{toLabel}</Label>
<TimeField
aria-label={toLabel}
granularity="minute"
hourCycle={24}
value={state.timeRange?.end ?? null}
onChange={(value) =>
state.setTimeRange({
start: state.timeRange?.start as TimeValue,
end: value as TimeValue,
})
}
>
<TimeField.Group className="w-[7.5rem]" variant="secondary">
<TimeField.Input>
{(segment) => <TimeField.Segment segment={segment} />}
</TimeField.Input>
</TimeField.Group>
</TimeField>
</div>
</div>
</DateRangePicker.Popover>
</>
)}
</DateRangePicker>
)
}
14 changes: 14 additions & 0 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,13 @@ export const messages: Record<Lang, Dict> = {
statsWindow1h: '1h',
statsWindow24h: '24h',
statsWindow7d: '7d',
statsWindow30d: '30d',
statsWindowCustom: 'Custom',
statsRefreshPause: 'Pause auto-refresh',
statsRefreshResume: 'Resume auto-refresh',
statsRefreshAuto: 'Refreshing every 30s · click to pause',
statsRefreshPaused: 'Auto-refresh paused · click to resume',
statsDragHint: 'Drag to open logs for a range',
statsWindowHint: '{window} window',
statsTraffic: 'Traffic',
statsTrafficHint: 'Completed chat requests in the selected window',
Expand Down Expand Up @@ -1025,6 +1032,13 @@ export const messages: Record<Lang, Dict> = {
statsWindow1h: '1小时',
statsWindow24h: '24小时',
statsWindow7d: '7天',
statsWindow30d: '30天',
statsWindowCustom: '自定义',
statsRefreshPause: '暂停自动刷新',
statsRefreshResume: '恢复自动刷新',
statsRefreshAuto: '每 30 秒刷新 · 点击暂停',
statsRefreshPaused: '自动刷新已暂停 · 点击恢复',
statsDragHint: '拖选时间段查看日志',
statsWindowHint: '{window}窗口',
statsTraffic: '流量',
statsTrafficHint: '所选时间窗口内的已完成聊天请求',
Expand Down
21 changes: 21 additions & 0 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,18 @@ body {
color: var(--muted);
}

/* Drill-down rows in the overview rankings: a quiet tone shift, no outline. */
.rank-row {
transition: background-color 120ms ease;
}
.rank-row:hover {
background: color-mix(in oklch, var(--foreground) 3%, transparent);
}
.rank-row:focus-visible {
outline: none;
background: color-mix(in oklch, var(--foreground) 5%, transparent);
}

/* Icon tile used by metric cards and section headers. */
.icon-tile {
display: inline-grid;
Expand Down Expand Up @@ -682,3 +694,12 @@ h1, h2, h3 {
.field-label {
font-weight: 500;
}

/* Recharts' accessibility layer focuses the chart on mousedown; the drag
selection already shows where you are, so no focus outline on the plot. */
.recharts-wrapper:focus,
.recharts-wrapper:focus-visible,
.recharts-surface:focus,
.recharts-surface:focus-visible {
outline: none;
}
Loading
Loading