Spaces:
Runtime error
Runtime error
| "use client"; | |
| import { useTranslations } from "next-intl"; | |
| import { useCallback, useEffect, useMemo, useState } from "react"; | |
| import { useProviderNodeMap, resolveProviderName } from "@/lib/display/useProviderNodeMap"; | |
| import { | |
| CartesianGrid, | |
| Legend, | |
| Line, | |
| LineChart, | |
| ResponsiveContainer, | |
| Tooltip, | |
| XAxis, | |
| YAxis, | |
| } from "recharts"; | |
| import Card from "@/shared/components/Card"; | |
| import ProviderIcon from "@/shared/components/ProviderIcon"; | |
| import TimeRangeSelector from "@/shared/components/analytics/TimeRangeSelector"; | |
| import type { | |
| ProviderUtilizationPoint, | |
| ProviderUtilizationResponse, | |
| UtilizationTimeRange, | |
| } from "@/shared/types/utilization"; | |
| const RANGE_LABELS: Record<UtilizationTimeRange, string> = { | |
| "1h": "Last hour", | |
| "24h": "Last 24 hours", | |
| "7d": "Last 7 days", | |
| "30d": "Last 30 days", | |
| }; | |
| const PROVIDER_COLORS = [ | |
| "var(--color-primary)", | |
| "var(--color-accent)", | |
| "var(--color-success)", | |
| "var(--color-warning)", | |
| "var(--color-error)", | |
| "var(--color-text-muted)", | |
| ]; | |
| function formatTimestamp(value: string, range: UtilizationTimeRange) { | |
| const date = new Date(value); | |
| if (Number.isNaN(date.getTime())) { | |
| return value; | |
| } | |
| if (range === "1h" || range === "24h") { | |
| return new Intl.DateTimeFormat(undefined, { | |
| hour: "2-digit", | |
| minute: "2-digit", | |
| }).format(date); | |
| } | |
| return new Intl.DateTimeFormat(undefined, { | |
| month: "short", | |
| day: "numeric", | |
| }).format(date); | |
| } | |
| function formatTooltipTimestamp(value: string, range: UtilizationTimeRange) { | |
| const date = new Date(value); | |
| if (Number.isNaN(date.getTime())) { | |
| return value; | |
| } | |
| return new Intl.DateTimeFormat(undefined, { | |
| month: "short", | |
| day: "numeric", | |
| hour: range === "1h" || range === "24h" ? "2-digit" : undefined, | |
| minute: range === "1h" || range === "24h" ? "2-digit" : undefined, | |
| }).format(date); | |
| } | |
| function formatPercent(value: number) { | |
| return `${Math.round(value)}%`; | |
| } | |
| function getLatestPoints(points: ProviderUtilizationPoint[]) { | |
| const latestByProvider = new Map<string, ProviderUtilizationPoint>(); | |
| for (const point of points) { | |
| const current = latestByProvider.get(point.provider); | |
| if (!current || new Date(point.timestamp).getTime() > new Date(current.timestamp).getTime()) { | |
| latestByProvider.set(point.provider, point); | |
| } | |
| } | |
| return Array.from(latestByProvider.values()).sort((a, b) => b.remainingPct - a.remainingPct); | |
| } | |
| export default function ProviderUtilizationTab() { | |
| const t = useTranslations("analytics"); | |
| const nodeMap = useProviderNodeMap(); | |
| const [range, setRange] = useState<UtilizationTimeRange>("24h"); | |
| const [aggregateBy, setAggregateBy] = useState<"provider" | "connection">("provider"); | |
| const [data, setData] = useState<ProviderUtilizationResponse | null>(null); | |
| const [loading, setLoading] = useState(true); | |
| const [error, setError] = useState<string | null>(null); | |
| const fetchUtilization = useCallback( | |
| async ( | |
| selectedRange: UtilizationTimeRange, | |
| selectedAggregate: "provider" | "connection", | |
| signal?: AbortSignal | |
| ) => { | |
| setLoading(true); | |
| try { | |
| const response = await fetch( | |
| `/api/usage/utilization?range=${selectedRange}&aggregateBy=${selectedAggregate}`, | |
| { | |
| signal, | |
| cache: "no-store", | |
| } | |
| ); | |
| if (!response.ok) { | |
| throw new Error("Failed to fetch utilization data"); | |
| } | |
| const json = (await response.json()) as ProviderUtilizationResponse; | |
| setData(json); | |
| setError(null); | |
| } catch (fetchError) { | |
| if (fetchError instanceof DOMException && fetchError.name === "AbortError") { | |
| return; | |
| } | |
| setError( | |
| fetchError instanceof Error ? fetchError.message : "Failed to fetch utilization data" | |
| ); | |
| setData(null); | |
| } finally { | |
| if (!signal?.aborted) { | |
| setLoading(false); | |
| } | |
| } | |
| }, | |
| [] | |
| ); | |
| useEffect(() => { | |
| const controller = new AbortController(); | |
| fetchUtilization(range, aggregateBy, controller.signal); | |
| return () => controller.abort(); | |
| }, [fetchUtilization, range, aggregateBy]); | |
| const providerColors = useMemo(() => { | |
| const colors = new Map<string, string>(); | |
| for (const [index, provider] of (data?.providers ?? []).entries()) { | |
| colors.set(provider, PROVIDER_COLORS[index % PROVIDER_COLORS.length]); | |
| } | |
| return colors; | |
| }, [data?.providers]); | |
| const chartData = useMemo(() => { | |
| if (!data?.data.length) { | |
| return []; | |
| } | |
| const byTimestamp = new Map<string, Record<string, number | string>>(); | |
| for (const point of data.data) { | |
| const entry = byTimestamp.get(point.timestamp) ?? { | |
| timestamp: point.timestamp, | |
| label: formatTimestamp(point.timestamp, data.timeRange), | |
| }; | |
| entry[point.provider] = Number(point.remainingPct.toFixed(2)); | |
| byTimestamp.set(point.timestamp, entry); | |
| } | |
| return Array.from(byTimestamp.entries()) | |
| .sort(([left], [right]) => new Date(left).getTime() - new Date(right).getTime()) | |
| .map(([, value]) => value); | |
| }, [data]); | |
| const latestPoints = useMemo(() => getLatestPoints(data?.data ?? []), [data?.data]); | |
| const hasData = Boolean(data?.data.length); | |
| const [retrying, setRetrying] = useState(false); | |
| const handleRetry = useCallback(() => { | |
| setRetrying(true); | |
| setError(null); | |
| fetchUtilization(range, aggregateBy).finally(() => setRetrying(false)); | |
| }, [range, aggregateBy, fetchUtilization]); | |
| return ( | |
| <div className="flex flex-col gap-6"> | |
| <Card | |
| title={t("providerUtilizationTitle")} | |
| subtitle={RANGE_LABELS[range]} | |
| icon="monitoring" | |
| action={ | |
| <div className="flex items-center gap-4"> | |
| <div className="flex rounded-lg border border-border/50 bg-black/5 p-1 dark:bg-white/5"> | |
| <button | |
| onClick={() => setAggregateBy("provider")} | |
| className={`flex items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${ | |
| aggregateBy === "provider" | |
| ? "bg-surface text-text-main shadow-sm" | |
| : "text-text-muted hover:text-text-main" | |
| }`} | |
| > | |
| <span className="material-symbols-outlined text-[14px]">dns</span> | |
| Global View | |
| </button> | |
| <button | |
| onClick={() => setAggregateBy("connection")} | |
| className={`flex items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${ | |
| aggregateBy === "connection" | |
| ? "bg-surface text-text-main shadow-sm" | |
| : "text-text-muted hover:text-text-main" | |
| }`} | |
| > | |
| <span className="material-symbols-outlined text-[14px]">account_tree</span> | |
| Account Split | |
| </button> | |
| </div> | |
| <TimeRangeSelector value={range} onChange={setRange} /> | |
| </div> | |
| } | |
| className="overflow-hidden" | |
| > | |
| {loading && !hasData ? ( | |
| <div className="flex min-h-80 items-center justify-center text-sm text-text-muted"> | |
| <span className="material-symbols-outlined mr-2 animate-spin text-[18px]"> | |
| progress_activity | |
| </span> | |
| Loading utilization data… | |
| </div> | |
| ) : error ? ( | |
| <div className="flex min-h-80 flex-col items-center justify-center gap-4 text-center"> | |
| <span className="material-symbols-outlined text-[32px] text-error">error</span> | |
| <div className="flex flex-col gap-1"> | |
| <p className="text-sm font-medium text-text-main"> | |
| {t("providerUtilizationFailedToLoad")} | |
| </p> | |
| <p className="text-sm text-text-muted">{error}</p> | |
| </div> | |
| <button | |
| type="button" | |
| onClick={handleRetry} | |
| disabled={retrying} | |
| className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| {retrying ? ( | |
| <> | |
| <span className="material-symbols-outlined animate-spin text-[18px]"> | |
| progress_activity | |
| </span> | |
| Retrying… | |
| </> | |
| ) : ( | |
| <> | |
| <span className="material-symbols-outlined text-[18px]">refresh</span> | |
| Retry | |
| </> | |
| )} | |
| </button> | |
| </div> | |
| ) : !hasData ? ( | |
| <div className="flex min-h-80 flex-col items-center justify-center gap-4 text-center"> | |
| <span className="material-symbols-outlined text-[40px] text-text-muted/70"> | |
| timeline | |
| </span> | |
| <div className="flex flex-col gap-2"> | |
| <p className="text-sm font-medium text-text-main">{t("providerUtilizationNoData")}</p> | |
| <p className="max-w-md text-sm text-text-muted"> | |
| Provider quota snapshots will appear here after utilization data is collected. | |
| </p> | |
| </div> | |
| <div className="rounded-lg border border-black/5 bg-black/[0.02] p-4 dark:border-white/5 dark:bg-white/[0.02]"> | |
| <p className="text-xs font-medium text-text-main"> | |
| {t("providerUtilizationGettingStarted")} | |
| </p> | |
| <ul className="mt-2 text-left text-xs text-text-muted"> | |
| <li className="flex items-start gap-2"> | |
| <span className="material-symbols-outlined text-[14px] text-primary"> | |
| check_circle | |
| </span> | |
| <span> | |
| Connect providers via OAuth or API keys in <strong>Providers</strong> | |
| </span> | |
| </li> | |
| <li className="mt-1 flex items-start gap-2"> | |
| <span className="material-symbols-outlined text-[14px] text-primary"> | |
| check_circle | |
| </span> | |
| <span> | |
| Enable quota tracking by using the provider in a combo or direct request | |
| </span> | |
| </li> | |
| <li className="mt-1 flex items-start gap-2"> | |
| <span className="material-symbols-outlined text-[14px] text-primary"> | |
| check_circle | |
| </span> | |
| <span>Data will appear automatically as quota snapshots are collected</span> | |
| </li> | |
| </ul> | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="flex flex-col gap-5"> | |
| <div className="h-80 w-full rounded-xl border border-black/5 bg-black/[0.02] px-3 py-4 dark:border-white/5 dark:bg-white/[0.02]"> | |
| <ResponsiveContainer width="100%" height="100%"> | |
| <LineChart data={chartData} margin={{ top: 8, right: 16, bottom: 0, left: 0 }}> | |
| <CartesianGrid | |
| stroke="var(--color-border)" | |
| strokeDasharray="3 3" | |
| vertical={false} | |
| /> | |
| <XAxis | |
| dataKey="timestamp" | |
| tickFormatter={(value) => formatTimestamp(String(value), range)} | |
| tick={{ fill: "var(--color-text-muted)", fontSize: 12 }} | |
| axisLine={{ stroke: "var(--color-border)" }} | |
| tickLine={{ stroke: "var(--color-border)" }} | |
| minTickGap={24} | |
| /> | |
| <YAxis | |
| domain={[0, 100]} | |
| tickFormatter={formatPercent} | |
| tick={{ fill: "var(--color-text-muted)", fontSize: 12 }} | |
| axisLine={{ stroke: "var(--color-border)" }} | |
| tickLine={{ stroke: "var(--color-border)" }} | |
| width={44} | |
| /> | |
| <Tooltip | |
| labelFormatter={(value) => formatTooltipTimestamp(String(value), range)} | |
| formatter={(value: number, name: string) => [formatPercent(value), name]} | |
| contentStyle={{ | |
| backgroundColor: "var(--color-surface)", | |
| borderColor: "var(--color-border)", | |
| borderRadius: 12, | |
| color: "var(--color-text-main)", | |
| boxShadow: "var(--shadow-soft)", | |
| }} | |
| itemStyle={{ color: "var(--color-text-main)" }} | |
| labelStyle={{ color: "var(--color-text-main)", fontWeight: 600 }} | |
| /> | |
| <Legend /> | |
| {data?.providers.map((provider) => ( | |
| <Line | |
| key={provider} | |
| type="monotone" | |
| dataKey={provider} | |
| name={resolveProviderName(provider, nodeMap)} | |
| stroke={providerColors.get(provider) ?? "var(--color-primary)"} | |
| strokeWidth={2.5} | |
| dot={false} | |
| activeDot={{ r: 4, strokeWidth: 0 }} | |
| connectNulls | |
| /> | |
| ))} | |
| </LineChart> | |
| </ResponsiveContainer> | |
| </div> | |
| <div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"> | |
| {latestPoints.map((point) => { | |
| const isLow = point.remainingPct <= 20; | |
| return ( | |
| <Card.Section key={point.provider} className="flex h-full flex-col gap-4"> | |
| <div className="flex items-start justify-between gap-3"> | |
| <div className="flex items-center gap-3"> | |
| <div className="flex h-11 w-11 items-center justify-center rounded-xl border border-black/5 bg-surface text-text-main dark:border-white/5"> | |
| <ProviderIcon providerId={point.provider} size={22} /> | |
| </div> | |
| <div> | |
| <p className="text-sm font-semibold text-text-main"> | |
| {resolveProviderName(point.provider, nodeMap)} | |
| </p> | |
| <p className="text-xs text-text-muted"> | |
| {t("providerUtilizationLatestSnapshot")} | |
| </p> | |
| </div> | |
| </div> | |
| <span | |
| className={`inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium ${ | |
| point.isExhausted | |
| ? "bg-error/10 text-error" | |
| : isLow | |
| ? "bg-warning/10 text-warning" | |
| : "bg-success/10 text-success" | |
| }`} | |
| > | |
| {point.isExhausted ? "Exhausted" : isLow ? "Low" : "Healthy"} | |
| </span> | |
| </div> | |
| <div className="flex items-end justify-between gap-3"> | |
| <div> | |
| <p className="text-3xl font-bold text-text-main"> | |
| {point.remainingPct.toFixed(point.remainingPct < 10 ? 1 : 0)}% | |
| </p> | |
| <p className="mt-1 text-xs text-text-muted"> | |
| {t("providerUtilizationRemainingCapacity")} | |
| </p> | |
| </div> | |
| <div className="text-right text-xs text-text-muted"> | |
| <p>{formatTooltipTimestamp(point.timestamp, range)}</p> | |
| <p className="mt-1 uppercase tracking-[0.14em]">{point.windowKey}</p> | |
| </div> | |
| </div> | |
| <div className="flex flex-col gap-2"> | |
| <div className="h-2 overflow-hidden rounded-full bg-black/5 dark:bg-white/5"> | |
| <div | |
| className={`h-full rounded-full transition-all ${ | |
| point.isExhausted ? "bg-error" : isLow ? "bg-warning" : "bg-primary" | |
| }`} | |
| style={{ width: `${Math.max(point.remainingPct, 0)}%` }} | |
| /> | |
| </div> | |
| <div className="flex items-center justify-between text-xs text-text-muted"> | |
| <span>0%</span> | |
| <span>Remaining quota</span> | |
| <span>100%</span> | |
| </div> | |
| </div> | |
| </Card.Section> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| </Card> | |
| </div> | |
| ); | |
| } | |