import { Component, Show, createMemo, createSignal, onMount, For } from 'solid-js'; import { Card } from '@/components/shared/Card'; import { SectionHeader } from '@/components/shared/SectionHeader'; import { AIAPI } from '@/api/ai'; import { formatNumber } from '@/utils/format'; import { logger } from '@/utils/logger'; import { notificationStore } from '@/stores/notifications'; import type { AICostSummary, AISettings } from '@/types/ai'; import { PROVIDER_NAMES } from '@/types/ai'; const usdFormatter = new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2, }); const TinySparkline: Component<{ values: number[]; width?: number; height?: number; stroke?: string; }> = (props) => { const width = () => props.width ?? 160; const height = () => props.height ?? 28; const stroke = () => props.stroke ?? '#22c55e'; const pathD = createMemo(() => { const values = props.values; const w = width(); const h = height(); if (!values || values.length === 0) return ''; const max = Math.max(...values, 0); const safeMax = max <= 0 ? 1 : max; const xStep = values.length <= 1 ? 0 : w / (values.length - 1); let d = ''; values.forEach((v, idx) => { const x = idx * xStep; const y = h - (Math.max(0, v) / safeMax) * h; d += `${idx === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)} `; }); return d.trim(); }); return ( ); }; export const AICostDashboard: Component = () => { const [days, setDays] = createSignal(30); const [loading, setLoading] = createSignal(false); const [loadError, setLoadError] = createSignal(null); const [summary, setSummary] = createSignal(null); const [aiSettings, setAISettings] = createSignal(null); let requestSeq = 0; const anyPricingKnown = createMemo(() => { const data = summary(); if (!data) return false; return data.provider_models.some((pm) => pm.pricing_known); }); const unpricedProviderModels = createMemo(() => { const data = summary(); if (!data) return []; return (data.provider_models ?? []).filter((pm) => !pm.pricing_known && (pm.total_tokens ?? 0) > 0); }); const estimatedTotalUSD = createMemo(() => { const data = summary(); if (!data || !anyPricingKnown()) return null; return data.totals.estimated_usd ?? 0; }); const useCaseMap = createMemo(() => { const data = summary(); const map = new Map(); if (!data) return map; for (const uc of data.use_cases ?? []) { map.set(uc.use_case, { tokens: uc.total_tokens, usd: uc.estimated_usd ?? 0, pricingKnown: uc.pricing_known, }); } return map; }); const dailyTokenValues = createMemo(() => (summary()?.daily_totals ?? []).map((d) => d.total_tokens)); const dailyUSDValues = createMemo(() => (summary()?.daily_totals ?? []).map((d) => d.estimated_usd ?? 0)); const formatUSD = (usd: number) => usdFormatter.format(usd); const loadSummary = async (rangeDays: number) => { const seq = ++requestSeq; const isInitialLoad = summary() === null; setLoading(true); setLoadError(null); try { const data = await AIAPI.getCostSummary(rangeDays); if (seq !== requestSeq) return; setSummary(data); } catch (err) { if (seq !== requestSeq) return; logger.error('[AICostDashboard] Failed to load cost summary:', err); // Only show notification on refresh failures, not initial load if (!isInitialLoad) { notificationStore.error('Failed to refresh AI cost summary'); } const message = err instanceof Error && err.message ? err.message : 'Failed to load usage data'; setLoadError(message); } finally { if (seq === requestSeq) setLoading(false); } }; onMount(() => { loadSummary(days()); }); const loadBudgetSettings = async () => { try { const s = await AIAPI.getSettings(); setAISettings(s); } catch (err) { logger.debug('[AICostDashboard] Failed to load AI settings for budget:', err); setAISettings(null); } }; onMount(() => { loadBudgetSettings(); }); const parsedBudgetUSD30d = createMemo(() => { const s = aiSettings(); const n = s?.cost_budget_usd_30d; if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) return null; return n; }); const budgetForRange = createMemo(() => { const budget30d = parsedBudgetUSD30d(); if (budget30d == null) return null; const rangeDays = days(); return (budget30d * rangeDays) / 30; }); const isOverBudget = createMemo(() => { const budget = budgetForRange(); const usd = estimatedTotalUSD(); if (budget == null || usd == null) return false; return usd > budget; }); const resetHistory = async () => { if (!confirm('Reset AI usage history? A backup will be created in the Pulse config directory.')) return; try { const result = await AIAPI.resetCostHistory(); if (result.backup_file) { notificationStore.success(`AI usage history reset (backup: ${result.backup_file})`); } else { notificationStore.success('AI usage history reset'); } await loadSummary(days()); } catch (err) { logger.error('[AICostDashboard] Failed to reset AI cost history:', err); notificationStore.error('Failed to reset AI usage history'); } }; const downloadExport = async (format: 'csv' | 'json') => { try { const resp = await AIAPI.exportCostHistory(days(), format); if (!resp.ok) { throw new Error(`Export failed (${resp.status})`); } const blob = await resp.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `pulse-ai-usage-${new Date().toISOString().split('T')[0]}-${days()}d.${format}`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } catch (err) { logger.error('[AICostDashboard] Failed to export cost history:', err); notificationStore.error('Failed to export AI usage history'); } }; const handleRangeClick = (rangeDays: number) => { if (loading() || rangeDays === days()) return; setDays(rangeDays); loadSummary(rangeDays); }; return (
Loading…
Loading usage…
Showing the last {summary()?.effective_days} days due to a {summary()?.retention_days}-day retention window.
Estimated spend ({formatUSD(estimatedTotalUSD() ?? 0)}) is above your budget ({formatUSD(budgetForRange() ?? 0)}).
Couldn’t refresh. Showing last loaded data. {loadError()}
{loadError()}
No usage data yet.
{(data) => ( <>
Estimated spend
—} > {formatUSD(estimatedTotalUSD() ?? 0)}
Total tokens
{formatNumber(data().totals.total_tokens)}
Model/provider pairs
{formatNumber(data().provider_models.length)}
Chat
{formatNumber(useCaseMap().get('chat')?.tokens ?? 0)} tokens
—} > {formatUSD(useCaseMap().get('chat')?.usd ?? 0)}
Patrol
{formatNumber(useCaseMap().get('patrol')?.tokens ?? 0)} tokens
—} > {formatUSD(useCaseMap().get('patrol')?.usd ?? 0)}
Budget alert (USD per 30d)
—}> {formatUSD(parsedBudgetUSD30d() ?? 0)}
Set in AI settings. Pro-rated for {days()}d:{' '} —}> {formatUSD(budgetForRange() ?? 0)}
Daily estimated USD
{data().daily_totals.length} points
Daily total tokens
{data().daily_totals.length} points
USD is an estimate based on public list prices. It may differ from billing. 0}> Estimated spend is partial. Pricing is unknown for{' '} {unpricedProviderModels() .slice(0, 6) .map( (pm) => `${PROVIDER_NAMES[pm.provider as keyof typeof PROVIDER_NAMES] || pm.provider}/${pm.model}`, ) .join(', ')} 6}> (+{unpricedProviderModels().length - 6} more) . Prices as of {summary()?.pricing_as_of}.
History retention: {data().retention_days} days
0}>
{(t) => ( )}
Top targets Est. USD Calls Tokens
{t.target_type}:{t.target_id} —} > {formatUSD(t.estimated_usd ?? 0)} {formatNumber(t.calls)} {formatNumber(t.total_tokens)}
{(pm) => ( )}
Provider Model Est. USD Input Output Total
{PROVIDER_NAMES[pm.provider as keyof typeof PROVIDER_NAMES] || pm.provider} {pm.model} —} > {formatUSD(pm.estimated_usd ?? 0)} {formatNumber(pm.input_tokens)} {formatNumber(pm.output_tokens)} {formatNumber(pm.total_tokens)}
)}
); }; export default AICostDashboard;