diff --git a/backend/apps/service/service.py b/backend/apps/service/service.py index 2da912b8..b7c12422 100644 --- a/backend/apps/service/service.py +++ b/backend/apps/service/service.py @@ -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)), diff --git a/frontend/src/app/pages/Settings/sections/usage/UsageStats.tsx b/frontend/src/app/pages/Settings/sections/usage/UsageStats.tsx index 8f1d4d22..e132c979 100644 --- a/frontend/src/app/pages/Settings/sections/usage/UsageStats.tsx +++ b/frontend/src/app/pages/Settings/sections/usage/UsageStats.tsx @@ -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; top_tools: Record; 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; + 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('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 ( - + 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 = () => { Loading… ) : ( <> - - Chats - {fmtCount(stats.total_sessions)} - - - Messages - {fmtCount(stats.total_messages)} - - - Tool calls - {fmtCount(stats.total_tool_calls)} - - - Agent time - {fmtDuration(stats.total_run_seconds)} - - - Finished cleanly - {Math.round(stats.completion_rate * 100)}% + + + + Chats + + + + Messages + + + + Tool calls + + + n.toFixed(1)} /> + Messages per chat + + {stats.daily_activity.length > 1 && ( + <> + Chats per day + ({ key: d.day, value: d.chats, caption: d.day.slice(5) }))} + /> + + )} + + When you work + ({ key: String(h), value: v, caption: hourLabel(h) }))} + /> + + Busiest around {hourLabel(peakHour)}. + + + How chats end + s.value > 0)} + /> + Models - {Object.entries(stats.models_used).slice(0, 6).map(([model, count]) => ( - - {model} - {fmtCount(count)} chats - - ))} + ({ label, value, suffix: 'chats' }))} + /> Most used tools - {Object.entries(stats.top_tools).slice(0, 8).map(([tool, count]) => ( - - {tool.replace(/^mcp__[^_]+(?:__)+/, '').replace(/^openswarm-\w+__/, '')} - {fmtCount(count)} calls - - ))} + ({ label: cleanToolName(t), value, suffix: 'calls' }))} + /> Routed requests (all traffic, lifetime) - + Everything routed through the local model router since install, including background helpers; not limited to the window above. - - Tokens in / out - {fmtCount(stats.total_prompt_tokens)} / {fmtCount(stats.total_completion_tokens)} - - - API value covered - ${stats.total_cost_usd.toFixed(2)} + + + + Tokens in + + + + Tokens out + + + $ n.toFixed(2)} /> + API value covered + )} diff --git a/frontend/src/app/pages/Settings/sections/usage/parts/ActivityColumns.tsx b/frontend/src/app/pages/Settings/sections/usage/parts/ActivityColumns.tsx new file mode 100644 index 00000000..98f110ab --- /dev/null +++ b/frontend/src/app/pages/Settings/sections/usage/parts/ActivityColumns.tsx @@ -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 = ({ data, height = 84, highlightIndex }) => { + const c = useClaudeTokens(); + const [grown, setGrown] = useState(false); + const [hover, setHover] = useState(null); + useEffect(() => { + const id = requestAnimationFrame(() => setGrown(true)); + return () => cancelAnimationFrame(id); + }, []); + const peak = Math.max(1, ...data.map((d) => d.value)); + + return ( + + + {data.map((d, i) => ( + setHover(i)} + onMouseLeave={() => setHover(null)} + sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'flex-end', height: '100%', cursor: 'default' }} + > + + + ))} + + + {data[0]?.caption ?? ''} + + {hover === null ? (data[data.length - 1]?.caption ?? '') : `${data[hover].caption}: ${data[hover].value.toLocaleString()}`} + + + + ); +}; + +export default ActivityColumns; diff --git a/frontend/src/app/pages/Settings/sections/usage/parts/BarSeries.tsx b/frontend/src/app/pages/Settings/sections/usage/parts/BarSeries.tsx new file mode 100644 index 00000000..8cb7f063 --- /dev/null +++ b/frontend/src/app/pages/Settings/sections/usage/parts/BarSeries.tsx @@ -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 = ({ 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 ( + + {data.map((d, i) => ( + + + {d.label} + + {d.value.toLocaleString()}{d.suffix ? ` ${d.suffix}` : ''} + + + + + + + ))} + + ); +}; + +export default BarSeries; diff --git a/frontend/src/app/pages/Settings/sections/usage/parts/CountUp.tsx b/frontend/src/app/pages/Settings/sections/usage/parts/CountUp.tsx new file mode 100644 index 00000000..99cdc6dc --- /dev/null +++ b/frontend/src/app/pages/Settings/sections/usage/parts/CountUp.tsx @@ -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 = ({ 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; diff --git a/frontend/src/app/pages/Settings/sections/usage/parts/StatusDonut.tsx b/frontend/src/app/pages/Settings/sections/usage/parts/StatusDonut.tsx new file mode 100644 index 00000000..138598b6 --- /dev/null +++ b/frontend/src/app/pages/Settings/sections/usage/parts/StatusDonut.tsx @@ -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 = ({ 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 ( + + + + + {slices.map((s, i) => { + const frac = s.value / total; + const dash = drawn ? frac * P_CIRC : 0; + const rot = offset; + offset += frac; + return ( + + ); + })} + + + + {Math.round((slices[0]?.value ?? 0) / total * 100)}% + + clean + + + + {slices.map((s) => ( + + + {s.label} + + {s.value.toLocaleString()} + + + ))} + + + ); +}; + +export default StatusDonut;