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/live-concurrency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
### English

- Overview is denser (tighter card padding, gaps, metric size and chart height) and opens with a slim one-row live readout: current concurrency against total capacity (sum of `max_inflight` over enabled accounts), requests started in the last 60 seconds, and a 60-second concurrency trace. It polls the new lightweight `GET /api/live` every 2.5s, only while the tab is visible.
- The overview account pool shows a thin `in_flight / max_inflight` bar per account; saturated accounts are marked in a warning tone.

### 中文

- 概览页整体更紧凑(卡片内边距、间距、指标字号与图表高度下调),顶部新增单行实时读数:当前并发 / 总容量(已启用账号 `max_inflight` 之和)、近 60 秒请求数,以及近 60 秒并发走势。通过新增的轻量接口 `GET /api/live` 每 2.5 秒轮询,仅在页面可见时轮询。
- 概览账号池为每个账号显示 `in_flight / max_inflight` 细条,满载账号以警示色标出。
15 changes: 15 additions & 0 deletions frontend/src/api/live.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { api } from './client'

export type LiveAccount = { id: string; in_flight: number; max_inflight: number; enabled: boolean }

export type LiveSnapshot = {
time: string
in_flight: number
capacity: number
rpm: number
accounts: LiveAccount[]
}

export function fetchLive() {
return api<LiveSnapshot>('/api/live')
}
28 changes: 28 additions & 0 deletions frontend/src/components/overview/LiveSparkline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Area, AreaChart, ResponsiveContainer, YAxis } from 'recharts'
import type { LiveSample } from '@/hooks/useLive'

/** Bare ~60s concurrency trace; no axes, no animation. */
export function LiveSparkline({ samples, capacity, label }: { samples: LiveSample[]; capacity: number; label: string }) {
const data = samples.length ? samples : [{ at: 0, value: 0 }]
const peak = Math.max(1, capacity, ...data.map((s) => s.value))
return (
<div className="h-6 w-[120px] shrink-0" role="img" aria-label={label}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 1, right: 0, bottom: 1, left: 0 }}>
<YAxis hide domain={[0, peak]} />
<Area
type="monotone"
dataKey="value"
stroke="var(--foreground)"
strokeOpacity={0.7}
strokeWidth={1.25}
fill="var(--foreground)"
fillOpacity={0.08}
isAnimationActive={false}
dot={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
)
}
44 changes: 44 additions & 0 deletions frontend/src/components/overview/LiveStrip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { lazy, Suspense } from 'react'
import type { LiveSnapshot } from '@/api/live'
import type { LiveSample } from '@/hooks/useLive'

const LiveSparkline = lazy(() => import('@/components/overview/LiveSparkline').then((mod) => ({ default: mod.LiveSparkline })))

type T = (key: string, vars?: Record<string, string | number>) => string

