Makelore 2.0 initial clean snapshot
This commit is contained in:
131
src/pages/Models/OpencodeRuntimePanel.tsx
Normal file
131
src/pages/Models/OpencodeRuntimePanel.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
CircleDot,
|
||||
Play,
|
||||
RefreshCw,
|
||||
ServerCog,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useOpencodeStore } from '@/stores/opencode';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function statusClass(state: string): string {
|
||||
if (state === 'running') {
|
||||
return 'border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300';
|
||||
}
|
||||
if (state === 'error') {
|
||||
return 'border-red-500/20 bg-red-500/10 text-red-700 dark:text-red-300';
|
||||
}
|
||||
if (state === 'starting') {
|
||||
return 'border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300';
|
||||
}
|
||||
return 'border-black/10 bg-black/5 text-muted-foreground dark:border-white/10 dark:bg-white/5';
|
||||
}
|
||||
|
||||
export function OpencodeRuntimePanel() {
|
||||
const { t } = useTranslation('dashboard');
|
||||
const status = useOpencodeStore((state) => state.status);
|
||||
const summary = useOpencodeStore((state) => state.runtimeConfigSummary);
|
||||
const loading = useOpencodeStore((state) => state.loading);
|
||||
const error = useOpencodeStore((state) => state.error);
|
||||
const refreshStatus = useOpencodeStore((state) => state.refreshStatus);
|
||||
const loadRuntimeConfigSummary = useOpencodeStore((state) => state.loadRuntimeConfigSummary);
|
||||
const restart = useOpencodeStore((state) => state.restart);
|
||||
const start = useOpencodeStore((state) => state.start);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshStatus();
|
||||
void loadRuntimeConfigSummary();
|
||||
}, [loadRuntimeConfigSummary, refreshStatus]);
|
||||
|
||||
const isStopped = status.state === 'stopped';
|
||||
const isBusy = loading || status.state === 'starting';
|
||||
|
||||
const handleRuntimeAction = async () => {
|
||||
if (isStopped) {
|
||||
await start();
|
||||
} else {
|
||||
await restart();
|
||||
}
|
||||
await refreshStatus();
|
||||
await loadRuntimeConfigSummary();
|
||||
};
|
||||
|
||||
return (
|
||||
<section data-testid="opencode-runtime-panel" className="space-y-5">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ServerCog className="h-5 w-5 text-foreground/70" aria-hidden="true" />
|
||||
<h2 className="text-3xl font-serif text-foreground font-normal tracking-tight">
|
||||
{t('opencodeRuntime.title', 'Makelore runtime')}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void handleRuntimeAction()}
|
||||
disabled={isBusy}
|
||||
aria-label={isStopped
|
||||
? t('opencodeRuntime.start', 'Start runtime')
|
||||
: t('opencodeRuntime.restart', 'Restart runtime')}
|
||||
className="h-10 rounded-xl border-black/10 bg-transparent px-4 hover:bg-black/5 dark:border-white/10 dark:hover:bg-white/5"
|
||||
>
|
||||
{isStopped ? (
|
||||
<Play className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<RefreshCw className={cn('mr-2 h-4 w-4', isBusy && 'animate-spin')} aria-hidden="true" />
|
||||
)}
|
||||
{isStopped
|
||||
? t('opencodeRuntime.start', 'Start runtime')
|
||||
: t('opencodeRuntime.restart', 'Restart runtime')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-black/10 bg-transparent p-4 dark:border-white/10">
|
||||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
{t('opencodeRuntime.status', 'Status')}
|
||||
</p>
|
||||
<div className={cn('mt-3 inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-semibold', statusClass(status.state))}>
|
||||
<CircleDot className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{status.state}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-black/10 bg-transparent p-4 dark:border-white/10">
|
||||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
{t('opencodeRuntime.port', 'Port')}
|
||||
</p>
|
||||
<p className="mt-3 font-mono text-lg font-semibold text-foreground">
|
||||
{status.port}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-black/10 bg-transparent p-4 dark:border-white/10">
|
||||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
{t('opencodeRuntime.providers', 'Providers')}
|
||||
</p>
|
||||
<p className="mt-3 font-mono text-lg font-semibold text-foreground">
|
||||
{summary?.providerCount ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-black/10 bg-black/5 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
{t('opencodeRuntime.defaultModel', 'Default model')}
|
||||
</p>
|
||||
<p className="mt-2 break-all font-mono text-sm font-semibold text-foreground">
|
||||
{summary?.model || t('opencodeRuntime.noModel', 'No configured model')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm font-medium text-red-700 dark:text-red-300">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
680
src/pages/Models/index.tsx
Normal file
680
src/pages/Models/index.tsx
Normal file
@@ -0,0 +1,680 @@
|
||||
import { useEffect, useReducer, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useOpencodeStore } from '@/stores/opencode';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import { trackUiEvent } from '@/lib/telemetry';
|
||||
import { ProvidersSettings } from '@/components/settings/ProvidersSettings';
|
||||
import { FeedbackState } from '@/components/common/FeedbackState';
|
||||
import { OpencodeRuntimePanel } from './OpencodeRuntimePanel';
|
||||
import {
|
||||
filterUsageHistoryByWindow,
|
||||
groupUsageHistory,
|
||||
resolveStableUsageHistory,
|
||||
resolveVisibleUsageHistory,
|
||||
type UsageGroupBy,
|
||||
type UsageHistoryEntry,
|
||||
type UsageWindow,
|
||||
} from './usage-history';
|
||||
const DEFAULT_USAGE_FETCH_MAX_ATTEMPTS = 2;
|
||||
const WINDOWS_USAGE_FETCH_MAX_ATTEMPTS = 3;
|
||||
const USAGE_FETCH_RETRY_DELAY_MS = 1500;
|
||||
const USAGE_AUTO_REFRESH_INTERVAL_MS = 15_000;
|
||||
|
||||
const HIDDEN_USAGE_MARKERS = ['delivery-mirror'];
|
||||
|
||||
function isHiddenUsageSource(source?: string): boolean {
|
||||
if (!source) return false;
|
||||
const normalizedSource = source.trim().toLowerCase();
|
||||
return HIDDEN_USAGE_MARKERS.some((marker) => normalizedSource.includes(marker));
|
||||
}
|
||||
|
||||
export function Models() {
|
||||
const { t } = useTranslation(['dashboard', 'settings']);
|
||||
const opencodeStatus = useOpencodeStore((state) => state.status);
|
||||
const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked);
|
||||
const runtimeRunning = opencodeStatus.state === 'running';
|
||||
const usageFetchMaxAttempts = window.electron.platform === 'win32'
|
||||
? WINDOWS_USAGE_FETCH_MAX_ATTEMPTS
|
||||
: DEFAULT_USAGE_FETCH_MAX_ATTEMPTS;
|
||||
|
||||
const [usageGroupBy, setUsageGroupBy] = useState<UsageGroupBy>('model');
|
||||
const [usageWindow, setUsageWindow] = useState<UsageWindow>('7d');
|
||||
const [usagePage, setUsagePage] = useState(1);
|
||||
const [selectedUsageEntry, setSelectedUsageEntry] = useState<UsageHistoryEntry | null>(null);
|
||||
const [usageRefreshNonce, setUsageRefreshNonce] = useState(0);
|
||||
function formatUsageSource(source?: string): string | undefined {
|
||||
if (!source) return undefined;
|
||||
|
||||
if (isHiddenUsageSource(source)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
function shouldHideUsageEntry(entry: UsageHistoryEntry): boolean {
|
||||
return (
|
||||
isHiddenUsageSource(entry.provider)
|
||||
|| isHiddenUsageSource(entry.model)
|
||||
);
|
||||
}
|
||||
|
||||
type FetchState = {
|
||||
status: 'idle' | 'loading' | 'done';
|
||||
data: UsageHistoryEntry[];
|
||||
stableData: UsageHistoryEntry[];
|
||||
};
|
||||
type FetchAction =
|
||||
| { type: 'start' }
|
||||
| { type: 'done'; data: UsageHistoryEntry[] }
|
||||
| { type: 'failed' }
|
||||
| { type: 'reset' };
|
||||
|
||||
const [fetchState, dispatchFetch] = useReducer(
|
||||
(state: FetchState, action: FetchAction): FetchState => {
|
||||
switch (action.type) {
|
||||
case 'start':
|
||||
return { ...state, status: 'loading' };
|
||||
case 'done':
|
||||
return {
|
||||
status: 'done',
|
||||
data: action.data,
|
||||
stableData: resolveStableUsageHistory(state.stableData, action.data),
|
||||
};
|
||||
case 'failed':
|
||||
return { ...state, status: 'done' };
|
||||
case 'reset':
|
||||
return { status: 'idle', data: [], stableData: [] };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
},
|
||||
{ status: 'idle' as const, data: [] as UsageHistoryEntry[], stableData: [] as UsageHistoryEntry[] },
|
||||
);
|
||||
|
||||
const usageFetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const usageFetchGenerationRef = useRef(0);
|
||||
const usageFetchStatusRef = useRef<FetchState['status']>('idle');
|
||||
|
||||
useEffect(() => {
|
||||
usageFetchStatusRef.current = fetchState.status;
|
||||
}, [fetchState.status]);
|
||||
|
||||
useEffect(() => {
|
||||
trackUiEvent('models.page_viewed');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runtimeRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestRefresh = () => {
|
||||
if (usageFetchStatusRef.current === 'loading') return;
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
|
||||
setUsageRefreshNonce((value) => value + 1);
|
||||
};
|
||||
|
||||
const intervalId = window.setInterval(requestRefresh, USAGE_AUTO_REFRESH_INTERVAL_MS);
|
||||
const handleFocus = () => {
|
||||
requestRefresh();
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
requestRefresh();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleFocus);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [runtimeRunning]);
|
||||
|
||||
useEffect(() => {
|
||||
if (usageFetchTimerRef.current) {
|
||||
clearTimeout(usageFetchTimerRef.current);
|
||||
usageFetchTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (!runtimeRunning) {
|
||||
dispatchFetch({ type: 'reset' });
|
||||
return;
|
||||
}
|
||||
|
||||
dispatchFetch({ type: 'start' });
|
||||
const generation = usageFetchGenerationRef.current + 1;
|
||||
usageFetchGenerationRef.current = generation;
|
||||
const restartMarker = `${opencodeStatus.pid ?? 'na'}:${opencodeStatus.startedAt ?? 'na'}`;
|
||||
trackUiEvent('models.token_usage_fetch_started', {
|
||||
generation,
|
||||
restartMarker,
|
||||
});
|
||||
|
||||
// Safety timeout: if the fetch cycle hasn't resolved after 30 s,
|
||||
// force-resolve to "done" with empty data to avoid an infinite spinner.
|
||||
const safetyTimeout = setTimeout(() => {
|
||||
if (usageFetchGenerationRef.current !== generation) return;
|
||||
trackUiEvent('models.token_usage_fetch_safety_timeout', {
|
||||
generation,
|
||||
restartMarker,
|
||||
});
|
||||
dispatchFetch({ type: 'failed' });
|
||||
}, 30_000);
|
||||
|
||||
const fetchUsageHistoryWithRetry = async (attempt: number) => {
|
||||
trackUiEvent('models.token_usage_fetch_attempt', {
|
||||
generation,
|
||||
attempt,
|
||||
restartMarker,
|
||||
});
|
||||
try {
|
||||
const entries = await hostApiFetch<UsageHistoryEntry[]>('/api/usage/recent-token-history');
|
||||
if (usageFetchGenerationRef.current !== generation) return;
|
||||
|
||||
const normalized = Array.isArray(entries) ? entries : [];
|
||||
setUsagePage(1);
|
||||
trackUiEvent('models.token_usage_fetch_succeeded', {
|
||||
generation,
|
||||
attempt,
|
||||
records: normalized.length,
|
||||
restartMarker,
|
||||
});
|
||||
|
||||
if (normalized.length === 0 && attempt < usageFetchMaxAttempts) {
|
||||
trackUiEvent('models.token_usage_fetch_retry_scheduled', {
|
||||
generation,
|
||||
attempt,
|
||||
reason: 'empty',
|
||||
restartMarker,
|
||||
});
|
||||
usageFetchTimerRef.current = setTimeout(() => {
|
||||
void fetchUsageHistoryWithRetry(attempt + 1);
|
||||
}, USAGE_FETCH_RETRY_DELAY_MS);
|
||||
} else {
|
||||
if (normalized.length === 0) {
|
||||
trackUiEvent('models.token_usage_fetch_exhausted', {
|
||||
generation,
|
||||
attempt,
|
||||
reason: 'empty',
|
||||
restartMarker,
|
||||
});
|
||||
}
|
||||
dispatchFetch({ type: 'done', data: normalized });
|
||||
}
|
||||
} catch (error) {
|
||||
if (usageFetchGenerationRef.current !== generation) return;
|
||||
trackUiEvent('models.token_usage_fetch_failed_attempt', {
|
||||
generation,
|
||||
attempt,
|
||||
restartMarker,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
if (attempt < usageFetchMaxAttempts) {
|
||||
trackUiEvent('models.token_usage_fetch_retry_scheduled', {
|
||||
generation,
|
||||
attempt,
|
||||
reason: 'error',
|
||||
restartMarker,
|
||||
});
|
||||
usageFetchTimerRef.current = setTimeout(() => {
|
||||
void fetchUsageHistoryWithRetry(attempt + 1);
|
||||
}, USAGE_FETCH_RETRY_DELAY_MS);
|
||||
return;
|
||||
}
|
||||
dispatchFetch({ type: 'failed' });
|
||||
trackUiEvent('models.token_usage_fetch_exhausted', {
|
||||
generation,
|
||||
attempt,
|
||||
reason: 'error',
|
||||
restartMarker,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
void fetchUsageHistoryWithRetry(1);
|
||||
|
||||
return () => {
|
||||
clearTimeout(safetyTimeout);
|
||||
if (usageFetchTimerRef.current) {
|
||||
clearTimeout(usageFetchTimerRef.current);
|
||||
usageFetchTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [runtimeRunning, opencodeStatus.startedAt, opencodeStatus.pid, usageFetchMaxAttempts, usageRefreshNonce]);
|
||||
|
||||
const usageHistory = runtimeRunning
|
||||
? fetchState.data.filter((entry) => !shouldHideUsageEntry(entry))
|
||||
: [];
|
||||
const stableUsageHistory = runtimeRunning
|
||||
? fetchState.stableData.filter((entry) => !shouldHideUsageEntry(entry))
|
||||
: [];
|
||||
const visibleUsageHistory = resolveVisibleUsageHistory(usageHistory, stableUsageHistory, {
|
||||
preferStableOnEmpty: runtimeRunning && fetchState.status === 'loading',
|
||||
});
|
||||
const filteredUsageHistory = filterUsageHistoryByWindow(visibleUsageHistory, usageWindow);
|
||||
const usageGroups = groupUsageHistory(filteredUsageHistory, usageGroupBy);
|
||||
const usagePageSize = 5;
|
||||
const usageTotalPages = Math.max(1, Math.ceil(filteredUsageHistory.length / usagePageSize));
|
||||
const safeUsagePage = Math.min(usagePage, usageTotalPages);
|
||||
const pagedUsageHistory = filteredUsageHistory.slice((safeUsagePage - 1) * usagePageSize, safeUsagePage * usagePageSize);
|
||||
const usageLoading = runtimeRunning && fetchState.status === 'loading' && visibleUsageHistory.length === 0;
|
||||
const usageRefreshing = runtimeRunning && fetchState.status === 'loading' && visibleUsageHistory.length > 0;
|
||||
|
||||
return (
|
||||
<div data-testid="models-page" className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden">
|
||||
<div className="w-full max-w-5xl mx-auto flex flex-col h-full p-10 pt-16">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-start justify-between mb-12 shrink-0 gap-4">
|
||||
<div>
|
||||
<h1 data-testid="models-page-title" className="text-5xl md:text-6xl font-serif text-foreground mb-3 font-normal tracking-tight">
|
||||
{t('dashboard:models.title')}
|
||||
</h1>
|
||||
<p className="text-subtitle text-foreground/70 font-medium">
|
||||
{t('dashboard:models.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 overflow-y-auto pr-2 pb-10 min-h-0 -mr-2 space-y-12">
|
||||
<OpencodeRuntimePanel />
|
||||
|
||||
{/* AI Providers Section */}
|
||||
<ProvidersSettings />
|
||||
|
||||
{/* Token Usage History Section */}
|
||||
<div>
|
||||
<h2 className="text-3xl font-serif text-foreground mb-6 font-normal tracking-tight">
|
||||
{t('dashboard:recentTokenHistory.title', 'Token Usage History')}
|
||||
</h2>
|
||||
<div>
|
||||
{usageLoading ? (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground bg-black/5 dark:bg-white/5 rounded-3xl border border-transparent border-dashed">
|
||||
<FeedbackState state="loading" title={t('dashboard:recentTokenHistory.loading')} />
|
||||
</div>
|
||||
) : visibleUsageHistory.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground bg-black/5 dark:bg-white/5 rounded-3xl border border-transparent border-dashed">
|
||||
<FeedbackState state="empty" title={t('dashboard:recentTokenHistory.empty')} />
|
||||
</div>
|
||||
) : filteredUsageHistory.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground bg-black/5 dark:bg-white/5 rounded-3xl border border-transparent border-dashed">
|
||||
<FeedbackState state="empty" title={t('dashboard:recentTokenHistory.emptyForWindow')} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex rounded-xl bg-transparent p-1 border border-black/10 dark:border-white/10">
|
||||
<Button
|
||||
variant={usageGroupBy === 'model' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setUsageGroupBy('model');
|
||||
setUsagePage(1);
|
||||
}}
|
||||
className={usageGroupBy === 'model' ? "rounded-lg bg-black/5 dark:bg-white/10 text-foreground" : "rounded-lg text-muted-foreground"}
|
||||
>
|
||||
{t('dashboard:recentTokenHistory.groupByModel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={usageGroupBy === 'day' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setUsageGroupBy('day');
|
||||
setUsagePage(1);
|
||||
}}
|
||||
className={usageGroupBy === 'day' ? "rounded-lg bg-black/5 dark:bg-white/10 text-foreground" : "rounded-lg text-muted-foreground"}
|
||||
>
|
||||
{t('dashboard:recentTokenHistory.groupByTime')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex rounded-xl bg-transparent p-1 border border-black/10 dark:border-white/10">
|
||||
<Button
|
||||
variant={usageWindow === '7d' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setUsageWindow('7d');
|
||||
setUsagePage(1);
|
||||
}}
|
||||
className={usageWindow === '7d' ? "rounded-lg bg-black/5 dark:bg-white/10 text-foreground" : "rounded-lg text-muted-foreground"}
|
||||
>
|
||||
{t('dashboard:recentTokenHistory.last7Days')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={usageWindow === '30d' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setUsageWindow('30d');
|
||||
setUsagePage(1);
|
||||
}}
|
||||
className={usageWindow === '30d' ? "rounded-lg bg-black/5 dark:bg-white/10 text-foreground" : "rounded-lg text-muted-foreground"}
|
||||
>
|
||||
{t('dashboard:recentTokenHistory.last30Days')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={usageWindow === 'all' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setUsageWindow('all');
|
||||
setUsagePage(1);
|
||||
}}
|
||||
className={usageWindow === 'all' ? "rounded-lg bg-black/5 dark:bg-white/10 text-foreground" : "rounded-lg text-muted-foreground"}
|
||||
>
|
||||
{t('dashboard:recentTokenHistory.allTime')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-meta font-medium text-muted-foreground">
|
||||
{usageRefreshing
|
||||
? t('dashboard:recentTokenHistory.loading')
|
||||
: t('dashboard:recentTokenHistory.showingLast', { count: filteredUsageHistory.length })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UsageBarChart
|
||||
groups={usageGroups}
|
||||
emptyLabel={t('dashboard:recentTokenHistory.empty')}
|
||||
totalLabel={t('dashboard:recentTokenHistory.totalTokens')}
|
||||
inputLabel={t('dashboard:recentTokenHistory.inputShort')}
|
||||
outputLabel={t('dashboard:recentTokenHistory.outputShort')}
|
||||
cacheLabel={t('dashboard:recentTokenHistory.cacheShort')}
|
||||
/>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
{pagedUsageHistory.map((entry) => (
|
||||
<div
|
||||
key={`${entry.sessionId}-${entry.timestamp}`}
|
||||
data-testid="token-usage-entry"
|
||||
className="rounded-2xl bg-transparent border border-black/10 dark:border-white/10 p-5 hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold text-sm text-foreground truncate">
|
||||
{entry.model || t('dashboard:recentTokenHistory.unknownModel')}
|
||||
</p>
|
||||
<p className="text-meta text-muted-foreground truncate mt-0.5">
|
||||
{[formatUsageSource(entry.provider), formatUsageSource(entry.agentId), entry.sessionId].filter(Boolean).join(' / ')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<p className={getUsageTotalClass(entry)}>
|
||||
{formatUsageTotal(entry)}
|
||||
</p>
|
||||
{entry.usageStatus === 'missing' && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t('dashboard:recentTokenHistory.noUsage')}
|
||||
</p>
|
||||
)}
|
||||
{entry.usageStatus === 'error' && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400 mt-0.5">
|
||||
{t('dashboard:recentTokenHistory.usageParseError')}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{formatUsageTimestamp(entry.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1.5 text-meta font-medium text-muted-foreground">
|
||||
{entry.usageStatus === 'available' || entry.usageStatus === undefined ? (
|
||||
<>
|
||||
<span className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full bg-sky-500"></div>{t('dashboard:recentTokenHistory.input', { value: formatTokenCount(entry.inputTokens) })}</span>
|
||||
<span className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full bg-violet-500"></div>{t('dashboard:recentTokenHistory.output', { value: formatTokenCount(entry.outputTokens) })}</span>
|
||||
{entry.cacheReadTokens > 0 && (
|
||||
<span className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full bg-amber-500"></div>{t('dashboard:recentTokenHistory.cacheRead', { value: formatTokenCount(entry.cacheReadTokens) })}</span>
|
||||
)}
|
||||
{entry.cacheWriteTokens > 0 && (
|
||||
<span className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full bg-amber-500"></div>{t('dashboard:recentTokenHistory.cacheWrite', { value: formatTokenCount(entry.cacheWriteTokens) })}</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs">
|
||||
{entry.usageStatus === 'missing'
|
||||
? t('dashboard:recentTokenHistory.noUsage')
|
||||
: t('dashboard:recentTokenHistory.usageParseError')}
|
||||
</span>
|
||||
)}
|
||||
{typeof entry.costUsd === 'number' && Number.isFinite(entry.costUsd) && (
|
||||
<span className="flex items-center gap-1.5 ml-auto text-foreground/80 bg-black/5 dark:bg-white/5 px-2 py-0.5 rounded-md">{t('dashboard:recentTokenHistory.cost', { amount: entry.costUsd.toFixed(4) })}</span>
|
||||
)}
|
||||
{devModeUnlocked && entry.content && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 rounded-full px-2.5 text-tiny border-black/10 dark:border-white/10"
|
||||
onClick={() => setSelectedUsageEntry(entry)}
|
||||
>
|
||||
{t('dashboard:recentTokenHistory.viewContent')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 pt-2">
|
||||
<p className="text-meta font-medium text-muted-foreground">
|
||||
{t('dashboard:recentTokenHistory.page', { current: safeUsagePage, total: usageTotalPages })}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setUsagePage((page) => Math.max(1, page - 1))}
|
||||
disabled={safeUsagePage <= 1}
|
||||
className="rounded-full px-4 h-9 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
{t('dashboard:recentTokenHistory.prev')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setUsagePage((page) => Math.min(usageTotalPages, page + 1))}
|
||||
disabled={safeUsagePage >= usageTotalPages}
|
||||
className="rounded-full px-4 h-9 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
{t('dashboard:recentTokenHistory.next')}
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{devModeUnlocked && selectedUsageEntry && (
|
||||
<UsageContentPopup
|
||||
entry={selectedUsageEntry}
|
||||
onClose={() => setSelectedUsageEntry(null)}
|
||||
title={t('dashboard:recentTokenHistory.contentDialogTitle')}
|
||||
closeLabel={t('dashboard:recentTokenHistory.close')}
|
||||
unknownModelLabel={t('dashboard:recentTokenHistory.unknownModel')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTokenCount(value: number): string {
|
||||
return Intl.NumberFormat().format(value);
|
||||
}
|
||||
|
||||
function getUsageTotalClass(entry: UsageHistoryEntry): string {
|
||||
if (entry.usageStatus === 'error') return '!';
|
||||
if (entry.usageStatus === 'missing') return '-';
|
||||
return 'font-bold text-sm';
|
||||
}
|
||||
|
||||
function formatUsageTotal(entry: UsageHistoryEntry): string {
|
||||
if (entry.usageStatus === 'error') return '!';
|
||||
if (entry.usageStatus === 'missing') return '-';
|
||||
return formatTokenCount(entry.totalTokens);
|
||||
}
|
||||
|
||||
function formatUsageTimestamp(timestamp: string): string {
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) return timestamp;
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function UsageBarChart({
|
||||
groups,
|
||||
emptyLabel,
|
||||
totalLabel,
|
||||
inputLabel,
|
||||
outputLabel,
|
||||
cacheLabel,
|
||||
}: {
|
||||
groups: Array<{
|
||||
label: string;
|
||||
totalTokens: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheTokens: number;
|
||||
}>;
|
||||
emptyLabel: string;
|
||||
totalLabel: string;
|
||||
inputLabel: string;
|
||||
outputLabel: string;
|
||||
cacheLabel: string;
|
||||
}) {
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-black/10 dark:border-white/10 p-8 text-center text-sm font-medium text-muted-foreground">
|
||||
{emptyLabel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxTokens = Math.max(...groups.map((group) => group.totalTokens), 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 bg-transparent p-5 rounded-2xl border border-black/10 dark:border-white/10">
|
||||
<div className="flex flex-wrap gap-4 text-meta font-medium text-muted-foreground mb-2">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-sky-500" />
|
||||
{inputLabel}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-violet-500" />
|
||||
{outputLabel}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-amber-500" />
|
||||
{cacheLabel}
|
||||
</span>
|
||||
</div>
|
||||
{groups.map((group) => (
|
||||
<div key={group.label} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="truncate font-semibold text-foreground">{group.label}</span>
|
||||
<span className="text-muted-foreground font-medium">
|
||||
{totalLabel}: {formatTokenCount(group.totalTokens)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3.5 overflow-hidden rounded-full bg-black/5 dark:bg-white/5">
|
||||
<div
|
||||
className="flex h-full overflow-hidden rounded-full"
|
||||
style={{
|
||||
width: group.totalTokens > 0
|
||||
? `${Math.max((group.totalTokens / maxTokens) * 100, 6)}%`
|
||||
: '0%',
|
||||
}}
|
||||
>
|
||||
{group.inputTokens > 0 && (
|
||||
<div
|
||||
className="h-full bg-sky-500"
|
||||
style={{ width: `${(group.inputTokens / group.totalTokens) * 100}%` }}
|
||||
/>
|
||||
)}
|
||||
{group.outputTokens > 0 && (
|
||||
<div
|
||||
className="h-full bg-violet-500"
|
||||
style={{ width: `${(group.outputTokens / group.totalTokens) * 100}%` }}
|
||||
/>
|
||||
)}
|
||||
{group.cacheTokens > 0 && (
|
||||
<div
|
||||
className="h-full bg-amber-500"
|
||||
style={{ width: `${(group.cacheTokens / group.totalTokens) * 100}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Models;
|
||||
|
||||
function UsageContentPopup({
|
||||
entry,
|
||||
onClose,
|
||||
title,
|
||||
closeLabel,
|
||||
unknownModelLabel,
|
||||
}: {
|
||||
entry: UsageHistoryEntry;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
closeLabel: string;
|
||||
unknownModelLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4" role="dialog" aria-modal="true">
|
||||
<div className="w-full max-w-3xl rounded-2xl border border-black/10 dark:border-white/10 bg-background shadow-xl">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-black/10 dark:border-white/10 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-foreground">{title}</p>
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{(entry.model || unknownModelLabel)} / {formatUsageTimestamp(entry.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={onClose}
|
||||
aria-label={closeLabel}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[65vh] overflow-y-auto px-5 py-4">
|
||||
<pre className="whitespace-pre-wrap break-words text-sm text-foreground font-mono">
|
||||
{entry.content}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-black/10 dark:border-white/10 px-5 py-3">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{closeLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
src/pages/Models/usage-history.ts
Normal file
118
src/pages/Models/usage-history.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
export type UsageHistoryEntry = {
|
||||
timestamp: string;
|
||||
sessionId: string;
|
||||
agentId: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
content?: string;
|
||||
usageStatus?: 'available' | 'missing' | 'error';
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
totalTokens: number;
|
||||
costUsd?: number;
|
||||
};
|
||||
|
||||
export type UsageWindow = '7d' | '30d' | 'all';
|
||||
export type UsageGroupBy = 'model' | 'day';
|
||||
|
||||
export type UsageGroup = {
|
||||
label: string;
|
||||
totalTokens: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheTokens: number;
|
||||
sortKey: number | string;
|
||||
};
|
||||
|
||||
export function resolveStableUsageHistory(
|
||||
previousStableEntries: UsageHistoryEntry[],
|
||||
nextEntries: UsageHistoryEntry[],
|
||||
options: { preservePreviousOnEmpty?: boolean } = {},
|
||||
): UsageHistoryEntry[] {
|
||||
if (nextEntries.length > 0) {
|
||||
return nextEntries;
|
||||
}
|
||||
|
||||
return options.preservePreviousOnEmpty ? previousStableEntries : [];
|
||||
}
|
||||
|
||||
export function resolveVisibleUsageHistory(
|
||||
currentEntries: UsageHistoryEntry[],
|
||||
stableEntries: UsageHistoryEntry[],
|
||||
options: { preferStableOnEmpty?: boolean } = {},
|
||||
): UsageHistoryEntry[] {
|
||||
if (options.preferStableOnEmpty && currentEntries.length === 0) {
|
||||
return stableEntries;
|
||||
}
|
||||
|
||||
return currentEntries;
|
||||
}
|
||||
|
||||
export function formatUsageDay(timestamp: string): string {
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) return timestamp;
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function getUsageDaySortKey(timestamp: string): number {
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) return 0;
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date.getTime();
|
||||
}
|
||||
|
||||
export function groupUsageHistory(
|
||||
entries: UsageHistoryEntry[],
|
||||
groupBy: UsageGroupBy,
|
||||
): UsageGroup[] {
|
||||
const grouped = new Map<string, UsageGroup>();
|
||||
|
||||
for (const entry of entries) {
|
||||
const label = groupBy === 'model'
|
||||
? (entry.model || 'Unknown')
|
||||
: formatUsageDay(entry.timestamp);
|
||||
const current = grouped.get(label) ?? {
|
||||
label,
|
||||
totalTokens: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheTokens: 0,
|
||||
sortKey: groupBy === 'day' ? getUsageDaySortKey(entry.timestamp) : label.toLowerCase(),
|
||||
};
|
||||
current.totalTokens += entry.totalTokens;
|
||||
current.inputTokens += entry.inputTokens;
|
||||
current.outputTokens += entry.outputTokens;
|
||||
current.cacheTokens += entry.cacheReadTokens + entry.cacheWriteTokens;
|
||||
grouped.set(label, current);
|
||||
}
|
||||
|
||||
const sorted = Array.from(grouped.values()).sort((a, b) => {
|
||||
if (groupBy === 'day') {
|
||||
return Number(a.sortKey) - Number(b.sortKey);
|
||||
}
|
||||
return b.totalTokens - a.totalTokens;
|
||||
});
|
||||
|
||||
return groupBy === 'model' ? sorted.slice(0, 8) : sorted;
|
||||
}
|
||||
|
||||
export function filterUsageHistoryByWindow(
|
||||
entries: UsageHistoryEntry[],
|
||||
window: UsageWindow,
|
||||
now = Date.now(),
|
||||
): UsageHistoryEntry[] {
|
||||
if (window === 'all') return entries;
|
||||
|
||||
const days = window === '7d' ? 7 : 30;
|
||||
const cutoff = now - days * 24 * 60 * 60 * 1000;
|
||||
|
||||
return entries.filter((entry) => {
|
||||
const timestamp = Date.parse(entry.timestamp);
|
||||
return Number.isFinite(timestamp) && timestamp >= cutoff;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user