diff --git a/.dockerignore b/.dockerignore index dbc82d28..d1dd0b94 100644 --- a/.dockerignore +++ b/.dockerignore @@ -22,3 +22,7 @@ htmlcov/ refs/ docs/ tests/ + +# Explicit frontend excludes to keep Docker context small +frontend/node_modules/ +**/node_modules/ diff --git a/frontend/src/features/dashboard/components/dashboard-page.tsx b/frontend/src/features/dashboard/components/dashboard-page.tsx index fd59ee55..38115236 100644 --- a/frontend/src/features/dashboard/components/dashboard-page.tsx +++ b/frontend/src/features/dashboard/components/dashboard-page.tsx @@ -15,6 +15,7 @@ import { useDashboard } from "@/features/dashboard/hooks/use-dashboard"; import { useRequestLogs } from "@/features/dashboard/hooks/use-request-logs"; import { buildDashboardView } from "@/features/dashboard/utils"; import type { AccountSummary } from "@/features/dashboard/schemas"; +import { useDashboardPreferencesStore } from "@/hooks/use-dashboard-preferences"; import { useThemeStore } from "@/hooks/use-theme"; import { REQUEST_STATUS_LABELS } from "@/utils/constants"; import { formatModelLabel, formatSlug } from "@/utils/formatters"; @@ -25,6 +26,7 @@ export function DashboardPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); const isDark = useThemeStore((s) => s.theme === "dark"); + const showAccountBurnrate = useDashboardPreferencesStore((s) => s.accountBurnrateEnabled); const dashboardQuery = useDashboard(); const { filters, logsQuery, optionsQuery, updateFilters } = useRequestLogs(); const { resumeMutation } = useAccountMutations(); @@ -59,8 +61,11 @@ export function DashboardPage() { if (!overview || !logPage) { return null; } - return buildDashboardView(overview, logPage.requests, isDark); - }, [overview, logPage, isDark]); + return buildDashboardView(overview, logPage.requests, { + isDark, + showAccountBurnrate, + }); + }, [overview, logPage, isDark, showAccountBurnrate]); const accountOptions = useMemo(() => { const entries = new Map(); diff --git a/frontend/src/features/dashboard/components/stats-grid.test.tsx b/frontend/src/features/dashboard/components/stats-grid.test.tsx index 5756f75e..f902ca06 100644 --- a/frontend/src/features/dashboard/components/stats-grid.test.tsx +++ b/frontend/src/features/dashboard/components/stats-grid.test.tsx @@ -1,5 +1,6 @@ +// @vitest-environment jsdom import { render, screen } from "@testing-library/react"; -import { Activity, AlertTriangle, Coins, DollarSign } from "lucide-react"; +import { Activity, AlertTriangle, Coins, DollarSign, Flame } from "lucide-react"; import { describe, expect, it } from "vitest"; import { StatsGrid } from "@/features/dashboard/components/stats-grid"; @@ -8,13 +9,14 @@ const EMPTY_TREND: { value: number }[] = []; const SAMPLE_TREND = [{ value: 1 }, { value: 2 }, { value: 3 }]; describe("StatsGrid", () => { - it("renders four metric cards with values", () => { + it("renders five metric cards with values", () => { render( , @@ -26,6 +28,8 @@ describe("StatsGrid", () => { expect(screen.getByText("45K")).toBeInTheDocument(); expect(screen.getByText("Cost (7d)")).toBeInTheDocument(); expect(screen.getByText("Avg/hr $0.01")).toBeInTheDocument(); + expect(screen.getByText("Account burn rate (5h/7d)")).toBeInTheDocument(); + expect(screen.getByText("Primary 0.7 acc/5h · Secondary 0.8 acc/7d")).toBeInTheDocument(); expect(screen.getByText("Error rate")).toBeInTheDocument(); expect(screen.getByText("Top: rate_limit_exceeded")).toBeInTheDocument(); }); diff --git a/frontend/src/features/dashboard/components/stats-grid.tsx b/frontend/src/features/dashboard/components/stats-grid.tsx index 4600fa3b..7944f704 100644 --- a/frontend/src/features/dashboard/components/stats-grid.tsx +++ b/frontend/src/features/dashboard/components/stats-grid.tsx @@ -6,6 +6,7 @@ const ACCENT_STYLES = [ "bg-blue-500/10 text-blue-600 dark:bg-blue-500/15 dark:text-blue-400", "bg-violet-500/10 text-violet-600 dark:bg-violet-500/15 dark:text-violet-400", "bg-emerald-500/10 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-400", + "bg-rose-500/10 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400", "bg-amber-500/10 text-amber-600 dark:bg-amber-500/15 dark:text-amber-400", ]; @@ -14,8 +15,10 @@ export type StatsGridProps = { }; export function StatsGrid({ stats }: StatsGridProps) { + const columnsClass = stats.length >= 5 ? "xl:grid-cols-5" : "xl:grid-cols-4"; + return ( -
+
{stats.map((stat, index) => { const Icon = stat.icon; const accent = ACCENT_STYLES[index % ACCENT_STYLES.length]; diff --git a/frontend/src/features/dashboard/utils.test.ts b/frontend/src/features/dashboard/utils.test.ts index 76242b81..705fc9ac 100644 --- a/frontend/src/features/dashboard/utils.test.ts +++ b/frontend/src/features/dashboard/utils.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from "vitest"; import { + buildDashboardView, buildDepletionView, buildRemainingItems, } from "@/features/dashboard/utils"; import type { AccountSummary, Depletion } from "@/features/dashboard/schemas"; +import { createAccountSummary, createDashboardOverview, createDefaultRequestLogs } from "@/test/mocks/factories"; import { formatCompactAccountId } from "@/utils/account-identifiers"; function account(overrides: Partial & Pick): AccountSummary { @@ -123,3 +125,146 @@ describe("buildRemainingItems", () => { expect(items[2].isEmail).toBe(true); }); }); + +describe("buildDashboardView", () => { + it("adds plus-burn stat between cost and error rate", () => { + const overview = createDashboardOverview(); + const logs = createDefaultRequestLogs(); + + const view = buildDashboardView(overview, logs); + + expect(view.stats[2].label).toBe("Cost (7d)"); + expect(view.stats[3].label).toBe("Account burn rate (5h/7d)"); + expect(view.stats[3].value).toBe("0.7 / 1.2"); + expect(view.stats[3].meta).toBe("Primary 0.7 acc/5h · Secondary 1.2 acc/7d"); + expect(view.stats[3].trend.length).toBeGreaterThan(0); + expect(view.stats[4].label).toBe("Error rate"); + }); + + it("falls back to usage equivalents when depletion data is missing", () => { + const overview = createDashboardOverview({ + depletionPrimary: null, + depletionSecondary: null, + }); + + const view = buildDashboardView(overview, createDefaultRequestLogs()); + const burn = view.stats[3]; + + expect(burn.label).toBe("Account burn rate (5h/7d)"); + expect(burn.value).toBe("0.7 / 1.2"); + expect(burn.meta).toBe("Primary 0.7 acc/5h · Secondary 1.2 acc/7d"); + expect(burn.trend.length).toBeGreaterThan(0); + }); + + it("projects burn rate to full 7d window when reset is still in the future", () => { + const now = Date.now(); + const overview = createDashboardOverview({ + accounts: [ + createAccountSummary({ + accountId: "acc-idle", + email: "idle@example.com", + usage: { + primaryRemainingPercent: 100, + secondaryRemainingPercent: 100, + }, + resetAtSecondary: new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString(), + windowMinutesSecondary: 10_080, + }), + createAccountSummary({ + accountId: "acc-hot", + email: "hot@example.com", + usage: { + primaryRemainingPercent: 100, + secondaryRemainingPercent: 16, + }, + resetAtSecondary: new Date(now + 74 * 60 * 60 * 1000).toISOString(), + windowMinutesSecondary: 10_080, + }), + ], + depletionPrimary: null, + depletionSecondary: null, + }); + + const view = buildDashboardView(overview, createDefaultRequestLogs()); + const burn = view.stats[3]; + + expect(burn.value).toBe("0.0 / 1.5"); + expect(burn.meta).toBe("Primary 0.0 acc/5h · Secondary 1.5 acc/7d"); + }); + + it("counts quota_exceeded secondary accounts as fully burned and caps to account count", () => { + const now = Date.now(); + const overview = createDashboardOverview({ + accounts: [ + createAccountSummary({ + accountId: "acc-quota", + email: "quota@example.com", + status: "quota_exceeded", + usage: { + primaryRemainingPercent: 100, + secondaryRemainingPercent: 100, + }, + resetAtSecondary: new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString(), + windowMinutesSecondary: 10_080, + }), + createAccountSummary({ + accountId: "acc-hot", + email: "hot@example.com", + status: "active", + usage: { + primaryRemainingPercent: 100, + secondaryRemainingPercent: 16, + }, + resetAtSecondary: new Date(now + 74 * 60 * 60 * 1000).toISOString(), + windowMinutesSecondary: 10_080, + }), + ], + depletionPrimary: null, + depletionSecondary: null, + }); + + const view = buildDashboardView(overview, createDefaultRequestLogs()); + const burn = view.stats[3]; + + expect(burn.value).toBe("0.0 / 2.0"); + expect(burn.meta).toBe("Primary 0.0 acc/5h · Secondary 2.0 acc/7d"); + }); + + it("uses usage-equivalent fallback when burn rate is zero", () => { + const overview = createDashboardOverview({ + depletionSecondary: { + risk: 1, + riskLevel: "critical", + burnRate: 0, + safeUsagePercent: 98, + projectedExhaustionAt: null, + secondsUntilExhaustion: null, + }, + }); + + const view = buildDashboardView(overview, createDefaultRequestLogs()); + const burn = view.stats[3]; + + expect(burn.value).toBe("0.7 / 1.2"); + expect(burn.meta).toBe("Primary 0.7 acc/5h · Secondary 1.2 acc/7d"); + }); + + it("caps burn-equivalent to available account count per window", () => { + const overview = createDashboardOverview({ + depletionSecondary: { + risk: 1, + riskLevel: "critical", + burnRate: 999, + safeUsagePercent: 98, + projectedExhaustionAt: null, + secondsUntilExhaustion: null, + }, + }); + + const view = buildDashboardView(overview, createDefaultRequestLogs()); + const burn = view.stats[3]; + + expect(burn.value).toBe("0.7 / 2.0"); + expect(burn.meta).toBe("Primary 0.7 acc/5h · Secondary 2.0 acc/7d"); + }); +}); diff --git a/frontend/src/features/dashboard/utils.ts b/frontend/src/features/dashboard/utils.ts index cc5a030c..ba4c315f 100644 --- a/frontend/src/features/dashboard/utils.ts +++ b/frontend/src/features/dashboard/utils.ts @@ -1,4 +1,4 @@ -import { Activity, AlertTriangle, Coins, DollarSign } from "lucide-react"; +import { Activity, AlertTriangle, Coins, DollarSign, Flame } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { buildDonutPalette } from "@/utils/colors"; @@ -20,6 +20,11 @@ import type { UsageWindow, } from "@/features/dashboard/schemas"; +const PLUS_DEFAULT_CAPACITY = { + primary: 225, + secondary: 7560, +} as const; + export type RemainingItem = { accountId: string; label: string; @@ -55,6 +60,24 @@ export type DashboardView = { safeLineSecondary: SafeLineView | null; }; +type DashboardViewOptions = { + isDark?: boolean; + showAccountBurnrate?: boolean; +}; + +function resolveDashboardViewOptions(optionsOrIsDark: DashboardViewOptions | boolean): Required { + if (typeof optionsOrIsDark === "boolean") { + return { + isDark: optionsOrIsDark, + showAccountBurnrate: true, + }; + } + return { + isDark: optionsOrIsDark.isDark ?? false, + showAccountBurnrate: optionsOrIsDark.showAccountBurnrate ?? true, + }; +} + export function buildDepletionView(depletion: Depletion | null | undefined): SafeLineView | null { if (!depletion || depletion.riskLevel === "safe") return null; return { safePercent: depletion.safeUsagePercent, riskLevel: depletion.riskLevel }; @@ -123,7 +146,190 @@ export function avgPerHour(cost7d: number, hours = 24 * 7): number { return cost7d / hours; } -const TREND_COLORS = ["#3b82f6", "#8b5cf6", "#10b981", "#f59e0b"]; +function isFiniteNumber(value: number | null | undefined): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function clampPercent(value: number): number { + return Math.min(100, Math.max(0, value)); +} + +function windowUsedAccountEquivalents( + overview: DashboardOverview, + windowKey: "primary" | "secondary", +): number | null { + let usedEquivalent = 0; + let includedAccounts = 0; + + for (const account of overview.accounts) { + const windowMinutes = windowKey === "primary" ? account.windowMinutesPrimary : account.windowMinutesSecondary; + const remainingPercent = + windowKey === "primary" ? account.usage?.primaryRemainingPercent : account.usage?.secondaryRemainingPercent; + + if (windowMinutes == null || !isFiniteNumber(remainingPercent)) { + continue; + } + + let accountEquivalent = (100 - clampPercent(remainingPercent)) / 100; + if (windowKey === "secondary" && account.status === "quota_exceeded") { + accountEquivalent = Math.max(accountEquivalent, 1); + } + + usedEquivalent += accountEquivalent; + includedAccounts += 1; + } + + return includedAccounts > 0 ? usedEquivalent : null; +} + +function windowProjectedAccountEquivalents( + overview: DashboardOverview, + windowKey: "primary" | "secondary", +): number | null { + let projectedEquivalent = 0; + let includedAccounts = 0; + const nowMs = Date.now(); + + for (const account of overview.accounts) { + const windowMinutes = windowKey === "primary" ? account.windowMinutesPrimary : account.windowMinutesSecondary; + const remainingPercent = + windowKey === "primary" ? account.usage?.primaryRemainingPercent : account.usage?.secondaryRemainingPercent; + const resetAt = windowKey === "primary" ? account.resetAtPrimary : account.resetAtSecondary; + + if (windowMinutes == null || !isFiniteNumber(remainingPercent) || windowMinutes <= 0) { + continue; + } + + const usedEquivalent = (100 - clampPercent(remainingPercent)) / 100; + let projected = usedEquivalent; + + if (resetAt) { + const resetAtMs = Date.parse(resetAt); + if (Number.isFinite(resetAtMs)) { + const windowSeconds = windowMinutes * 60; + const secondsUntilReset = Math.max(0, (resetAtMs - nowMs) / 1000); + const elapsedSeconds = Math.max(0, windowSeconds - secondsUntilReset); + if (elapsedSeconds > 0) { + projected = usedEquivalent * (windowSeconds / elapsedSeconds); + } + } + } + + if (windowKey === "secondary" && account.status === "quota_exceeded") { + projected = Math.max(projected, 1); + } + + projectedEquivalent += projected; + includedAccounts += 1; + } + + return includedAccounts > 0 ? projectedEquivalent : null; +} + +function windowIncludedAccountCount( + overview: DashboardOverview, + windowKey: "primary" | "secondary", +): number { + let includedAccounts = 0; + + for (const account of overview.accounts) { + const windowMinutes = windowKey === "primary" ? account.windowMinutesPrimary : account.windowMinutesSecondary; + const remainingPercent = + windowKey === "primary" ? account.usage?.primaryRemainingPercent : account.usage?.secondaryRemainingPercent; + + if (windowMinutes == null || !isFiniteNumber(remainingPercent)) { + continue; + } + + includedAccounts += 1; + } + + return includedAccounts; +} + +function clampBurnEquivalent(value: number | null, maxEquivalent: number): number | null { + if (!isFiniteNumber(value)) { + return null; + } + + const clamped = Math.max(0, value); + if (maxEquivalent <= 0) { + return clamped; + } + return Math.min(clamped, maxEquivalent); +} + +function plusAccountsBurnEquivalent( + overview: DashboardOverview, + windowKey: "primary" | "secondary", +): number | null { + const summaryWindow = windowKey === "primary" ? overview.summary.primaryWindow : overview.summary.secondaryWindow; + const depletion = windowKey === "primary" ? overview.depletionPrimary : overview.depletionSecondary; + const maxEquivalent = windowIncludedAccountCount(overview, windowKey); + const fallbackProjectedEquivalent = clampBurnEquivalent( + windowProjectedAccountEquivalents(overview, windowKey), + maxEquivalent, + ); + const fallbackUsedEquivalent = clampBurnEquivalent(windowUsedAccountEquivalents(overview, windowKey), maxEquivalent); + + if (!summaryWindow) { + return fallbackProjectedEquivalent ?? fallbackUsedEquivalent; + } + + const remainingCredits = summaryWindow.remainingCredits; + const burnRate = depletion?.burnRate; + let burnEquivalent: number | null = null; + + if (isFiniteNumber(remainingCredits) && remainingCredits >= 0 && isFiniteNumber(burnRate) && burnRate > 0) { + const plusCapacity = PLUS_DEFAULT_CAPACITY[windowKey]; + const equivalent = (remainingCredits * burnRate) / plusCapacity; + if (isFiniteNumber(equivalent)) { + burnEquivalent = Math.max(0, equivalent); + } + } + + burnEquivalent = clampBurnEquivalent(burnEquivalent, maxEquivalent); + + if (windowKey === "secondary") { + if (isFiniteNumber(fallbackProjectedEquivalent)) { + return clampBurnEquivalent( + burnEquivalent === null ? fallbackProjectedEquivalent : Math.max(burnEquivalent, fallbackProjectedEquivalent), + maxEquivalent, + ); + } + if (isFiniteNumber(fallbackUsedEquivalent)) { + return clampBurnEquivalent( + burnEquivalent === null ? fallbackUsedEquivalent : Math.max(burnEquivalent, fallbackUsedEquivalent), + maxEquivalent, + ); + } + } + + return burnEquivalent ?? fallbackProjectedEquivalent ?? fallbackUsedEquivalent; +} + +function formatBurnEquivalent(value: number | null): string { + if (value === null || !Number.isFinite(value)) { + return "--"; + } + return value.toFixed(1); +} + +function buildBurnTrend(points: TrendPoint[], currentValue: number | null): { value: number }[] { + if (currentValue === null || !Number.isFinite(currentValue) || currentValue <= 0 || points.length === 0) { + return []; + } + + const lastPoint = points[points.length - 1]?.v ?? 0; + if (!Number.isFinite(lastPoint) || lastPoint <= 0) { + return points.map(() => ({ value: currentValue })); + } + + const scale = currentValue / lastPoint; + return points.map((point) => ({ value: Math.max(0, point.v * scale) })); +} + +const TREND_COLORS = ["#3b82f6", "#8b5cf6", "#10b981", "#ef4444", "#f59e0b"]; function trendPointsToValues(points: TrendPoint[]): { value: number }[] { return points.map((p) => ({ value: p.v })); @@ -132,15 +338,25 @@ function trendPointsToValues(points: TrendPoint[]): { value: number }[] { export function buildDashboardView( overview: DashboardOverview, requestLogs: RequestLog[], - isDark = false, + optionsOrIsDark: DashboardViewOptions | boolean = false, ): DashboardView { + const { isDark, showAccountBurnrate } = resolveDashboardViewOptions(optionsOrIsDark); const primaryWindow = overview.windows.primary; const secondaryWindow = overview.windows.secondary; const metrics = overview.summary.metrics; const cost = overview.summary.cost.totalUsd7d; const secondaryLabel = formatWindowLabel("secondary", secondaryWindow?.windowMinutes ?? null); + const primaryBurnLabel = formatWindowLabel("primary", overview.summary.primaryWindow.windowMinutes ?? null); + const secondaryBurnLabel = formatWindowLabel("secondary", overview.summary.secondaryWindow?.windowMinutes ?? null); const trends = overview.trends; + const primaryBurnEquivalent = plusAccountsBurnEquivalent(overview, "primary"); + const secondaryBurnEquivalent = plusAccountsBurnEquivalent(overview, "secondary"); + const combinedBurnEquivalent = + (primaryBurnEquivalent ?? 0) + (secondaryBurnEquivalent ?? 0) > 0 + ? (primaryBurnEquivalent ?? 0) + (secondaryBurnEquivalent ?? 0) + : null; + const stats: DashboardStat[] = [ { label: "Requests (7d)", @@ -166,18 +382,30 @@ export function buildDashboardView( trend: trendPointsToValues(trends.cost), trendColor: TREND_COLORS[2], }, - { - label: "Error rate", - value: formatRate(metrics?.errorRate7d ?? null), - meta: metrics?.topError - ? `Top: ${metrics.topError}` - : `~${formatCompactNumber(Math.round((metrics?.errorRate7d ?? 0) * (metrics?.requests7d ?? 0)))} errors in 7d`, - icon: AlertTriangle, - trend: trendPointsToValues(trends.errorRate), - trendColor: TREND_COLORS[3], - }, ]; + if (showAccountBurnrate) { + stats.push({ + label: `Account burn rate (${primaryBurnLabel}/${secondaryBurnLabel})`, + value: `${formatBurnEquivalent(primaryBurnEquivalent)} / ${formatBurnEquivalent(secondaryBurnEquivalent)}`, + meta: `Primary ${formatBurnEquivalent(primaryBurnEquivalent)} acc/${primaryBurnLabel} · Secondary ${formatBurnEquivalent(secondaryBurnEquivalent)} acc/${secondaryBurnLabel}`, + icon: Flame, + trend: buildBurnTrend(trends.tokens, combinedBurnEquivalent), + trendColor: TREND_COLORS[3], + }); + } + + stats.push({ + label: "Error rate", + value: formatRate(metrics?.errorRate7d ?? null), + meta: metrics?.topError + ? `Top: ${metrics.topError}` + : `~${formatCompactNumber(Math.round((metrics?.errorRate7d ?? 0) * (metrics?.requests7d ?? 0)))} errors in 7d`, + icon: AlertTriangle, + trend: trendPointsToValues(trends.errorRate), + trendColor: TREND_COLORS[4], + }); + return { stats, primaryUsageItems: buildRemainingItems(overview.accounts, primaryWindow, "primary", isDark), diff --git a/frontend/src/features/settings/components/appearance-settings.tsx b/frontend/src/features/settings/components/appearance-settings.tsx index 045eb304..01e7a997 100644 --- a/frontend/src/features/settings/components/appearance-settings.tsx +++ b/frontend/src/features/settings/components/appearance-settings.tsx @@ -1,5 +1,7 @@ import { Monitor, Moon, Palette, Sun } from "lucide-react"; +import { Switch } from "@/components/ui/switch"; +import { useDashboardPreferencesStore } from "@/hooks/use-dashboard-preferences"; import { useThemeStore, type ThemePreference } from "@/hooks/use-theme"; import { cn } from "@/lib/utils"; @@ -12,6 +14,8 @@ const THEME_OPTIONS: { value: ThemePreference; label: string; icon: typeof Sun } export function AppearanceSettings() { const preference = useThemeStore((s) => s.preference); const setTheme = useThemeStore((s) => s.setTheme); + const accountBurnrateEnabled = useDashboardPreferencesStore((s) => s.accountBurnrateEnabled); + const setAccountBurnrateEnabled = useDashboardPreferencesStore((s) => s.setAccountBurnrateEnabled); return (
@@ -28,28 +32,38 @@ export function AppearanceSettings() {
-
-
-

Theme

-

Select your preferred color scheme.

+
+
+
+

Theme

+

Select your preferred color scheme.

+
+
+ {THEME_OPTIONS.map(({ value, label, icon: Icon }) => ( + + ))} +
-
- {THEME_OPTIONS.map(({ value, label, icon: Icon }) => ( - - ))} + +
+
+

Account burn rate

+

Show the account burn rate card on the dashboard.

+
+
diff --git a/frontend/src/features/settings/schemas.test.ts b/frontend/src/features/settings/schemas.test.ts index 1ed32ea8..533c9ef3 100644 --- a/frontend/src/features/settings/schemas.test.ts +++ b/frontend/src/features/settings/schemas.test.ts @@ -26,6 +26,21 @@ describe("DashboardSettingsSchema", () => { expect(parsed.importWithoutOverwrite).toBe(true); expect(parsed.apiKeyAuthEnabled).toBe(true); }); + + it("parses legacy settings payload and applies defaults for missing routing fields", () => { + const parsed = DashboardSettingsSchema.parse({ + stickyThreadsEnabled: true, + preferEarlierResetAccounts: false, + importWithoutOverwrite: false, + totpRequiredOnLogin: false, + totpConfigured: false, + apiKeyAuthEnabled: true, + }); + + expect(parsed.upstreamStreamTransport).toBe("default"); + expect(parsed.routingStrategy).toBe("usage_weighted"); + expect(parsed.openaiCacheAffinityMaxAgeSeconds).toBe(300); + }); }); describe("SettingsUpdateRequestSchema", () => { diff --git a/frontend/src/features/settings/schemas.ts b/frontend/src/features/settings/schemas.ts index 53114d04..c272abb8 100644 --- a/frontend/src/features/settings/schemas.ts +++ b/frontend/src/features/settings/schemas.ts @@ -5,10 +5,10 @@ export const UpstreamStreamTransportSchema = z.enum(["default", "auto", "http", export const DashboardSettingsSchema = z.object({ stickyThreadsEnabled: z.boolean(), - upstreamStreamTransport: UpstreamStreamTransportSchema, + upstreamStreamTransport: UpstreamStreamTransportSchema.optional().default("default"), preferEarlierResetAccounts: z.boolean(), - routingStrategy: RoutingStrategySchema, - openaiCacheAffinityMaxAgeSeconds: z.number().int().positive(), + routingStrategy: RoutingStrategySchema.optional().default("usage_weighted"), + openaiCacheAffinityMaxAgeSeconds: z.number().int().positive().optional().default(300), importWithoutOverwrite: z.boolean(), totpRequiredOnLogin: z.boolean(), totpConfigured: z.boolean(), diff --git a/frontend/src/hooks/use-dashboard-preferences.ts b/frontend/src/hooks/use-dashboard-preferences.ts new file mode 100644 index 00000000..67145b15 --- /dev/null +++ b/frontend/src/hooks/use-dashboard-preferences.ts @@ -0,0 +1,45 @@ +import { create } from "zustand"; + +const ACCOUNT_BURNRATE_STORAGE_KEY = "codex-lb-account-burnrate-enabled"; + +type DashboardPreferencesState = { + accountBurnrateEnabled: boolean; + initialized: boolean; + initializePreferences: () => void; + setAccountBurnrateEnabled: (enabled: boolean) => void; +}; + +function readStoredAccountBurnrateEnabled(): boolean | null { + if (typeof window === "undefined") { + return null; + } + const stored = window.localStorage.getItem(ACCOUNT_BURNRATE_STORAGE_KEY); + if (stored === "true") { + return true; + } + if (stored === "false") { + return false; + } + return null; +} + +function persistAccountBurnrateEnabled(enabled: boolean): void { + if (typeof window === "undefined") { + return; + } + window.localStorage.setItem(ACCOUNT_BURNRATE_STORAGE_KEY, String(enabled)); +} + +export const useDashboardPreferencesStore = create((set) => ({ + accountBurnrateEnabled: true, + initialized: false, + initializePreferences: () => { + const accountBurnrateEnabled = readStoredAccountBurnrateEnabled() ?? true; + persistAccountBurnrateEnabled(accountBurnrateEnabled); + set({ accountBurnrateEnabled, initialized: true }); + }, + setAccountBurnrateEnabled: (enabled) => { + persistAccountBurnrateEnabled(enabled); + set({ accountBurnrateEnabled: enabled, initialized: true }); + }, +})); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 72c85c24..cca098cd 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,12 +4,14 @@ import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import App from "./App.tsx"; +import { useDashboardPreferencesStore } from "@/hooks/use-dashboard-preferences"; import { queryClient } from "@/lib/query-client"; import { useThemeStore } from "@/hooks/use-theme"; import "./index.css"; useThemeStore.getState().initializeTheme(); +useDashboardPreferencesStore.getState().initializePreferences(); createRoot(document.getElementById("root")!).render(