mirror of
https://github.com/hanzoai/billing.git
synced 2026-10-30 18:46:54 +00:00
A focused input painted THREE affordances (brightened border + 3px halo +
2px outline). Root cause: two rules in @hanzo/design's @layer base (inlined
into @hanzo/ui's dist/styles.css) both compute to (0,1,0) because :where()
zeroes specificity, so the generic `:focus-visible{outline:2px}` overrode the
field rule's own `outline:none`. The one-place fix already shipped in
@hanzo/design 0.4.9 / @hanzo/ui 8.0.69+ — but @hanzo/ui inlines the base layer
at ITS build time, so the layer billing renders is set by its @hanzo/ui pin,
not its @hanzo/design pin. Billing sat at ui 8.0.47 (the double-ring layer).
Bump ui ^8.0.47->^8.0.72 + design ^0.5.1 + the gui/shell peers (caret already
permitted it; this raises the floor past the fix), and delete billing's own 16
focus overrides across 12 files — after the bump they double-up (and the coupon
input painted nothing without removing them). Verified in a browser: one 2px
ring at 2px offset on billing/console/app, light + dark, :focus-visible correct
(a mouse-clicked button doesn't ring). tsc clean, build green, gui-css-check
24/24, the --page workaround re-checked at 8.0.72.
223 lines
8.7 KiB
TypeScript
223 lines
8.7 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useMemo } from 'react'
|
|
import type { UsageSummary, UsageRecord } from './types'
|
|
import { money } from '@/lib/money'
|
|
|
|
export interface CostExplorerProps {
|
|
usage: UsageSummary | null
|
|
usageLoading: boolean
|
|
}
|
|
|
|
type TimeRange = '7d' | '30d' | '90d'
|
|
type SortKey = 'cost' | 'usage' | 'label'
|
|
|
|
const meterLabels: Record<string, string> = {
|
|
ai: 'LLM / Chat',
|
|
api_calls: 'API Requests',
|
|
storage: 'Storage',
|
|
gpu: 'GPU Compute',
|
|
network: 'Network',
|
|
network_egress: 'Egress',
|
|
}
|
|
|
|
const meterColors: Record<string, string> = {
|
|
ai: 'bg-violet-500',
|
|
api_calls: 'bg-indigo-500',
|
|
storage: 'bg-blue-500',
|
|
gpu: 'bg-amber-500',
|
|
network: 'bg-emerald-500',
|
|
network_egress: 'bg-teal-500',
|
|
}
|
|
|
|
export function CostExplorer({ usage, usageLoading }: CostExplorerProps) {
|
|
const [timeRange, setTimeRange] = useState<TimeRange>('30d')
|
|
const [productFilter, setProductFilter] = useState<string>('all')
|
|
const [sortKey, setSortKey] = useState<SortKey>('cost')
|
|
const [sortAsc, setSortAsc] = useState(false)
|
|
|
|
const records = usage?.records ?? []
|
|
const totalCost = usage?.totalCost ?? 0
|
|
const currency = usage?.currency ?? 'usd'
|
|
|
|
// Available product types from records
|
|
const productTypes = useMemo(() => {
|
|
const types = new Set(records.map(r => r.meterId))
|
|
return Array.from(types)
|
|
}, [records])
|
|
|
|
// Filtered + sorted records
|
|
const filtered = useMemo(() => {
|
|
let list = productFilter === 'all' ? records : records.filter(r => r.meterId === productFilter)
|
|
list = [...list].sort((a, b) => {
|
|
let cmp = 0
|
|
if (sortKey === 'cost') cmp = a.cost - b.cost
|
|
else if (sortKey === 'usage') cmp = a.current - b.current
|
|
else cmp = a.label.localeCompare(b.label)
|
|
return sortAsc ? cmp : -cmp
|
|
})
|
|
return list
|
|
}, [records, productFilter, sortKey, sortAsc])
|
|
|
|
// Top 5 drivers by cost
|
|
const topDrivers = useMemo(() =>
|
|
[...records].sort((a, b) => b.cost - a.cost).slice(0, 5),
|
|
[records]
|
|
)
|
|
|
|
const handleSort = (key: SortKey) => {
|
|
if (sortKey === key) setSortAsc(!sortAsc)
|
|
else { setSortKey(key); setSortAsc(false) }
|
|
}
|
|
|
|
const sortIndicator = (key: SortKey) =>
|
|
sortKey === key ? (sortAsc ? ' ↑' : ' ↓') : ''
|
|
|
|
if (usageLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-16">
|
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-text-dim border-t-brand" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Filters */}
|
|
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-border bg-bg-card p-4">
|
|
{/* Time range */}
|
|
<div className="flex gap-1 rounded-lg bg-bg p-0.5">
|
|
{(['7d', '30d', '90d'] as TimeRange[]).map(t => (
|
|
<button
|
|
key={t}
|
|
type="button"
|
|
onClick={() => setTimeRange(t)}
|
|
className={`rounded-md px-3 py-1.5 text-xs font-medium transition ${
|
|
timeRange === t ? 'bg-bg-elevated text-text' : 'text-text-muted hover:text-text'
|
|
}`}
|
|
>
|
|
{t}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Product */}
|
|
<select
|
|
value={productFilter}
|
|
onChange={e => setProductFilter(e.target.value)}
|
|
className="rounded-lg border border-border bg-bg-input px-3 py-1.5 text-xs text-text"
|
|
>
|
|
<option value="all">All products</option>
|
|
{productTypes.map(t => (
|
|
<option key={t} value={t}>{meterLabels[t] ?? t}</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* Placeholders for future filters */}
|
|
<select disabled className="rounded-lg border border-border bg-bg-input px-3 py-1.5 text-xs text-text-dim opacity-50 cursor-not-allowed">
|
|
<option>Model (coming soon)</option>
|
|
</select>
|
|
<select disabled className="rounded-lg border border-border bg-bg-input px-3 py-1.5 text-xs text-text-dim opacity-50 cursor-not-allowed">
|
|
<option>Project (coming soon)</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Total + chart placeholder */}
|
|
<div className="rounded-xl border border-border bg-bg-card p-6">
|
|
<div className="flex items-end justify-between mb-4">
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wide text-text-dim">Total Cost ({timeRange})</p>
|
|
<p className="mono text-3xl font-bold text-text mt-1">{money(totalCost, currency)}</p>
|
|
</div>
|
|
{usage?.period && (
|
|
<p className="text-xs text-text-muted">
|
|
{new Date(usage.period.start).toLocaleDateString()} — {new Date(usage.period.end).toLocaleDateString()}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center justify-center h-32 rounded-lg bg-bg border border-dashed border-border">
|
|
<p className="text-xs text-text-dim">Cost trend chart — coming soon</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Top drivers */}
|
|
{topDrivers.length > 0 && (
|
|
<div>
|
|
<h3 className="text-xs font-medium uppercase tracking-wide text-text-dim mb-3">Top Drivers</h3>
|
|
<div className="space-y-2">
|
|
{topDrivers.map(rec => {
|
|
const pct = totalCost > 0 ? (rec.cost / totalCost) * 100 : 0
|
|
const color = meterColors[rec.meterId] ?? 'bg-gray-500'
|
|
return (
|
|
<div key={rec.meterId} className="flex items-center gap-3">
|
|
<span className="w-24 shrink-0 text-xs text-text-muted truncate">{meterLabels[rec.meterId] ?? rec.label}</span>
|
|
<div className="flex-1 h-4 rounded bg-bg-elevated overflow-hidden">
|
|
<div className={`h-full rounded ${color} transition-all`} style={{ width: `${pct}%` }} />
|
|
</div>
|
|
<span className="mono w-20 shrink-0 text-right text-xs font-medium text-text">{money(rec.cost)}</span>
|
|
<span className="w-12 shrink-0 text-right text-[11px] text-text-muted">{pct.toFixed(1)}%</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Detail table */}
|
|
<div className="rounded-xl border border-border overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-bg-elevated text-xs uppercase tracking-wide text-text-muted">
|
|
<th className="px-4 py-3 text-left cursor-pointer select-none" onClick={() => handleSort('label')}>
|
|
Product{sortIndicator('label')}
|
|
</th>
|
|
<th className="px-4 py-3 text-left">Meter</th>
|
|
<th className="px-4 py-3 text-right cursor-pointer select-none" onClick={() => handleSort('usage')}>
|
|
Usage{sortIndicator('usage')}
|
|
</th>
|
|
<th className="px-4 py-3 text-right cursor-pointer select-none" onClick={() => handleSort('cost')}>
|
|
Cost{sortIndicator('cost')}
|
|
</th>
|
|
<th className="px-4 py-3 text-right">% of Total</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{filtered.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={5} className="px-4 py-8 text-center text-text-muted text-sm">
|
|
No usage data for this period.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
filtered.map(rec => {
|
|
const pct = totalCost > 0 ? (rec.cost / totalCost) * 100 : 0
|
|
return (
|
|
<tr key={rec.meterId} className="hover:bg-bg-card transition-colors">
|
|
<td className="px-4 py-3 text-text font-medium">{meterLabels[rec.meterId] ?? rec.label}</td>
|
|
<td className="px-4 py-3 text-text-muted">{rec.meterId}</td>
|
|
<td className="px-4 py-3 text-right text-text">
|
|
{rec.current.toLocaleString()} {rec.unit}
|
|
{rec.limit ? <span className="text-text-dim"> / {rec.limit.toLocaleString()}</span> : null}
|
|
</td>
|
|
<td className="mono px-4 py-3 text-right text-text font-medium">{money(rec.cost)}</td>
|
|
<td className="px-4 py-3 text-right text-text-muted">{pct.toFixed(1)}%</td>
|
|
</tr>
|
|
)
|
|
})
|
|
)}
|
|
</tbody>
|
|
{filtered.length > 0 && (
|
|
<tfoot>
|
|
<tr className="bg-bg-elevated font-medium">
|
|
<td colSpan={3} className="px-4 py-3 text-xs uppercase tracking-wide text-text-muted">Total</td>
|
|
<td className="mono px-4 py-3 text-right text-text">{money(totalCost, currency)}</td>
|
|
<td className="px-4 py-3 text-right text-text-muted">100%</td>
|
|
</tr>
|
|
</tfoot>
|
|
)}
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|