| "use client"; |
|
|
| import { useState, useEffect, useMemo, useCallback, useRef } from "react"; |
| import PropTypes from "prop-types"; |
| import { useSearchParams, useRouter } from "next/navigation"; |
| import Card from "./Card"; |
| import Badge from "./Badge"; |
| import { CardSkeleton } from "./Loading"; |
| import { fmtFull, fmtCost } from "@/shared/utils/formatting"; |
|
|
| function SortIcon({ |
| field, |
| currentSort, |
| currentOrder, |
| }: { |
| field: string; |
| currentSort: string; |
| currentOrder: string; |
| }) { |
| if (currentSort !== field) return <span className="ml-1 opacity-20">↕</span>; |
| return <span className="ml-1">{currentOrder === "asc" ? "↑" : "↓"}</span>; |
| } |
|
|
| SortIcon.propTypes = { |
| field: PropTypes.string.isRequired, |
| currentSort: PropTypes.string.isRequired, |
| currentOrder: PropTypes.string.isRequired, |
| }; |
|
|
| function MiniBarGraph({ |
| data, |
| colorClass = "bg-primary", |
| }: { |
| data: number[]; |
| colorClass?: string; |
| }) { |
| const max = Math.max(...data, 1); |
| return ( |
| <div className="flex items-end gap-1 h-8 w-24"> |
| {data.slice(-9).map((val, idx) => ( |
| <div |
| key={`bar-${idx}-${val}`} |
| className={`flex-1 rounded-t-sm transition-all duration-500 ${colorClass}`} |
| style={{ height: `${Math.max((val / max) * 100, 5)}%` }} |
| title={String(val)} |
| /> |
| ))} |
| </div> |
| ); |
| } |
|
|
| MiniBarGraph.propTypes = { |
| data: PropTypes.arrayOf(PropTypes.number).isRequired, |
| colorClass: PropTypes.string, |
| }; |
|
|
| export default function UsageStats() { |
| const router = useRouter(); |
| const searchParams = useSearchParams(); |
|
|
| const sortBy = searchParams.get("sortBy") || "rawModel"; |
| const sortOrder = searchParams.get("sortOrder") || "asc"; |
|
|
| const [stats, setStats] = useState<any>(null); |
| const [loading, setLoading] = useState(true); |
| const [autoRefresh, setAutoRefresh] = useState(true); |
| const [viewMode, setViewMode] = useState("tokens"); |
| const [refreshInterval, setRefreshInterval] = useState(5000); |
| const prevTotalRequestsRef = useRef(0); |
|
|
| const toggleSort = (field: string) => { |
| const params = new URLSearchParams(searchParams.toString()); |
| if (sortBy === field) { |
| params.set("sortOrder", sortOrder === "asc" ? "desc" : "asc"); |
| } else { |
| params.set("sortBy", field); |
| params.set("sortOrder", "asc"); |
| } |
| router.replace(`?${params.toString()}`, { scroll: false }); |
| }; |
|
|
| const sortData = useCallback( |
| (dataMap: Record<string, any>, pendingMap: Record<string, any> = {}) => { |
| return Object.entries(dataMap || {}) |
| .map(([key, data]: [string, any]) => { |
| const totalTokens = (data.promptTokens || 0) + (data.completionTokens || 0); |
| const totalCost = data.cost || 0; |
|
|
| |
| const inputCost = |
| totalTokens > 0 ? (data.promptTokens || 0) * (totalCost / totalTokens) : 0; |
| const outputCost = |
| totalTokens > 0 ? (data.completionTokens || 0) * (totalCost / totalTokens) : 0; |
|
|
| return { |
| ...data, |
| key, |
| totalTokens, |
| totalCost, |
| inputCost, |
| outputCost, |
| pending: pendingMap[key] || 0, |
| }; |
| }) |
| .sort((a, b) => { |
| let valA = a[sortBy]; |
| let valB = b[sortBy]; |
|
|
| |
| if (typeof valA === "string") valA = valA.toLowerCase(); |
| if (typeof valB === "string") valB = valB.toLowerCase(); |
|
|
| if (valA < valB) return sortOrder === "asc" ? -1 : 1; |
| if (valA > valB) return sortOrder === "asc" ? 1 : -1; |
| return 0; |
| }); |
| }, |
| [sortBy, sortOrder] |
| ); |
|
|
| const sortedModels = useMemo( |
| () => sortData(stats?.byModel, stats?.pending?.byModel), |
| [stats?.byModel, stats?.pending?.byModel, sortData] |
| ); |
| const sortedAccounts = useMemo(() => { |
| |
| |
| const accountPendingMap: Record<string, any> = {}; |
| if (stats?.pending?.byAccount) { |
| Object.entries(stats.byAccount || {}).forEach(([accountKey, data]: [string, any]) => { |
| const connPending = stats.pending.byAccount[data.connectionId]; |
| if (connPending) { |
| |
| const modelKey = data.provider ? `${data.rawModel} (${data.provider})` : data.rawModel; |
| accountPendingMap[accountKey] = connPending[modelKey] || 0; |
| } |
| }); |
| } |
| return sortData(stats?.byAccount, accountPendingMap); |
| }, [stats?.byAccount, stats?.pending?.byAccount, sortData]); |
|
|
| const fetchStats = useCallback(async (showLoading = true): Promise<void> => { |
| if (showLoading) setLoading(true); |
| try { |
| const res = await fetch("/api/usage/history"); |
| if (res.ok) { |
| const data = await res.json(); |
| setStats(data); |
|
|
| |
| const currentTotal = data.totalRequests || 0; |
| if (currentTotal > prevTotalRequestsRef.current) { |
| |
| setRefreshInterval(5000); |
| } else { |
| |
| setRefreshInterval((prev) => Math.min(prev * 2, 60000)); |
| } |
| prevTotalRequestsRef.current = currentTotal; |
| } |
| } catch (error) { |
| console.error("Failed to fetch usage stats:", error); |
| } finally { |
| if (showLoading) setLoading(false); |
| } |
| }, []); |
|
|
| useEffect(() => { |
| fetchStats(); |
| }, [fetchStats]); |
|
|
| useEffect(() => { |
| let intervalId: ReturnType<typeof setInterval> | undefined; |
| let isPageVisible = true; |
|
|
| |
| const handleVisibilityChange = () => { |
| isPageVisible = !document.hidden; |
| if (isPageVisible && autoRefresh) { |
| fetchStats(false); |
| } |
| }; |
|
|
| document.addEventListener("visibilitychange", handleVisibilityChange); |
|
|
| if (autoRefresh) { |
| |
| if (intervalId) clearInterval(intervalId); |
|
|
| intervalId = setInterval(() => { |
| if (isPageVisible) { |
| fetchStats(false); |
| } |
| }, refreshInterval); |
| } |
|
|
| return () => { |
| if (intervalId) clearInterval(intervalId); |
| document.removeEventListener("visibilitychange", handleVisibilityChange); |
| }; |
| }, [autoRefresh, refreshInterval, fetchStats]); |
|
|
| if (loading) return <CardSkeleton />; |
|
|
| if (!stats) return <div className="text-text-muted">Failed to load usage statistics.</div>; |
|
|
| |
| const fmt = (n: number) => fmtFull(n); |
|
|
| |
|
|
| |
| const fmtTime = (iso: string) => { |
| if (!iso) return "Never"; |
| const date = new Date(iso); |
| const now = new Date(); |
| const diffMs = now.getTime() - date.getTime(); |
| const diffMins = Math.floor(diffMs / 60000); |
|
|
| if (diffMins < 1) return "Just now"; |
| if (diffMins < 60) return `${diffMins}m ago`; |
| if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`; |
| return date.toLocaleDateString(); |
| }; |
|
|
| return ( |
| <div className="flex flex-col gap-6"> |
| {/* Header with Auto Refresh Toggle and View Toggle */} |
| <div className="flex items-center justify-between"> |
| <h2 className="text-xl font-semibold">Usage Overview</h2> |
| <div className="flex items-center gap-2"> |
| {/* View Toggle */} |
| <div className="flex items-center gap-1 bg-bg-subtle rounded-lg p-1 border border-border"> |
| <button |
| onClick={() => setViewMode("tokens")} |
| className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${ |
| viewMode === "tokens" |
| ? "bg-primary text-white shadow-sm" |
| : "text-text-muted hover:text-text hover:bg-bg-hover" |
| }`} |
| > |
| Tokens |
| </button> |
| <button |
| onClick={() => setViewMode("costs")} |
| className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${ |
| viewMode === "costs" |
| ? "bg-primary text-white shadow-sm" |
| : "text-text-muted hover:text-text hover:bg-bg-hover" |
| }`} |
| > |
| Costs |
| </button> |
| </div> |
| |
| {/* Auto Refresh Toggle */} |
| <div className="text-sm font-medium text-text-muted flex items-center gap-2"> |
| <span>Auto Refresh ({refreshInterval / 1000}s)</span> |
| <button |
| type="button" |
| onClick={() => setAutoRefresh(!autoRefresh)} |
| role="switch" |
| aria-checked={autoRefresh} |
| aria-label="Toggle auto refresh" |
| className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50 ${ |
| autoRefresh ? "bg-primary" : "bg-bg-subtle border border-border" |
| }`} |
| > |
| <span |
| className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${ |
| autoRefresh ? "translate-x-5" : "translate-x-1" |
| }`} |
| /> |
| </button> |
| </div> |
| </div> |
| </div> |
| |
| {/* Active Requests Summary */} |
| {(stats.activeRequests || []).length > 0 && ( |
| <Card className="p-3 border-primary/20 bg-primary/5"> |
| <div className="flex flex-col gap-2"> |
| <div className="flex items-center gap-2 text-primary font-semibold text-sm uppercase tracking-wider"> |
| <span className="relative flex h-2 w-2"> |
| <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span> |
| <span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span> |
| </span> |
| Active Requests |
| </div> |
| <div className="flex flex-wrap gap-3"> |
| {stats.activeRequests.map((req) => ( |
| <div |
| key={`${req.model}-${req.provider}-${req.account}`} |
| className="px-3 py-1.5 rounded-md bg-bg-subtle border border-primary/20 text-xs font-mono shadow-sm" |
| > |
| <span className="text-primary font-bold">{req.model}</span> |
| <span className="mx-1 text-text-muted">|</span> |
| <span className="text-text">{req.provider}</span> |
| <span className="mx-1 text-text-muted">|</span> |
| <span className="text-text font-medium">{req.account}</span> |
| {req.count > 1 && ( |
| <span className="ml-2 px-1.5 py-0.5 rounded bg-primary text-white font-bold"> |
| x{req.count} |
| </span> |
| )} |
| </div> |
| ))} |
| </div> |
| </div> |
| </Card> |
| )} |
| |
| {/* Overview Cards */} |
| <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> |
| <Card className="px-4 py-3 flex flex-col gap-1"> |
| <div className="flex justify-between items-start"> |
| <div className="flex flex-col gap-1"> |
| <span className="text-text-muted text-sm uppercase font-semibold"> |
| Total Requests |
| </span> |
| <span className="text-2xl font-bold">{fmt(stats.totalRequests)}</span> |
| </div> |
| <MiniBarGraph |
| data={(stats.last10Minutes || []).map((m) => m.requests)} |
| colorClass="bg-text-muted/30" |
| /> |
| </div> |
| </Card> |
| <Card className="px-4 py-3 flex flex-col gap-1"> |
| <div className="flex justify-between items-start"> |
| <div className="flex flex-col gap-1"> |
| <span className="text-text-muted text-sm uppercase font-semibold"> |
| Total Input Tokens |
| </span> |
| <span className="text-2xl font-bold text-primary"> |
| {fmt(stats.totalPromptTokens)} |
| </span> |
| </div> |
| <MiniBarGraph |
| data={(stats.last10Minutes || []).map((m) => m.promptTokens)} |
| colorClass="bg-primary/50" |
| /> |
| </div> |
| </Card> |
| <Card className="px-4 py-2 flex flex-col gap-1"> |
| <div className="flex justify-between items-start gap-4"> |
| <div className="flex flex-col gap-1 flex-1"> |
| <span className="text-text-muted text-sm uppercase font-semibold">Output Tokens</span> |
| <span className="text-2xl font-bold text-success"> |
| {fmt(stats.totalCompletionTokens)} |
| </span> |
| </div> |
| <div className="w-px bg-border self-stretch mx-2" /> |
| <div className="flex flex-col gap-1 flex-1"> |
| <span className="text-text-muted text-sm uppercase font-semibold">Total Cost</span> |
| <span className="text-2xl font-bold text-warning">{fmtCost(stats.totalCost)}</span> |
| </div> |
| </div> |
| </Card> |
| </div> |
| |
| {/* Usage by Model Table */} |
| <Card className="overflow-hidden"> |
| <div className="p-4 border-b border-border bg-bg-subtle/50"> |
| <h3 className="font-semibold">Usage by Model</h3> |
| </div> |
| <div className="overflow-x-auto"> |
| <table className="w-full text-sm text-left"> |
| <thead className="bg-bg-subtle/30 text-text-muted uppercase text-xs"> |
| <tr> |
| <th |
| className="px-6 py-3 cursor-pointer hover:bg-bg-subtle/50" |
| onClick={() => toggleSort("rawModel")} |
| > |
| Model <SortIcon field="rawModel" currentSort={sortBy} currentOrder={sortOrder} /> |
| </th> |
| <th |
| className="px-6 py-3 cursor-pointer hover:bg-bg-subtle/50" |
| onClick={() => toggleSort("provider")} |
| > |
| Provider{" "} |
| <SortIcon field="provider" currentSort={sortBy} currentOrder={sortOrder} /> |
| </th> |
| <th |
| className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50" |
| onClick={() => toggleSort("requests")} |
| > |
| Requests{" "} |
| <SortIcon field="requests" currentSort={sortBy} currentOrder={sortOrder} /> |
| </th> |
| <th |
| className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50" |
| onClick={() => toggleSort("lastUsed")} |
| > |
| Last Used{" "} |
| <SortIcon field="lastUsed" currentSort={sortBy} currentOrder={sortOrder} /> |
| </th> |
| {viewMode === "tokens" ? ( |
| <> |
| <th |
| className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50" |
| onClick={() => toggleSort("promptTokens")} |
| > |
| Input Tokens{" "} |
| <SortIcon |
| field="promptTokens" |
| currentSort={sortBy} |
| currentOrder={sortOrder} |
| /> |
| </th> |
| <th |
| className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50" |
| onClick={() => toggleSort("completionTokens")} |
| > |
| Output Tokens{" "} |
| <SortIcon |
| field="completionTokens" |
| currentSort={sortBy} |
| currentOrder={sortOrder} |
| /> |
| </th> |
| <th |
| className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50" |
| onClick={() => toggleSort("totalTokens")} |
| > |
| Total Tokens{" "} |
| <SortIcon field="totalTokens" currentSort={sortBy} currentOrder={sortOrder} /> |
| </th> |
| </> |
| ) : ( |
| <> |
| <th |
| className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50" |
| onClick={() => toggleSort("promptTokens")} |
| > |
| Input Cost{" "} |
| <SortIcon |
| field="promptTokens" |
| currentSort={sortBy} |
| currentOrder={sortOrder} |
| /> |
| </th> |
| <th |
| className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50" |
| onClick={() => toggleSort("completionTokens")} |
| > |
| Output Cost{" "} |
| <SortIcon |
| field="completionTokens" |
| currentSort={sortBy} |
| currentOrder={sortOrder} |
| /> |
| </th> |
| <th |
| className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50" |
| onClick={() => toggleSort("cost")} |
| > |
| Total Cost{" "} |
| <SortIcon field="cost" currentSort={sortBy} currentOrder={sortOrder} /> |
| </th> |
| </> |
| )} |
| </tr> |
| </thead> |
| <tbody className="divide-y divide-border"> |
| {sortedModels.map((data) => ( |
| <tr key={data.key} className="hover:bg-bg-subtle/20"> |
| <td |
| className={`px-6 py-3 font-medium transition-colors ${ |
| data.pending > 0 ? "text-primary" : "" |
| }`} |
| > |
| {data.rawModel} |
| </td> |
| <td className="px-6 py-3"> |
| <Badge variant={data.pending > 0 ? "primary" : "default"} size="sm"> |
| {data.provider} |
| </Badge> |
| </td> |
| <td className="px-6 py-3 text-right">{fmt(data.requests)}</td> |
| <td className="px-6 py-3 text-right text-text-muted whitespace-nowrap"> |
| {fmtTime(data.lastUsed)} |
| </td> |
| {viewMode === "tokens" ? ( |
| <> |
| <td className="px-6 py-3 text-right text-text-muted"> |
| {fmt(data.promptTokens)} |
| </td> |
| <td className="px-6 py-3 text-right text-text-muted"> |
| {fmt(data.completionTokens)} |
| </td> |
| <td className="px-6 py-3 text-right font-medium">{fmt(data.totalTokens)}</td> |
| </> |
| ) : ( |
| <> |
| <td className="px-6 py-3 text-right text-text-muted"> |
| {fmtCost(data.inputCost)} |
| </td> |
| <td className="px-6 py-3 text-right text-text-muted"> |
| {fmtCost(data.outputCost)} |
| </td> |
| <td className="px-6 py-3 text-right font-medium text-warning"> |
| {fmtCost(data.totalCost)} |
| </td> |
| </> |
| )} |
| </tr> |
| ))} |
| {sortedModels.length === 0 && ( |
| <tr> |
| <td colSpan={8} className="px-6 py-8 text-center text-text-muted"> |
| No usage recorded yet. Make some requests to see data here. |
| </td> |
| </tr> |
| )} |
| </tbody> |
| </table> |
| </div> |
| </Card> |
|
|
| {} |
| <Card className="overflow-hidden"> |
| <div className="p-4 border-b border-border bg-bg-subtle/50"> |
| <h3 className="font-semibold">Usage by Account</h3> |
| </div> |
| <div className="overflow-x-auto"> |
| <table className="w-full text-sm text-left"> |
| <thead className="bg-bg-subtle/30 text-text-muted uppercase text-xs"> |
| <tr> |
| <th |
| className="px-6 py-3 cursor-pointer hover:bg-bg-subtle/50" |
| onClick={() => toggleSort("rawModel")} |
| > |
| Model <SortIcon field="rawModel" currentSort={sortBy} currentOrder={sortOrder} /> |
| </th> |
| <th |
| className="px-6 py-3 cursor-pointer hover:bg-bg-subtle/50" |
| onClick={() => toggleSort("provider")} |
| > |
| Provider{" "} |
| <SortIcon field="provider" currentSort={sortBy} currentOrder={sortOrder} /> |
| </th> |
| <th |
| className="px-6 py-3 cursor-pointer hover:bg-bg-subtle/50" |
| onClick={() => toggleSort("accountName")} |
| > |
| Account{" "} |
| <SortIcon field="accountName" currentSort={sortBy} currentOrder={sortOrder} /> |
| </th> |
| <th |
| className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50" |
| onClick={() => toggleSort("requests")} |
| > |
| Requests{" "} |
| <SortIcon field="requests" currentSort={sortBy} currentOrder={sortOrder} /> |
| </th> |
| <th |
| className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50" |
| onClick={() => toggleSort("lastUsed")} |
| > |
| Last Used{" "} |
| <SortIcon field="lastUsed" currentSort={sortBy} currentOrder={sortOrder} /> |
| </th> |
| {viewMode === "tokens" ? ( |
| <> |
| <th |
| className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50" |
| onClick={() => toggleSort("promptTokens")} |
| > |
| Input Tokens{" "} |
| <SortIcon |
| field="promptTokens" |
| currentSort={sortBy} |
| currentOrder={sortOrder} |
| /> |
| </th> |
| <th |
| className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50" |
| onClick={() => toggleSort("completionTokens")} |
| > |
| Output Tokens{" "} |
| <SortIcon |
| field="completionTokens" |
| currentSort={sortBy} |
| currentOrder={sortOrder} |
| /> |
| </th> |
| <th |
| className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50" |
| onClick={() => toggleSort("totalTokens")} |
| > |
| Total Tokens{" "} |
| <SortIcon field="totalTokens" currentSort={sortBy} currentOrder={sortOrder} /> |
| </th> |
| </> |
| ) : ( |
| <> |
| <th |
| className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50" |
| onClick={() => toggleSort("promptTokens")} |
| > |
| Input Cost{" "} |
| <SortIcon |
| field="promptTokens" |
| currentSort={sortBy} |
| currentOrder={sortOrder} |
| /> |
| </th> |
| <th |
| className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50" |
| onClick={() => toggleSort("completionTokens")} |
| > |
| Output Cost{" "} |
| <SortIcon |
| field="completionTokens" |
| currentSort={sortBy} |
| currentOrder={sortOrder} |
| /> |
| </th> |
| <th |
| className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50" |
| onClick={() => toggleSort("cost")} |
| > |
| Total Cost{" "} |
| <SortIcon field="cost" currentSort={sortBy} currentOrder={sortOrder} /> |
| </th> |
| </> |
| )} |
| </tr> |
| </thead> |
| <tbody className="divide-y divide-border"> |
| {sortedAccounts.map((data) => ( |
| <tr key={data.key} className="hover:bg-bg-subtle/20"> |
| <td |
| className={`px-6 py-3 font-medium transition-colors ${ |
| data.pending > 0 ? "text-primary" : "" |
| }`} |
| > |
| {data.rawModel} |
| </td> |
| <td className="px-6 py-3"> |
| <Badge variant={data.pending > 0 ? "primary" : "default"} size="sm"> |
| {data.provider} |
| </Badge> |
| </td> |
| <td className="px-6 py-3"> |
| <span |
| className={`font-medium transition-colors ${ |
| data.pending > 0 ? "text-primary" : "" |
| }`} |
| > |
| {data.accountName || `Account ${data.connectionId?.slice(0, 8)}...`} |
| </span> |
| </td> |
| <td className="px-6 py-3 text-right">{fmt(data.requests)}</td> |
| <td className="px-6 py-3 text-right text-text-muted whitespace-nowrap"> |
| {fmtTime(data.lastUsed)} |
| </td> |
| {viewMode === "tokens" ? ( |
| <> |
| <td className="px-6 py-3 text-right text-text-muted"> |
| {fmt(data.promptTokens)} |
| </td> |
| <td className="px-6 py-3 text-right text-text-muted"> |
| {fmt(data.completionTokens)} |
| </td> |
| <td className="px-6 py-3 text-right font-medium">{fmt(data.totalTokens)}</td> |
| </> |
| ) : ( |
| <> |
| <td className="px-6 py-3 text-right text-text-muted"> |
| {fmtCost(data.inputCost)} |
| </td> |
| <td className="px-6 py-3 text-right text-text-muted"> |
| {fmtCost(data.outputCost)} |
| </td> |
| <td className="px-6 py-3 text-right font-medium text-warning"> |
| {fmtCost(data.totalCost)} |
| </td> |
| </> |
| )} |
| </tr> |
| ))} |
| {sortedAccounts.length === 0 && ( |
| <tr> |
| <td colSpan={9} className="px-6 py-8 text-center text-text-muted"> |
| No account-specific usage recorded yet. Make requests using OAuth accounts to |
| see data here. |
| </td> |
| </tr> |
| )} |
| </tbody> |
| </table> |
| </div> |
| </Card> |
| </div> |
| ); |
| } |
|
|