mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
Improve dashboard responsive layouts (#805)
* Improve dashboard responsive layouts * Improve proxy pools mobile layout * Improve CLI tool card input responsiveness --------- Co-authored-by: Delynn Assistant <zhen@dkzhen.org>
This commit is contained in:
@@ -8,22 +8,22 @@ const fmtCost = (n) => `$${(n || 0).toFixed(2)}`;
|
||||
|
||||
export default function OverviewCards({ stats }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="px-4 py-3 flex flex-col gap-1">
|
||||
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-4 sm:gap-4">
|
||||
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
|
||||
<span className="text-text-muted text-sm uppercase font-semibold">Total Requests</span>
|
||||
<span className="text-2xl font-bold">{fmt(stats.totalRequests)}</span>
|
||||
<span className="truncate text-2xl font-bold">{fmt(stats.totalRequests)}</span>
|
||||
</Card>
|
||||
<Card className="px-4 py-3 flex flex-col gap-1">
|
||||
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
|
||||
<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>
|
||||
<span className="truncate text-2xl font-bold text-primary">{fmt(stats.totalPromptTokens)}</span>
|
||||
</Card>
|
||||
<Card className="px-4 py-3 flex flex-col gap-1">
|
||||
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
|
||||
<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>
|
||||
<span className="truncate text-2xl font-bold text-success">{fmt(stats.totalCompletionTokens)}</span>
|
||||
</Card>
|
||||
<Card className="px-4 py-3 flex flex-col gap-1">
|
||||
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
|
||||
<span className="text-text-muted text-sm uppercase font-semibold">Est. Cost</span>
|
||||
<span className="text-2xl font-bold text-warning">~{fmtCost(stats.totalCost)}</span>
|
||||
<span className="truncate text-2xl font-bold text-warning">~{fmtCost(stats.totalCost)}</span>
|
||||
<span className="text-[10px] text-text-muted">Estimated, not actual billing</span>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,7 @@ export default function ProviderLimits() {
|
||||
const [proxyPools, setProxyPools] = useState([]);
|
||||
const [providerFilter, setProviderFilter] = useState("all");
|
||||
const [expiringFirst, setExpiringFirst] = useState(false);
|
||||
const [providerMenuOpen, setProviderMenuOpen] = useState(false);
|
||||
|
||||
const intervalRef = useRef(null);
|
||||
const countdownRef = useRef(null);
|
||||
@@ -389,6 +390,7 @@ export default function ProviderLimits() {
|
||||
});
|
||||
|
||||
const providerOptions = Array.from(new Set(filteredConnections.map((conn) => conn.provider))).sort();
|
||||
const selectedProviderLabel = providerFilter === "all" ? "All providers" : providerFilter;
|
||||
|
||||
// Calculate summary stats
|
||||
const totalProviders = sortedConnections.length;
|
||||
@@ -431,8 +433,8 @@ export default function ProviderLimits() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header Controls */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-3">
|
||||
<h2 className="text-xl font-semibold text-text-primary">
|
||||
Provider Limits
|
||||
</h2>
|
||||
@@ -441,31 +443,89 @@ export default function ProviderLimits() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={providerFilter}
|
||||
onChange={(event) => setProviderFilter(event.target.value)}
|
||||
className="h-10 rounded-lg border border-black/10 bg-transparent px-3 text-sm text-text-primary dark:border-white/10"
|
||||
aria-label="Filter quota providers"
|
||||
>
|
||||
<option value="all">All providers</option>
|
||||
{providerOptions.map((provider) => (
|
||||
<option key={provider} value={provider}>{provider}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProviderMenuOpen((prev) => !prev)}
|
||||
className="flex h-10 min-w-[116px] items-center justify-between gap-2 rounded-xl border border-black/10 bg-black/[0.02] px-3 text-sm text-text-primary transition-colors hover:bg-black/5 dark:border-white/10 dark:bg-white/[0.03] dark:hover:bg-white/10 sm:min-w-[180px]"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={providerMenuOpen}
|
||||
title="Filter quota providers"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{providerFilter === "all" ? (
|
||||
<span className="material-symbols-outlined text-[20px] text-text-muted">apps</span>
|
||||
) : (
|
||||
<ProviderIcon
|
||||
src={`/providers/${providerFilter}.png`}
|
||||
alt={providerFilter}
|
||||
size={22}
|
||||
className="size-[22px] rounded-md object-contain"
|
||||
fallbackText={providerFilter.slice(0, 2).toUpperCase()}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate capitalize hidden sm:inline">{selectedProviderLabel}</span>
|
||||
</span>
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">expand_more</span>
|
||||
</button>
|
||||
|
||||
{providerMenuOpen && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="fixed inset-0 z-30 bg-transparent"
|
||||
aria-label="Close provider filter"
|
||||
onClick={() => setProviderMenuOpen(false)}
|
||||
/>
|
||||
<div className="absolute left-0 z-40 mt-2 w-64 overflow-hidden rounded-2xl border border-black/10 bg-surface/95 p-1.5 shadow-xl shadow-black/10 backdrop-blur dark:border-white/10 dark:bg-surface/95 sm:w-72">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setProviderFilter("all"); setProviderMenuOpen(false); }}
|
||||
className={`flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm transition-colors ${providerFilter === "all" ? "bg-primary/10 text-primary" : "text-text-primary hover:bg-black/5 dark:hover:bg-white/10"}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[22px]">apps</span>
|
||||
<span className="font-medium">All providers</span>
|
||||
{providerFilter === "all" && <span className="material-symbols-outlined ml-auto text-[20px]">check</span>}
|
||||
</button>
|
||||
<div className="my-1 h-px bg-black/10 dark:bg-white/10" />
|
||||
<div className="max-h-72 overflow-y-auto pr-1">
|
||||
{providerOptions.map((provider) => (
|
||||
<button
|
||||
key={provider}
|
||||
type="button"
|
||||
onClick={() => { setProviderFilter(provider); setProviderMenuOpen(false); }}
|
||||
className={`flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm transition-colors ${providerFilter === provider ? "bg-primary/10 text-primary" : "text-text-primary hover:bg-black/5 dark:hover:bg-white/10"}`}
|
||||
>
|
||||
<ProviderIcon
|
||||
src={`/providers/${provider}.png`}
|
||||
alt={provider}
|
||||
size={24}
|
||||
className="size-6 rounded-md object-contain"
|
||||
fallbackText={provider.slice(0, 2).toUpperCase()}
|
||||
/>
|
||||
<span className="font-medium capitalize">{provider}</span>
|
||||
{providerFilter === provider && <span className="material-symbols-outlined ml-auto text-[20px]">check</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpiringFirst((prev) => !prev)}
|
||||
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors ${expiringFirst ? "border-amber-500/40 bg-amber-500/10 text-amber-500" : "border-black/10 text-text-primary hover:bg-black/5 dark:border-white/10 dark:hover:bg-white/5"}`}
|
||||
className={`flex shrink-0 items-center gap-1.5 rounded-lg border px-3 py-2 text-sm transition-colors ${expiringFirst ? "border-amber-500/40 bg-amber-500/10 text-amber-500" : "border-black/10 text-text-primary hover:bg-black/5 dark:border-white/10 dark:hover:bg-white/5"}`}
|
||||
title="Sort accounts by earliest quota reset time"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">hourglass_top</span>
|
||||
Expiring first
|
||||
<span className="hidden sm:inline">Expiring first</span>
|
||||
</button>
|
||||
{/* Auto-refresh toggle */}
|
||||
<button
|
||||
onClick={() => setAutoRefresh((prev) => !prev)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
className="flex shrink-0 items-center gap-2 rounded-lg border border-black/10 px-3 py-2 transition-colors hover:bg-black/5 dark:border-white/10 dark:hover:bg-white/5"
|
||||
title={autoRefresh ? "Disable auto-refresh" : "Enable auto-refresh"}
|
||||
>
|
||||
<span
|
||||
@@ -475,7 +535,7 @@ export default function ProviderLimits() {
|
||||
>
|
||||
{autoRefresh ? "toggle_on" : "toggle_off"}
|
||||
</span>
|
||||
<span className="text-sm text-text-primary">Auto-refresh</span>
|
||||
<span className="hidden text-sm text-text-primary sm:inline">Auto-refresh</span>
|
||||
{autoRefresh && (
|
||||
<span className="text-xs text-text-muted">({countdown}s)</span>
|
||||
)}
|
||||
|
||||
@@ -221,7 +221,7 @@ export default function ProviderTopology({ providers = [], activeRequests = [],
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-lg border border-border bg-bg-subtle/30" style={{ height: 480 }}>
|
||||
<div className="h-[320px] w-full min-w-0 rounded-lg border border-border bg-bg-subtle/30 sm:h-[480px]">
|
||||
{providers.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center text-text-muted text-sm">
|
||||
No providers connected
|
||||
|
||||
@@ -169,10 +169,10 @@ export default function RequestDetailsTab() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
<Card padding="md">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<label htmlFor="provider-filter" className="text-sm font-medium text-text-main">Provider</label>
|
||||
<select
|
||||
id="provider-filter"
|
||||
@@ -181,7 +181,7 @@ export default function RequestDetailsTab() {
|
||||
className={cn(
|
||||
"h-9 px-3 rounded-lg border border-black/10 dark:border-white/10 bg-surface",
|
||||
"text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20",
|
||||
"cursor-pointer min-w-[150px]"
|
||||
"w-full min-w-0 cursor-pointer"
|
||||
)}
|
||||
>
|
||||
<option value="">All Providers</option>
|
||||
@@ -193,7 +193,7 @@ export default function RequestDetailsTab() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<label htmlFor="start-date-filter" className="text-sm font-medium text-text-main">Start Date</label>
|
||||
<input
|
||||
id="start-date-filter"
|
||||
@@ -202,12 +202,12 @@ export default function RequestDetailsTab() {
|
||||
onChange={(e) => setFilters({ ...filters, startDate: e.target.value })}
|
||||
className={cn(
|
||||
"h-9 px-3 rounded-lg border border-black/10 dark:border-white/10 bg-surface",
|
||||
"text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
"w-full min-w-0 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<label htmlFor="end-date-filter" className="text-sm font-medium text-text-main">End Date</label>
|
||||
<input
|
||||
id="end-date-filter"
|
||||
@@ -216,17 +216,18 @@ export default function RequestDetailsTab() {
|
||||
onChange={(e) => setFilters({ ...filters, endDate: e.target.value })}
|
||||
className={cn(
|
||||
"h-9 px-3 rounded-lg border border-black/10 dark:border-white/10 bg-surface",
|
||||
"text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
"w-full min-w-0 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-text-main opacity-0" aria-hidden="true">Clear</span>
|
||||
<div className="flex min-w-0 flex-col gap-2 sm:col-span-2 lg:col-span-1">
|
||||
<span className="hidden text-sm font-medium text-text-main opacity-0 lg:block" aria-hidden="true">Clear</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleClearFilters}
|
||||
disabled={!filters.provider && !filters.startDate && !filters.endDate}
|
||||
className="w-full"
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
@@ -236,7 +237,7 @@ export default function RequestDetailsTab() {
|
||||
|
||||
<Card padding="none">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<table className="w-full min-w-[880px]">
|
||||
<thead>
|
||||
<tr className="border-b border-black/5 dark:border-white/5">
|
||||
<th className="text-left p-4 text-sm font-semibold text-text-main">Timestamp</th>
|
||||
@@ -270,13 +271,13 @@ export default function RequestDetailsTab() {
|
||||
key={`${detail.id}-${index}`}
|
||||
className="border-b border-black/5 dark:border-white/5 last:border-b-0 hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors"
|
||||
>
|
||||
<td className="p-4 text-sm text-text-main">
|
||||
<td className="whitespace-nowrap p-4 text-sm text-text-main">
|
||||
{new Date(detail.timestamp).toLocaleString()}
|
||||
</td>
|
||||
<td className="p-4 text-sm text-text-main font-mono">
|
||||
<td className="max-w-[260px] truncate p-4 font-mono text-sm text-text-main">
|
||||
{detail.model}
|
||||
</td>
|
||||
<td className="p-4 text-sm text-text-main">
|
||||
<td className="max-w-[180px] truncate p-4 text-sm text-text-main">
|
||||
<span className="font-medium">
|
||||
{getProviderName(detail.provider, providerNameCache)}
|
||||
</span>
|
||||
@@ -330,10 +331,10 @@ export default function RequestDetailsTab() {
|
||||
>
|
||||
{selectedDetail && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="grid min-w-0 grid-cols-1 gap-4 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<span className="text-text-muted">ID:</span>{" "}
|
||||
<span className="text-text-main font-mono">{selectedDetail.id}</span>
|
||||
<span className="break-all font-mono text-text-main">{selectedDetail.id}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted">Timestamp:</span>{" "}
|
||||
@@ -378,14 +379,14 @@ export default function RequestDetailsTab() {
|
||||
|
||||
<div className="space-y-4">
|
||||
<CollapsibleSection title="1. Client Request (Input)" defaultOpen={true} icon="input">
|
||||
<pre className="bg-black/5 dark:bg-white/5 p-4 rounded-lg overflow-auto max-h-[300px] text-xs font-mono text-text-main border border-black/5 dark:border-white/5">
|
||||
<pre className="max-h-[300px] max-w-full overflow-auto rounded-lg border border-black/5 bg-black/5 p-3 font-mono text-xs text-text-main dark:border-white/5 dark:bg-white/5 sm:p-4">
|
||||
{JSON.stringify(selectedDetail.request, null, 2)}
|
||||
</pre>
|
||||
</CollapsibleSection>
|
||||
|
||||
{selectedDetail.providerRequest && (
|
||||
<CollapsibleSection title="2. Provider Request (Translated)" icon="translate">
|
||||
<pre className="bg-black/5 dark:bg-white/5 p-4 rounded-lg overflow-auto max-h-[300px] text-xs font-mono text-text-main border border-black/5 dark:border-white/5">
|
||||
<pre className="max-h-[300px] max-w-full overflow-auto rounded-lg border border-black/5 bg-black/5 p-3 font-mono text-xs text-text-main dark:border-white/5 dark:bg-white/5 sm:p-4">
|
||||
{JSON.stringify(selectedDetail.providerRequest, null, 2)}
|
||||
</pre>
|
||||
</CollapsibleSection>
|
||||
@@ -393,7 +394,7 @@ export default function RequestDetailsTab() {
|
||||
|
||||
{selectedDetail.providerResponse && (
|
||||
<CollapsibleSection title="3. Provider Response (Raw)" icon="data_object">
|
||||
<pre className="bg-black/5 dark:bg-white/5 p-4 rounded-lg overflow-auto max-h-[300px] text-xs font-mono text-text-main border border-black/5 dark:border-white/5">
|
||||
<pre className="max-h-[300px] max-w-full overflow-auto rounded-lg border border-black/5 bg-black/5 p-3 font-mono text-xs text-text-main dark:border-white/5 dark:bg-white/5 sm:p-4">
|
||||
{typeof selectedDetail.providerResponse === 'object'
|
||||
? JSON.stringify(selectedDetail.providerResponse, null, 2)
|
||||
: selectedDetail.providerResponse
|
||||
@@ -409,7 +410,7 @@ export default function RequestDetailsTab() {
|
||||
<span className="material-symbols-outlined text-[16px]">psychology</span>
|
||||
Thinking Process
|
||||
</h4>
|
||||
<pre className="bg-amber-50 dark:bg-amber-950/30 p-4 rounded-lg overflow-auto max-h-[200px] text-xs font-mono text-amber-900 dark:text-amber-100 border border-amber-200 dark:border-amber-800">
|
||||
<pre className="max-h-[200px] max-w-full overflow-auto rounded-lg border border-amber-200 bg-amber-50 p-3 font-mono text-xs text-amber-900 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-100 sm:p-4">
|
||||
{selectedDetail.response.thinking}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -418,7 +419,7 @@ export default function RequestDetailsTab() {
|
||||
<h4 className="font-semibold text-text-main mb-2 text-xs uppercase tracking-wide opacity-70">
|
||||
Content
|
||||
</h4>
|
||||
<pre className="bg-black/5 dark:bg-white/5 p-4 rounded-lg overflow-auto max-h-[300px] text-xs font-mono text-text-main border border-black/5 dark:border-white/5">
|
||||
<pre className="max-h-[300px] max-w-full overflow-auto rounded-lg border border-black/5 bg-black/5 p-3 font-mono text-xs text-text-main dark:border-white/5 dark:bg-white/5 sm:p-4">
|
||||
{selectedDetail.response?.content || "[No content]"}
|
||||
</pre>
|
||||
</CollapsibleSection>
|
||||
|
||||
@@ -49,8 +49,8 @@ export default function UsageChart({ period = "7d" }) {
|
||||
const hasData = data.some((d) => d.tokens > 0 || d.cost > 0);
|
||||
|
||||
return (
|
||||
<Card className="p-4 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-1 bg-bg-subtle rounded-lg p-1 border border-border self-start">
|
||||
<Card className="flex min-w-0 flex-col gap-3 p-3 sm:p-4">
|
||||
<div className="grid w-full grid-cols-2 items-center gap-1 rounded-lg border border-border bg-bg-subtle p-1 sm:w-auto sm:self-start">
|
||||
<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"}`}
|
||||
@@ -70,7 +70,7 @@ export default function UsageChart({ period = "7d" }) {
|
||||
) : !hasData ? (
|
||||
<div className="h-48 flex items-center justify-center text-text-muted text-sm">No data for this period</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<AreaChart data={data} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="gradTokens" x1="0" y1="0" x2="0" y2="1">
|
||||
|
||||
Reference in New Issue
Block a user