Add overview provider and model filters

This commit is contained in:
musistudio
2026-07-13 20:08:19 +08:00
parent a8bfa6363e
commit 8a49c51ebf
4 changed files with 132 additions and 6 deletions
+37 -2
View File
@@ -276,6 +276,8 @@ function App() {
const [agentAnalysisLoading, setAgentAnalysisLoading] = useState(false);
const [agentAnalysisRange, setAgentAnalysisRange] = useState<UsageStatsRange>("7d");
const [agentAnalysisSession, setAgentAnalysisSession] = useState<AgentAnalysisSessionSelection>();
const [usageModelFilter, setUsageModelFilter] = useState("");
const [usageProviderFilter, setUsageProviderFilter] = useState("");
const [usageRange, setUsageRange] = useState<UsageStatsRange>("7d");
const [usageStats, setUsageStats] = useState<UsageStatsSnapshot>(fallbackUsageStats);
const [providerAccountSnapshots, setProviderAccountSnapshots] = useState<ProviderAccountSnapshot[]>([]);
@@ -454,7 +456,11 @@ function App() {
let cancelled = false;
const refreshUsageStats = () => {
const filter: UsageStatsFilter | undefined = usageRange === "today" ? { includeProxy: true } : undefined;
const filter: UsageStatsFilter = {
...(usageRange === "today" ? { includeProxy: true } : {}),
...(usageProviderFilter ? { provider: usageProviderFilter } : {}),
...(usageModelFilter ? { model: usageModelFilter } : {})
};
void window.ccr?.getUsageStats(usageRange, filter).then((snapshot) => {
if (!cancelled) {
setUsageStats(snapshot);
@@ -466,7 +472,29 @@ function App() {
cancelled = true;
stopPolling();
};
}, [usageRange]);
}, [usageModelFilter, usageProviderFilter, usageRange]);
useEffect(() => {
if (!usageProviderFilter) {
return;
}
if (!draftConfig.Providers.some((provider) => provider.name === usageProviderFilter)) {
setUsageProviderFilter("");
}
}, [draftConfig.Providers, usageProviderFilter]);
useEffect(() => {
if (!usageModelFilter) {
return;
}
const modelAvailable = draftConfig.Providers.some((provider) =>
(!usageProviderFilter || provider.name === usageProviderFilter) &&
provider.models.some((model) => model.trim() === usageModelFilter)
);
if (!modelAvailable) {
setUsageModelFilter("");
}
}, [draftConfig.Providers, usageModelFilter, usageProviderFilter]);
useEffect(() => {
if (!window.ccr) {
@@ -2900,6 +2928,13 @@ function App() {
snapshot: agentAnalysis
},
overview: {
usageFilters: {
modelFilter: usageModelFilter,
providerFilter: usageProviderFilter,
providers: draftConfig.Providers,
setModelFilter: setUsageModelFilter,
setProviderFilter: setUsageProviderFilter
},
onWidgetsChange: changeOverviewWidgets,
overviewWidgets: normalizeOverviewWidgets(draftConfig.overviewWidgets),
providerAccounts: providerAccountSnapshots,
@@ -18,13 +18,24 @@ import {
ProviderAccountMeter, ProviderAccountSnapshot, ReactNode, ReactPointerEvent, rectSortingStrategy, RefreshCw, Select,
SelectControl, SortableContext, sortableKeyboardCoordinates, systemStatusIconClass, systemStatusPointTooltip, systemStatusSegmentClass,
systemStatusTooltipPositionClass, Tooltip, translateOptions, Trash2, UsageComparisonRow, usageRangeOptions,
UsageSeriesPoint, UsageStatsRange, UsageStatsSnapshot, usageStatusTone, UsageTotals, useAppText,
GatewayProviderConfig, UsageSeriesPoint, UsageStatsRange, UsageStatsSnapshot, usageStatusTone, UsageTotals, useAppText,
useEffect, useMemo, useRef, useSensor, useSensors, useSortable,
useState, X, XAxis, YAxis
} from "../shared/index";
import { buildTokenActivity, type TokenActivityCell } from "@/lib/usage-activity";
import { ShareCardWidget } from "./share-cards";
import { Cloud, Rocket } from "lucide-react";
type OverviewUsageFilters = {
modelFilter: string;
providerFilter: string;
providers: GatewayProviderConfig[];
setModelFilter: (model: string) => void;
setProviderFilter: (provider: string) => void;
};
const emptyOverviewProviders: GatewayProviderConfig[] = [];
export function OverviewView({
onWidgetsChange,
overviewWidgets,
@@ -32,6 +43,7 @@ export function OverviewView({
providerAccountRefreshing = false,
refreshProviderAccounts,
setUsageRange,
usageFilters,
usageRange,
usageStats
}: {
@@ -41,6 +53,7 @@ export function OverviewView({
providerAccountRefreshing?: boolean;
refreshProviderAccounts?: () => void | Promise<void>;
setUsageRange: (range: UsageStatsRange) => void;
usageFilters?: OverviewUsageFilters;
usageRange: UsageStatsRange;
usageStats: UsageStatsSnapshot;
}) {
@@ -67,6 +80,11 @@ export function OverviewView({
const visibleWidgets = displayWidgets.filter((widget) => widget.enabled);
const activeWidget = visibleWidgets.find((widget) => widget.id === activeWidgetId);
const selectedWidget = widgets.find((widget) => widget.id === selectedWidgetId);
const filterProviders = usageFilters?.providers ?? emptyOverviewProviders;
const providerFilter = usageFilters?.providerFilter ?? "";
const modelFilter = usageFilters?.modelFilter ?? "";
const providerOptions = useMemo(() => overviewProviderFilterOptions(filterProviders, t), [filterProviders, t]);
const modelOptions = useMemo(() => overviewModelFilterOptions(filterProviders, providerFilter, t), [filterProviders, providerFilter, t]);
useEffect(() => {
if (!editing) {
@@ -153,6 +171,17 @@ export function OverviewView({
setSelectedWidgetId((current) => current === id ? undefined : current);
}
function changeProviderFilter(provider: string) {
usageFilters?.setProviderFilter(provider);
if (modelFilter && provider && !overviewProviderHasModel(filterProviders, provider, modelFilter)) {
usageFilters?.setModelFilter("");
}
}
function changeModelFilter(model: string) {
usageFilters?.setModelFilter(model);
}
useEffect(() => {
if (!editing || !selectedWidgetId || activeWidgetId) {
return;
@@ -308,8 +337,21 @@ export function OverviewView({
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h2 className="truncate text-[18px] font-semibold tracking-tight">{t("Overview")}</h2>
<OverviewUsageRangeSelector range={usageRange} setRange={setUsageRange} />
<Select
aria-label={t("Provider")}
className="h-8 w-[168px] bg-[length:14px] px-2 pr-7 text-[12px]"
onValueChange={changeProviderFilter}
options={providerOptions}
value={providerFilter}
/>
<Select
aria-label={t("Model")}
className="h-8 w-[220px] bg-[length:14px] px-2 pr-7 text-[12px]"
onValueChange={changeModelFilter}
options={modelOptions}
value={modelFilter}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
{editing ? (
@@ -403,6 +445,50 @@ function OverviewUsageRangeSelector({
);
}
function overviewProviderFilterOptions(providers: GatewayProviderConfig[], translate: (value: string) => string): Array<{ label: string; value: string }> {
const providerNames = new Set<string>();
for (const provider of providers) {
const name = provider.name.trim();
if (name) {
providerNames.add(name);
}
}
return [
{ label: translate("All providers"), value: "" },
...Array.from(providerNames).map((provider) => ({ label: provider, value: provider }))
];
}
function overviewModelFilterOptions(
providers: GatewayProviderConfig[],
providerFilter: string,
translate: (value: string) => string
): Array<{ label: string; value: string }> {
const models = new Set<string>();
for (const provider of providers) {
if (providerFilter && provider.name !== providerFilter) {
continue;
}
for (const rawModel of provider.models) {
const model = rawModel.trim();
if (model) {
models.add(model);
}
}
}
return [
{ label: translate("All models"), value: "" },
...Array.from(models).map((model) => ({ label: model, value: model }))
];
}
function overviewProviderHasModel(providers: GatewayProviderConfig[], providerName: string, modelName: string): boolean {
return providers.some((provider) =>
provider.name === providerName &&
provider.models.some((model) => model.trim() === modelName)
);
}
function isEditableKeyboardTarget(target: Element | undefined): boolean {
return Boolean(target?.closest("input, textarea, select, [contenteditable='true'], [contenteditable='plaintext-only'], [role='textbox']"));
}
@@ -596,6 +596,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Agent Mix": "Agent 分布",
"Agent profiles": "Agent 配置档案",
"All agents": "全部 Agent",
"All models": "全部模型",
"All providers": "全部供应商",
"API key": "API 密钥",
"API key created": "API 密钥已创建",
+6 -2
View File
@@ -42,7 +42,9 @@ test("OverviewView renders every overview widget type", () => {
/>
);
assert.match(html, /<h2 class="[^"]*">Overview<\/h2>/);
assert.doesNotMatch(html, /<h2 class="[^"]*">Overview<\/h2>/);
assert.match(html, /All providers/);
assert.match(html, /All models/);
assert.match(html, /aria-label="Edit widgets"/);
assert.match(html, /System status/);
assert.match(html, /API Service/);
@@ -79,7 +81,9 @@ test("OverviewView renders the empty widget layout state", () => {
/>
);
assert.match(html, /Overview/);
assert.doesNotMatch(html, /<h2 class="[^"]*">Overview<\/h2>/);
assert.match(html, /All providers/);
assert.match(html, /All models/);
assert.match(html, /No widgets configured/);
assert.match(html, /aria-label="Edit widgets"/);
});