feat: Multi-provider Usage Dashboard & Quota Monitoring (#259)

* feat(openchamber): persist usage auto-refresh settings

Enable a switch to toggle automatic usage refresh
Provide input to configure refresh interval in milliseconds
Persist changes to desktop settings and server API when changed

* feat: add UsageCard component

Add a new UsageCard component to display a usage window with title, optional subtitle, and a progress bar
Show current usage percentage and a reset time label for the window
Render a formatted window label and a compact subtitle for concise UI

* feat(usage): add UsagePage UI for quota usage

Add a dedicated UsagePage with provider-based usage details
Show last updated time and auto-refresh status
Handle empty, not configured, and error states with informative banners

* feat(usage): add UsageProgressBar component

Introduce a new UsageProgressBar component to visualize quota usage
Display a gradient fill that changes with critical, warn, or normal tones
Expose accessible progress attributes for screen readers

* feat(usage): add UsageSidebar component

Display quotas for all providers in a scrollable sidebar
Refresh quotas with a button and loading indicator
Colorize provider rows based on usage status and runtime context

* feat: add usage section to Settings

Add a new Usage item to the Settings sidebar for desktop and mobile
Render UsagePage when the Usage tab is selected in Settings
Wire up new UsageSidebar and UsagePage components under usage

* feat: add Usage section to sidebar

Add new Usage section in the sidebar for API quota monitoring.
Display a bar chart icon and description for the Usage item.
Monitor and display API quota usage across providers.

* feat(desktop): add usage auto-refresh settings

Enable automatic refresh for usage data with new settings
Store refresh interval in milliseconds for usage updates

* feat(persistence): support usageAutoRefresh and usageRefreshIntervalMs

Persist usageAutoRefresh in desktop settings
Persist usageRefreshIntervalMs in desktop settings
Validate types for new fields during sanitizeWebSettings

* feat(quota): export providers and utilities

Expose QUOTA_PROVIDERS and QUOTA_PROVIDER_MAP for consumers
Export QuotaProviderMeta type for user code
Make formatting and usage resolution utilities available from quota module

* feat(quota): define base quota provider interface

Define QuotaProvider interface with id, name, isConfigured, and fetchQuota
Expose ProviderResult type in fetchQuota contract

* feat: add quota providers index and map

Expose QUOTA_PROVIDERS with OpenAI, Google and z.ai
Provide QUOTA_PROVIDER_MAP for quick provider lookup by id

* feat: add quota utils for percent formatting and tone

Add clampPercent to sanitize and clamp numbers to 0-100
Add formatPercent to render '-' for null and 'x%' for values
Add resolveUsageTone to categorize percent as safe, warn, or critical

* feat: add useQuotaStore for quota data

Load usage settings from desktop, VSCode, or API at startup
Fetch quotas for all providers in parallel and update loading state
Expose lastUpdated timestamp and error state for UI feedback

* feat: export quota types from quota module

Expose quota-related types in UI type definitions
Allow downstream code to import QuotaProviderId and related types
Aggregate quota exports under the quota module in index

* feat: add quota types for usage providers

Add QuotaProviderId and UsageWindow shapes to model quota data
Add ProviderUsage, ProviderResult, and related usage mapping for providers

* feat: add quota provider endpoints API

List available quota providers via GET /api/quota/providers
Retrieve quota details for a specific provider with GET /api/quota/:providerId
Log errors and return 500 with error message on quota fetch failures

* feat: add quota providers discovery and formatting

Detect configured quota providers from auth and account files
Normalize auth entries to tokens or objects for API usage
Expose formatted reset times and remaining window metrics

* feat: persist usage settings in UsageSidebar and remove from defaults

Load usage settings on mount for the sidebar
Persist changes to auto-refresh and refresh interval to server
Remove usage settings state and effects from DefaultsSettings

* fix(usage): guard auto-select in UsagePage when results empty

Guard auto-select in UsagePage when results are empty
Prevent unexpected provider selection on initial render

* fix(quota): set error state during quota updates

Reset error to null when a new quota result is added
Set error to the error message on fetch failure or fallback
Keep error state alongside results in all update paths
This commit is contained in:
Nelson Pires
2026-02-01 18:30:30 +02:00
committed by GitHub
parent ddedc02687
commit 0981194eed
18 changed files with 1225 additions and 3 deletions
@@ -125,6 +125,7 @@ export const DefaultsSettings: React.FC = () => {
loadSettings();
}, []);
const handleModelChange = React.useCallback(async (providerId: string, modelId: string) => {
const newValue = providerId && modelId ? `${providerId}/${modelId}` : undefined;
setDefaultModel(newValue);
@@ -243,6 +244,7 @@ export const DefaultsSettings: React.FC = () => {
}
}, [setSettingsAutoCreateWorktree]);
if (isLoading) {
return null;
}
@@ -301,7 +303,7 @@ export const DefaultsSettings: React.FC = () => {
</div>
</div>
{(parsedModel.providerId || defaultAgent) && (
{(parsedModel.providerId || defaultAgent) && (
<div className="typography-meta text-muted-foreground">
New sessions will start with:{' '}
{parsedModel.providerId && (
@@ -315,6 +317,7 @@ export const DefaultsSettings: React.FC = () => {
</div>
)}
{!isVSCode && (
<div className="pt-2">
<label className="flex items-center gap-2 cursor-pointer">
@@ -0,0 +1,39 @@
import React from 'react';
import type { UsageWindow } from '@/types';
import { formatPercent, formatWindowLabel } from '@/lib/quota';
import { UsageProgressBar } from './UsageProgressBar';
interface UsageCardProps {
title: string;
window: UsageWindow;
subtitle?: string | null;
}
export const UsageCard: React.FC<UsageCardProps> = ({ title, window, subtitle }) => {
const percentLabel = formatPercent(window.usedPercent);
const resetLabel = window.resetAfterFormatted ?? window.resetAtFormatted ?? '-';
const windowLabel = formatWindowLabel(title);
return (
<div className="rounded-xl border border-border/60 bg-card/40 p-4 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="typography-ui-label text-foreground truncate">{windowLabel}</div>
{subtitle && (
<div className="typography-micro text-muted-foreground truncate">{subtitle}</div>
)}
</div>
<div className="typography-ui-label text-foreground tabular-nums">{percentLabel}</div>
</div>
<div className="mt-3">
<UsageProgressBar percent={window.usedPercent} />
</div>
<div className="mt-3 flex items-center justify-between text-muted-foreground">
<span className="typography-micro">Resets in</span>
<span className="typography-micro tabular-nums">{resetLabel}</span>
</div>
</div>
);
};
@@ -0,0 +1,124 @@
import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { UsageCard } from './UsageCard';
import { QUOTA_PROVIDERS } from '@/lib/quota';
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
const formatTime = (timestamp: number | null) => {
if (!timestamp) return '-';
try {
return new Date(timestamp).toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit'
});
} catch {
return '-';
}
};
export const UsagePage: React.FC = () => {
const results = useQuotaStore((state) => state.results);
const selectedProviderId = useQuotaStore((state) => state.selectedProviderId);
const setSelectedProvider = useQuotaStore((state) => state.setSelectedProvider);
const loadSettings = useQuotaStore((state) => state.loadSettings);
const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas);
const isLoading = useQuotaStore((state) => state.isLoading);
const lastUpdated = useQuotaStore((state) => state.lastUpdated);
const error = useQuotaStore((state) => state.error);
useQuotaAutoRefresh();
React.useEffect(() => {
void loadSettings();
void fetchAllQuotas();
}, [loadSettings, fetchAllQuotas]);
React.useEffect(() => {
if (selectedProviderId) {
return;
}
if (results.length === 0) {
return;
}
const firstConfigured = results.find((entry) => entry.configured)?.providerId;
setSelectedProvider(firstConfigured ?? QUOTA_PROVIDERS[0]?.id ?? null);
}, [results, selectedProviderId, setSelectedProvider]);
const selectedResult = results.find((entry) => entry.providerId === selectedProviderId) ?? null;
if (!selectedProviderId) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<p className="typography-body">Select a provider to view usage details.</p>
</div>
);
}
const providerMeta = QUOTA_PROVIDERS.find((provider) => provider.id === selectedProviderId);
const providerName = providerMeta?.name ?? selectedProviderId;
const usage = selectedResult?.usage;
return (
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6">
<div className="space-y-1">
<h1 className="typography-ui-header font-semibold text-lg">{providerName} Usage</h1>
<p className="typography-meta text-muted-foreground">
{isLoading ? 'Refreshing usage...' : `Last updated ${formatTime(lastUpdated)}`}
</p>
</div>
{!selectedResult && (
<div className="rounded-lg border border-border/60 bg-card/40 p-4 text-muted-foreground">
<p className="typography-body">No usage data available yet.</p>
</div>
)}
{error && (
<div className="rounded-lg border border-border/60 bg-card/40 p-4 text-muted-foreground">
<p className="typography-body">Failed to refresh usage data.</p>
<p className="typography-meta mt-1">{error}</p>
</div>
)}
{selectedResult && !selectedResult.configured && (
<div className="rounded-lg border border-border/60 bg-card/40 p-4 text-muted-foreground">
<p className="typography-body">Provider is not configured yet.</p>
<p className="typography-meta mt-1">
Add credentials in the Providers tab to enable usage tracking.
</p>
</div>
)}
{usage?.windows && Object.keys(usage.windows).length > 0 && (
<div className="space-y-3">
{Object.entries(usage.windows).map(([label, window]) => (
<UsageCard key={label} title={label} window={window} />
))}
</div>
)}
{usage?.models && Object.keys(usage.models).length > 0 && (
<div className="space-y-3">
<div className="typography-ui-header font-semibold text-foreground">Model Quotas</div>
{Object.entries(usage.models).map(([modelName, modelUsage]) => {
const entries = Object.entries(modelUsage.windows);
if (entries.length === 0) {
return null;
}
const [label, window] = entries[0];
return <UsageCard key={modelName} title={label} subtitle={modelName} window={window} />;
})}
</div>
)}
{selectedResult?.configured && usage && Object.keys(usage.windows ?? {}).length === 0 &&
Object.keys(usage.models ?? {}).length === 0 && (
<div className="rounded-lg border border-border/60 bg-card/40 p-4 text-muted-foreground">
<p className="typography-body">No quota windows reported for this provider.</p>
</div>
)}
</div>
</ScrollableOverlay>
);
};
@@ -0,0 +1,32 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { clampPercent, resolveUsageTone } from '@/lib/quota';
interface UsageProgressBarProps {
percent: number | null;
className?: string;
}
export const UsageProgressBar: React.FC<UsageProgressBarProps> = ({ percent, className }) => {
const clamped = clampPercent(percent) ?? 0;
const tone = resolveUsageTone(percent);
const fillClass = tone === 'critical'
? 'from-rose-500 to-rose-400'
: tone === 'warn'
? 'from-amber-500 to-amber-400'
: 'from-emerald-500 to-emerald-400';
return (
<div className={cn('h-2.5 rounded-full bg-muted/60 overflow-hidden', className)}>
<div
className={cn('h-full bg-gradient-to-r transition-all duration-300', fillClass)}
style={{ width: `${clamped}%` }}
role="progressbar"
aria-valuenow={clamped}
aria-valuemin={0}
aria-valuemax={100}
/>
</div>
);
};
@@ -0,0 +1,175 @@
import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useDeviceInfo } from '@/lib/device';
import { isVSCodeRuntime } from '@/lib/desktop';
import { cn } from '@/lib/utils';
import { QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota';
import { useQuotaStore } from '@/stores/useQuotaStore';
import { updateDesktopSettings } from '@/lib/persistence';
import { RiRefreshLine } from '@remixicon/react';
interface UsageSidebarProps {
onItemSelect?: () => void;
}
const getUsagePercent = (usage: { windows?: Record<string, { usedPercent: number | null }> } | null | undefined) => {
const windows = usage?.windows ?? {};
const values = Object.values(windows)
.map((window) => window.usedPercent)
.filter((value): value is number => typeof value === 'number');
if (values.length === 0) {
return null;
}
return Math.max(...values);
};
export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
const results = useQuotaStore((state) => state.results);
const selectedProviderId = useQuotaStore((state) => state.selectedProviderId);
const setSelectedProvider = useQuotaStore((state) => state.setSelectedProvider);
const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas);
const isLoading = useQuotaStore((state) => state.isLoading);
const usageAutoRefresh = useQuotaStore((state) => state.autoRefresh);
const usageRefreshIntervalMs = useQuotaStore((state) => state.refreshIntervalMs);
const setUsageAutoRefresh = useQuotaStore((state) => state.setAutoRefresh);
const setUsageRefreshInterval = useQuotaStore((state) => state.setRefreshInterval);
const loadUsageSettings = useQuotaStore((state) => state.loadSettings);
const { isMobile } = useDeviceInfo();
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return typeof window.opencodeDesktop !== 'undefined';
});
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
React.useEffect(() => {
if (typeof window === 'undefined') return;
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
}, []);
React.useEffect(() => {
void loadUsageSettings();
}, [loadUsageSettings]);
const persistUsageSettings = React.useCallback(async (changes: { usageAutoRefresh?: boolean; usageRefreshIntervalMs?: number }) => {
try {
await updateDesktopSettings(changes);
} catch (error) {
console.warn('Failed to save usage settings:', error);
}
}, []);
const handleUsageAutoRefreshChange = React.useCallback((enabled: boolean) => {
setUsageAutoRefresh(enabled);
void persistUsageSettings({ usageAutoRefresh: enabled });
}, [persistUsageSettings, setUsageAutoRefresh]);
const handleUsageRefreshIntervalChange = React.useCallback((value: string) => {
const next = Number(value);
if (!Number.isFinite(next)) {
return;
}
setUsageRefreshInterval(next);
void persistUsageSettings({ usageRefreshIntervalMs: next });
}, [persistUsageSettings, setUsageRefreshInterval]);
const bgClass = isDesktopRuntime
? 'bg-transparent'
: isVSCode
? 'bg-background'
: 'bg-sidebar';
return (
<div className={cn('flex h-full flex-col', bgClass)}>
<div className={cn('border-b px-3', isMobile ? 'mt-2 py-3' : 'py-3')}>
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">Total {QUOTA_PROVIDERS.length}</span>
<div className="flex items-center gap-2">
<Switch
checked={usageAutoRefresh}
onCheckedChange={handleUsageAutoRefreshChange}
aria-label="Toggle auto refresh"
/>
<Select
value={String(usageRefreshIntervalMs)}
onValueChange={handleUsageRefreshIntervalChange}
disabled={!usageAutoRefresh}
>
<SelectTrigger size="sm" className="min-w-[72px]">
<SelectValue placeholder="Interval" />
</SelectTrigger>
<SelectContent>
<SelectItem value="30000" className="pr-2 [&>span:first-child]:hidden">30s</SelectItem>
<SelectItem value="60000" className="pr-2 [&>span:first-child]:hidden">1m</SelectItem>
<SelectItem value="300000" className="pr-2 [&>span:first-child]:hidden">5m</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 -my-1 text-muted-foreground"
onClick={() => fetchAllQuotas()}
aria-label="Refresh usage"
title="Refresh usage"
disabled={isLoading}
>
<RiRefreshLine className={cn('size-4', isLoading && 'animate-spin')} />
</Button>
</div>
</div>
</div>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2 overflow-x-hidden">
{QUOTA_PROVIDERS.map((provider) => {
const result = results.find((entry) => entry.providerId === provider.id);
const percent = getUsagePercent(result?.usage);
const tone = resolveUsageTone(percent);
const isSelected = provider.id === selectedProviderId;
const configured = result?.configured ?? false;
const statusClass = !configured
? 'bg-muted-foreground/40'
: tone === 'critical'
? 'bg-rose-500'
: tone === 'warn'
? 'bg-amber-500'
: 'bg-emerald-500';
return (
<div
key={provider.id}
className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200',
isSelected ? 'dark:bg-accent/80 bg-primary/12' : 'hover:dark:bg-accent/40 hover:bg-primary/6'
)}
>
<button
type="button"
onClick={() => {
setSelectedProvider(provider.id);
onItemSelect?.();
}}
className="flex min-w-0 flex-1 items-center gap-2 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
>
<span className={cn('h-2.5 w-2.5 rounded-full flex-shrink-0', statusClass)} />
<ProviderLogo providerId={provider.id} className="h-4 w-4 flex-shrink-0" />
<span className="typography-ui-label font-normal truncate flex-1 min-w-0 text-foreground">
{provider.name}
</span>
{!configured && (
<span className="typography-micro text-muted-foreground/60 flex-shrink-0">Not set</span>
)}
</button>
</div>
);
})}
</ScrollableOverlay>
</div>
);
};
@@ -25,6 +25,8 @@ import { SkillsSidebar } from '@/components/sections/skills/SkillsSidebar';
import { SkillsPage } from '@/components/sections/skills/SkillsPage';
import { ProvidersSidebar } from '@/components/sections/providers/ProvidersSidebar';
import { ProvidersPage } from '@/components/sections/providers/ProvidersPage';
import { UsageSidebar } from '@/components/sections/usage/UsageSidebar';
import { UsagePage } from '@/components/sections/usage/UsagePage';
import { GitIdentitiesSidebar } from '@/components/sections/git-identities/GitIdentitiesSidebar';
import { GitIdentitiesPage } from '@/components/sections/git-identities/GitIdentitiesPage';
import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage';
@@ -305,6 +307,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
return <SkillsSidebar onItemSelect={handleMobileSidebarClick} />;
case 'providers':
return <ProvidersSidebar onItemSelect={handleMobileSidebarClick} />;
case 'usage':
return <UsageSidebar onItemSelect={handleMobileSidebarClick} />;
case 'git-identities':
return <GitIdentitiesSidebar onItemSelect={handleMobileSidebarClick} />;
default:
@@ -324,6 +328,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
return <SkillsPage />;
case 'providers':
return <ProvidersPage />;
case 'usage':
return <UsagePage />;
case 'git-identities':
return <GitIdentitiesPage />;
case 'settings':