mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-08-28 19:01:32 +08:00
Add account filtering and range-aware tray activity widgets
This commit is contained in:
@@ -1288,13 +1288,24 @@ function parseTrayWidget(value: unknown): TrayWidgetConfig | undefined {
|
||||
return undefined;
|
||||
}
|
||||
const variant = parseTrayWidgetVariant(type, value.variant);
|
||||
const accountProviders = type === "account" ? parseTrayWidgetAccountProviders(value) : [];
|
||||
return {
|
||||
...(accountProviders.length === 1 ? { accountProvider: accountProviders[0] } : {}),
|
||||
...(accountProviders.length > 0 ? { accountProviders } : {}),
|
||||
id: readString(value.id) || trayWidgetId(type),
|
||||
type,
|
||||
...(variant ? { variant } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function parseTrayWidgetAccountProviders(value: Record<string, unknown>): string[] {
|
||||
const accountProvider = readString(value.accountProvider);
|
||||
return uniqueStrings([
|
||||
...parseStringList(value.accountProviders),
|
||||
...(accountProvider ? [accountProvider] : [])
|
||||
]);
|
||||
}
|
||||
|
||||
function parseTrayWidgetType(value: unknown): TrayWidgetType | undefined {
|
||||
return parseEnumValue(value, ["account", "activity", "header", "model-share", "rings", "source-tabs", "stats", "token-flow", "token-mix"], undefined);
|
||||
}
|
||||
|
||||
@@ -1429,6 +1429,8 @@ export const TRAY_SINGLETON_WIDGET_TYPES = ["source-tabs", "header"] as const sa
|
||||
export const TRAY_TOP_WIDGET_TYPES = ["source-tabs", "header"] as const satisfies readonly TrayWidgetType[];
|
||||
|
||||
export type TrayWidgetConfig = {
|
||||
accountProvider?: string;
|
||||
accountProviders?: string[];
|
||||
id: string;
|
||||
type: TrayWidgetType;
|
||||
variant?: TrayWidgetVariant;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { UsageSeriesPoint } from "@ccr/core/contracts/app";
|
||||
import type { UsageSeriesPoint, UsageStatsRange } from "@ccr/core/contracts/app";
|
||||
|
||||
export type TokenActivityCell = {
|
||||
date: Date;
|
||||
@@ -32,6 +32,8 @@ export type TokenActivitySummary = {
|
||||
type TokenActivityOptions = {
|
||||
maxWeeks?: number;
|
||||
minWeeks?: number;
|
||||
now?: Date | string;
|
||||
range?: UsageStatsRange;
|
||||
};
|
||||
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
@@ -53,8 +55,9 @@ export function buildTokenActivity(series: UsageSeriesPoint[], options: TokenAct
|
||||
}
|
||||
|
||||
const today = startOfLocalDay(new Date());
|
||||
observedStart = observedStart ?? today;
|
||||
observedEnd = observedEnd ?? today;
|
||||
const rangeWindow = activityRangeWindow(options.range, options.now);
|
||||
observedStart = rangeWindow?.start ?? observedStart ?? today;
|
||||
observedEnd = rangeWindow?.end ?? observedEnd ?? today;
|
||||
|
||||
let gridStart = startOfActivityWeek(observedStart);
|
||||
const gridEnd = endOfActivityWeek(observedEnd);
|
||||
@@ -74,7 +77,7 @@ export function buildTokenActivity(series: UsageSeriesPoint[], options: TokenAct
|
||||
|
||||
const dayCount = Math.max(1, daysBetween(observedStart, observedEnd) + 1);
|
||||
const totalTokens = sumObservedTokens(totalsByDay, observedStart, observedEnd);
|
||||
const maxTokens = Math.max(...Array.from(totalsByDay.values()), 0);
|
||||
const maxTokens = maxObservedTokens(totalsByDay, observedStart, observedEnd);
|
||||
const cells: TokenActivityCell[] = [];
|
||||
|
||||
for (let weekIndex = 0; weekIndex < weekCount; weekIndex += 1) {
|
||||
@@ -127,6 +130,39 @@ function parseActivityDate(bucket: string): Date {
|
||||
return date;
|
||||
}
|
||||
|
||||
function activityRangeWindow(range: UsageStatsRange | undefined, nowInput: Date | string | undefined): { end: Date; start: Date } | undefined {
|
||||
if (!range) {
|
||||
return undefined;
|
||||
}
|
||||
const now = normalizeActivityNow(nowInput);
|
||||
if (!isFiniteDate(now)) {
|
||||
return undefined;
|
||||
}
|
||||
const end = startOfLocalDay(now);
|
||||
if (range === "today") {
|
||||
return { end, start: end };
|
||||
}
|
||||
if (range === "24h") {
|
||||
const start = new Date(now);
|
||||
start.setHours(start.getHours() - 24);
|
||||
return { end, start: startOfLocalDay(start) };
|
||||
}
|
||||
return {
|
||||
end,
|
||||
start: addDays(end, range === "7d" ? -6 : -29)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeActivityNow(value: Date | string | undefined): Date {
|
||||
if (value instanceof Date) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return new Date(value);
|
||||
}
|
||||
return new Date();
|
||||
}
|
||||
|
||||
export function activityDateKey(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
@@ -174,6 +210,14 @@ function sumObservedTokens(totalsByDay: Map<string, number>, start: Date, end: D
|
||||
return total;
|
||||
}
|
||||
|
||||
function maxObservedTokens(totalsByDay: Map<string, number>, start: Date, end: Date): number {
|
||||
let max = 0;
|
||||
walkDays(start, end, (date) => {
|
||||
max = Math.max(max, totalsByDay.get(activityDateKey(date)) ?? 0);
|
||||
});
|
||||
return max;
|
||||
}
|
||||
|
||||
function countObservedDays(totalsByDay: Map<string, number>, start: Date, end: Date, predicate: (value: number) => boolean): number {
|
||||
let count = 0;
|
||||
walkDays(start, end, (date) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
Activity, AppConfig, AppCopy, AppInfo, AppLanguagePreference, Boxes, BotGatewayConfigDraft, botGatewayAuthSpecsForPlatform,
|
||||
botGatewayDefaultAuthType, botGatewayFieldsForAuth, botGatewayPickAuthFields, botGatewayPlatformLabel, botGatewayPlatformOptions,
|
||||
botGatewaySavedConfigFromDraft, BotGatewayQrLoginStartResult, BotGatewayQrLoginWaitResult, BotGatewayQrWindowOpenResult, BotGatewaySavedConfig, Button,
|
||||
CircleAlert, closestCenter, cn, CSS, Database, Dialog, DialogBody, DialogContent,
|
||||
Checkbox, ChevronDown, CircleAlert, closestCenter, cn, compareProviderAccountSnapshots, CSS, Database, Dialog, DialogBody, DialogContent,
|
||||
DialogFooter, DialogHeader, DialogTitle, endpointFromHostPort, Field, formatAppError, formatProviderAccountMeterValue, formatSystemOption, Gauge,
|
||||
Globe,
|
||||
createBotGatewayConfigDraft, createMcpServerDraft, createMcpServerDraftFromConfig, createMcpServerDraftFromUnknown, DndContext, DragEndEvent, GatewayMcpServerConfig, GatewayProviderConfig, Input, isBotGatewayConfigDraftSubmittable, KeyboardSensor, KeyRound, KeyValueRowsControl, languageDisplayName, Layers3, LoaderCircle,
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
PointerSensor, rectSortingStrategy, Settings, SettingsPageId, SortableContext, sortableKeyboardCoordinates, themeDisplayName,
|
||||
translateOptions, TrayBalanceProgressConfig, TrayComponentVariants, TrayWidgetConfig, TrayWidgetType, TrayWidgetVariant,
|
||||
appLogoUrl, trayMascotIconUrls, arrayMove, defaultTrayWidgetVariant, isTraySingletonWidgetType, normalizeTrayWidget, normalizeTrayWidgets, Switch, Textarea, Trash2, trayWidgetVariantOptions, useAppText, useEffect, useMemo, useRef, useSensor, useSensors, useSortable, useState, validateMcpServerDraft,
|
||||
X
|
||||
providerAccountSnapshotKey, providerAccountSnapshotLabel, uniqueStrings, X
|
||||
} from "../shared/index";
|
||||
import { ModelSelector } from "./model-selector";
|
||||
|
||||
@@ -1921,6 +1921,11 @@ function TraySettingsPage({
|
||||
const selectedCategory = selectedWidget ? trayComponentCategoryForType(selectedWidget.type) : "provider-tabs";
|
||||
const selectedCategoryOption = paletteItems.find((item) => item.value === selectedCategory) ?? paletteItems[0];
|
||||
const selectedStyleOptions = selectedWidget ? trayWidgetVariantOptions(selectedWidget.type) : [];
|
||||
const selectedAccountProviderValues = selectedWidget ? trayWidgetAccountProviderValues(selectedWidget) : [];
|
||||
const accountDataOptions = useMemo(
|
||||
() => trayAccountDataOptions(providerAccountSnapshots, selectedAccountProviderValues),
|
||||
[providerAccountSnapshots, selectedAccountProviderValues]
|
||||
);
|
||||
const SelectedTrayCategoryIcon = selectedCategoryOption.icon;
|
||||
const trayPreviewSensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
@@ -2015,6 +2020,16 @@ function TraySettingsPage({
|
||||
updateTrayWidget(selectedWidget.id, { variant });
|
||||
}
|
||||
|
||||
function changeTrayWidgetAccountProviders(accountProviders: string[]) {
|
||||
if (!selectedWidget || selectedWidget.type !== "account") {
|
||||
return;
|
||||
}
|
||||
updateTrayWidget(selectedWidget.id, {
|
||||
accountProvider: accountProviders.length === 1 ? accountProviders[0] : undefined,
|
||||
accountProviders: accountProviders.length > 0 ? accountProviders : undefined
|
||||
});
|
||||
}
|
||||
|
||||
function removeSelectedTrayWidget() {
|
||||
if (!selectedWidget || selectedWidgetIndex < 0) {
|
||||
return;
|
||||
@@ -2220,6 +2235,16 @@ function TraySettingsPage({
|
||||
</Field>
|
||||
) : null}
|
||||
|
||||
{selectedWidget.type === "account" ? (
|
||||
<Field label={trayT("Accounts")}>
|
||||
<TrayAccountDataSelector
|
||||
options={accountDataOptions}
|
||||
value={selectedAccountProviderValues}
|
||||
onChange={changeTrayWidgetAccountProviders}
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
|
||||
<Button className="w-full justify-center" onClick={removeSelectedTrayWidget} size="sm" type="button" variant="outline">
|
||||
{trayT("Remove widget")}
|
||||
</Button>
|
||||
@@ -2356,6 +2381,129 @@ function uniqueTrayWidgetId(widgets: TrayWidgetConfig[], baseId: string): string
|
||||
return `${baseId}-${index}`;
|
||||
}
|
||||
|
||||
function trayWidgetAccountProviderValues(widget: TrayWidgetConfig): string[] {
|
||||
return uniqueStrings([
|
||||
...(widget.accountProviders ?? []),
|
||||
...(widget.accountProvider ? [widget.accountProvider] : [])
|
||||
]);
|
||||
}
|
||||
|
||||
function trayAccountDataOptions(
|
||||
providerAccountSnapshots: ProviderAccountSnapshot[],
|
||||
selectedValues: string[]
|
||||
): Array<{ label: string; value: string }> {
|
||||
const options = providerAccountSnapshots
|
||||
.filter((account) => account.provider)
|
||||
.sort(compareProviderAccountSnapshots)
|
||||
.map((account) => ({ label: providerAccountSnapshotLabel(account), value: providerAccountSnapshotKey(account) }));
|
||||
for (const selectedValue of selectedValues) {
|
||||
if (!options.some((option) => option.value === selectedValue)) {
|
||||
options.push({ label: selectedValue, value: selectedValue });
|
||||
}
|
||||
}
|
||||
return [{ label: "All accounts", value: "" }, ...options];
|
||||
}
|
||||
|
||||
function TrayAccountDataSelector({
|
||||
onChange,
|
||||
options,
|
||||
value
|
||||
}: {
|
||||
onChange: (value: string[]) => void;
|
||||
options: Array<{ label: string; value: string }>;
|
||||
value: string[];
|
||||
}) {
|
||||
const t = useAppText();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const selected = new Set(value);
|
||||
const accountOptions = options.filter((option) => option.value);
|
||||
const allSelected = selected.size === 0;
|
||||
const selectedLabels = accountOptions
|
||||
.filter((option) => selected.has(option.value))
|
||||
.map((option) => option.label);
|
||||
const summary = allSelected
|
||||
? t("All accounts")
|
||||
: selected.size === 1
|
||||
? selectedLabels[0] ?? value[0] ?? t("Select account")
|
||||
: `${selected.size} ${t("accounts selected")}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const closeOnOutsidePointer = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (target instanceof Node && rootRef.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", closeOnOutsidePointer);
|
||||
document.addEventListener("keydown", closeOnEscape);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", closeOnOutsidePointer);
|
||||
document.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
function toggleAccount(account: string, checked: boolean) {
|
||||
const next = new Set(selected);
|
||||
if (checked) {
|
||||
next.add(account);
|
||||
} else {
|
||||
next.delete(account);
|
||||
}
|
||||
onChange([...next]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative min-w-0" ref={rootRef}>
|
||||
<button
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
className="flex h-8 w-full min-w-0 items-center gap-2 rounded-md border border-input bg-background px-3 text-left text-[12px] text-foreground shadow-[inset_0_1px_1px_rgba(0,0,0,0.03)] outline-none transition-[background-color,border-color,box-shadow,color] hover:border-muted-foreground/45 focus:border-primary/60 focus:ring-2 focus:ring-ring/25"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{summary}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
{open ? (
|
||||
<div
|
||||
aria-multiselectable="true"
|
||||
className="absolute left-0 top-[calc(100%+4px)] z-50 max-h-56 w-full min-w-[220px] overflow-y-auto rounded-md border border-border bg-popover p-1.5 text-popover-foreground shadow-card-elevated"
|
||||
role="listbox"
|
||||
>
|
||||
<label className="flex min-w-0 cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-[12px] font-medium transition-colors hover:bg-muted/50">
|
||||
<Checkbox checked={allSelected} onCheckedChange={(checked) => checked ? onChange([]) : undefined} />
|
||||
<span className="min-w-0 flex-1 truncate">{t("All accounts")}</span>
|
||||
</label>
|
||||
<div className="my-1 h-px bg-border/70" />
|
||||
{accountOptions.length > 0 ? (
|
||||
accountOptions.map((option) => (
|
||||
<label className="flex min-w-0 cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-[12px] transition-colors hover:bg-muted/50" key={option.value} role="option" aria-selected={selected.has(option.value)}>
|
||||
<Checkbox
|
||||
checked={selected.has(option.value)}
|
||||
onCheckedChange={(checked) => toggleAccount(option.value, checked)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{option.label}</span>
|
||||
</label>
|
||||
))
|
||||
) : (
|
||||
<div className="px-2 py-1.5 text-[12px] text-muted-foreground">{t("No account data configured")}</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrayIconSelect({
|
||||
onChange,
|
||||
options,
|
||||
|
||||
@@ -181,13 +181,24 @@ export function normalizeTrayWidget(value: unknown): TrayWidgetConfig | undefine
|
||||
return undefined;
|
||||
}
|
||||
const variant = normalizeTrayWidgetVariant(type, value.variant);
|
||||
const accountProviders = type === "account" ? normalizeTrayWidgetAccountProviders(value) : [];
|
||||
return {
|
||||
...(accountProviders.length === 1 ? { accountProvider: accountProviders[0] } : {}),
|
||||
...(accountProviders.length > 0 ? { accountProviders } : {}),
|
||||
id: stringValue(value.id) || trayWidgetId(type),
|
||||
type,
|
||||
...(variant ? { variant } : {})
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeTrayWidgetAccountProviders(value: Record<string, unknown>): string[] {
|
||||
const accountProvider = stringValue(value.accountProvider);
|
||||
return uniqueStrings([
|
||||
...overviewAccountProviderListValue(value.accountProviders),
|
||||
...(accountProvider ? [accountProvider] : [])
|
||||
]);
|
||||
}
|
||||
|
||||
export function normalizeTrayWidgetType(value: unknown): TrayWidgetType | undefined {
|
||||
return typeof value === "string" && ["account", "activity", "header", "model-share", "rings", "source-tabs", "stats", "token-flow", "token-mix"].includes(value)
|
||||
? value as TrayWidgetType
|
||||
|
||||
@@ -484,6 +484,8 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
||||
"Account Balance": "Account Balance",
|
||||
"Account Usage": "Account Usage",
|
||||
"Account component": "Account component",
|
||||
"Accounts": "Accounts",
|
||||
"accounts selected": "accounts selected",
|
||||
"All accounts": "All accounts",
|
||||
"All credentials": "All credentials",
|
||||
"Last updated": "Last updated",
|
||||
@@ -530,6 +532,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
||||
"Move down": "Move down",
|
||||
"Move up": "Move up",
|
||||
"Nested rings": "Nested rings",
|
||||
"No account data configured": "No account data configured",
|
||||
"No model activity": "No model activity",
|
||||
"No widget selected": "No widget selected",
|
||||
"No widgets configured": "No widgets configured",
|
||||
@@ -1520,6 +1523,8 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
||||
"Startup timeout ms": "启动超时 ms",
|
||||
"State directory": "状态目录",
|
||||
"Account component": "账户组件",
|
||||
"Accounts": "账户",
|
||||
"accounts selected": "个账户已选择",
|
||||
"All accounts": "所有账户",
|
||||
"All credentials": "全部凭据",
|
||||
"Last updated": "上次更新",
|
||||
@@ -1567,6 +1572,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
||||
"Model Distribution": "模型分布",
|
||||
"Model distribution": "模型分布",
|
||||
"Nested rings": "内外圆",
|
||||
"No account data configured": "未配置账户数据",
|
||||
"No widget selected": "未选择组件",
|
||||
"No widgets configured": "未配置组件",
|
||||
"Overview layout": "概览布局",
|
||||
|
||||
@@ -218,7 +218,7 @@ function TrayRuntimeWidget({
|
||||
}
|
||||
|
||||
if (widget.type === "account") {
|
||||
return <AccountSummaryPanel refreshing={accountRefreshing} snapshots={accountSnapshots} variant={(widget.variant ?? defaultTrayWidgetVariant("account")) as TrayComponentVariants["account"]} onRefresh={onRefreshAccount} />;
|
||||
return <AccountSummaryPanel accountProviders={trayWidgetAccountProviderValues(widget)} refreshing={accountRefreshing} snapshots={accountSnapshots} variant={(widget.variant ?? defaultTrayWidgetVariant("account")) as TrayComponentVariants["account"]} onRefresh={onRefreshAccount} />;
|
||||
}
|
||||
|
||||
if (widget.type === "token-flow") {
|
||||
@@ -230,7 +230,7 @@ function TrayRuntimeWidget({
|
||||
}
|
||||
|
||||
if (widget.type === "activity") {
|
||||
return <TokenActivityPanel series={activeStats.series} />;
|
||||
return <TokenActivityPanel generatedAt={activeStats.generatedAt} range={activeStats.range} series={activeStats.series} />;
|
||||
}
|
||||
|
||||
if (widget.type === "stats") {
|
||||
@@ -258,6 +258,14 @@ function TrayRuntimeWidget({
|
||||
return <ModelShareChart rows={activeStats.models} variant={(widget.variant ?? defaultTrayWidgetVariant("model-share")) as TrayComponentVariants["modelShare"]} />;
|
||||
}
|
||||
|
||||
function trayWidgetAccountProviderValues(widget: TrayWidgetConfig): string[] | undefined {
|
||||
const values = [
|
||||
...(widget.accountProviders ?? []),
|
||||
...(widget.accountProvider ? [widget.accountProvider] : [])
|
||||
].map((value) => value.trim()).filter(Boolean);
|
||||
return values.length > 0 ? Array.from(new Set(values)) : undefined;
|
||||
}
|
||||
|
||||
function TrayHeaderRangeSwitch({
|
||||
range,
|
||||
onChange
|
||||
|
||||
@@ -1,28 +1,27 @@
|
||||
import {
|
||||
accountMetersForDisplay, accountProgressClass, accountProgressColor, accountSnapshotLabel, compareAccountSnapshots, formatAccountMeterTitle, formatAccountMeterValue,
|
||||
accountMetersForDisplay, accountProgressClass, accountProgressColor, accountSnapshotKey, accountSnapshotLabel, compareAccountSnapshots, formatAccountMeterTitle, formatAccountMeterValue,
|
||||
LoaderCircle, meterProgress, meterRemainingRatio, meterValidityProgress, ProviderAccountMeter, ProviderAccountSnapshot, RefreshCw, TrayComponentVariants,
|
||||
useTrayText
|
||||
} from "../shared";
|
||||
import { RadialMetric } from "./widgets";
|
||||
|
||||
export function AccountSummaryPanel({
|
||||
accountProviders,
|
||||
onRefresh,
|
||||
refreshing = false,
|
||||
snapshots,
|
||||
variant
|
||||
}: {
|
||||
accountProviders?: string[];
|
||||
onRefresh?: () => void | Promise<void>;
|
||||
refreshing?: boolean;
|
||||
snapshots: ProviderAccountSnapshot[];
|
||||
variant: TrayComponentVariants["account"];
|
||||
}) {
|
||||
const t = useTrayText();
|
||||
const snapshot = snapshots
|
||||
.filter((snapshot) => snapshot.meters.length > 0 || snapshot.status === "error")
|
||||
.sort(compareAccountSnapshots)
|
||||
[0];
|
||||
const selectedSnapshots = accountSnapshotsForDisplay(snapshots, accountProviders);
|
||||
|
||||
if (!snapshot) {
|
||||
if (selectedSnapshots.length === 0) {
|
||||
return (
|
||||
<div className="tray-panel-subtle px-3 py-2 text-[11px] font-medium text-slate-400">
|
||||
{t("No account data configured")}
|
||||
@@ -30,15 +29,22 @@ export function AccountSummaryPanel({
|
||||
);
|
||||
}
|
||||
|
||||
const meters = accountMetersForDisplay(snapshot, variant === "stacked" ? 3 : 2);
|
||||
const title = selectedSnapshots.length === 1
|
||||
? accountSnapshotLabel(selectedSnapshots[0])
|
||||
: t("Account");
|
||||
|
||||
return (
|
||||
<div className="tray-panel p-2.5">
|
||||
<div className="mb-2 flex min-w-0 items-center justify-between gap-2">
|
||||
<h3 className="truncate text-[11px] font-bold text-slate-100">{accountSnapshotLabel(snapshot)}</h3>
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-[11px] font-bold text-slate-100">{title}</h3>
|
||||
{selectedSnapshots.length > 1 ? (
|
||||
<div className="mt-0.5 truncate text-[9px] font-medium text-slate-400">{selectedSnapshots.length} {t("accounts selected")}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
aria-label={t("Refresh")}
|
||||
className={`inline-flex h-5 w-5 shrink-0 appearance-none items-center justify-center rounded-md border-0 bg-transparent p-0 shadow-none transition-colors hover:bg-white/[.07] disabled:cursor-not-allowed disabled:opacity-50 ${accountStatusButtonClass(snapshot.status)}`}
|
||||
className={`inline-flex h-5 w-5 shrink-0 appearance-none items-center justify-center rounded-md border-0 bg-transparent p-0 shadow-none transition-colors hover:bg-white/[.07] disabled:cursor-not-allowed disabled:opacity-50 ${accountStatusButtonClass(highestAccountStatus(selectedSnapshots))}`}
|
||||
disabled={refreshing || !onRefresh}
|
||||
onClick={() => {
|
||||
void onRefresh?.();
|
||||
@@ -49,6 +55,50 @@ export function AccountSummaryPanel({
|
||||
{refreshing ? <LoaderCircle className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className={selectedSnapshots.length > 1 ? "space-y-2" : ""}>
|
||||
{selectedSnapshots.map((snapshot) => (
|
||||
<AccountSnapshotBlock
|
||||
key={accountSnapshotKey(snapshot)}
|
||||
showLabel={selectedSnapshots.length > 1}
|
||||
snapshot={snapshot}
|
||||
variant={variant}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function accountSnapshotsForDisplay(
|
||||
snapshots: ProviderAccountSnapshot[],
|
||||
accountProviders: string[] | undefined
|
||||
): ProviderAccountSnapshot[] {
|
||||
const selected = new Set((accountProviders ?? []).map((provider) => provider.trim()).filter(Boolean));
|
||||
return snapshots
|
||||
.filter((snapshot) => snapshot.meters.length > 0 || snapshot.status === "error")
|
||||
.filter((snapshot) => selected.size === 0 || selected.has(accountSnapshotKey(snapshot)) || selected.has(snapshot.provider))
|
||||
.sort(compareAccountSnapshots);
|
||||
}
|
||||
|
||||
function highestAccountStatus(snapshots: ProviderAccountSnapshot[]): ProviderAccountSnapshot["status"] {
|
||||
return [...snapshots].sort(compareAccountSnapshots)[0]?.status ?? "unsupported";
|
||||
}
|
||||
|
||||
function AccountSnapshotBlock({
|
||||
showLabel,
|
||||
snapshot,
|
||||
variant
|
||||
}: {
|
||||
showLabel: boolean;
|
||||
snapshot: ProviderAccountSnapshot;
|
||||
variant: TrayComponentVariants["account"];
|
||||
}) {
|
||||
const t = useTrayText();
|
||||
const meters = accountMetersForDisplay(snapshot, variant === "stacked" ? 3 : 2);
|
||||
|
||||
return (
|
||||
<div className={showLabel ? "border-t border-white/10 pt-2 first:border-t-0 first:pt-0" : undefined}>
|
||||
{showLabel ? <div className="mb-1.5 truncate text-[10px] font-semibold text-slate-100">{accountSnapshotLabel(snapshot)}</div> : null}
|
||||
{meters.length > 0 ? (
|
||||
<AccountMeters meters={meters} status={snapshot.status} variant={variant} />
|
||||
) : (
|
||||
|
||||
@@ -47,7 +47,7 @@ export function UsageOverviewPanel({
|
||||
</ChartShell>
|
||||
) : null}
|
||||
|
||||
{modules.has("activity") ? <TokenActivityPanel series={activeStats.series} /> : null}
|
||||
{modules.has("activity") ? <TokenActivityPanel generatedAt={activeStats.generatedAt} range={activeStats.range} series={activeStats.series} /> : null}
|
||||
|
||||
{modules.has("stats") ? (
|
||||
<StatsGrid
|
||||
|
||||
@@ -61,7 +61,7 @@ export function UsageDetailPanel({
|
||||
);
|
||||
}
|
||||
if (widget.type === "account") {
|
||||
return <AccountSummaryPanel key={`${widget.id}-${index}`} refreshing={accountRefreshing} snapshots={accountSnapshots} variant={(widget.variant ?? defaultTrayWidgetVariant("account")) as TrayComponentVariants["account"]} onRefresh={onRefreshAccount} />;
|
||||
return <AccountSummaryPanel accountProviders={trayWidgetAccountProviderValues(widget)} key={`${widget.id}-${index}`} refreshing={accountRefreshing} snapshots={accountSnapshots} variant={(widget.variant ?? defaultTrayWidgetVariant("account")) as TrayComponentVariants["account"]} onRefresh={onRefreshAccount} />;
|
||||
}
|
||||
if (widget.type === "token-flow") {
|
||||
return (
|
||||
@@ -71,7 +71,7 @@ export function UsageDetailPanel({
|
||||
);
|
||||
}
|
||||
if (widget.type === "activity") {
|
||||
return <TokenActivityPanel key={`${widget.id}-${index}`} series={activeStats.series} />;
|
||||
return <TokenActivityPanel generatedAt={activeStats.generatedAt} key={`${widget.id}-${index}`} range={activeStats.range} series={activeStats.series} />;
|
||||
}
|
||||
if (widget.type === "token-mix") {
|
||||
return <TokenMixPanel key={`${widget.id}-${index}`} totals={totals} variant={(widget.variant ?? defaultTrayWidgetVariant("token-mix")) as TrayComponentVariants["tokenMix"]} />;
|
||||
@@ -90,3 +90,11 @@ export function UsageDetailPanel({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function trayWidgetAccountProviderValues(widget: TrayWidgetConfig): string[] | undefined {
|
||||
const values = [
|
||||
...(widget.accountProviders ?? []),
|
||||
...(widget.accountProvider ? [widget.accountProvider] : [])
|
||||
].map((value) => value.trim()).filter(Boolean);
|
||||
return values.length > 0 ? Array.from(new Set(values)) : undefined;
|
||||
}
|
||||
|
||||
@@ -144,12 +144,16 @@ export function AnimatedUsageChart({
|
||||
}
|
||||
|
||||
export function TokenActivityPanel({
|
||||
generatedAt,
|
||||
range,
|
||||
series
|
||||
}: {
|
||||
generatedAt?: string;
|
||||
range?: UsageStatsRange;
|
||||
series: UsageStatsSnapshot["series"];
|
||||
}) {
|
||||
const t = useTrayText();
|
||||
const activity = buildTokenActivity(series, { maxWeeks: 14, minWeeks: 10 });
|
||||
const activity = buildTokenActivity(series, { maxWeeks: 30, minWeeks: 30, now: generatedAt, range });
|
||||
|
||||
return (
|
||||
<div className="tray-panel min-w-0 p-2.5">
|
||||
@@ -211,25 +215,25 @@ function TokenActivityGrid({
|
||||
const t = useTrayText();
|
||||
const dayLabels = [t("M"), "", t("W"), "", t("F"), "", ""];
|
||||
const cellGap = 3;
|
||||
const cellSize = 9;
|
||||
const labelColumnWidth = 14;
|
||||
const gridTemplateColumns = `${labelColumnWidth}px repeat(${activity.weekCount}, minmax(0, 1fr))`;
|
||||
|
||||
return (
|
||||
<div className="min-w-0 overflow-visible">
|
||||
<div className="w-max">
|
||||
<div className="w-full">
|
||||
<div
|
||||
className="mb-1 grid text-[8px] font-medium text-slate-500"
|
||||
style={{
|
||||
columnGap: `${cellGap}px`,
|
||||
gridTemplateColumns: `repeat(${activity.weekCount}, ${cellSize}px)`,
|
||||
marginLeft: `${labelColumnWidth + cellGap}px`
|
||||
gridTemplateColumns
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true" />
|
||||
{activity.months.map((month) => (
|
||||
<span
|
||||
className="truncate"
|
||||
key={`${month.label}-${month.weekIndex}`}
|
||||
style={{ gridColumn: `${month.weekIndex + 1} / span ${Math.min(3, activity.weekCount - month.weekIndex)}` }}
|
||||
style={{ gridColumn: `${month.weekIndex + 2} / span ${Math.min(3, activity.weekCount - month.weekIndex)}` }}
|
||||
>
|
||||
{month.label}
|
||||
</span>
|
||||
@@ -241,8 +245,8 @@ function TokenActivityGrid({
|
||||
aria-label={`${t("Activity")} ${t("Tokens")}`}
|
||||
style={{
|
||||
gap: `${cellGap}px`,
|
||||
gridTemplateColumns: `${labelColumnWidth}px repeat(${activity.weekCount}, ${cellSize}px)`,
|
||||
gridTemplateRows: `repeat(7, ${cellSize}px)`
|
||||
gridTemplateColumns,
|
||||
gridTemplateRows: "repeat(7, auto)"
|
||||
}}
|
||||
>
|
||||
{dayLabels.map((label, index) => (
|
||||
@@ -254,27 +258,27 @@ function TokenActivityGrid({
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
{activity.cells.map((cell) => (
|
||||
<Tooltip
|
||||
aria-label={`${cell.dateLabel}: ${formatActivityTokenCount(cell.totalTokens)} ${t("tokens")}`}
|
||||
align={cell.weekIndex <= 1 ? "start" : cell.weekIndex >= activity.weekCount - 2 ? "end" : "center"}
|
||||
className="rounded-[3px]"
|
||||
content={(
|
||||
<>
|
||||
<span className="block font-bold">{cell.dateLabel}</span>
|
||||
<span className="tray-activity-tooltip-detail mt-0.5 block font-medium">{formatActivityTokenCount(cell.totalTokens)} {t("tokens")}</span>
|
||||
</>
|
||||
)}
|
||||
contentClassName="tray-activity-tooltip min-w-[96px] px-2 py-1.5 text-left text-[10px] font-normal"
|
||||
key={cell.dateKey}
|
||||
side={cell.dayIndex <= 1 ? "bottom" : "top"}
|
||||
style={{
|
||||
backgroundColor: trayActivityColor(cell.intensity, cell.inObservedRange),
|
||||
gridColumn: cell.weekIndex + 2,
|
||||
gridRow: cell.dayIndex + 1
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{activity.cells.map((cell) => (
|
||||
<Tooltip
|
||||
aria-label={`${cell.dateLabel}: ${formatActivityTokenCount(cell.totalTokens)} ${t("tokens")}`}
|
||||
align={cell.weekIndex <= 1 ? "start" : cell.weekIndex >= activity.weekCount - 2 ? "end" : "center"}
|
||||
className="aspect-square w-full rounded-[3px]"
|
||||
content={(
|
||||
<>
|
||||
<span className="block font-bold">{cell.dateLabel}</span>
|
||||
<span className="tray-activity-tooltip-detail mt-0.5 block font-medium">{formatActivityTokenCount(cell.totalTokens)} {t("tokens")}</span>
|
||||
</>
|
||||
)}
|
||||
contentClassName="tray-activity-tooltip min-w-[96px] px-2 py-1.5 text-left text-[10px] font-normal"
|
||||
key={cell.dateKey}
|
||||
side={cell.dayIndex <= 1 ? "bottom" : "top"}
|
||||
style={{
|
||||
backgroundColor: trayActivityColor(cell.intensity, cell.inObservedRange),
|
||||
gridColumn: cell.weekIndex + 2,
|
||||
gridRow: cell.dayIndex + 1
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -58,7 +58,9 @@ export const trayText: Record<ResolvedLanguage, Record<string, string>> = {
|
||||
"7d": "7 天",
|
||||
"30d": "30 天",
|
||||
"All": "全部",
|
||||
"accounts selected": "个账户已选择",
|
||||
"Account": "账户",
|
||||
"Accounts": "账户",
|
||||
"All providers": "全部供应商",
|
||||
"Activity": "活跃度",
|
||||
"Avg / day": "日均",
|
||||
@@ -390,13 +392,24 @@ export function normalizeTrayWidget(value: unknown): TrayWidgetConfig | undefine
|
||||
return undefined;
|
||||
}
|
||||
const variant = normalizeTrayWidgetVariant(type, value.variant);
|
||||
const accountProviders = type === "account" ? normalizeTrayWidgetAccountProviders(value) : [];
|
||||
return {
|
||||
...(accountProviders.length === 1 ? { accountProvider: accountProviders[0] } : {}),
|
||||
...(accountProviders.length > 0 ? { accountProviders } : {}),
|
||||
id: typeof value.id === "string" && value.id.trim() ? value.id.trim() : trayWidgetId(type),
|
||||
type,
|
||||
...(variant ? { variant } : {})
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeTrayWidgetAccountProviders(value: Record<string, unknown>): string[] {
|
||||
const accountProvider = stringValue(value.accountProvider);
|
||||
return uniqueTrayStrings([
|
||||
...trayStringListValue(value.accountProviders),
|
||||
...(accountProvider ? [accountProvider] : [])
|
||||
]);
|
||||
}
|
||||
|
||||
export function normalizeTrayWidgetType(value: unknown): TrayWidgetType | undefined {
|
||||
return typeof value === "string" && ["account", "activity", "header", "model-share", "rings", "source-tabs", "stats", "token-flow", "token-mix"].includes(value)
|
||||
? value as TrayWidgetType
|
||||
@@ -527,6 +540,34 @@ export function isObjectRecord(value: unknown): value is Record<string, unknown>
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function trayStringListValue(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return uniqueTrayStrings(value.map((item) => stringValue(item)).filter((item): item is string => Boolean(item)));
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return uniqueTrayStrings(value.split(/\r?\n|,/g).map((item) => item.trim()).filter(Boolean));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function uniqueTrayStrings(values: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const value of values) {
|
||||
const item = value.trim();
|
||||
if (!item || seen.has(item)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(item);
|
||||
result.push(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function normalizeTrayIconPreference(value: AppConfig["trayIcon"] | undefined): AppConfig["trayIcon"] {
|
||||
return value === "violet" || value === "orange" || value === "cyan" || value === "progress" || value === "random"
|
||||
? value
|
||||
@@ -741,6 +782,10 @@ export function accountSnapshotLabel(snapshot: ProviderAccountSnapshot): string
|
||||
return credential ? `${snapshot.provider} / ${credential}` : snapshot.provider;
|
||||
}
|
||||
|
||||
export function accountSnapshotKey(snapshot: ProviderAccountSnapshot): string {
|
||||
return snapshot.credentialId ? `${snapshot.provider}::${snapshot.credentialId}` : snapshot.provider;
|
||||
}
|
||||
|
||||
export function accountSnapshotCredentialLabel(snapshot: ProviderAccountSnapshot): string {
|
||||
return snapshot.credentialLabel?.trim() || snapshot.credentialId?.trim() || "";
|
||||
}
|
||||
|
||||
@@ -167,12 +167,18 @@ test("AccountSummaryPanel covers empty and metered account states", () => {
|
||||
const meteredHtml = renderToStaticMarkup(
|
||||
<AccountSummaryPanel snapshots={accountSnapshots()} variant="stacked" onRefresh={() => undefined} />
|
||||
);
|
||||
const selectedHtml = renderToStaticMarkup(
|
||||
<AccountSummaryPanel accountProviders={["anthropic::secondary"]} snapshots={accountSnapshots()} variant="compact" />
|
||||
);
|
||||
|
||||
assert.match(emptyHtml, /No account data configured/);
|
||||
assert.match(meteredHtml, /openai \/ Primary Key/);
|
||||
assert.match(meteredHtml, /anthropic \/ Secondary Key/);
|
||||
assert.match(meteredHtml, /5h quota/);
|
||||
assert.match(meteredHtml, /42 requests/);
|
||||
assert.match(meteredHtml, /style="\s*width:42%"/);
|
||||
assert.match(selectedHtml, /anthropic \/ Secondary Key/);
|
||||
assert.doesNotMatch(selectedHtml, /openai \/ Primary Key/);
|
||||
});
|
||||
|
||||
test("AccountSummaryPanel prioritizes Codex manual reset meter with expiration", () => {
|
||||
@@ -297,6 +303,7 @@ test("TokenActivityPanel renders summary, grid, and legend", () => {
|
||||
assert.match(html, /Longest streak/);
|
||||
assert.match(html, /aria-label="Activity Tokens"/);
|
||||
assert.match(html, /data-ui-tooltip-trigger/);
|
||||
assert.equal(html.match(/data-ui-tooltip-trigger/g)?.length, 210);
|
||||
assert.doesNotMatch(html, /tray-activity-tooltip/);
|
||||
assert.doesNotMatch(html, /bg-slate-950/);
|
||||
assert.match(html, /Less/);
|
||||
|
||||
@@ -39,7 +39,7 @@ test("tray component variants retain valid values and fall back independently",
|
||||
|
||||
test("tray widgets normalize ids and variants, pin top widgets, and dedupe singletons", () => {
|
||||
const widgets = normalizeTrayWidgets([
|
||||
{ id: " custom-account ", type: "account", variant: "arc" },
|
||||
{ accountProvider: "openai::primary", accountProviders: ["anthropic::secondary", "openai::primary", " "], id: " custom-account ", type: "account", variant: "arc" },
|
||||
{ id: "", type: "header", variant: "ignored" },
|
||||
{ id: "duplicate-header", type: "header" },
|
||||
{ type: "source-tabs" },
|
||||
@@ -51,7 +51,7 @@ test("tray widgets normalize ids and variants, pin top widgets, and dedupe singl
|
||||
assert.deepEqual(widgets, [
|
||||
{ id: "header", type: "header" },
|
||||
{ id: "source-tabs", type: "source-tabs" },
|
||||
{ id: "custom-account", type: "account", variant: "arc" },
|
||||
{ accountProviders: ["anthropic::secondary", "openai::primary"], id: "custom-account", type: "account", variant: "arc" },
|
||||
{ id: "invalid-variant", type: "stats", variant: DEFAULT_TRAY_COMPONENT_VARIANTS.stats }
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -35,6 +35,25 @@ test("activityDateKey formats local calendar dates", () => {
|
||||
assert.equal(activityDateKey(new Date(2026, 0, 5, 14, 30)), "2026-01-05");
|
||||
});
|
||||
|
||||
test("buildTokenActivity averages sparse data over the selected range window", () => {
|
||||
withTimezone("America/New_York", () => {
|
||||
const summary = buildTokenActivity(
|
||||
[
|
||||
{ bucket: "2026-06-29", totalTokens: 10 },
|
||||
{ bucket: "2026-06-30", totalTokens: 30 }
|
||||
],
|
||||
{ now: "2026-06-30T12:00:00.000Z", range: "30d" }
|
||||
);
|
||||
|
||||
assert.equal(summary.totalTokens, 40);
|
||||
assert.equal(summary.activeDays, 2);
|
||||
assert.equal(summary.dayCount, 30);
|
||||
assert.equal(summary.longestStreak, 2);
|
||||
assert.equal(Math.round(summary.avgPerDay * 100) / 100, 1.33);
|
||||
assert.equal(Math.round(summary.avgPerWeek * 100) / 100, 9.33);
|
||||
});
|
||||
});
|
||||
|
||||
function withTimezone(timezone, run) {
|
||||
const previousTimezone = process.env.TZ;
|
||||
process.env.TZ = timezone;
|
||||
|
||||
Reference in New Issue
Block a user