import { useEffect, useState } from "react"
import {
Activity,
AlertTriangle,
CheckCircle2,
ChevronDown,
ChevronLeft,
ChevronRight,
RefreshCw,
XCircle,
} from "lucide-react"
import { errorMessage } from "@/App"
import { fetchUsageStats } from "@/lib/api"
import type { UsageRecord, UsageResult } from "@/lib/types"
import {
RANGES,
buildBuckets,
computeKpis,
filterByRange,
formatDuration,
formatShortTime,
formatTimestamp,
type RangeKey,
} from "@/lib/usage-data"
import { cn } from "@/lib/utils"
import { HourHeatmap } from "./usage/HourHeatmap"
import { KpiCards } from "./usage/KpiCards"
import { RoutingSankey } from "./usage/RoutingSankey"
import { TokenDonut } from "./usage/TokenDonut"
import { TopEndpoints } from "./usage/TopEndpoints"
import { VolumeAreaChart } from "./usage/VolumeAreaChart"
import { Badge } from "./ui/badge"
import { Button } from "./ui/button"
import { Card, CardContent } from "./ui/card"
import { Skeleton } from "./ui/skeleton"
const REFRESH_INTERVAL_MS = 10_000
const PAGE_SIZES = [10, 25, 50] as const
/** Compact page-number list with ellipses for large logs (1 … 3 4 5 … 40). */
function pageList(current: number, total: number): (number | "…")[] {
if (total <= 7) return Array.from({ length: total }, (_, index) => index + 1)
const pages: (number | "…")[] = [1]
if (current > 3) pages.push("…")
const start = Math.max(2, current - 1)
const end = Math.min(total - 1, current + 1)
for (let pageNumber = start; pageNumber <= end; pageNumber += 1) {
pages.push(pageNumber)
}
if (current < total - 2) pages.push("…")
pages.push(total)
return pages
}
const STATUS_VARIANT = {
success: "ok",
error: "error",
cancelled: "neutral",
} as const
const STATUS_ICON = {
success: ,
error: ,
cancelled: ,
} as const
function statusLabel(status: UsageRecord["status"]): string {
return status === "cancelled" ? "Cancelled" : status === "error" ? "Error" : "Success"
}
function RangeSelector({
value,
onChange,
}: {
value: RangeKey
onChange: (range: RangeKey) => void
}) {
return (
{RANGES.map((option) => (
onChange(option.key)}
>
{option.label}
))}
)
}
export function UsageView() {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [expandedId, setExpandedId] = useState(null)
const [range, setRange] = useState("1h")
const [updatedAt, setUpdatedAt] = useState(null)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const refresh = async () => {
try {
const result = await fetchUsageStats()
setData(result)
setError(null)
setUpdatedAt(Date.now() / 1000)
} catch (err) {
setError(errorMessage(err))
} finally {
setLoading(false)
}
}
useEffect(() => {
void refresh()
const timer = setInterval(() => {
void refresh()
}, REFRESH_INTERVAL_MS)
return () => clearInterval(timer)
// The interval deliberately captures the initial refresh closure.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const records = data?.records ?? []
const now = Date.now() / 1000
const filtered = filterByRange(records, range, now)
const buckets = buildBuckets(filtered, now)
const kpis = computeKpis(filtered, now)
// The request log is paginated; the page clamps when a refresh shrinks it.
const totalPages = Math.max(1, Math.ceil(records.length / pageSize))
const currentPage = Math.min(page, totalPages)
const pageStart = (currentPage - 1) * pageSize
const pageRecords = records.slice(pageStart, pageStart + pageSize)
const showingStart = records.length === 0 ? 0 : pageStart + 1
const showingEnd = Math.min(records.length, pageStart + pageSize)
if (loading && !data) {
return (
{Array.from({ length: 6 }).map((_, index) => (
))}
)
}
if (error && !data) {
return (
Could not load usage statistics: {error}
void refresh()}>
Retry
)
}
const statusSummary = {
success: records.reduce(
(sum, record) => sum + (record.status === "success" ? 1 : 0),
0,
),
error: records.reduce(
(sum, record) => sum + (record.status === "error" ? 1 : 0),
0,
),
cancelled: records.reduce(
(sum, record) => sum + (record.status === "cancelled" ? 1 : 0),
0,
),
}
return (
Usage
Token throughput and request routing for the gateway, refreshed
automatically every 10 seconds. Full prompts are captured for each
request (capped at 64 KB) in ~/.fcc/usage.json.
void refresh()}>
Refresh
{updatedAt !== null ? (
Updated {formatShortTime(updatedAt)}
) : null}
{records.length === 0 ? (
No requests tracked yet. Send a request through the proxy and it will
appear here.
) : filtered.length === 0 ? (
No requests in the selected window. Try a wider range.
setRange("all")}>
Show all requests
) : (
<>
>
)}
Recent requests
{records.length} tracked · newest first
{STATUS_ICON.success}
{statusSummary.success}
{STATUS_ICON.error}
{statusSummary.error}
{STATUS_ICON.cancelled}
{statusSummary.cancelled}
Time
Provider / Model
Input
Output
Cache
Reasoning
Duration
Status
{pageRecords.map((record) => {
const expanded = record.request_id === expandedId
const cacheTokens =
record.cache_creation_tokens + record.cache_read_tokens
return (
setExpandedId(expanded ? null : record.request_id)
}
/>
)
})}
{records.length > pageSize ? (
Showing{" "}
{showingStart}–{showingEnd}
{" "}
of {records.length}
setPage(currentPage - 1)}
aria-label="Previous page"
>
{pageList(currentPage, totalPages).map((entry, index) =>
entry === "…" ? (
…
) : (
setPage(entry)}
>
{entry}
),
)}
= totalPages}
onClick={() => setPage(currentPage + 1)}
aria-label="Next page"
>
Per page
{PAGE_SIZES.map((size) => (
{
setPageSize(size)
setPage(1)
}}
>
{size}
))}
) : null}
)
}
interface UsageRowProps {
record: UsageRecord
expanded: boolean
cacheTokens: number
onToggle: () => void
}
function UsageRow({ record, expanded, cacheTokens, onToggle }: UsageRowProps) {
const modelLabel = record.gateway_model || record.provider_model
const cacheRead = record.cache_read_tokens
const cacheCreation = record.cache_creation_tokens
return (
<>
{expanded ? (
) : (
)}
{formatTimestamp(record.timestamp)}
{modelLabel}
{record.provider}
{record.provider_model && record.provider_model !== modelLabel
? ` → ${record.provider_model}`
: ""}
{record.input_tokens}
{record.output_tokens}
{cacheTokens > 0 ? (
{cacheTokens}
) : (
–
)}
{record.reasoning_tokens > 0 ? (
record.reasoning_tokens
) : (
–
)}
{formatDuration(record.duration_ms)}
{STATUS_ICON[record.status]}
{statusLabel(record.status)}
{expanded ? (
{record.wire_api}
{record.error_type ? (
{record.error_type}
) : null}
{record.request_id}
{record.prompt || "(no prompt captured)"}
) : null}
>
)
}