/** Slim one-row live readout: concurrency/capacity, requests in last 60s, 60s trace. */
export function LiveStrip({ live, samples, t }: { live: LiveSnapshot | null; samples: LiveSample[]; t: T }) {
const inFlight = live?.in_flight ?? null
const capacity = live?.capacity ?? 0
const load = inFlight != null && capacity > 0 ? inFlight / capacity : 0
const saturated = capacity > 0 && inFlight != null && inFlight >= capacity
return (
<div
data-testid="live-strip"
className="flex h-11 flex-wrap items-center gap-x-6 gap-y-1 overflow-hidden rounded-lg bg-surface-secondary px-4 text-caption text-muted"
>
<span className="inline-flex items-center">
<span className="status-dot" data-state={inFlight == null ? '' : saturated ? 'warn' : inFlight > 0 ? 'ok' : ''} />
</span>
<span className="inline-flex items-center gap-2">
{t('liveConcurrency')}
<span className={`mono tabular text-body font-semibold ${saturated ? 'text-warning' : 'text-foreground'}`}>
{inFlight ?? '—'}<span className="font-normal text-muted">/{live ? capacity : '—'}</span>
</span>
<span className="inline-block h-[3px] w-12 overflow-hidden rounded-[2px] bg-separator">
<span className={`block h-full ${saturated ? 'bg-warning' : 'bg-foreground/60'}`} style={{ width: `${Math.min(100, load * 100)}%` }} />
</span>
</span>
<span className="inline-flex items-center gap-2">
{t('liveRpm')}
<span className="mono tabular text-body font-semibold text-foreground">{live?.rpm ?? '—'}</span>
</span>
<span className="inline-flex items-center gap-2">
{t('liveTrace')}
<Suspense fallback={<span className="inline-block h-6 w-[120px]" />}>
<LiveSparkline samples={samples} capacity={capacity} label={t('liveTrace')} />
</Suspense>
</span>
</div>
)
}
2 changes: 1 addition & 1 deletion frontend/src/components/overview/TrafficChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ export function TrafficChart({
]}
/>
</div>
<div className="h-72 w-full">
<div className="h-56 w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={data}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/overview/TrafficChartEmpty.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export function TrafficChartEmpty({ emptyLabel }: { emptyLabel: string }) {
return (
<div className="grid min-h-72 place-items-center rounded-lg bg-surface-secondary px-4 text-body text-muted">
<div className="grid min-h-56 place-items-center rounded-lg bg-surface-secondary px-4 text-body text-muted">
{emptyLabel}
</div>
)
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/components/ui/PageSkeletons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export function RankListSkeleton({ rows = 5 }: { rows?: number }) {
return (
<div className="divide-y divide-separator">
{Array.from({ length: rows }, (_, index) => (
<div key={index} className="space-y-2 px-5 py-3">
<div key={index} className="space-y-2 px-4 py-2.5">
<div className="flex items-center justify-between gap-3">
<SkeletonBlock className="h-4 w-28" />
<SkeletonBlock className="h-3 w-12" />
Expand All @@ -21,7 +21,7 @@ export function RankListSkeleton({ rows = 5 }: { rows?: number }) {
}

export function TrafficChartSkeleton() {
return <SkeletonBlock className="h-72 w-full" />
return <SkeletonBlock className="h-56 w-full" />
}

export function ProvidersTableSkeleton({ rows = 6 }: { rows?: number }) {
Expand Down Expand Up @@ -94,7 +94,7 @@ export function OverviewPageSkeleton() {
<section className="overflow-hidden rounded-3xl bg-surface shadow-surface p-5">
<SkeletonBlock className="h-5 w-24" />
<SkeletonBlock className="mt-2 h-3 w-48" />
<SkeletonBlock className="mt-6 h-72 w-full" />
<SkeletonBlock className="mt-6 h-56 w-full" />
<div className="mt-5 grid grid-cols-4 gap-3">
{Array.from({ length: 4 }, (_, index) => <SkeletonBlock key={index} className="h-10 w-full" />)}
</div>
Expand Down
51 changes: 51 additions & 0 deletions frontend/src/hooks/useLive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react'
import { fetchLive, type LiveSnapshot } from '@/api/live'

export const LIVE_POLL_MS = 2500
/** Samples kept client-side for the ~60s concurrency sparkline. */
export const LIVE_SAMPLES = Math.round(60_000 / LIVE_POLL_MS)

export type LiveSample = { at: number; value: number }

/**
* Polls /api/live every 2.5s while the document is visible. Keeps the last
* good snapshot on error so the strip never flashes back to a placeholder.
*/
export function useLive() {
const [live, setLive] = useState<LiveSnapshot | null>(null)
const [samples, setSamples] = useState<LiveSample[]>([])

useEffect(() => {
let cancelled = false
let timer: ReturnType<typeof setInterval> | null = null
const tick = () => {
if (document.hidden) return
void fetchLive()
.then((data) => {
if (cancelled) return
setLive(data)
setSamples((prev) => [...prev, { at: Date.now(), value: data.in_flight }].slice(-LIVE_SAMPLES))
})
.catch(() => { /* keep last good snapshot */ })
}
const start = () => {
if (timer || document.hidden) return
tick()
timer = setInterval(tick, LIVE_POLL_MS)
}
const stop = () => {
if (timer) clearInterval(timer)
timer = null
}
const onVisibility = () => (document.hidden ? stop() : start())
start()
document.addEventListener('visibilitychange', onVisibility)
return () => {
cancelled = true
stop()
document.removeEventListener('visibilitychange', onVisibility)
}
}, [])

return { live, samples }
}
14 changes: 14 additions & 0 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,13 @@ export const messages: Record<Lang, Dict> = {
autoAccount: 'Auto (round-robin)',
cooldown: 'Cooldown',
inFlight: 'In-flight',
liveConcurrency: 'Concurrency',
liveRpm: 'Last 60s',
liveRpmUnit: 'req',
liveRpmHint: 'started in the last minute',
liveTrace: 'Trend',
livePeak: 'peak {n}',
liveSaturated: 'Saturated',
restarts: 'Restarts',
dropSystemPrompt: 'Drop system prompt',
dropSystemPromptHint: 'Strip caller system prompts before sending; WorkBuddy still gets an empty leading system slot so Global does not reject the request',
Expand Down Expand Up @@ -1327,6 +1334,13 @@ export const messages: Record<Lang, Dict> = {
autoAccount: '自动(轮询)',
cooldown: '冷却',
inFlight: '进行中',
liveConcurrency: '当前并发',
liveRpm: '近 60 秒',
liveRpmUnit: '次',
liveRpmHint: '最近一分钟发起',
liveTrace: '走势',
livePeak: '峰值 {n}',
liveSaturated: '已满载',
restarts: '重启次数',
dropSystemPrompt: '丢弃系统提示词',
dropSystemPromptHint: '发送前剥离调用方的系统提示词;仍会补一条空的 system,避免国际版要求第一条必须是系统提示词',
Expand Down
17 changes: 2 additions & 15 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@

/* Type scale. One sans family for all UI; mono only via .mono for IDs,
keys, versions and numbers. Weights: 600 titles, 500 labels, 400 body. */
--text-metric: 1.875rem;
--text-metric--line-height: 2.25rem;
--text-metric: 1.5rem;
--text-metric--line-height: 2rem;
--text-metric--font-weight: 500;
--text-title: 1.125rem;
--text-title--line-height: 1.75rem;
Expand Down Expand Up @@ -619,19 +619,6 @@ body {
}

/* Icon tile used by metric cards and section headers. */
.icon-tile {
display: inline-grid;
place-items: center;
width: 1.75rem;
height: 1.75rem;
border-radius: calc(var(--radius) - 2px);
background: var(--surface-tertiary);
color: var(--muted);
box-shadow:
inset 0 1px 0 var(--tile-rim),
inset 0 -1px 0 var(--tile-under);
}

/* Header toolbar: a flat hairline group instead of a floating pill. */
@layer components {
.toolbar--attached {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/typeScale.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Pixel values of the @theme type scale in src/index.css, for APIs that need a
// number (recharts ticks, canvas). Keep in sync with --text-* (rem × 16).
export const TYPE_SCALE_PX = {
metric: 30,
metric: 24,
title: 18,
section: 15,
body: 13,
Expand Down
Loading
Loading