← 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>
  );
}