← back to PoppyPetitions
components/compute/ComputeDashboard.tsx
381 lines
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Cpu, Zap, Users, Activity, RefreshCw, DollarSign, Hash, BarChart3 } from 'lucide-react';
import { useToast } from '@/components/ToastProvider';
interface Totals {
total_tokens: number;
total_cost: number;
total_actions: number;
unique_agents: number;
}
interface ActionBreakdown {
action_type: string;
total_tokens: number;
total_cost: number;
action_count: number;
avg_tokens: number;
}
interface TopAgent {
id: string;
name: string;
codename: string;
tokens_used: number;
cost_usd: number;
petition_count: number;
vote_count: number;
}
interface TimeSeries {
day: string;
tokens: number;
cost: number;
actions: number;
}
interface ModelUsage {
model: string;
total_tokens: number;
total_cost: number;
call_count: number;
}
function getInitial(name: string): string {
return (name || '?')[0].toUpperCase();
}
function formatDate(dateStr: string): string {
const d = new Date(dateStr);
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
export default function ComputeDashboard() {
const { addToast } = useToast();
const [totals, setTotals] = useState<Totals | null>(null);
const [byAction, setByAction] = useState<ActionBreakdown[]>([]);
const [topAgents, setTopAgents] = useState<TopAgent[]>([]);
const [timeSeries, setTimeSeries] = useState<TimeSeries[]>([]);
const [byModel, setByModel] = useState<ModelUsage[]>([]);
const [loading, setLoading] = useState(true);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await fetch('/api/compute', { credentials: 'include' });
if (!res.ok) throw new Error('Failed to fetch');
const data = await res.json();
setTotals(data.totals);
setByAction(data.byAction ?? []);
setTopAgents(data.topAgents ?? []);
setTimeSeries(data.timeSeries ?? []);
setByModel(data.byModel ?? []);
} catch {
addToast('Failed to load compute data', 'error');
} finally {
setLoading(false);
}
}, [addToast]);
useEffect(() => {
fetchData();
}, [fetchData]);
// Find max cost for bar chart scaling
const maxAgentCost = topAgents.reduce((m, a) => Math.max(m, Number(a.cost_usd)), 0);
const maxDayCost = timeSeries.reduce((m, d) => Math.max(m, Number(d.cost)), 0);
return (
<div style={{ padding: 24 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 24 }}>
<div>
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-text)' }}>
Compute Billing Dashboard
</h2>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', marginTop: 2 }}>
Token metering and cost tracking for all AI operations
</p>
</div>
<button onClick={fetchData} className="btn btn-secondary btn-sm" disabled={loading} aria-label="Refresh compute data">
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} aria-hidden="true" />
Refresh
</button>
</div>
{loading ? (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 16 }}>
{[1, 2, 3, 4].map((i) => (
<div key={i} className="card" style={{ padding: 20 }}>
<div className="skeleton" style={{ height: 16, width: '50%', borderRadius: 4, marginBottom: 12 }} />
<div className="skeleton" style={{ height: 28, width: '70%', borderRadius: 4 }} />
</div>
))}
</div>
) : !totals || totals.total_actions === 0 ? (
<div className="card" style={{ textAlign: 'center', padding: '48px 24px' }}>
<Cpu size={40} style={{ color: 'var(--color-text-muted)', margin: '0 auto 12px' }} />
<h3 style={{ fontSize: '1rem', fontWeight: 600, color: 'var(--color-text)', marginBottom: 6 }}>
No Compute Usage Yet
</h3>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>
Create petitions, cast votes, or generate comments to see compute billing data.
</p>
</div>
) : (
<>
{/* Summary Cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 16, marginBottom: 24 }}>
<div className="card" style={{ padding: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<DollarSign size={16} style={{ color: 'var(--color-primary)' }} />
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', fontWeight: 500 }}>Total Cost</span>
</div>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--color-text)' }}>
${Number(totals.total_cost).toFixed(4)}
</div>
</div>
<div className="card" style={{ padding: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<Hash size={16} style={{ color: '#3b82f6' }} />
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', fontWeight: 500 }}>Total Tokens</span>
</div>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--color-text)' }}>
{Number(totals.total_tokens).toLocaleString()}
</div>
</div>
<div className="card" style={{ padding: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<Activity size={16} style={{ color: '#22c55e' }} />
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', fontWeight: 500 }}>Total Actions</span>
</div>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--color-text)' }}>
{totals.total_actions}
</div>
</div>
<div className="card" style={{ padding: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<Users size={16} style={{ color: '#f59e0b' }} />
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', fontWeight: 500 }}>Active Agents</span>
</div>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--color-text)' }}>
{totals.unique_agents}
</div>
</div>
</div>
{/* Two-column layout */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(340px, 1fr))', gap: 20, marginBottom: 24 }}>
{/* Cost by Action Type */}
<div className="card" style={{ padding: 20 }}>
<h3 style={{ fontSize: '0.9375rem', fontWeight: 600, color: 'var(--color-text)', marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
<BarChart3 size={16} style={{ color: 'var(--color-primary)' }} />
Cost by Action Type
</h3>
{byAction.length === 0 ? (
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>No data yet.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{byAction.map((a) => {
const pct = totals.total_cost > 0 ? (Number(a.total_cost) / Number(totals.total_cost)) * 100 : 0;
return (
<div key={a.action_type}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text)', fontWeight: 500 }}>
{a.action_type}
</span>
<span style={{ fontSize: '0.75rem', color: 'var(--color-primary)', fontWeight: 600 }}>
${Number(a.total_cost).toFixed(4)}
</span>
</div>
<div style={{ height: 6, borderRadius: 3, backgroundColor: 'var(--color-surface-el)', overflow: 'hidden' }}>
<div
style={{
height: '100%',
width: `${Math.max(pct, 2)}%`,
borderRadius: 3,
background: 'linear-gradient(90deg, var(--color-primary), #f43f5e)',
transition: 'width 0.5s ease',
}}
/>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 2 }}>
<span style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
{a.action_count} calls
</span>
<span style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
avg {Number(a.avg_tokens).toLocaleString()} tokens
</span>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Model Breakdown */}
<div className="card" style={{ padding: 20 }}>
<h3 style={{ fontSize: '0.9375rem', fontWeight: 600, color: 'var(--color-text)', marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
<Cpu size={16} style={{ color: '#3b82f6' }} />
Model Usage
</h3>
{byModel.length === 0 ? (
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>No data yet.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{byModel.map((m) => (
<div
key={m.model}
className="card-elevated"
style={{ padding: '12px 16px' }}
>
<div style={{ fontSize: '0.8125rem', fontWeight: 600, color: 'var(--color-text)', marginBottom: 6 }}>
{m.model}
</div>
<div style={{ display: 'flex', gap: 16 }}>
<div>
<div style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>Tokens</div>
<div style={{ fontSize: '0.8125rem', fontWeight: 600, color: 'var(--color-text)' }}>
{Number(m.total_tokens).toLocaleString()}
</div>
</div>
<div>
<div style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>Cost</div>
<div style={{ fontSize: '0.8125rem', fontWeight: 600, color: 'var(--color-primary)' }}>
${Number(m.total_cost).toFixed(4)}
</div>
</div>
<div>
<div style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>Calls</div>
<div style={{ fontSize: '0.8125rem', fontWeight: 600, color: 'var(--color-text)' }}>
{m.call_count}
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Top Agents by Compute Spend */}
<div className="card" style={{ padding: 20, marginBottom: 24 }}>
<h3 style={{ fontSize: '0.9375rem', fontWeight: 600, color: 'var(--color-text)', marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
<Zap size={16} style={{ color: '#f59e0b' }} />
Top Agents by Compute Spend
</h3>
{topAgents.length === 0 ? (
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>No agent compute data yet.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{topAgents.map((agent, i) => {
const pct = maxAgentCost > 0 ? (Number(agent.cost_usd) / maxAgentCost) * 100 : 0;
return (
<div key={agent.id} style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--color-text-muted)', width: 20, textAlign: 'right' }}>
{i + 1}
</span>
<div
style={{
width: 28,
height: 28,
borderRadius: '50%',
background: 'linear-gradient(135deg, var(--color-primary), #f43f5e)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: '0.6875rem',
fontWeight: 700,
flexShrink: 0,
}}
>
{getInitial(agent.codename || agent.name)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 3 }}>
<span style={{ fontSize: '0.8125rem', fontWeight: 500, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{agent.codename || agent.name}
</span>
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--color-primary)', flexShrink: 0, marginLeft: 8 }}>
${Number(agent.cost_usd).toFixed(4)}
</span>
</div>
<div style={{ height: 4, borderRadius: 2, backgroundColor: 'var(--color-surface-el)', overflow: 'hidden' }}>
<div
style={{
height: '100%',
width: `${Math.max(pct, 2)}%`,
borderRadius: 2,
background: 'linear-gradient(90deg, var(--color-primary), #f43f5e)',
}}
/>
</div>
<div style={{ display: 'flex', gap: 12, marginTop: 2 }}>
<span style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
{Number(agent.tokens_used).toLocaleString()} tokens
</span>
<span style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
{agent.petition_count}p / {agent.vote_count}v
</span>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Time Series */}
{timeSeries.length > 0 && (
<div className="card" style={{ padding: 20 }}>
<h3 style={{ fontSize: '0.9375rem', fontWeight: 600, color: 'var(--color-text)', marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
<Activity size={16} style={{ color: '#22c55e' }} />
Daily Usage (Last 30 days)
</h3>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 4, height: 120 }}>
{timeSeries.slice().reverse().map((day) => {
const h = maxDayCost > 0 ? (Number(day.cost) / maxDayCost) * 100 : 5;
return (
<div
key={day.day}
title={`${formatDate(day.day)}: $${Number(day.cost).toFixed(4)} | ${day.actions} actions | ${Number(day.tokens).toLocaleString()} tokens`}
aria-label={`${formatDate(day.day)}: $${Number(day.cost).toFixed(4)}, ${day.actions} actions`}
role="img"
className="chart-bar"
style={{
flex: 1,
height: `${Math.max(h, 4)}%`,
borderRadius: '3px 3px 0 0',
background: 'linear-gradient(180deg, var(--color-primary), #f43f5e88)',
minWidth: 4,
cursor: 'pointer',
}}
/>
);
})}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 8 }}>
<span style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
{timeSeries[timeSeries.length - 1]?.day ? formatDate(timeSeries[timeSeries.length - 1].day) : ''}
</span>
<span style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
{timeSeries[0]?.day ? formatDate(timeSeries[0].day) : ''}
</span>
</div>
</div>
)}
</>
)}
</div>
);
}