Add Codex manual reset account metrics

This commit is contained in:
musi 2026-07-03 13:58:05 +08:00
parent 15fcaaecf5
commit b6f8bb3c52
11 changed files with 389 additions and 34 deletions

View file

@ -39,21 +39,124 @@ const codexAccountRateLimitMapping: ProviderAccountMappingConfig = {
kind: "quota",
label: "Primary quota",
limit: 100,
remaining: "100 - $.rate_limit.primary_window.used_percent",
resetAt: "$.rate_limit.primary_window.reset_at",
remaining: [
"100 - $.rate_limit.primary_window.used_percent",
"100 - $.rate_limits.primary.used_percent"
],
resetAt: [
"$.rate_limit.primary_window.reset_at",
"$.rate_limit.primary_window.resets_at",
"$.rate_limits.primary.resets_at"
],
unit: "%",
used: "$.rate_limit.primary_window.used_percent",
used: [
"$.rate_limit.primary_window.used_percent",
"$.rate_limits.primary.used_percent"
],
window: "primary"
},
{
id: "codex_manual_resets",
kind: "requests",
label: "Manual resets",
remaining: [
"$.resetsAvailable",
"$.availableRateLimitResetCount",
"$.rate_limit.resetsAvailable",
"$.rate_limits.resetsAvailable",
"$.rate_limit.manual_resets.remaining",
"$.rate_limit.manual_resets.resetsAvailable",
"$.rate_limit.manual_reset.remaining",
"$.rate_limit.manual_reset.resetsAvailable",
"$.rate_limits.manual_resets.remaining",
"$.rate_limits.manual_resets.resetsAvailable",
"$.rate_limits.manual_reset.remaining",
"$.rate_limits.manual_reset.resetsAvailable",
"$.manual_resets.remaining",
"$.manual_resets.resetsAvailable",
"$.manual_reset.remaining",
"$.manual_reset.resetsAvailable",
"$.resets.remaining",
"$.resets.resetsAvailable",
"$.rate_limit.manual_resets.available",
"$.rate_limit.manual_reset.available",
"$.rate_limit.resets.available",
"$.rate_limits.resets.available",
"$.manual_resets.available",
"$.manual_reset.available",
"$.resets.available"
],
resetAt: [
"$.resetExpires",
"$.expires_at",
"$.resets_at",
"$.rate_limit.manual_resets.expires_at",
"$.rate_limit.manual_resets.expire_at",
"$.rate_limit.manual_resets.reset_at",
"$.rate_limit.manual_resets.resets_at",
"$.rate_limit.manual_reset.expires_at",
"$.rate_limit.manual_reset.expire_at",
"$.rate_limit.manual_reset.reset_at",
"$.rate_limit.manual_reset.resets_at",
"$.rate_limits.manual_resets.expires_at",
"$.rate_limits.manual_resets.expire_at",
"$.rate_limits.manual_resets.reset_at",
"$.rate_limits.manual_resets.resets_at",
"$.rate_limits.manual_reset.expires_at",
"$.rate_limits.manual_reset.expire_at",
"$.rate_limits.manual_reset.reset_at",
"$.rate_limits.manual_reset.resets_at",
"$.rate_limit.resets.expires_at",
"$.rate_limit.resets.expire_at",
"$.rate_limit.resets.reset_at",
"$.rate_limit.resets.resets_at",
"$.rate_limits.resets.expires_at",
"$.rate_limits.resets.expire_at",
"$.rate_limits.resets.reset_at",
"$.rate_limits.resets.resets_at",
"$.manual_resets.expires_at",
"$.manual_resets.expire_at",
"$.manual_resets.reset_at",
"$.manual_resets.resets_at",
"$.manual_reset.expires_at",
"$.manual_reset.expire_at",
"$.manual_reset.reset_at",
"$.manual_reset.resets_at",
"$.resets.expires_at",
"$.resets.expire_at",
"$.resets.reset_at",
"$.resets.resets_at"
],
unit: "resets",
used: [
"$.rate_limit.manual_resets.used",
"$.rate_limit.manual_reset.used",
"$.rate_limits.manual_resets.used",
"$.manual_resets.used",
"$.manual_reset.used",
"$.resets.used"
],
window: "manual-reset"
},
{
id: "codex_secondary_quota",
kind: "quota",
label: "Secondary quota",
limit: 100,
remaining: "100 - $.rate_limit.secondary_window.used_percent",
resetAt: "$.rate_limit.secondary_window.reset_at",
remaining: [
"100 - $.rate_limit.secondary_window.used_percent",
"100 - $.rate_limits.secondary.used_percent"
],
resetAt: [
"$.rate_limit.secondary_window.reset_at",
"$.rate_limit.secondary_window.resets_at",
"$.rate_limits.secondary.resets_at"
],
unit: "%",
used: "$.rate_limit.secondary_window.used_percent",
used: [
"$.rate_limit.secondary_window.used_percent",
"$.rate_limits.secondary.used_percent"
],
window: "secondary"
},
{
@ -164,7 +267,7 @@ export function readCodexAuth(): OAuthTokenSet | undefined {
};
}
function codexProviderAccountConfig(): ProviderAccountConfig {
export function codexProviderAccountConfig(): ProviderAccountConfig {
return {
connectors: [
{

View file

@ -18,6 +18,8 @@ import type {
ProviderAccountLocalEstimateConnectorConfig,
ProviderAccountLocalWindowConfig,
ProviderAccountMappedMeterConfig,
ProviderAccountMappedNumberExpression,
ProviderAccountMappedStringExpression,
ProviderAccountMeter,
ProviderAccountMeterKind,
ProviderAccountMeterUnit,
@ -1345,7 +1347,16 @@ type JsonPathFilterCondition = {
path: string[];
};
function readMappedNumber(value: number | string | undefined, payload: unknown): number | undefined {
function readMappedNumber(value: ProviderAccountMappedNumberExpression | undefined, payload: unknown): number | undefined {
if (Array.isArray(value)) {
for (const candidate of value) {
const resolved = readMappedNumber(candidate, payload);
if (resolved !== undefined) {
return resolved;
}
}
return undefined;
}
if (typeof value === "number") {
return normalizeNumber(value);
}
@ -1382,7 +1393,16 @@ function resolveMappedNumberTerm(term: string, payload: unknown): unknown {
return trimmed.startsWith("$") ? readJsonPath(payload, trimmed) : trimmed;
}
function readMappedString(value: string | undefined, payload: unknown): string | undefined {
function readMappedString(value: ProviderAccountMappedStringExpression | undefined, payload: unknown): string | undefined {
if (Array.isArray(value)) {
for (const candidate of value) {
const resolved = readMappedString(candidate, payload);
if (resolved !== undefined) {
return resolved;
}
}
return undefined;
}
if (!value) {
return undefined;
}
@ -1390,7 +1410,16 @@ function readMappedString(value: string | undefined, payload: unknown): string |
return readString(resolved);
}
function readMappedDateString(value: string | undefined, payload: unknown): string | undefined {
function readMappedDateString(value: ProviderAccountMappedStringExpression | undefined, payload: unknown): string | undefined {
if (Array.isArray(value)) {
for (const candidate of value) {
const resolved = readMappedDateString(candidate, payload);
if (resolved !== undefined) {
return resolved;
}
}
return undefined;
}
if (!value) {
return undefined;
}

View file

@ -12,7 +12,7 @@ import {
metricToneBar, metricToneStroke, motion, normalizeAgentFilterValue, normalizeOverviewWidget, normalizeOverviewWidgets,
OverviewMetricKind, overviewMetricOptions, overviewWidgetCollisionDetection, OverviewWidgetConfig, OverviewWidgetSize, overviewWidgetSizeOptions,
OverviewWidgetType, OverviewWidgetVariant, Pencil, Pie, PieChart, Plus,
PointerSensor, primaryProviderAccountMeter, providerAccountMeterProgress, providerAccountMetersForDisplay, providerAccountProgressClass,
PointerSensor, primaryProviderAccountMeter, providerAccountMeterProgress, providerAccountMetersForDisplay, providerAccountProgressClass, isProviderAccountManualResetMeter,
providerAccountSnapshotKey, providerAccountSnapshotLabel,
ProviderAccountMeter, ProviderAccountSnapshot, ReactNode, ReactPointerEvent, rectSortingStrategy, RefreshCw, Select,
SelectControl, SortableContext, sortableKeyboardCoordinates, systemStatusIconClass, systemStatusPointTooltip, systemStatusSegmentClass,
@ -2432,7 +2432,15 @@ function primaryProviderAccountBalanceMeter(account: ProviderAccountSnapshot): P
}
function providerAccountMetersForDisplayOrdered(account: ProviderAccountSnapshot, maxCount: number): ProviderAccountMeter[] {
const ordered = [...providerAccountQuotaMeters(account), ...providerAccountBalanceMeters(account)];
const quotaMeters = providerAccountQuotaMeters(account);
const manualResetMeters = account.meters.filter(isProviderAccountManualResetMeter);
const leadingQuotaCount = manualResetMeters.length > 0 ? Math.min(quotaMeters.length, maxCount <= 2 ? 1 : 2) : quotaMeters.length;
const ordered = [
...quotaMeters.slice(0, leadingQuotaCount),
...manualResetMeters,
...providerAccountBalanceMeters(account),
...quotaMeters.slice(leadingQuotaCount)
];
const seen = new Set<string>();
const unique = ordered.filter((meter) => {
const key = `${meter.id}:${meter.kind}:${meter.window ?? ""}`;
@ -2470,10 +2478,11 @@ function compareProviderAccountQuotaMeters(a: ProviderAccountMeter, b: ProviderA
}
function providerAccountMeterWindowRank(meter: ProviderAccountMeter): number {
if (meter.window === "5h" || meter.id.toLowerCase().includes("5h") || meter.label.toLowerCase().includes("5h")) {
const text = `${meter.window ?? ""} ${meter.id} ${meter.label}`.toLowerCase();
if (meter.window === "5h" || text.includes("5h") || text.includes("primary")) {
return 0;
}
if (meter.window === "weekly" || meter.id.toLowerCase().includes("weekly") || meter.label.toLowerCase().includes("weekly")) {
if (meter.window === "weekly" || text.includes("weekly") || text.includes("secondary")) {
return 1;
}
if (meter.window === "daily") return 2;

View file

@ -1357,12 +1357,21 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Wrapper plugin": "包装插件",
"5h quota": "5 小时额度",
"Granted balance": "赠送余额",
"Individual limit": "个人额度",
"Lifetime tokens": "累计令牌",
"Manual resets": "主动重置次数",
"Monthly budget": "月度预算",
"Peak daily tokens": "日峰值令牌",
"Primary quota": "主额度",
"Secondary quota": "副额度",
"Topped-up balance": "充值余额",
"Total credits": "总额度",
"Total usage": "总用量",
"Voucher balance": "代金券余额",
"Weekly quota": "周额度",
"expired": "已过期",
"expires in": "剩余",
"resets": "次",
"All": "全部",
"API endpoint": "API 地址",
"Available": "可用",

View file

@ -410,7 +410,12 @@ export function primaryProviderAccountMeter(account: ProviderAccountSnapshot): P
}
export function providerAccountMetersForDisplay(account: ProviderAccountSnapshot, maxCount: number): ProviderAccountMeter[] {
return account.meters.slice(0, maxCount);
const meters = account.meters.slice(0, maxCount);
const manualResetMeter = account.meters.find(isProviderAccountManualResetMeter);
if (!manualResetMeter || meters.includes(manualResetMeter) || meters.length < maxCount) {
return meters;
}
return [...meters.slice(0, Math.max(0, maxCount - 1)), manualResetMeter];
}
export function providerAccountMeterRemainingRatio(meter: ProviderAccountMeter): number | undefined {
@ -483,26 +488,32 @@ export function formatProviderAccountNumber(value: number): string {
return new Intl.NumberFormat(undefined, { maximumFractionDigits: 6 }).format(value);
}
export function formatProviderAccountReset(value: string): string {
export function formatProviderAccountReset(value: string, translate: (value: string) => string = (item) => item): string {
const timestamp = new Date(value).getTime();
if (!Number.isFinite(timestamp)) {
return value;
}
const minutes = Math.round((timestamp - Date.now()) / 60000);
if (minutes <= 0) {
return "soon";
return translate("expired");
}
const prefix = translate("expires in");
if (minutes < 60) {
return `${minutes}m`;
return `${prefix} ${minutes}m`;
}
const hours = Math.round(minutes / 60);
if (hours < 48) {
return `${hours}h`;
return `${prefix} ${hours}h`;
}
return `${Math.round(hours / 24)}d`;
return `${prefix} ${Math.round(hours / 24)}d`;
}
export function formatProviderAccountMeterTitle(meter: ProviderAccountMeter, translate: (value: string) => string): string {
const label = translate(meter.label);
return meter.resetAt ? `${label} (${formatProviderAccountReset(meter.resetAt)})` : label;
return meter.resetAt ? `${label} (${formatProviderAccountReset(meter.resetAt, translate)})` : label;
}
export function isProviderAccountManualResetMeter(meter: ProviderAccountMeter): boolean {
const text = `${meter.id} ${meter.label} ${meter.window ?? ""}`.toLowerCase();
return text.includes("manual_reset") || text.includes("manual reset") || text.includes("manual-reset");
}

View file

@ -1286,7 +1286,7 @@ export function createProviderAccountDraftFromConfig(account: ProviderAccountCon
usageStatusPath: httpJsonConnector.mapping.status ?? "",
usageSubscriptionLimitPath: stringValue(subscriptionMeter?.limit) || "",
usageSubscriptionRemainingPath: stringValue(subscriptionMeter?.remaining) || "",
usageSubscriptionResetPath: subscriptionMeter?.resetAt ?? "",
usageSubscriptionResetPath: stringValue(subscriptionMeter?.resetAt) || "",
usageSubscriptionUnit: stringValue(subscriptionMeter?.unit) || "tokens"
};
}

View file

@ -71,9 +71,12 @@ export const trayText: Record<ResolvedLanguage, Record<string, string>> = {
"F": "五",
"Granted balance": "赠送余额",
"Input": "输入",
"Individual limit": "个人额度",
"Less": "少",
"Lifetime tokens": "累计令牌",
"Longest streak": "最长连续",
"M": "一",
"Manual resets": "主动重置次数",
"Monthly budget": "月度预算",
"Model Share": "模型占比",
"More": "多",
@ -84,8 +87,11 @@ export const trayText: Record<ResolvedLanguage, Record<string, string>> = {
"Output": "输出",
"Overview": "概览",
"Open CCR": "打开 CCR",
"Peak daily tokens": "日峰值令牌",
"Primary quota": "主额度",
"Quit": "退出",
"Refresh": "刷新",
"Secondary quota": "副额度",
"Subscription": "订阅",
"Success": "成功",
"Success rate": "成功率",
@ -112,10 +118,13 @@ export const trayText: Record<ResolvedLanguage, Record<string, string>> = {
"day": "天",
"days": "天",
"error": "错误",
"expired": "已过期",
"expires in": "剩余",
"hours": "小时",
"minutes": "分钟",
"ok": "正常",
"requests": "请求",
"resets": "次",
"soon": "即将",
"tokens": "令牌",
"unsupported": "不支持",
@ -652,7 +661,17 @@ export function accountStatusRank(status: ProviderAccountSnapshot["status"]): nu
}
export function accountMetersForDisplay(snapshot: ProviderAccountSnapshot, maxCount: number): ProviderAccountMeter[] {
return snapshot.meters.slice(0, maxCount);
const meters = snapshot.meters.slice(0, maxCount);
const manualResetMeter = snapshot.meters.find(isAccountManualResetMeter);
if (!manualResetMeter || meters.includes(manualResetMeter) || meters.length < maxCount) {
return meters;
}
return [...meters.slice(0, Math.max(0, maxCount - 1)), manualResetMeter];
}
function isAccountManualResetMeter(meter: ProviderAccountMeter): boolean {
const text = `${meter.id} ${meter.label} ${meter.window ?? ""}`.toLowerCase();
return text.includes("manual_reset") || text.includes("manual reset") || text.includes("manual-reset");
}
export function meterRemainingRatio(meter: ProviderAccountMeter): number | undefined {
@ -706,28 +725,29 @@ export function formatMeterNumber(value: number): string {
return new Intl.NumberFormat(undefined, { maximumFractionDigits: 6 }).format(value);
}
export function formatAccountReset(value: string): string {
export function formatAccountReset(value: string, translate: (value: string) => string = (item) => item): string {
const timestamp = new Date(value).getTime();
if (!Number.isFinite(timestamp)) {
return value;
}
const minutes = Math.round((timestamp - Date.now()) / 60000);
if (minutes <= 0) {
return "soon";
return translate("expired");
}
const prefix = translate("expires in");
if (minutes < 60) {
return `${minutes}m`;
return `${prefix} ${minutes}m`;
}
const hours = Math.round(minutes / 60);
if (hours < 48) {
return `${hours}h`;
return `${prefix} ${hours}h`;
}
return `${Math.round(hours / 24)}d`;
return `${prefix} ${Math.round(hours / 24)}d`;
}
export function formatAccountMeterTitle(meter: ProviderAccountMeter, translate: (value: string) => string): string {
const label = translateAccountMeterLabel(meter.label, translate);
return meter.resetAt ? `${label} (${formatAccountReset(meter.resetAt)})` : label;
return meter.resetAt ? `${label} (${formatAccountReset(meter.resetAt, translate)})` : label;
}
export function accountStatusClass(status: ProviderAccountSnapshot["status"]): string {

View file

@ -223,15 +223,18 @@ export type ProviderAccountMappingConfig = {
status?: string;
};
export type ProviderAccountMappedNumberExpression = number | string | Array<number | string>;
export type ProviderAccountMappedStringExpression = string | string[];
export type ProviderAccountMappedMeterConfig = {
id: string;
kind?: ProviderAccountMeterKind;
label: string;
limit?: number | string;
remaining?: number | string;
resetAt?: string;
limit?: ProviderAccountMappedNumberExpression;
remaining?: ProviderAccountMappedNumberExpression;
resetAt?: ProviderAccountMappedStringExpression;
unit?: ProviderAccountMeterUnit;
used?: number | string;
used?: ProviderAccountMappedNumberExpression;
window?: ProviderAccountMeterWindow;
};

View file

@ -0,0 +1,41 @@
import assert from "node:assert/strict";
import test from "node:test";
import { codexProviderAccountConfig } from "../../src/main/local-agent-providers/codex.ts";
test("Codex provider account config includes manual reset meter", () => {
const config = codexProviderAccountConfig();
const usageConnector = config.connectors.find((connector) => connector.type === "http-json" && connector.endpoint.endsWith("/wham/usage"));
assert.ok(usageConnector);
const meters = usageConnector.mapping.meters;
const manualResetMeter = meters.find((meter) => meter.id === "codex_manual_resets");
assert.ok(manualResetMeter);
assert.equal(manualResetMeter.label, "Manual resets");
assert.equal(manualResetMeter.unit, "resets");
assert.equal(manualResetMeter.window, "manual-reset");
assert.ok(Array.isArray(manualResetMeter.remaining));
assert.ok(manualResetMeter.remaining.includes("$.resetsAvailable"));
assert.ok(manualResetMeter.remaining.includes("$.availableRateLimitResetCount"));
assert.ok(manualResetMeter.remaining.includes("$.rate_limit.manual_resets.remaining"));
assert.ok(Array.isArray(manualResetMeter.resetAt));
assert.ok(manualResetMeter.resetAt.includes("$.resetExpires"));
assert.ok(manualResetMeter.resetAt.includes("$.expires_at"));
assert.ok(manualResetMeter.resetAt.includes("$.rate_limit.manual_resets.expires_at"));
assert.ok(manualResetMeter.resetAt.includes("$.manual_resets.reset_at"));
});
test("Codex quota meters accept reset_at and resets_at usage shapes", () => {
const config = codexProviderAccountConfig();
const usageConnector = config.connectors.find((connector) => connector.type === "http-json" && connector.endpoint.endsWith("/wham/usage"));
const primaryQuota = usageConnector.mapping.meters.find((meter) => meter.id === "codex_primary_quota");
const secondaryQuota = usageConnector.mapping.meters.find((meter) => meter.id === "codex_secondary_quota");
assert.ok(Array.isArray(primaryQuota.resetAt));
assert.ok(primaryQuota.resetAt.includes("$.rate_limit.primary_window.reset_at"));
assert.ok(primaryQuota.resetAt.includes("$.rate_limit.primary_window.resets_at"));
assert.ok(primaryQuota.resetAt.includes("$.rate_limits.primary.resets_at"));
assert.ok(Array.isArray(secondaryQuota.resetAt));
assert.ok(secondaryQuota.resetAt.includes("$.rate_limit.secondary_window.reset_at"));
assert.ok(secondaryQuota.resetAt.includes("$.rate_limit.secondary_window.resets_at"));
assert.ok(secondaryQuota.resetAt.includes("$.rate_limits.secondary.resets_at"));
});

View file

@ -3,7 +3,7 @@ import test from "node:test";
import * as React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { OverviewView } from "../../src/renderer/pages/home/components/dashboard.tsx";
import type { OverviewWidgetConfig } from "../../src/shared/app.ts";
import type { OverviewWidgetConfig, ProviderAccountSnapshot } from "../../src/shared/app.ts";
import { accountSnapshots, installBrowserGlobals, usageStats } from "./fixtures.ts";
installBrowserGlobals();
@ -82,3 +82,80 @@ test("OverviewView renders the empty widget layout state", () => {
assert.match(html, /No widgets configured/);
assert.match(html, /aria-label="Edit widgets"/);
});
test("OverviewView prioritizes Codex manual resets before folded balance meters", () => {
const resetAt = new Date(Date.now() + 72 * 60 * 60 * 1000).toISOString();
const codexAccount: ProviderAccountSnapshot = {
meters: [
{
id: "codex_primary_quota",
kind: "quota",
label: "Primary quota",
limit: 100,
remaining: 96,
resetAt,
unit: "%",
window: "primary"
},
{
id: "codex_secondary_quota",
kind: "quota",
label: "Secondary quota",
limit: 100,
remaining: 68,
resetAt,
unit: "%",
window: "secondary"
},
{
id: "codex_individual_limit",
kind: "quota",
label: "Individual limit",
limit: 100,
remaining: 42,
resetAt,
unit: "credits",
window: "monthly"
},
{
id: "codex_credit_balance",
kind: "balance",
label: "Credit balance",
remaining: 0,
unit: "credits"
},
{
id: "codex_manual_resets",
kind: "requests",
label: "Manual resets",
remaining: 2,
resetAt,
unit: "resets",
window: "manual-reset"
}
],
provider: "Codex API",
source: "http-json",
status: "ok",
updatedAt: new Date().toISOString()
};
const html = renderToStaticMarkup(
<OverviewView
overviewWidgets={[{ enabled: true, id: "account", size: "4:2", type: "account-balance", variant: "cards" }]}
providerAccounts={[codexAccount]}
refreshProviderAccounts={() => undefined}
setUsageRange={() => undefined}
usageRange="30d"
usageStats={usageStats("30d")}
onWidgetsChange={() => undefined}
/>
);
assert.match(html, /Primary quota/);
assert.match(html, /Secondary quota/);
assert.match(html, /Manual resets/);
assert.match(html, /expires in/);
assert.match(html, /2 resets/);
assert.doesNotMatch(html, /Credit balance/);
});

View file

@ -144,6 +144,59 @@ test("AccountSummaryPanel covers empty and metered account states", () => {
assert.match(meteredHtml, /style="\s*width:42%"/);
});
test("AccountSummaryPanel prioritizes Codex manual reset meter with expiration", () => {
const resetAt = new Date(Date.now() + 72 * 60 * 60 * 1000).toISOString();
const html = renderToStaticMarkup(
<AccountSummaryPanel
snapshots={[
{
meters: [
{
id: "codex_primary_quota",
kind: "quota",
label: "Primary quota",
limit: 100,
remaining: 66,
resetAt,
unit: "%",
window: "primary"
},
{
id: "codex_secondary_quota",
kind: "quota",
label: "Secondary quota",
limit: 100,
remaining: 80,
unit: "%",
window: "secondary"
},
{
id: "codex_manual_resets",
kind: "requests",
label: "Manual resets",
remaining: 2,
resetAt,
unit: "resets",
window: "manual-reset"
}
],
provider: "Codex API",
source: "http-json",
status: "ok",
updatedAt: new Date().toISOString()
}
]}
variant="bar"
/>
);
assert.match(html, /Primary quota/);
assert.match(html, /Manual resets/);
assert.match(html, /expires in/);
assert.match(html, /2 resets/);
assert.doesNotMatch(html, /Secondary quota/);
});
test("RangeSwitch renders every usage range option", () => {
const html = renderToStaticMarkup(<RangeSwitch range="7d" onChange={() => undefined} />);