mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] usage: agent time stops billing idle cards as work, and the panel gains real figures
This commit is contained in:
@@ -304,8 +304,13 @@ async def usage_summary(window: str = "30d"):
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
|
||||
sessions = p_load_all_sessions()
|
||||
# A live session is usually already on disk, so appending it blind counted the same chat twice.
|
||||
seen_ids = {s.get("id") for s in sessions if s.get("id")}
|
||||
for s in agent_manager.get_all_sessions():
|
||||
sessions.append(s.model_dump(mode="json"))
|
||||
live = s.model_dump(mode="json")
|
||||
if live.get("id") and live["id"] in seen_ids:
|
||||
sessions = [d for d in sessions if d.get("id") != live["id"]]
|
||||
sessions.append(live)
|
||||
|
||||
days = P_WINDOW_DAYS.get(window, 30)
|
||||
if days:
|
||||
@@ -332,6 +337,9 @@ async def usage_summary(window: str = "30d"):
|
||||
provider_counts: Counter = Counter()
|
||||
tool_counts: Counter = Counter()
|
||||
status_counts: Counter = Counter()
|
||||
day_counts: Counter = Counter()
|
||||
hour_counts: Counter = Counter()
|
||||
longest_run_seconds = 0.0
|
||||
|
||||
excluded_automation = 0
|
||||
kept = []
|
||||
@@ -359,6 +367,13 @@ async def usage_summary(window: str = "30d"):
|
||||
model_counts[p_friendly_model(s.get("model", "unknown"))] += 1
|
||||
provider_counts[s.get("provider", "anthropic")] += 1
|
||||
status_counts[s.get("status", "unknown")] += 1
|
||||
created = s.get("created_at") or ""
|
||||
if len(created) >= 13:
|
||||
day_counts[created[:10]] += 1
|
||||
try:
|
||||
hour_counts[int(created[11:13])] += 1
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Tool calls: tool_latencies carries authoritative per-tool counts; older sessions only have the sparse tool_call messages. Per session take whichever source recorded more so we never undercount what's on record (and so the total never drops below the old message-only count).
|
||||
lat_counts: Counter = Counter()
|
||||
@@ -376,18 +391,14 @@ async def usage_summary(window: str = "30d"):
|
||||
total_tool_calls += sum(chosen.values())
|
||||
tool_counts.update(chosen)
|
||||
|
||||
# Run time: real agent-active time when tracked, else session wall-clock as a rough proxy.
|
||||
# Measured agent-active time only. The old wall-clock fallback billed a card you left open all
|
||||
# day as work: one "Open browser" session claimed 11 hours, and 45% of the headline came from
|
||||
# 4% of sessions that way.
|
||||
run_s = (s.get("agent_active_ms") or 0) / 1000.0
|
||||
if run_s <= 0:
|
||||
created, closed = s.get("created_at"), s.get("closed_at")
|
||||
if created and closed:
|
||||
try:
|
||||
run_s = (datetime.fromisoformat(closed[:19]) - datetime.fromisoformat(created[:19])).total_seconds()
|
||||
except Exception:
|
||||
run_s = 0
|
||||
if run_s > 0:
|
||||
total_run_seconds += run_s
|
||||
timed_sessions += 1
|
||||
longest_run_seconds = max(longest_run_seconds, run_s)
|
||||
|
||||
avg_duration = total_run_seconds / timed_sessions if timed_sessions > 0 else 0
|
||||
completed = status_counts.get("completed", 0)
|
||||
@@ -437,7 +448,12 @@ async def usage_summary(window: str = "30d"):
|
||||
"total_messages": total_messages,
|
||||
"total_tool_calls": total_tool_calls,
|
||||
"total_run_seconds": round(total_run_seconds, 1),
|
||||
"timed_sessions": timed_sessions,
|
||||
"longest_run_seconds": round(longest_run_seconds, 1),
|
||||
"avg_duration_seconds": round(avg_duration, 1),
|
||||
"status_breakdown": dict(status_counts),
|
||||
"daily_activity": [{"day": d, "chats": n} for d, n in sorted(day_counts.items())],
|
||||
"hourly_activity": [hour_counts.get(h, 0) for h in range(24)],
|
||||
"avg_cost_per_session": round(avg_cost, 4),
|
||||
"completion_rate": round(completion_rate, 3),
|
||||
"models_used": dict(model_counts.most_common(10)),
|
||||
|
||||
@@ -5,36 +5,47 @@ import ToggleButton from '@mui/material/ToggleButton';
|
||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import CountUp from './parts/CountUp';
|
||||
import BarSeries from './parts/BarSeries';
|
||||
import ActivityColumns from './parts/ActivityColumns';
|
||||
import StatusDonut from './parts/StatusDonut';
|
||||
|
||||
type Window = '7d' | '30d' | 'all';
|
||||
|
||||
interface DayPoint { day: string; chats: number }
|
||||
|
||||
interface UsageSummary {
|
||||
window: string;
|
||||
excluded_automation_sessions: number;
|
||||
total_sessions: number;
|
||||
total_messages: number;
|
||||
total_tool_calls: number;
|
||||
total_run_seconds: number;
|
||||
avg_duration_seconds: number;
|
||||
completion_rate: number;
|
||||
models_used: Record<string, number>;
|
||||
top_tools: Record<string, number>;
|
||||
total_prompt_tokens: number;
|
||||
total_completion_tokens: number;
|
||||
total_cost_usd: number;
|
||||
}
|
||||
|
||||
function fmtDuration(seconds: number): string {
|
||||
if (seconds >= 3600) return `${(seconds / 3600).toFixed(1)} hrs`;
|
||||
if (seconds >= 60) return `${Math.round(seconds / 60)} min`;
|
||||
return `${Math.round(seconds)}s`;
|
||||
status_breakdown: Record<string, number>;
|
||||
daily_activity: DayPoint[];
|
||||
hourly_activity: number[];
|
||||
}
|
||||
|
||||
function fmtCount(n: number): string {
|
||||
return n >= 10000 ? `${(n / 1000).toFixed(1)}k` : n.toLocaleString();
|
||||
return n >= 10000 ? `${(n / 1000).toFixed(1)}k` : Math.round(n).toLocaleString();
|
||||
}
|
||||
|
||||
/** Your real activity, claude-flat: windowed, automation excluded, friendly names, honest scopes. */
|
||||
function cleanToolName(t: string): string {
|
||||
return t.replace(/^mcp__[^_]+(?:__)+/, '').replace(/^openswarm-\w+__/, '');
|
||||
}
|
||||
|
||||
function hourLabel(h: number): string {
|
||||
if (h === 0) return '12am';
|
||||
if (h === 12) return '12pm';
|
||||
return h < 12 ? `${h}am` : `${h - 12}pm`;
|
||||
}
|
||||
|
||||
/** Your real activity: windowed, automation excluded, and only numbers we actually measure. */
|
||||
const UsageStats: React.FC = () => {
|
||||
const c = useClaudeTokens();
|
||||
const [win, setWin] = useState<Window>('30d');
|
||||
@@ -49,17 +60,23 @@ const UsageStats: React.FC = () => {
|
||||
return () => { alive = false; };
|
||||
}, [win]);
|
||||
|
||||
const rowSx = {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
py: 1.1, borderBottom: `1px solid ${c.border.subtle}`, '&:last-of-type': { borderBottom: 'none' },
|
||||
const sectionSx = {
|
||||
color: c.text.muted, fontSize: '0.71875rem', fontWeight: 650, letterSpacing: '0.05em',
|
||||
textTransform: 'uppercase', mt: 3, mb: 1,
|
||||
} as const;
|
||||
const labelSx = { color: c.text.primary, fontSize: '0.8438rem', fontWeight: 500 } as const;
|
||||
const valueSx = { color: c.text.primary, fontSize: '0.8438rem', fontVariantNumeric: 'tabular-nums' } as const;
|
||||
const sectionSx = { color: c.text.muted, fontSize: '0.71875rem', fontWeight: 650, letterSpacing: '0.05em', textTransform: 'uppercase', mt: 2.5, mb: 0.5 } as const;
|
||||
const cardSx = {
|
||||
flex: 1, minWidth: 0, px: 1.75, py: 1.5, borderRadius: `${c.radius.md}px`,
|
||||
border: `1px solid ${c.border.subtle}`, background: c.bg.elevated,
|
||||
} as const;
|
||||
const bigSx = { color: c.text.primary, fontSize: '1.5rem', fontWeight: 600, lineHeight: 1.1, fontVariantNumeric: 'tabular-nums' } as const;
|
||||
const capSx = { color: c.text.muted, fontSize: '0.75rem', mt: 0.4 } as const;
|
||||
|
||||
const peakHour = stats ? stats.hourly_activity.indexOf(Math.max(...stats.hourly_activity)) : 0;
|
||||
const avgMsgs = stats && stats.total_sessions > 0 ? stats.total_messages / stats.total_sessions : 0;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
||||
<Typography sx={{ color: c.text.secondary, fontSize: '0.8125rem' }}>
|
||||
Your own sessions on this device{stats && stats.excluded_automation_sessions > 0
|
||||
? `; ${fmtCount(stats.excluded_automation_sessions)} automated runs excluded`
|
||||
@@ -82,54 +99,80 @@ const UsageStats: React.FC = () => {
|
||||
<Box sx={{ py: 4, textAlign: 'center', color: c.text.ghost, fontSize: '0.8125rem' }}>Loading…</Box>
|
||||
) : (
|
||||
<>
|
||||
<Box sx={rowSx}>
|
||||
<Typography sx={labelSx}>Chats</Typography>
|
||||
<Typography sx={valueSx}>{fmtCount(stats.total_sessions)}</Typography>
|
||||
</Box>
|
||||
<Box sx={rowSx}>
|
||||
<Typography sx={labelSx}>Messages</Typography>
|
||||
<Typography sx={valueSx}>{fmtCount(stats.total_messages)}</Typography>
|
||||
</Box>
|
||||
<Box sx={rowSx}>
|
||||
<Typography sx={labelSx}>Tool calls</Typography>
|
||||
<Typography sx={valueSx}>{fmtCount(stats.total_tool_calls)}</Typography>
|
||||
</Box>
|
||||
<Box sx={rowSx}>
|
||||
<Typography sx={labelSx}>Agent time</Typography>
|
||||
<Typography sx={valueSx}>{fmtDuration(stats.total_run_seconds)}</Typography>
|
||||
</Box>
|
||||
<Box sx={rowSx}>
|
||||
<Typography sx={labelSx}>Finished cleanly</Typography>
|
||||
<Typography sx={valueSx}>{Math.round(stats.completion_rate * 100)}%</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1.25 }}>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={bigSx}><CountUp value={stats.total_sessions} format={fmtCount} /></Typography>
|
||||
<Typography sx={capSx}>Chats</Typography>
|
||||
</Box>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={bigSx}><CountUp value={stats.total_messages} format={fmtCount} /></Typography>
|
||||
<Typography sx={capSx}>Messages</Typography>
|
||||
</Box>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={bigSx}><CountUp value={stats.total_tool_calls} format={fmtCount} /></Typography>
|
||||
<Typography sx={capSx}>Tool calls</Typography>
|
||||
</Box>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={bigSx}><CountUp value={avgMsgs} format={(n) => n.toFixed(1)} /></Typography>
|
||||
<Typography sx={capSx}>Messages per chat</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{stats.daily_activity.length > 1 && (
|
||||
<>
|
||||
<Typography sx={sectionSx}>Chats per day</Typography>
|
||||
<ActivityColumns
|
||||
data={stats.daily_activity.map((d) => ({ key: d.day, value: d.chats, caption: d.day.slice(5) }))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Typography sx={sectionSx}>When you work</Typography>
|
||||
<ActivityColumns
|
||||
height={64}
|
||||
highlightIndex={peakHour}
|
||||
data={stats.hourly_activity.map((v, h) => ({ key: String(h), value: v, caption: hourLabel(h) }))}
|
||||
/>
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.75rem', mt: 0.5 }}>
|
||||
Busiest around {hourLabel(peakHour)}.
|
||||
</Typography>
|
||||
|
||||
<Typography sx={sectionSx}>How chats end</Typography>
|
||||
<StatusDonut
|
||||
slices={[
|
||||
{ label: 'Finished cleanly', value: stats.status_breakdown.completed ?? 0, color: c.accent.primary },
|
||||
{ label: 'You stopped it', value: stats.status_breakdown.stopped ?? 0, color: c.text.ghost },
|
||||
{ label: 'Hit an error', value: stats.status_breakdown.error ?? 0, color: c.status?.error ?? '#c2554d' },
|
||||
].filter((s) => s.value > 0)}
|
||||
/>
|
||||
|
||||
<Typography sx={sectionSx}>Models</Typography>
|
||||
{Object.entries(stats.models_used).slice(0, 6).map(([model, count]) => (
|
||||
<Box key={model} sx={rowSx}>
|
||||
<Typography sx={labelSx}>{model}</Typography>
|
||||
<Typography sx={{ ...valueSx, color: c.text.secondary }}>{fmtCount(count)} chats</Typography>
|
||||
</Box>
|
||||
))}
|
||||
<BarSeries
|
||||
data={Object.entries(stats.models_used).slice(0, 6).map(([label, value]) => ({ label, value, suffix: 'chats' }))}
|
||||
/>
|
||||
|
||||
<Typography sx={sectionSx}>Most used tools</Typography>
|
||||
{Object.entries(stats.top_tools).slice(0, 8).map(([tool, count]) => (
|
||||
<Box key={tool} sx={rowSx}>
|
||||
<Typography sx={labelSx}>{tool.replace(/^mcp__[^_]+(?:__)+/, '').replace(/^openswarm-\w+__/, '')}</Typography>
|
||||
<Typography sx={{ ...valueSx, color: c.text.secondary }}>{fmtCount(count)} calls</Typography>
|
||||
</Box>
|
||||
))}
|
||||
<BarSeries
|
||||
data={Object.entries(stats.top_tools).slice(0, 8).map(([t, value]) => ({ label: cleanToolName(t), value, suffix: 'calls' }))}
|
||||
/>
|
||||
|
||||
<Typography sx={sectionSx}>Routed requests (all traffic, lifetime)</Typography>
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.75rem', mb: 0.5 }}>
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.75rem', mb: 1 }}>
|
||||
Everything routed through the local model router since install, including background helpers; not limited to the window above.
|
||||
</Typography>
|
||||
<Box sx={rowSx}>
|
||||
<Typography sx={labelSx}>Tokens in / out</Typography>
|
||||
<Typography sx={valueSx}>{fmtCount(stats.total_prompt_tokens)} / {fmtCount(stats.total_completion_tokens)}</Typography>
|
||||
</Box>
|
||||
<Box sx={rowSx}>
|
||||
<Typography sx={labelSx}>API value covered</Typography>
|
||||
<Typography sx={valueSx}>${stats.total_cost_usd.toFixed(2)}</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1.25 }}>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={bigSx}><CountUp value={stats.total_prompt_tokens} format={fmtCount} /></Typography>
|
||||
<Typography sx={capSx}>Tokens in</Typography>
|
||||
</Box>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={bigSx}><CountUp value={stats.total_completion_tokens} format={fmtCount} /></Typography>
|
||||
<Typography sx={capSx}>Tokens out</Typography>
|
||||
</Box>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={bigSx}>$<CountUp value={stats.total_cost_usd} format={(n) => n.toFixed(2)} /></Typography>
|
||||
<Typography sx={capSx}>API value covered</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
export interface ColumnDatum {
|
||||
key: string;
|
||||
value: number;
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
interface ActivityColumnsProps {
|
||||
data: ColumnDatum[];
|
||||
height?: number;
|
||||
highlightIndex?: number;
|
||||
}
|
||||
|
||||
/** Column chart with a staggered grow-in; heights ride CSS transitions so nothing animates per frame. */
|
||||
const ActivityColumns: React.FC<ActivityColumnsProps> = ({ data, height = 84, highlightIndex }) => {
|
||||
const c = useClaudeTokens();
|
||||
const [grown, setGrown] = useState(false);
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => setGrown(true));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, []);
|
||||
const peak = Math.max(1, ...data.map((d) => d.value));
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-end', gap: '3px', height }}>
|
||||
{data.map((d, i) => (
|
||||
<Box
|
||||
key={d.key}
|
||||
onMouseEnter={() => setHover(i)}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'flex-end', height: '100%', cursor: 'default' }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: '3px 3px 0 0',
|
||||
background: i === highlightIndex || i === hover ? c.accent.primary : c.text.ghost,
|
||||
opacity: i === highlightIndex || i === hover ? 1 : 0.42,
|
||||
height: grown ? `${Math.max(2, (d.value / peak) * 100)}%` : '0%',
|
||||
transition: `height 600ms cubic-bezier(0.22,1,0.36,1) ${Math.min(i * 18, 420)}ms, background 140ms ease, opacity 140ms ease`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 0.6, minHeight: 16 }}>
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.6875rem' }}>{data[0]?.caption ?? ''}</Typography>
|
||||
<Typography sx={{ color: hover === null ? c.text.ghost : c.text.secondary, fontSize: '0.6875rem', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{hover === null ? (data[data.length - 1]?.caption ?? '') : `${data[hover].caption}: ${data[hover].value.toLocaleString()}`}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityColumns;
|
||||
@@ -0,0 +1,55 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
export interface BarDatum {
|
||||
label: string;
|
||||
value: number;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
interface BarSeriesProps {
|
||||
data: BarDatum[];
|
||||
max?: number;
|
||||
}
|
||||
|
||||
/** Ranked horizontal bars that grow in on mount; width rides a CSS transition, so no JS runs per frame. */
|
||||
const BarSeries: React.FC<BarSeriesProps> = ({ data, max }) => {
|
||||
const c = useClaudeTokens();
|
||||
const [grown, setGrown] = useState(false);
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => setGrown(true));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, []);
|
||||
const peak = max ?? Math.max(1, ...data.map((d) => d.value));
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{data.map((d, i) => (
|
||||
<Box key={d.label} sx={{ py: 0.7 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.35 }}>
|
||||
<Typography sx={{ color: c.text.primary, fontSize: '0.8125rem', fontWeight: 500 }}>{d.label}</Typography>
|
||||
<Typography sx={{ color: c.text.secondary, fontSize: '0.8125rem', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{d.value.toLocaleString()}{d.suffix ? ` ${d.suffix}` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ height: 5, borderRadius: 3, background: c.border.subtle, overflow: 'hidden' }}>
|
||||
<Box
|
||||
sx={{
|
||||
height: '100%',
|
||||
borderRadius: 3,
|
||||
background: c.accent.primary,
|
||||
opacity: 0.55 + 0.45 * (1 - i / Math.max(1, data.length)),
|
||||
width: grown ? `${Math.max(2, (d.value / peak) * 100)}%` : '0%',
|
||||
transition: `width 620ms cubic-bezier(0.22,1,0.36,1) ${i * 45}ms`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BarSeries;
|
||||
@@ -0,0 +1,38 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface CountUpProps {
|
||||
value: number;
|
||||
durationMs?: number;
|
||||
format?: (n: number) => string;
|
||||
}
|
||||
|
||||
// One-shot rAF that parks when it lands; a permanent loop here would cost the whole machine 60fps forever.
|
||||
const CountUp: React.FC<CountUpProps> = ({ value, durationMs = 650, format }) => {
|
||||
const [shown, setShown] = useState(value);
|
||||
const fromRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
if (reduced || durationMs <= 0) { setShown(value); return undefined; }
|
||||
const from = fromRef.current;
|
||||
const delta = value - from;
|
||||
if (delta === 0) { setShown(value); return undefined; }
|
||||
let raf = 0;
|
||||
const t0 = performance.now();
|
||||
const tick = (now: number): void => {
|
||||
const p = Math.min(1, (now - t0) / durationMs);
|
||||
const eased = 1 - Math.pow(1 - p, 4);
|
||||
setShown(from + delta * eased);
|
||||
if (p < 1) raf = requestAnimationFrame(tick);
|
||||
else fromRef.current = value;
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [value, durationMs]);
|
||||
|
||||
useEffect(() => { fromRef.current = value; }, [value]);
|
||||
|
||||
return <>{format ? format(shown) : Math.round(shown).toLocaleString()}</>;
|
||||
};
|
||||
|
||||
export default CountUp;
|
||||
@@ -0,0 +1,78 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
export interface DonutSlice {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface StatusDonutProps {
|
||||
slices: DonutSlice[];
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const P_R = 42;
|
||||
const P_CIRC = 2 * Math.PI * P_R;
|
||||
|
||||
/** Donut whose arcs sweep in via stroke-dashoffset; SVG stroke transitions run on the compositor. */
|
||||
const StatusDonut: React.FC<StatusDonutProps> = ({ slices, size = 116 }) => {
|
||||
const c = useClaudeTokens();
|
||||
const [drawn, setDrawn] = useState(false);
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => setDrawn(true));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, []);
|
||||
const total = Math.max(1, slices.reduce((a, s) => a + s.value, 0));
|
||||
|
||||
let offset = 0;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2.5 }}>
|
||||
<Box sx={{ position: 'relative', width: size, height: size, flexShrink: 0 }}>
|
||||
<svg width={size} height={size} viewBox="0 0 100 100" style={{ transform: 'rotate(-90deg)' }}>
|
||||
<circle cx="50" cy="50" r={P_R} fill="none" stroke={c.border.subtle} strokeWidth="11" />
|
||||
{slices.map((s, i) => {
|
||||
const frac = s.value / total;
|
||||
const dash = drawn ? frac * P_CIRC : 0;
|
||||
const rot = offset;
|
||||
offset += frac;
|
||||
return (
|
||||
<circle
|
||||
key={s.label}
|
||||
cx="50" cy="50" r={P_R} fill="none"
|
||||
stroke={s.color} strokeWidth="11" strokeLinecap="butt"
|
||||
strokeDasharray={`${dash} ${P_CIRC}`}
|
||||
style={{
|
||||
transform: `rotate(${rot * 360}deg)`,
|
||||
transformOrigin: '50% 50%',
|
||||
transition: `stroke-dasharray 700ms cubic-bezier(0.22,1,0.36,1) ${i * 110}ms`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<Box sx={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Typography sx={{ color: c.text.primary, fontSize: '1.05rem', fontWeight: 600, lineHeight: 1 }}>
|
||||
{Math.round((slices[0]?.value ?? 0) / total * 100)}%
|
||||
</Typography>
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.625rem', mt: 0.25 }}>clean</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
{slices.map((s) => (
|
||||
<Box key={s.label} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.35 }}>
|
||||
<Box sx={{ width: 8, height: 8, borderRadius: '2px', background: s.color, flexShrink: 0 }} />
|
||||
<Typography sx={{ color: c.text.secondary, fontSize: '0.78125rem' }}>{s.label}</Typography>
|
||||
<Typography sx={{ color: c.text.primary, fontSize: '0.78125rem', fontVariantNumeric: 'tabular-nums', ml: 0.5 }}>
|
||||
{s.value.toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusDonut;
|
||||
Reference in New Issue
Block a user