mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-08-28 19:01:32 +08:00
Add provider logos to account balance widgets
This commit is contained in:
@@ -14,7 +14,7 @@ import {
|
||||
OverviewMetricKind, overviewMetricOptions, overviewWidgetCollisionDetection, OverviewWidgetConfig, OverviewWidgetSize, overviewWidgetSizeOptions,
|
||||
OverviewWidgetType, OverviewWidgetVariant, Pencil, Pie, PieChart, Plus,
|
||||
PointerSensor, primaryProviderAccountMeter, providerAccountMeterDetailValidityProgress, providerAccountMeterProgress, providerAccountMetersForDisplay, providerAccountProgressClass, isGatewayProviderEnabled, isProviderAccountManualResetMeter,
|
||||
providerAccountSnapshotKey, providerAccountSnapshotLabel,
|
||||
providerAccountSnapshotKey, providerAccountSnapshotLabel, providerDisplayIcon,
|
||||
ProviderAccountMeter, ProviderAccountSnapshot, ReactNode, ReactPointerEvent, rectSortingStrategy, RefreshCw, Select,
|
||||
SelectControl, SortableContext, sortableKeyboardCoordinates, systemStatusPointTooltip,
|
||||
Tooltip, translateOptions, Trash2, UsageComparisonRow, usageRangeOptions,
|
||||
@@ -304,6 +304,7 @@ export function OverviewView({
|
||||
<OverviewWidgetRenderer
|
||||
providerAccounts={providerAccounts}
|
||||
providerAccountRefreshing={providerAccountRefreshing}
|
||||
providers={filterProviders}
|
||||
refreshProviderAccounts={refreshProviderAccounts}
|
||||
usageRange={usageRange}
|
||||
usageStats={usageStats}
|
||||
@@ -323,6 +324,7 @@ export function OverviewView({
|
||||
<OverviewWidgetDragOverlay
|
||||
providerAccounts={providerAccounts}
|
||||
providerAccountRefreshing={providerAccountRefreshing}
|
||||
providers={filterProviders}
|
||||
refreshProviderAccounts={refreshProviderAccounts}
|
||||
usageRange={usageRange}
|
||||
usageStats={usageStats}
|
||||
@@ -754,6 +756,7 @@ function SortableOverviewWidget({
|
||||
function OverviewWidgetDragOverlay({
|
||||
providerAccounts,
|
||||
providerAccountRefreshing = false,
|
||||
providers,
|
||||
refreshProviderAccounts,
|
||||
usageRange,
|
||||
usageStats,
|
||||
@@ -761,6 +764,7 @@ function OverviewWidgetDragOverlay({
|
||||
}: {
|
||||
providerAccounts: ProviderAccountSnapshot[];
|
||||
providerAccountRefreshing?: boolean;
|
||||
providers: GatewayProviderConfig[];
|
||||
refreshProviderAccounts?: () => void | Promise<void>;
|
||||
usageRange: UsageStatsRange;
|
||||
usageStats: UsageStatsSnapshot;
|
||||
@@ -771,6 +775,7 @@ function OverviewWidgetDragOverlay({
|
||||
<OverviewWidgetRenderer
|
||||
providerAccounts={providerAccounts}
|
||||
providerAccountRefreshing={providerAccountRefreshing}
|
||||
providers={providers}
|
||||
refreshProviderAccounts={refreshProviderAccounts}
|
||||
usageRange={usageRange}
|
||||
usageStats={usageStats}
|
||||
@@ -1013,6 +1018,7 @@ function overviewWidgetResizeCursor(axis: OverviewWidgetResizeAxis): string {
|
||||
function OverviewWidgetRenderer({
|
||||
providerAccounts,
|
||||
providerAccountRefreshing = false,
|
||||
providers,
|
||||
refreshProviderAccounts,
|
||||
usageRange,
|
||||
usageStats,
|
||||
@@ -1020,6 +1026,7 @@ function OverviewWidgetRenderer({
|
||||
}: {
|
||||
providerAccounts: ProviderAccountSnapshot[];
|
||||
providerAccountRefreshing?: boolean;
|
||||
providers: GatewayProviderConfig[];
|
||||
refreshProviderAccounts?: () => void | Promise<void>;
|
||||
usageRange: UsageStatsRange;
|
||||
usageStats: UsageStatsSnapshot;
|
||||
@@ -1030,7 +1037,7 @@ function OverviewWidgetRenderer({
|
||||
if (widget.type === "system-status") {
|
||||
content = <SystemStatusBar usageRange={usageRange} usageStats={usageStats} variant={widget.variant === "compact" ? "compact" : "timeline"} />;
|
||||
} else if (widget.type === "account-balance") {
|
||||
content = <ProviderAccountsOverview accountProvider={widget.accountProvider} accounts={providerAccounts} dimensions={dimensions} refreshing={providerAccountRefreshing} variant={overviewAccountVariant(widget.variant)} onRefresh={refreshProviderAccounts} />;
|
||||
content = <ProviderAccountsOverview accountProvider={widget.accountProvider} accounts={providerAccounts} dimensions={dimensions} providers={providers} refreshing={providerAccountRefreshing} variant={overviewAccountVariant(widget.variant)} onRefresh={refreshProviderAccounts} />;
|
||||
} else if (widget.type === "metric") {
|
||||
content = <OverviewMetricWidget metric={widget.metric ?? "requests"} totals={usageStats.totals} variant={overviewMetricVariant(widget.variant)} />;
|
||||
} else if (widget.type === "usage-trend") {
|
||||
@@ -2292,6 +2299,7 @@ function ProviderAccountsOverview({
|
||||
accounts,
|
||||
dimensions,
|
||||
onRefresh,
|
||||
providers,
|
||||
refreshing = false,
|
||||
variant = "cards"
|
||||
}: {
|
||||
@@ -2299,6 +2307,7 @@ function ProviderAccountsOverview({
|
||||
accounts: ProviderAccountSnapshot[];
|
||||
dimensions: OverviewWidgetDimensions;
|
||||
onRefresh?: () => void | Promise<void>;
|
||||
providers: GatewayProviderConfig[];
|
||||
refreshing?: boolean;
|
||||
variant?: OverviewAccountVariant;
|
||||
}) {
|
||||
@@ -2326,17 +2335,20 @@ function ProviderAccountsOverview({
|
||||
{visibleAccounts.length === 0 ? (
|
||||
<OverviewEmptyState className="h-full py-4" compact label={t("No account balance connectors configured")} />
|
||||
) : isSingleAccount ? (
|
||||
<ProviderAccountSinglePanel account={visibleAccounts[0]} dimensions={dimensions} refreshing={refreshing} variant={variant} onRefresh={onRefresh} />
|
||||
<ProviderAccountSinglePanel account={visibleAccounts[0]} dimensions={dimensions} providers={providers} refreshing={refreshing} variant={variant} onRefresh={onRefresh} />
|
||||
) : variant === "compact" ? (
|
||||
<div className={cn("grid h-full min-h-0 grid-cols-1 overflow-y-auto pr-1", providerAccountGapClass(dimensions), providerAccountGridClass(dimensions, visibleAccounts.length))}>
|
||||
{visibleAccounts.map((account) => {
|
||||
const meter = primaryProviderAccountDisplayMeter(account);
|
||||
return (
|
||||
<div className="overview-nested-surface flex min-h-0 min-w-0 items-center justify-between gap-3 overflow-hidden border px-3 py-2" key={providerAccountSnapshotKey(account)}>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[12px] font-semibold">{providerAccountSnapshotLabel(account)}</div>
|
||||
{providerAccountShowSource(dimensions) && meter ? <div className="truncate text-[11px] text-muted-foreground">{t(meter.label)}</div> : null}
|
||||
{providerAccountShowRefreshTime(dimensions) ? <div className="truncate text-[11px] text-muted-foreground">{formatProviderAccountRefreshTime(account, t)}</div> : null}
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ProviderAccountLogo account={account} className="h-7 w-7 rounded-md" providers={providers} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[12px] font-semibold">{providerAccountSnapshotLabel(account)}</div>
|
||||
{providerAccountShowSource(dimensions) && meter ? <div className="truncate text-[11px] text-muted-foreground">{t(meter.label)}</div> : null}
|
||||
{providerAccountShowRefreshTime(dimensions) ? <div className="truncate text-[11px] text-muted-foreground">{formatProviderAccountRefreshTime(account, t)}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1 text-right">
|
||||
{providerAccountShowRefresh(dimensions) ? <ProviderAccountRefreshButton account={account} refreshing={refreshing} onRefresh={onRefresh} /> : null}
|
||||
@@ -2354,10 +2366,13 @@ function ProviderAccountsOverview({
|
||||
return (
|
||||
<div className="min-w-0 overflow-hidden" key={providerAccountSnapshotKey(account)}>
|
||||
<div className="flex min-w-0 items-end justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[12px] font-semibold">{providerAccountSnapshotLabel(account)}</div>
|
||||
{providerAccountShowSource(dimensions) && meter ? <div className="truncate text-[11px] text-muted-foreground">{t(meter.label)}</div> : null}
|
||||
{providerAccountShowRefreshTime(dimensions) ? <div className="truncate text-[11px] text-muted-foreground">{formatProviderAccountRefreshTime(account, t)}</div> : null}
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ProviderAccountLogo account={account} className="h-6 w-6 rounded-md" providers={providers} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[12px] font-semibold">{providerAccountSnapshotLabel(account)}</div>
|
||||
{providerAccountShowSource(dimensions) && meter ? <div className="truncate text-[11px] text-muted-foreground">{t(meter.label)}</div> : null}
|
||||
{providerAccountShowRefreshTime(dimensions) ? <div className="truncate text-[11px] text-muted-foreground">{formatProviderAccountRefreshTime(account, t)}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 text-[12px] font-semibold">
|
||||
{meter ? <span>{formatProviderAccountMeterValue(meter)}</span> : null}
|
||||
@@ -2383,7 +2398,7 @@ function ProviderAccountsOverview({
|
||||
data-provider-account-grid="true"
|
||||
>
|
||||
{visibleAccounts.map((account) => {
|
||||
return <ProviderAccountSummaryCard account={account} dimensions={dimensions} key={providerAccountSnapshotKey(account)} refreshing={refreshing} variant={variant} onRefresh={onRefresh} />;
|
||||
return <ProviderAccountSummaryCard account={account} dimensions={dimensions} key={providerAccountSnapshotKey(account)} providers={providers} refreshing={refreshing} variant={variant} onRefresh={onRefresh} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
@@ -2396,12 +2411,14 @@ function ProviderAccountSinglePanel({
|
||||
account,
|
||||
dimensions,
|
||||
onRefresh,
|
||||
providers,
|
||||
refreshing = false,
|
||||
variant
|
||||
}: {
|
||||
account: ProviderAccountSnapshot;
|
||||
dimensions: OverviewWidgetDimensions;
|
||||
onRefresh?: () => void | Promise<void>;
|
||||
providers: GatewayProviderConfig[];
|
||||
refreshing?: boolean;
|
||||
variant: OverviewAccountVariant;
|
||||
}) {
|
||||
@@ -2414,9 +2431,12 @@ function ProviderAccountSinglePanel({
|
||||
return (
|
||||
<div className={cn("flex h-full min-h-0 min-w-0 flex-col overflow-hidden", providerAccountStackClass(dimensions))}>
|
||||
<div className="flex min-w-0 shrink-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className={cn("truncate font-semibold", dimensions.height <= 1 ? "text-[12px]" : "text-[13px]")}>{providerAccountSnapshotLabel(account)}</div>
|
||||
{providerAccountShowRefreshTime(dimensions) ? <div className="mt-0.5 truncate text-[11px] text-muted-foreground">{formatProviderAccountRefreshTime(account, t)}</div> : null}
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<ProviderAccountLogo account={account} className={cn("rounded-md", dimensions.height <= 1 ? "h-7 w-7" : "h-9 w-9")} providers={providers} />
|
||||
<div className="min-w-0">
|
||||
<div className={cn("truncate font-semibold", dimensions.height <= 1 ? "text-[12px]" : "text-[13px]")}>{providerAccountSnapshotLabel(account)}</div>
|
||||
{providerAccountShowRefreshTime(dimensions) ? <div className="mt-0.5 truncate text-[11px] text-muted-foreground">{formatProviderAccountRefreshTime(account, t)}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
{providerAccountShowRefresh(dimensions) ? <ProviderAccountRefreshButton account={account} refreshing={refreshing} onRefresh={onRefresh} /> : null}
|
||||
</div>
|
||||
@@ -2444,12 +2464,14 @@ function ProviderAccountSummaryCard({
|
||||
account,
|
||||
dimensions,
|
||||
onRefresh,
|
||||
providers,
|
||||
refreshing = false,
|
||||
variant
|
||||
}: {
|
||||
account: ProviderAccountSnapshot;
|
||||
dimensions: OverviewWidgetDimensions;
|
||||
onRefresh?: () => void | Promise<void>;
|
||||
providers: GatewayProviderConfig[];
|
||||
refreshing?: boolean;
|
||||
variant: OverviewAccountVariant;
|
||||
}) {
|
||||
@@ -2462,9 +2484,12 @@ function ProviderAccountSummaryCard({
|
||||
return (
|
||||
<div className={cn("overview-nested-surface h-fit min-w-0 self-start overflow-hidden border", providerAccountCardPaddingClass(dimensions))}>
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[13px] font-semibold">{providerAccountSnapshotLabel(account)}</div>
|
||||
{providerAccountShowRefreshTime(dimensions) ? <div className="mt-0.5 truncate text-[11px] text-muted-foreground">{formatProviderAccountRefreshTime(account, t)}</div> : null}
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<ProviderAccountLogo account={account} className="h-8 w-8 rounded-md" providers={providers} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[13px] font-semibold">{providerAccountSnapshotLabel(account)}</div>
|
||||
{providerAccountShowRefreshTime(dimensions) ? <div className="mt-0.5 truncate text-[11px] text-muted-foreground">{formatProviderAccountRefreshTime(account, t)}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
{providerAccountShowRefresh(dimensions) ? <ProviderAccountRefreshButton account={account} refreshing={refreshing} onRefresh={onRefresh} /> : null}
|
||||
</div>
|
||||
@@ -2492,6 +2517,48 @@ function ProviderAccountSummaryCard({
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderAccountLogo({
|
||||
account,
|
||||
className,
|
||||
providers
|
||||
}: {
|
||||
account: ProviderAccountSnapshot;
|
||||
className?: string;
|
||||
providers: GatewayProviderConfig[];
|
||||
}) {
|
||||
const iconUrl = providerAccountIconUrl(account, providers);
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => setFailed(false), [iconUrl]);
|
||||
const fallbackLabel = account.provider.trim().slice(0, 1).toUpperCase();
|
||||
|
||||
if (iconUrl && !failed) {
|
||||
return (
|
||||
<span className={cn("flex shrink-0 items-center justify-center overflow-hidden border border-border bg-background p-0.5", className)}>
|
||||
<img alt="" className="h-full w-full object-contain" draggable={false} src={iconUrl} onError={() => setFailed(true)} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={cn("flex shrink-0 items-center justify-center border border-border bg-muted text-[10px] font-semibold text-muted-foreground", className)}>
|
||||
{fallbackLabel || <WalletCards className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function providerAccountIconUrl(account: ProviderAccountSnapshot, providers: GatewayProviderConfig[]): string {
|
||||
const providerName = account.provider.trim().toLowerCase();
|
||||
if (!providerName) {
|
||||
return "";
|
||||
}
|
||||
const provider = providers.find((item) => (
|
||||
item.name.trim().toLowerCase() === providerName
|
||||
|| item.id?.trim().toLowerCase() === providerName
|
||||
|| item.provider?.trim().toLowerCase() === providerName
|
||||
));
|
||||
return provider ? providerDisplayIcon(provider) : "";
|
||||
}
|
||||
|
||||
function ProviderAccountRefreshButton({
|
||||
account,
|
||||
onRefresh,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { formatCodexResetCardExpiry, formatCodexResetCardNumber, OverviewView }
|
||||
import { AppI18nContext, appCopy } from "@ccr/ui/pages/home/shared/i18n.tsx";
|
||||
import { parseStatusBucketDate } from "@ccr/ui/pages/home/shared/controls.tsx";
|
||||
import { formatProviderAccountMeterValue, providerAccountMeterDetailValidityProgress } from "@ccr/ui/pages/home/shared/provider-accounts.ts";
|
||||
import type { OverviewWidgetConfig, ProviderAccountSnapshot } from "@ccr/core/contracts/app.ts";
|
||||
import type { GatewayProviderConfig, OverviewWidgetConfig, ProviderAccountSnapshot } from "@ccr/core/contracts/app.ts";
|
||||
import { accountSnapshots, installBrowserGlobals, usageStats } from "../fixtures/index.ts";
|
||||
|
||||
installBrowserGlobals();
|
||||
@@ -163,6 +163,35 @@ test("OverviewView keeps multi-provider account cards at their content height",
|
||||
assert.match(html, /h-fit/);
|
||||
});
|
||||
|
||||
test("OverviewView renders provider logos in account balance widgets", () => {
|
||||
const providers: GatewayProviderConfig[] = [
|
||||
{ icon: "https://cdn.example.test/openai.png", models: ["gpt-4.1"], name: "openai" },
|
||||
{ icon: "https://cdn.example.test/anthropic.png", models: ["claude-sonnet"], name: "anthropic" }
|
||||
];
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<OverviewView
|
||||
overviewWidgets={[{ enabled: true, id: "account", size: "4:2", type: "account-balance", variant: "cards" }]}
|
||||
providerAccounts={accountSnapshots()}
|
||||
refreshProviderAccounts={() => undefined}
|
||||
setUsageRange={() => undefined}
|
||||
usageFilters={{
|
||||
modelFilter: "",
|
||||
providerFilter: "",
|
||||
providers,
|
||||
setModelFilter: () => undefined,
|
||||
setProviderFilter: () => undefined
|
||||
}}
|
||||
usageRange="30d"
|
||||
usageStats={usageStats("30d")}
|
||||
onWidgetsChange={() => undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
assert.match(html, /src="https:\/\/cdn\.example\.test\/openai\.png"/);
|
||||
assert.match(html, /src="https:\/\/cdn\.example\.test\/anthropic\.png"/);
|
||||
});
|
||||
|
||||
test("OverviewView prioritizes Codex manual resets before folded balance meters", () => {
|
||||
const resetAt = new Date(Date.now() + 72 * 60 * 60 * 1000).toISOString();
|
||||
const resetEffectiveAt = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
Reference in New Issue
Block a user