-
Notifications
You must be signed in to change notification settings - Fork 207
Add per-pane usage averages and custom date ranges #606
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| import type { UsageByPaneReport } from '../../../../shared/types/usage'; | ||
| import { formatTokens, formatUsd } from '../ui/charts/chartScales'; | ||
|
|
||
| export function PaneUsageSummary({ byPane, trim, onTrimChange }: { | ||
| byPane: UsageByPaneReport; | ||
| trim: boolean; | ||
| onTrimChange: (trim: boolean) => void; | ||
| }) { | ||
| const panes = byPane.panes.filter(pane => pane.messageCount > 0); | ||
| const count = panes.length; | ||
| const cut = trim ? Math.floor(count * 0.1) : 0; | ||
| const retained = count - cut * 2; | ||
| const mean = (values: number[]): number => { | ||
| const sample = values.sort((a, b) => a - b).slice(cut, count - cut); | ||
| return sample.reduce((sum, value) => sum + value, 0) / retained; | ||
| }; | ||
| const costIncomplete = panes.some(pane => pane.costIncomplete); | ||
| const metrics = [ | ||
| { | ||
| label: 'Tokens / pane', | ||
| value: count ? formatTokens(mean(panes.map(pane => pane.inputTokens + pane.outputTokens + pane.cacheCreationTokens))) : '—', | ||
| detail: 'Input + output + cache writes; excludes cache reads', | ||
| }, | ||
| { | ||
| label: 'Est. cost / pane', | ||
| value: !count ? '—' : costIncomplete ? 'n/a' : formatUsd(mean(panes.map(pane => pane.estimatedCostUsd))), | ||
| detail: costIncomplete ? 'Missing model prices in this sample' : 'All tokens at API rates, not subscription charges', | ||
| }, | ||
| { | ||
| label: 'Messages / pane', | ||
| value: count ? mean(panes.map(pane => pane.messageCount)).toLocaleString(undefined, { maximumFractionDigits: 1 }) : '—', | ||
| detail: 'Recorded usage events, not human prompts', | ||
| }, | ||
| ]; | ||
|
|
||
| return ( | ||
| <section data-testid="pane-usage-summary" aria-label="Per-pane usage" className="rounded border border-border-primary bg-surface-secondary p-3"> | ||
| <div className="mb-2 flex flex-wrap items-center justify-between gap-2"> | ||
| <h2 className="text-[11px] font-medium uppercase tracking-wider text-text-tertiary">Per-pane usage</h2> | ||
| <fieldset className="flex gap-1"> | ||
| <legend className="sr-only">Per-pane calculation</legend> | ||
| {[{ label: 'Average', value: false }, { label: 'Trim 10%', value: true }].map(option => ( | ||
| <button | ||
| key={option.label} | ||
| type="button" | ||
| aria-pressed={trim === option.value} | ||
| onClick={() => onTrimChange(option.value)} | ||
| className={`rounded px-2 py-1 text-[11px] ${trim === option.value ? 'bg-interactive text-text-on-interactive' : 'text-text-secondary hover:bg-surface-hover'}`} | ||
| > | ||
| {option.label} | ||
| </button> | ||
| ))} | ||
| </fieldset> | ||
| </div> | ||
| <div className="grid gap-2 sm:grid-cols-3"> | ||
| {metrics.map(metric => ( | ||
| <div key={metric.label} className="rounded border border-border-primary px-3 py-2"> | ||
| <h3 className="text-[10px] uppercase tracking-wider text-text-muted">{metric.label}</h3> | ||
| <p className="mt-0.5 text-lg font-semibold tabular-nums text-text-primary">{metric.value}</p> | ||
| <p className="text-[10px] text-text-tertiary">{metric.detail}</p> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| <p className="mt-2 text-[11px] text-text-tertiary"> | ||
| {count ? `${count.toLocaleString()} panes with recorded usage in this period, including archived panes.` : 'No pane-attributed usage in this period.'} | ||
| {' '}Empty panes and unattributed usage are excluded. | ||
| {trim && ` Each metric drops its ${cut} highest and ${cut} lowest values; ${retained} panes remain per metric.`} | ||
| {trim && count > 0 && cut === 0 && ' At least 10 panes are needed to trim.'} | ||
| </p> | ||
| </section> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| import { useState } from 'react'; | ||
| import { Modal, ModalHeader, ModalBody, ModalFooter } from '../ui/Modal'; | ||
| import { Input } from '../ui/Input'; | ||
| import { Button } from '../ui/Button'; | ||
| import { USAGE_RETENTION_DAYS } from '../../../../shared/types/usage'; | ||
|
|
||
| import { localDateString, type UsageDateRange } from './usageDateRange'; | ||
|
|
||
| export function UsageDateRangeDialog({ initialRange, onApply, onClose }: { | ||
| initialRange: UsageDateRange; | ||
| onApply: (range: UsageDateRange) => void; | ||
| onClose: () => void; | ||
| }) { | ||
| const [start, setStart] = useState(initialRange.start); | ||
| const [end, setEnd] = useState(initialRange.end); | ||
| const today = localDateString(new Date()); | ||
| const valid = Boolean(start && end && start <= end && end <= today); | ||
|
|
||
| return ( | ||
| <Modal isOpen onClose={onClose} size="sm"> | ||
| <form onSubmit={event => { event.preventDefault(); if (valid) onApply({ start, end }); }}> | ||
| <ModalHeader title="Custom usage range" /> | ||
| <ModalBody className="space-y-3"> | ||
| <Input label="Start date" type="date" value={start} max={end || today} required fullWidth onChange={event => setStart(event.target.value)} /> | ||
| <Input label="End date" type="date" value={end} min={start} max={today} required fullWidth onChange={event => setEnd(event.target.value)} /> | ||
| <p className="text-xs text-text-tertiary">Includes both dates in your local time zone. Only indexed history is available; usage records are retained for {USAGE_RETENTION_DAYS} days.</p> | ||
| {start && end && start > end && <p role="alert" className="text-xs text-status-error">End date must be on or after start date.</p>} | ||
| </ModalBody> | ||
| <ModalFooter> | ||
| <Button type="button" variant="secondary" onClick={onClose}>Cancel</Button> | ||
| <Button type="submit" disabled={!valid}>Apply range</Button> | ||
| </ModalFooter> | ||
| </form> | ||
| </Modal> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,14 +1,18 @@ | ||
| import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; | ||
| import { BarChart3, Check, Download, Loader2, RefreshCw, Share2 } from 'lucide-react'; | ||
| import { BarChart3, CalendarDays, Check, Download, Loader2, RefreshCw, Share2 } from 'lucide-react'; | ||
| import { toPng } from 'html-to-image'; | ||
| import { API } from '../../utils/api'; | ||
| import { useHotkey } from '../../hooks/useHotkey'; | ||
| import { useCommittedRef } from '../../hooks/useCommittedRef'; | ||
| import { AreaChart } from '../ui/charts/AreaChart'; | ||
| import { BarChart } from '../ui/charts/BarChart'; | ||
| import { DonutChart } from '../ui/charts/DonutChart'; | ||
| import { formatTokens, formatUsd } from '../ui/charts/chartScales'; | ||
| import { LimitBar, LimitStatusBanners, CreditsLine } from './ProviderLimits'; | ||
| import { LeaderboardTab } from './LeaderboardTab'; | ||
| import { PaneUsageSummary } from './PaneUsageSummary'; | ||
| import { UsageDateRangeDialog } from './UsageDateRangeDialog'; | ||
| import { localDateString, usageDateBounds, type UsageDateRange } from './usageDateRange'; | ||
| import { | ||
| DEFAULT_USAGE_RANGE_DAYS, | ||
| type UsageByPane, | ||
|
|
@@ -150,6 +154,10 @@ export function UsageView() { | |
| const [refreshing, setRefreshing] = useState(false); | ||
| const [error, setError] = useState<string | null>(null); | ||
| const [rangeDays, setRangeDays] = useState<number>(DEFAULT_USAGE_RANGE_DAYS); | ||
| const [customRange, setCustomRange] = useState<UsageDateRange | null>(null); | ||
| const [showDateRange, setShowDateRange] = useState(false); | ||
| const [trimPaneUsage, setTrimPaneUsage] = useState(false); | ||
| const requestId = useRef(0); | ||
| const [provider, setProvider] = useState<UsageProvider | 'all'>('all'); | ||
| const [downloadStatus, setDownloadStatus] = useState<'idle' | 'capturing' | 'done'>('idle'); | ||
| const [shareStatus, setShareStatus] = useState<'idle' | 'capturing' | 'done'>('idle'); | ||
|
|
@@ -162,29 +170,34 @@ export function UsageView() { | |
| const [hiddenSeries, setHiddenSeries] = useState<string[]>([]); | ||
|
|
||
| const load = useCallback(async (mode: 'initial' | 'refresh') => { | ||
| const id = ++requestId.current; | ||
| if (mode === 'refresh') setRefreshing(true); | ||
| else setLoading(true); | ||
| try { | ||
| const toMs = Date.now(); | ||
| const response = await API.usage.getReport({ | ||
| fromMs: toMs - rangeDays * DAY_MS, | ||
| toMs, | ||
| ...(customRange ? usageDateBounds(customRange) : { fromMs: toMs - rangeDays * DAY_MS, toMs }), | ||
| providers: provider === 'all' ? undefined : [provider], | ||
| }); | ||
| if (id !== requestId.current) return; | ||
| if (!response.success || !response.data) { | ||
| throw new Error(response.error || 'Failed to load usage'); | ||
| } | ||
| setReport(response.data); | ||
| setError(null); | ||
| } catch (err: unknown) { | ||
| setError(err instanceof Error ? err.message : 'Failed to load usage'); | ||
| if (id === requestId.current) setError(err instanceof Error ? err.message : 'Failed to load usage'); | ||
| } finally { | ||
| setLoading(false); | ||
| setRefreshing(false); | ||
| if (id === requestId.current) { | ||
| setLoading(false); | ||
| setRefreshing(false); | ||
| } | ||
| } | ||
| }, [rangeDays, provider]); | ||
| }, [rangeDays, customRange, provider]); | ||
|
|
||
| useEffect(() => { | ||
| void load('initial'); | ||
| return () => { requestId.current += 1; }; | ||
| }, [load]); | ||
|
|
||
| // While the index is still building, keep refreshing so numbers fill in. | ||
|
|
@@ -195,17 +208,20 @@ export function UsageView() { | |
| return () => window.clearInterval(timer); | ||
| }, [scanning, load]); | ||
|
|
||
| const currentLoad = useCommittedRef(load); | ||
| const handleRescan = useCallback(async () => { | ||
| setRefreshing(true); | ||
| try { | ||
| await API.usage.rescan(); | ||
| await load('refresh'); | ||
| await currentLoad.current('refresh'); | ||
| } catch { | ||
| setRefreshing(false); | ||
| } | ||
| }, [load]); | ||
| }, [currentLoad]); | ||
|
|
||
| const rangeLabel = RANGE_OPTIONS.find(o => o.days === rangeDays)?.label ?? `${rangeDays}d`; | ||
| const rangeLabel = customRange | ||
| ? `${customRange.start} to ${customRange.end}` | ||
| : RANGE_OPTIONS.find(o => o.days === rangeDays)?.label ?? `${rangeDays}d`; | ||
|
Comment on lines
+222
to
+224
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a custom range is active, Useful? React with 👍 / 👎. |
||
|
|
||
| const captureImage = useCallback(async (): Promise<string | null> => { | ||
| if (!contentRef.current) return null; | ||
|
|
@@ -224,7 +240,7 @@ export function UsageView() { | |
| }, []); | ||
|
|
||
| const handleDownload = useCallback(async () => { | ||
| if (downloadStatus !== 'idle') return; | ||
| if (downloadStatus !== 'idle' || loading || error || !report) return; | ||
| setDownloadStatus('capturing'); | ||
| try { | ||
| const data = await captureImage(); | ||
|
|
@@ -236,10 +252,10 @@ export function UsageView() { | |
| } catch { | ||
| setDownloadStatus('idle'); | ||
| } | ||
| }, [captureImage, downloadStatus, rangeLabel]); | ||
| }, [captureImage, downloadStatus, rangeLabel, loading, error, report]); | ||
|
|
||
| const handleShare = useCallback(async () => { | ||
| if (shareStatus !== 'idle') return; | ||
| if (shareStatus !== 'idle' || loading || error || !report) return; | ||
| setShareStatus('capturing'); | ||
| try { | ||
| const data = await captureImage(); | ||
|
|
@@ -255,7 +271,7 @@ export function UsageView() { | |
| } catch { | ||
| setShareStatus('idle'); | ||
| } | ||
| }, [captureImage, shareStatus, rangeLabel]); | ||
| }, [captureImage, shareStatus, rangeLabel, loading, error, report]); | ||
|
|
||
| useHotkey({ | ||
| id: 'usage-download', | ||
|
|
@@ -462,10 +478,10 @@ export function UsageView() { | |
| <button | ||
| key={option.days} | ||
| type="button" | ||
| aria-pressed={rangeDays === option.days} | ||
| onClick={() => setRangeDays(option.days)} | ||
| aria-pressed={!customRange && rangeDays === option.days} | ||
| onClick={() => { setCustomRange(null); setRangeDays(option.days); }} | ||
| className={`rounded px-2 py-0.5 text-[11px] transition-colors ${ | ||
| rangeDays === option.days | ||
| !customRange && rangeDays === option.days | ||
| ? 'bg-interactive text-text-on-interactive' | ||
| : 'text-text-secondary hover:bg-surface-hover' | ||
| }`} | ||
|
|
@@ -475,12 +491,24 @@ export function UsageView() { | |
| ))} | ||
| </fieldset> | ||
|
|
||
| <button | ||
| type="button" | ||
| aria-label="Choose custom date range" | ||
| aria-pressed={customRange !== null} | ||
| title="Choose custom date range" | ||
| onClick={() => setShowDateRange(true)} | ||
| className={`flex items-center gap-1 rounded px-2 py-1 text-[11px] ${customRange ? 'bg-interactive text-text-on-interactive' : 'text-text-secondary hover:bg-surface-hover'}`} | ||
| > | ||
| <CalendarDays className="h-3.5 w-3.5" aria-hidden="true" /> | ||
| {customRange ? rangeLabel : 'Custom'} | ||
| </button> | ||
|
|
||
| <span className="h-4 w-px bg-border-primary" aria-hidden="true" /> | ||
|
|
||
| <button | ||
| type="button" | ||
| onClick={() => { void handleDownload(); }} | ||
| disabled={downloadStatus === 'capturing' || !report} | ||
| disabled={downloadStatus === 'capturing' || !report || loading || !!error} | ||
| aria-label="Download usage as image" | ||
| title="Download usage as image" | ||
| className="rounded p-1 transition-colors hover:bg-surface-hover disabled:opacity-50" | ||
|
|
@@ -493,7 +521,7 @@ export function UsageView() { | |
| <button | ||
| type="button" | ||
| onClick={() => { void handleShare(); }} | ||
| disabled={shareStatus === 'capturing' || !report} | ||
| disabled={shareStatus === 'capturing' || !report || loading || !!error} | ||
| aria-label="Share usage image" | ||
| title="Share usage image" | ||
| className="rounded p-1 transition-colors hover:bg-surface-hover disabled:opacity-50" | ||
|
|
@@ -516,6 +544,17 @@ export function UsageView() { | |
| )} | ||
| </header> | ||
|
|
||
| {showDateRange && ( | ||
| <UsageDateRangeDialog | ||
| initialRange={customRange ?? { | ||
| start: localDateString(new Date(Date.now() - rangeDays * DAY_MS)), | ||
| end: localDateString(new Date()), | ||
|
Comment on lines
+549
to
+551
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When no custom range exists, opening the dialog after selecting 30d seeds Useful? React with 👍 / 👎. |
||
| }} | ||
| onClose={() => setShowDateRange(false)} | ||
| onApply={range => { setCustomRange(range); setShowDateRange(false); }} | ||
| /> | ||
| )} | ||
|
|
||
| {activeTab === 'usage' && report?.index.scanning && ( | ||
| <div className="flex flex-shrink-0 items-center gap-2 border-b border-border-primary bg-surface-tertiary px-4 py-1 text-[11px] text-text-tertiary"> | ||
| <Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" /> | ||
|
|
@@ -550,6 +589,7 @@ export function UsageView() { | |
| </div> | ||
| ) : report ? ( | ||
| <div className="mx-auto flex max-w-6xl flex-col gap-4"> | ||
| <p className="text-xs text-text-tertiary">{customRange ? rangeLabel : `Last ${rangeLabel}`} · {provider === 'all' ? 'All providers' : PROVIDER_OPTIONS.find(option => option.value === provider)?.label}</p> | ||
| {/* Summary — all from logs */} | ||
| <div className="grid grid-cols-2 gap-2 sm:grid-cols-4"> | ||
| <StatCard | ||
|
|
@@ -603,6 +643,8 @@ export function UsageView() { | |
| </div> | ||
| )} | ||
|
|
||
| <PaneUsageSummary byPane={report.byPane} trim={trimPaneUsage} onTrimChange={setTrimPaneUsage} /> | ||
|
|
||
| <div className="grid gap-4 lg:grid-cols-3"> | ||
| {/* Time series */} | ||
| <section className="rounded border border-border-primary bg-surface-secondary p-3 lg:col-span-2"> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| export interface UsageDateRange { | ||
| start: string; | ||
| end: string; | ||
| } | ||
|
|
||
| export function localDateString(date: Date): string { | ||
| return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; | ||
| } | ||
|
|
||
| export function usageDateBounds(range: UsageDateRange) { | ||
| const start = new Date(`${range.start}T00:00:00`); | ||
| const end = new Date(`${range.end}T00:00:00`); | ||
| end.setDate(end.getDate() + 1); | ||
| return { fromMs: start.getTime(), toMs: end.getTime() - 1 }; | ||
|
Comment on lines
+10
to
+14
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a custom interval longer than two days is selected outside UTC, these local-midnight bounds are passed into a report whose automatic daily series still floors timestamps to UTC-day boundaries ( Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
React Doctor ·
react-doctor/no-loading-flag-reset-outside-finally(warning)This resets a loading/busy flag only on the success path: if the awaited call rejects the reset never runs and the flag stays stuck truthy (a spinner that never stops, a button disabled forever). Move the reset into a
finallyblock, or mirror it on every catch, so it clears on rejection too.Fix → A trailing
setLoading(false)after anawaitnever runs if the awaited call rejects, so the flag stays stuck truthy; reset it in afinallyblock (or mirror the reset on every catch) so it clears on both paths.Docs