mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-09-01 14:52:19 +08:00
Refactor router config and request handling
This commit is contained in:
+69
-70
@@ -2,7 +2,7 @@ import electron from "electron";
|
||||
import esbuild from "esbuild";
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, readdirSync, readFileSync, statSync, watch } from "node:fs";
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
buildStyles,
|
||||
@@ -97,13 +97,6 @@ function readyState() {
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function describeWatchEvent(label, watchedPath, eventType, filename, isDirectory = false) {
|
||||
const changedPath = filename
|
||||
? path.join(isDirectory ? watchedPath : path.dirname(watchedPath), String(filename))
|
||||
: watchedPath;
|
||||
return `${label} ${eventType} ${relativePath(changedPath)}`;
|
||||
}
|
||||
|
||||
function contentSignature(targetPath) {
|
||||
try {
|
||||
return readContentSignature(targetPath);
|
||||
@@ -179,31 +172,14 @@ function listDirectoryFiles(targetPath, basePath = targetPath) {
|
||||
return files;
|
||||
}
|
||||
|
||||
function rememberWatchSignature(label, targetPath) {
|
||||
const signature = contentSignature(targetPath);
|
||||
function rememberWatchSignature(label, targetPath, options = {}) {
|
||||
const signature = options.metadataOnly
|
||||
? metadataSignature(targetPath)
|
||||
: contentSignature(targetPath);
|
||||
watchSignatures.set(label, signature.key);
|
||||
logDev(`watch baseline: ${label} ${relativePath(targetPath)}; ${signature.summary}`);
|
||||
}
|
||||
|
||||
function handleWatchedInput(label, watchedPath, eventType, filename, options, onChange) {
|
||||
const reason = describeWatchEvent(label, watchedPath, eventType, filename, options?.isDirectory);
|
||||
const signature = contentSignature(watchedPath);
|
||||
const previousSignature = watchSignatures.get(label);
|
||||
const changed = previousSignature !== signature.key;
|
||||
watchSignatures.set(label, signature.key);
|
||||
logDev(`watch event: ${reason}; ${signature.summary}; content=${changed ? "changed" : "unchanged"}`);
|
||||
|
||||
if (!changed) {
|
||||
logDev(`restart skipped: ${reason} (content unchanged)`);
|
||||
return;
|
||||
}
|
||||
|
||||
onChange();
|
||||
if (enabled.electron && options?.restart !== false) {
|
||||
scheduleRestart(reason);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleStyleBuild(reason) {
|
||||
queuedStyleBuildReason = reason;
|
||||
if (styleBuildTimer) {
|
||||
@@ -258,6 +234,64 @@ function pollStyleWatchRoots() {
|
||||
}
|
||||
}
|
||||
|
||||
function pollWatchedInput(label, targetPath, onChange, options = {}) {
|
||||
const signature = options.metadataOnly
|
||||
? metadataSignature(targetPath)
|
||||
: contentSignature(targetPath);
|
||||
const previousSignature = watchSignatures.get(label);
|
||||
if (previousSignature === signature.key) {
|
||||
return;
|
||||
}
|
||||
|
||||
watchSignatures.set(label, signature.key);
|
||||
logDev(`watch event: ${label} ${relativePath(targetPath)}; ${signature.summary}; content=changed`);
|
||||
try {
|
||||
onChange();
|
||||
if (enabled.electron && options.restart !== false) {
|
||||
scheduleRestart(label);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logDev(`watch action failed: ${label}; ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function metadataSignature(targetPath) {
|
||||
if (!existsSync(targetPath)) {
|
||||
return {
|
||||
key: "missing",
|
||||
summary: "missing"
|
||||
};
|
||||
}
|
||||
|
||||
const stats = statSync(targetPath);
|
||||
return {
|
||||
key: `metadata:${stats.size}:${stats.mtimeMs}:${stats.ctimeMs}`,
|
||||
summary: `size=${stats.size} mtime=${stats.mtime.toISOString()} ctime=${stats.ctime.toISOString()}`
|
||||
};
|
||||
}
|
||||
|
||||
function pollSourceWatchTargets() {
|
||||
pollWatchedInput("home html", rendererHtmlInput, () => {
|
||||
copyRendererHtml();
|
||||
syncUiRendererToRuntimeDists();
|
||||
});
|
||||
pollWatchedInput("browser html", browserRendererHtmlInput, () => {
|
||||
copyBrowserRendererHtml();
|
||||
syncUiRendererToRuntimeDists();
|
||||
});
|
||||
pollWatchedInput("tray html", trayRendererHtmlInput, () => {
|
||||
copyTrayRendererHtml();
|
||||
syncUiRendererToRuntimeDists();
|
||||
});
|
||||
if (enabled.electron) {
|
||||
pollWatchedInput("app assets", appAssetsInput, copyAppAssets);
|
||||
}
|
||||
if ((enabled.cli || enabled.electron) && existsSync(modelCatalogInput)) {
|
||||
pollWatchedInput("model catalog", modelCatalogInput, copyModelCatalog, { metadataOnly: true });
|
||||
}
|
||||
}
|
||||
|
||||
function markReady(name, reason = `${name} esbuild completed`) {
|
||||
if (name === "browser" || name === "cli" || name === "main" || name === "renderer" || name === "tray" || name === "webBridge") {
|
||||
ready[name] = true;
|
||||
@@ -385,43 +419,13 @@ if (enabled.electron) {
|
||||
rememberWatchSignature("app assets", appAssetsInput);
|
||||
}
|
||||
if ((enabled.cli || enabled.electron) && existsSync(modelCatalogInput)) {
|
||||
rememberWatchSignature("model catalog", modelCatalogInput);
|
||||
rememberWatchSignature("model catalog", modelCatalogInput, { metadataOnly: true });
|
||||
}
|
||||
|
||||
const htmlWatcher = watch(rendererHtmlInput, { persistent: true }, (eventType, filename) => {
|
||||
handleWatchedInput("home html", rendererHtmlInput, eventType, filename, undefined, () => {
|
||||
copyRendererHtml();
|
||||
syncUiRendererToRuntimeDists();
|
||||
});
|
||||
});
|
||||
|
||||
const browserHtmlWatcher = watch(browserRendererHtmlInput, { persistent: true }, (eventType, filename) => {
|
||||
handleWatchedInput("browser html", browserRendererHtmlInput, eventType, filename, undefined, () => {
|
||||
copyBrowserRendererHtml();
|
||||
syncUiRendererToRuntimeDists();
|
||||
});
|
||||
});
|
||||
|
||||
const trayHtmlWatcher = watch(trayRendererHtmlInput, { persistent: true }, (eventType, filename) => {
|
||||
handleWatchedInput("tray html", trayRendererHtmlInput, eventType, filename, undefined, () => {
|
||||
copyTrayRendererHtml();
|
||||
syncUiRendererToRuntimeDists();
|
||||
});
|
||||
});
|
||||
|
||||
const stylePoller = setInterval(pollStyleWatchRoots, stylePollIntervalMs);
|
||||
|
||||
const appAssetsWatcher = enabled.electron
|
||||
? watch(appAssetsInput, { persistent: true }, (eventType, filename) => {
|
||||
handleWatchedInput("app assets", appAssetsInput, eventType, filename, { isDirectory: true }, copyAppAssets);
|
||||
})
|
||||
: { close: () => undefined };
|
||||
|
||||
const modelCatalogWatcher = (enabled.cli || enabled.electron) && existsSync(modelCatalogInput)
|
||||
? watch(modelCatalogInput, { persistent: true }, (eventType, filename) => {
|
||||
handleWatchedInput("model catalog", modelCatalogInput, eventType, filename, undefined, copyModelCatalog);
|
||||
})
|
||||
: { close: () => undefined };
|
||||
const sourcePoller = setInterval(() => {
|
||||
pollStyleWatchRoots();
|
||||
pollSourceWatchTargets();
|
||||
}, stylePollIntervalMs);
|
||||
|
||||
const contexts = [];
|
||||
|
||||
@@ -521,12 +525,7 @@ async function shutdown() {
|
||||
if (styleBuildTimer) {
|
||||
clearTimeout(styleBuildTimer);
|
||||
}
|
||||
htmlWatcher.close();
|
||||
browserHtmlWatcher.close();
|
||||
trayHtmlWatcher.close();
|
||||
clearInterval(stylePoller);
|
||||
appAssetsWatcher.close();
|
||||
modelCatalogWatcher.close();
|
||||
clearInterval(sourcePoller);
|
||||
await Promise.all(contexts.map((context) => context.dispose()));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { getAgentAnalysis, getAgentTracePayload, getRequestLogDetail, getRequest
|
||||
import trayController from "./tray-controller";
|
||||
import { appUpdateService } from "./update-service";
|
||||
import { getUsageStats } from "@ccr/core/usage/store";
|
||||
import { applyNativeThemePreference } from "./native-theme";
|
||||
import windowsManager from "./windows";
|
||||
import type { AgentAnalysisFilter, AgentAnalysisTracePayloadRequest, ApiKeyConfig, AppCaptureElementPngRequest, AppCaptureElementPngResult, AppConfig, AppDataExportResult, AppImageExportTargetRequest, AppImageExportTargetResult, AppInfo, AppRenderHtmlPngRequest, AppRenderHtmlPngResult, AppSaveConfigOptions, BotGatewayQrLoginCancelRequest, BotGatewayQrLoginStartRequest, BotGatewayQrLoginWaitRequest, BotGatewayQrWindowCloseRequest, BotGatewayQrWindowOpenRequest, GatewayPluginAppConfig, GatewayProviderConnectivityCheckRequest, GatewayProviderProbeCandidatesRequest, GatewayProviderProbeRequest, GatewayStatus, LocalAgentProviderImportRequest, PluginDependency, PluginDirectorySelection, PluginMarketplaceEntry, ProfileApplyResult, ProfileOpenRequest, ProviderAccountResetRequest, ProviderAccountSnapshotRequestOptions, ProviderAccountTestRequest, ProviderCatalogModelsRequest, ProviderIconDetectionRequest, ProviderManifestFetchRequest, RequestLogListFilter, UsageStatsFilter, UsageStatsRange } from "@ccr/core/contracts/app";
|
||||
|
||||
@@ -181,6 +182,7 @@ ipcMain.handle(IPC_CHANNELS.appOpenProfile, async (_event, request: ProfileOpenR
|
||||
ipcMain.handle(IPC_CHANNELS.appApplyClaudeAppGateway, async (_event, config?: AppConfig) => {
|
||||
const previousConfig = await loadAppConfig();
|
||||
const baseConfig = config ? await saveAppConfig(config) : previousConfig;
|
||||
applyNativeThemePreference(baseConfig.theme);
|
||||
const synced = await syncClaudeAppGatewayConfig(baseConfig);
|
||||
const savedConfig = synced.config;
|
||||
let runtimeStatus = gatewayService.getStatus();
|
||||
@@ -266,6 +268,7 @@ ipcMain.handle(IPC_CHANNELS.appSaveConfig, async (_event, config: AppConfig, opt
|
||||
}
|
||||
const launchAtLoginChanged = Boolean(config.launchAtLogin) !== Boolean(previousConfig.launchAtLogin);
|
||||
let savedConfig = await saveAppConfig(config);
|
||||
applyNativeThemePreference(savedConfig.theme);
|
||||
if (launchAtLoginChanged) {
|
||||
try {
|
||||
syncLaunchAtLogin(savedConfig);
|
||||
|
||||
@@ -13,6 +13,7 @@ import trayController from "./tray-controller";
|
||||
import { appUpdateService } from "./update-service";
|
||||
import { browserAutomationMcpService } from "./browser-automation-mcp";
|
||||
import { browserWebSearchMcpService } from "./electron-web-search-mcp";
|
||||
import { applyNativeThemePreference } from "./native-theme";
|
||||
import windowsManager from "./windows";
|
||||
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
@@ -41,7 +42,8 @@ function startPrimaryInstance(): void {
|
||||
queueEnsureConfiguredProxyModeActive("second-instance");
|
||||
});
|
||||
|
||||
void app.whenReady().then(() => {
|
||||
void app.whenReady().then(async () => {
|
||||
applyNativeThemePreference((await loadAppConfig()).theme);
|
||||
configureProxyDesktopIntegration();
|
||||
let ccrLauncherPreparation: CcrCliLauncherPreparation | undefined;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { nativeTheme } from "electron";
|
||||
import type { AppConfig } from "@ccr/core/contracts/app";
|
||||
|
||||
export function applyNativeThemePreference(theme: AppConfig["theme"] | undefined): void {
|
||||
nativeTheme.themeSource = theme === "light" || theme === "dark" ? theme : "system";
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BrowserWindow, Menu, Tray, app, nativeImage, screen } from "electron";
|
||||
import { BrowserWindow, Menu, Tray, app, nativeImage, screen, type BrowserWindowConstructorOptions } from "electron";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { deflateSync } from "node:zlib";
|
||||
@@ -17,7 +17,21 @@ const popoverDetailWidth = 420;
|
||||
const popoverMargin = 8;
|
||||
const trayActivationSuppressMs = 750;
|
||||
const trayMenuBarIconSize = 20;
|
||||
const trayWindowBackgroundColor = "#020617";
|
||||
const trayWindowBackgroundColor = "#1c1c1e";
|
||||
const trayWindowMaterialOptions: Pick<
|
||||
BrowserWindowConstructorOptions,
|
||||
"backgroundColor" | "transparent" | "vibrancy" | "visualEffectState"
|
||||
> = process.platform === "darwin"
|
||||
? {
|
||||
backgroundColor: "#00000000",
|
||||
transparent: true,
|
||||
vibrancy: "under-window",
|
||||
visualEffectState: "active"
|
||||
}
|
||||
: {
|
||||
backgroundColor: trayWindowBackgroundColor,
|
||||
transparent: false
|
||||
};
|
||||
const trayTokenFallbackTitle = "0 tokens";
|
||||
const trayIconFallbackPath = path.join(__dirname, "../assets/tray.png");
|
||||
const trayMascotIconIds = ["violet", "orange", "cyan"] as const;
|
||||
@@ -190,7 +204,6 @@ class TrayController {
|
||||
this.popover = new BrowserWindow({
|
||||
acceptFirstMouse: true,
|
||||
alwaysOnTop: true,
|
||||
backgroundColor: trayWindowBackgroundColor,
|
||||
frame: false,
|
||||
fullscreenable: false,
|
||||
hasShadow: true,
|
||||
@@ -203,7 +216,7 @@ class TrayController {
|
||||
show: false,
|
||||
skipTaskbar: true,
|
||||
title: `${APP_NAME} Usage`,
|
||||
transparent: false,
|
||||
...trayWindowMaterialOptions,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
@@ -215,6 +228,7 @@ class TrayController {
|
||||
width: popoverMenuWidth
|
||||
});
|
||||
|
||||
reinforceMacOSTrayMaterial(this.popover);
|
||||
prepareTrayWindowForSharpRendering(this.popover);
|
||||
this.popover.setAlwaysOnTop(true, "pop-up-menu");
|
||||
this.popover.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||
@@ -235,7 +249,6 @@ class TrayController {
|
||||
this.detailPopover = new BrowserWindow({
|
||||
acceptFirstMouse: true,
|
||||
alwaysOnTop: true,
|
||||
backgroundColor: trayWindowBackgroundColor,
|
||||
frame: false,
|
||||
fullscreenable: false,
|
||||
hasShadow: true,
|
||||
@@ -248,7 +261,7 @@ class TrayController {
|
||||
show: false,
|
||||
skipTaskbar: true,
|
||||
title: `${APP_NAME} Usage Detail`,
|
||||
transparent: false,
|
||||
...trayWindowMaterialOptions,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
@@ -260,6 +273,7 @@ class TrayController {
|
||||
width: popoverDetailWidth
|
||||
});
|
||||
|
||||
reinforceMacOSTrayMaterial(this.detailPopover);
|
||||
prepareTrayWindowForSharpRendering(this.detailPopover);
|
||||
this.detailPopover.setAlwaysOnTop(true, "pop-up-menu");
|
||||
this.detailPopover.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||
@@ -563,6 +577,22 @@ function normalizeDetailProvider(provider?: string): string | undefined {
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function reinforceMacOSTrayMaterial(window: BrowserWindow): void {
|
||||
if (process.platform !== "darwin") {
|
||||
return;
|
||||
}
|
||||
|
||||
const applyMaterial = () => {
|
||||
if (!window.isDestroyed()) {
|
||||
window.setBackgroundColor("#00000000");
|
||||
window.setVibrancy("under-window");
|
||||
}
|
||||
};
|
||||
|
||||
applyMaterial();
|
||||
window.webContents.on("did-finish-load", applyMaterial);
|
||||
}
|
||||
|
||||
function prepareTrayWindowForSharpRendering(window: BrowserWindow): void {
|
||||
const resetZoom = () => {
|
||||
if (!window.isDestroyed() && !window.webContents.isDestroyed()) {
|
||||
|
||||
@@ -3,20 +3,20 @@ import {
|
||||
AnimatePresence, AnimatedDisclosure, AnimatedIconSwap,
|
||||
Area, arrayMove, Badge, Bar, BarChart, Button,
|
||||
Card, CardContent, CardHeader, CardTitle, CartesianGrid, Cell, constrainOverviewWidgetSize,
|
||||
Check, ChevronDown, ChevronLeft, ChevronRight, CircleAlert, cn, compactId,
|
||||
Check, ChevronDown, ChevronRight, CircleAlert, cn, compactId,
|
||||
compactUserAgent, compareProviderAccountSnapshots, ComposedChart, CSS, DEFAULT_OVERVIEW_WIDGETS, DndContext,
|
||||
Dialog, DialogBody, DialogContent, DialogHeader, DialogTitle,
|
||||
DragEndEvent, DragOverEvent, DragOverlay, DragStartEvent, Field, formatAxisNumber, formatBytes,
|
||||
formatCompactNumber, formatDuration, formatLogDateTime, formatPercent, formatProviderAccountDetailDate, formatProviderAccountMeterTitle, formatProviderAccountMeterValue,
|
||||
formatStatusBucketDate, formatStatusCodeCounts, formatSystemStatusRange, formatToolCounts, formatUsdCost, KeyboardSensor,
|
||||
LabelList, LayoutGroup, Line, LoaderCircle, MeasuringStrategy, MetricCard, MetricTone,
|
||||
metricToneBar, metricToneStroke, motion, normalizeAgentFilterValue, normalizeOverviewWidget, normalizeOverviewWidgets,
|
||||
LabelList, LayoutGroup, Line, LoaderCircle, MeasuringStrategy, MetricTone,
|
||||
motion, normalizeAgentFilterValue, normalizeOverviewWidget, normalizeOverviewWidgets,
|
||||
OverviewMetricKind, overviewMetricOptions, overviewWidgetCollisionDetection, OverviewWidgetConfig, OverviewWidgetSize, overviewWidgetSizeOptions,
|
||||
OverviewWidgetType, OverviewWidgetVariant, Pencil, Pie, PieChart, Plus,
|
||||
PointerSensor, primaryProviderAccountMeter, providerAccountMeterDetailValidityProgress, providerAccountMeterProgress, providerAccountMetersForDisplay, providerAccountProgressClass, isProviderAccountManualResetMeter,
|
||||
providerAccountSnapshotKey, providerAccountSnapshotLabel,
|
||||
ProviderAccountMeter, ProviderAccountSnapshot, ReactNode, ReactPointerEvent, rectSortingStrategy, RefreshCw, Select,
|
||||
SelectControl, SortableContext, sortableKeyboardCoordinates, systemStatusIconClass, systemStatusPointTooltip, systemStatusSegmentClass,
|
||||
SelectControl, SortableContext, sortableKeyboardCoordinates, systemStatusPointTooltip,
|
||||
systemStatusTooltipPositionClass, Tooltip, translateOptions, Trash2, UsageComparisonRow, usageRangeOptions,
|
||||
GatewayProviderConfig, UsageSeriesPoint, UsageStatsRange, UsageStatsSnapshot, usageStatusTone, UsageTotals, useAppText,
|
||||
useEffect, useMemo, useRef, useSensor, useSensors, useSortable,
|
||||
@@ -24,7 +24,10 @@ import {
|
||||
} from "../shared/index";
|
||||
import { buildTokenActivity, type TokenActivityCell } from "@/lib/usage-activity";
|
||||
import { ShareCardWidget } from "./share-cards";
|
||||
import { Cloud, Rocket } from "lucide-react";
|
||||
import {
|
||||
CalendarDays, ChartNoAxesCombined, ChartPie, Cloud, GripHorizontal, Inbox, Layers3,
|
||||
Rocket, Server, SlidersHorizontal, UsersRound, WalletCards
|
||||
} from "lucide-react";
|
||||
|
||||
type OverviewUsageFilters = {
|
||||
modelFilter: string;
|
||||
@@ -305,9 +308,7 @@ export function OverviewView({
|
||||
</SortableOverviewWidget>
|
||||
))}
|
||||
{visibleWidgets.length === 0 ? (
|
||||
<div className="col-span-1 rounded-lg border border-dashed border-border bg-muted/30 px-4 py-10 text-center text-[12px] text-muted-foreground sm:col-span-2 xl:col-span-4">
|
||||
{t("No widgets configured")}
|
||||
</div>
|
||||
<OverviewEmptyState className="col-span-1 sm:col-span-2 xl:col-span-4" label={t("No widgets configured")} />
|
||||
) : null}
|
||||
</section>
|
||||
</LayoutGroup>
|
||||
@@ -330,24 +331,28 @@ export function OverviewView({
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
className="space-y-4"
|
||||
className="overview-view space-y-5"
|
||||
data-editing={editing}
|
||||
initial={{ opacity: 0 }}
|
||||
ref={viewRef}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="overview-toolbar flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span aria-hidden="true" className="overview-toolbar-glyph hidden h-8 w-8 shrink-0 items-center justify-center rounded-[9px] sm:flex">
|
||||
<SlidersHorizontal className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<OverviewUsageRangeSelector range={usageRange} setRange={setUsageRange} />
|
||||
<Select
|
||||
aria-label={t("Provider")}
|
||||
className="h-8 w-[168px] bg-[length:14px] px-2 pr-7 text-[12px]"
|
||||
className="h-9 w-[168px] rounded-[10px] bg-[length:14px] px-3 pr-8 text-[12px] shadow-none"
|
||||
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]"
|
||||
className="h-9 w-[220px] rounded-[10px] bg-[length:14px] px-3 pr-8 text-[12px] shadow-none"
|
||||
onValueChange={changeModelFilter}
|
||||
options={modelOptions}
|
||||
value={modelFilter}
|
||||
@@ -376,7 +381,7 @@ export function OverviewView({
|
||||
|
||||
{editing ? (
|
||||
<div className="grid min-h-0 grid-cols-1 gap-4 xl:grid-cols-[220px_minmax(0,1fr)_260px]">
|
||||
<aside className="min-w-0 rounded-lg border border-border bg-card p-3 xl:sticky xl:top-4 xl:self-start">
|
||||
<aside className="overview-editor-panel min-w-0 border p-3 xl:sticky xl:top-4 xl:self-start">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<h3 className="truncate text-[12px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("Components")}</h3>
|
||||
<Badge variant="outline">{overviewWidgetTemplates().length}</Badge>
|
||||
@@ -392,7 +397,7 @@ export function OverviewView({
|
||||
{widgetGrid}
|
||||
</main>
|
||||
|
||||
<aside className="min-w-0 rounded-lg border border-border bg-card p-3 xl:sticky xl:top-4 xl:self-start">
|
||||
<aside className="overview-editor-panel min-w-0 border p-3 xl:sticky xl:top-4 xl:self-start">
|
||||
<OverviewWidgetProperties
|
||||
providerAccounts={providerAccounts}
|
||||
widget={selectedWidget}
|
||||
@@ -426,13 +431,15 @@ function OverviewUsageRangeSelector({
|
||||
const t = useAppText();
|
||||
|
||||
return (
|
||||
<div aria-label={t("Usage over time")} className="flex rounded-md border border-input bg-card p-0.5 shadow-sm" role="group">
|
||||
<div aria-label={t("Usage over time")} className="overview-segmented flex" role="group">
|
||||
{usageRangeOptions.map((option) => (
|
||||
<Button
|
||||
aria-pressed={range === option.value}
|
||||
className={cn(
|
||||
"h-7 rounded px-2.5 text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground",
|
||||
range === option.value && "bg-background text-foreground shadow-sm"
|
||||
"overview-segmented-item h-7 px-2.5 text-[11px] font-medium text-muted-foreground hover:text-foreground",
|
||||
range === option.value && "text-foreground"
|
||||
)}
|
||||
data-active={range === option.value}
|
||||
key={option.value}
|
||||
onClick={() => setRange(option.value)}
|
||||
type="button"
|
||||
@@ -445,6 +452,78 @@ function OverviewUsageRangeSelector({
|
||||
);
|
||||
}
|
||||
|
||||
type OverviewHeadingTone = "blue" | "green" | "orange" | "purple" | "red" | "slate";
|
||||
type OverviewHeadingIcon = typeof Inbox;
|
||||
|
||||
function OverviewCardHeading({
|
||||
icon: Icon,
|
||||
title,
|
||||
tone = "blue",
|
||||
trailing
|
||||
}: {
|
||||
icon: OverviewHeadingIcon;
|
||||
title: string;
|
||||
tone?: OverviewHeadingTone;
|
||||
trailing?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<CardHeader className="overview-card-header shrink-0 flex-row items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<span aria-hidden="true" className="overview-heading-icon" data-tone={tone}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</div>
|
||||
{trailing ? <div className="min-w-0 shrink-0">{trailing}</div> : null}
|
||||
</CardHeader>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewEmptyState({
|
||||
className,
|
||||
compact = false,
|
||||
label
|
||||
}: {
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"overview-empty-state overview-nested-surface flex min-h-0 flex-col items-center justify-center border border-dashed px-4 text-center text-muted-foreground",
|
||||
compact ? "py-7" : "py-10",
|
||||
className
|
||||
)}>
|
||||
<span aria-hidden="true" className="overview-empty-state-icon">
|
||||
<Inbox className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="mt-2 text-[12px] font-medium">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewChartLegend({ items }: { items: Array<{ color: string; label: string }> }) {
|
||||
return (
|
||||
<div className="overview-chart-legend hidden items-center gap-3 md:flex">
|
||||
{items.map((item) => (
|
||||
<span className="flex items-center gap-1.5 text-[10px] font-medium text-muted-foreground" key={item.label}>
|
||||
<span aria-hidden="true" className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: item.color }} />
|
||||
<span className="max-w-[96px] truncate">{item.label}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewDonutCenter({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="pointer-events-none absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center text-center">
|
||||
<span className="text-[17px] font-semibold tracking-[-0.025em] text-foreground">{value}</span>
|
||||
<span className="mt-0.5 text-[9px] font-medium uppercase tracking-[0.08em] text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function overviewProviderFilterOptions(providers: GatewayProviderConfig[], translate: (value: string) => string): Array<{ label: string; value: string }> {
|
||||
const providerNames = new Set<string>();
|
||||
for (const provider of providers) {
|
||||
@@ -513,7 +592,7 @@ function OverviewWidgetPalette({
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{templates.map((template) => (
|
||||
<Button
|
||||
className="grid h-auto w-full grid-cols-[18px_minmax(0,1fr)] items-center gap-2 rounded-md border border-border bg-background px-2.5 py-2 text-left transition-colors hover:bg-muted/55 focus-visible:ring-2 focus-visible:ring-ring/25"
|
||||
className="overview-palette-item grid h-auto w-full grid-cols-[18px_minmax(0,1fr)] items-center gap-2 border px-2.5 py-2 text-left focus-visible:ring-2 focus-visible:ring-ring/25"
|
||||
key={overviewWidgetTemplateKey(template)}
|
||||
onClick={() => onAdd(template)}
|
||||
type="button"
|
||||
@@ -558,11 +637,7 @@ function OverviewWidgetProperties({
|
||||
const t = useAppText();
|
||||
|
||||
if (!widget) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-border bg-muted/30 px-3 py-8 text-center text-[12px] text-muted-foreground">
|
||||
{t("No widget selected")}
|
||||
</div>
|
||||
);
|
||||
return <OverviewEmptyState compact label={t("No widget selected")} />;
|
||||
}
|
||||
|
||||
const category = overviewWidgetCategory(widget.type);
|
||||
@@ -769,10 +844,9 @@ function OverviewWidgetFrame({
|
||||
<div
|
||||
aria-selected={editing ? selected : undefined}
|
||||
className={cn(
|
||||
"group/overview-widget relative h-full min-h-0 min-w-0 transition-opacity",
|
||||
editing && (selected
|
||||
? "rounded-xl outline outline-2 outline-primary outline-offset-2 ring-2 ring-primary/20"
|
||||
: "rounded-xl outline outline-2 outline-primary/35 outline-offset-2")
|
||||
"overview-widget-frame group/overview-widget relative h-full min-h-0 min-w-0 transition-opacity",
|
||||
editing && "is-editing",
|
||||
selected && "is-selected"
|
||||
)}
|
||||
role={editing ? "group" : undefined}
|
||||
onFocus={editing ? onSelect : undefined}
|
||||
@@ -782,6 +856,12 @@ function OverviewWidgetFrame({
|
||||
{children}
|
||||
{editing ? (
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("overview-widget-drag-handle", selected && "is-selected")}
|
||||
>
|
||||
<GripHorizontal className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<OverviewWidgetResizeHandle
|
||||
axis="width"
|
||||
label={t("Resize widget width")}
|
||||
@@ -974,13 +1054,17 @@ function OverviewMetricWidget({
|
||||
}) {
|
||||
const t = useAppText();
|
||||
const item = overviewMetricDatum(metric, totals, t);
|
||||
const showsRatio = overviewMetricShowsRatio(metric);
|
||||
|
||||
if (variant === "compact") {
|
||||
return (
|
||||
<Card className="flex h-full min-h-0 min-w-0 flex-col">
|
||||
<Card className="overview-card overview-metric-card flex h-full min-h-0 min-w-0 flex-col" data-tone={item.tone}>
|
||||
<CardContent className="flex min-h-0 flex-1 items-center justify-between gap-3 p-3">
|
||||
<div className="min-w-0 truncate text-[12px] font-medium text-muted-foreground">{item.label}</div>
|
||||
<div className="shrink-0 text-[18px] font-semibold tracking-tight">{item.value}</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span aria-hidden="true" className="overview-metric-dot" />
|
||||
<div className="min-w-0 truncate text-[12px] font-medium text-muted-foreground">{item.label}</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-[19px] font-semibold tracking-[-0.02em]">{item.value}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -988,14 +1072,14 @@ function OverviewMetricWidget({
|
||||
|
||||
if (variant === "bar") {
|
||||
return (
|
||||
<Card className="flex h-full min-h-0 min-w-0 flex-col">
|
||||
<Card className="overview-card overview-metric-card flex h-full min-h-0 min-w-0 flex-col" data-tone={item.tone}>
|
||||
<CardContent className="min-h-0 flex-1 p-3">
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div className="min-w-0 truncate text-[12px] font-medium text-muted-foreground">{item.label}</div>
|
||||
<div className="shrink-0 text-[18px] font-semibold tracking-tight">{item.value}</div>
|
||||
</div>
|
||||
<div className="mt-3 h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div className={cn("h-full rounded-full", metricToneBar(item.tone))} style={{ width: `${Math.max(3, Math.round(item.ratio * 100))}%` }} />
|
||||
<div className="overview-metric-track mt-3">
|
||||
<div className="overview-metric-fill" style={{ width: `${Math.max(3, Math.round(item.ratio * 100))}%` }} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -1004,7 +1088,7 @@ function OverviewMetricWidget({
|
||||
|
||||
if (variant === "ring") {
|
||||
return (
|
||||
<Card className="flex h-full min-h-0 min-w-0 flex-col">
|
||||
<Card className="overview-card overview-metric-card flex h-full min-h-0 min-w-0 flex-col" data-tone={item.tone}>
|
||||
<CardContent className="grid min-h-0 flex-1 grid-cols-[58px_minmax(0,1fr)] items-center gap-3 p-3">
|
||||
<OverviewRingMetric ratio={item.ratio} tone={item.tone} />
|
||||
<div className="min-w-0">
|
||||
@@ -1016,7 +1100,27 @@ function OverviewMetricWidget({
|
||||
);
|
||||
}
|
||||
|
||||
return <MetricCard label={item.label} tone={item.tone} value={item.value} />;
|
||||
return (
|
||||
<Card className="overview-card overview-metric-card flex h-full min-h-0 min-w-0 flex-col" data-tone={item.tone}>
|
||||
<CardContent className="relative flex min-h-0 flex-1 flex-col justify-between p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span aria-hidden="true" className="overview-metric-dot" />
|
||||
{showsRatio ? (
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">{Math.round(Math.max(0, Math.min(1, item.ratio)) * 100)}%</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[11px] font-medium text-muted-foreground">{item.label}</div>
|
||||
<div className="mt-0.5 truncate text-[24px] font-semibold tracking-[-0.035em] text-foreground">{item.value}</div>
|
||||
</div>
|
||||
{showsRatio ? (
|
||||
<div className="overview-metric-track">
|
||||
<div className="overview-metric-fill" style={{ width: `${Math.max(3, Math.round(item.ratio * 100))}%` }} />
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewRingMetric({ ratio, tone }: { ratio: number; tone: MetricTone }) {
|
||||
@@ -1026,13 +1130,13 @@ function OverviewRingMetric({ ratio, tone }: { ratio: number; tone: MetricTone }
|
||||
|
||||
return (
|
||||
<svg aria-hidden="true" className="h-[58px] w-[58px]" viewBox="0 0 48 48">
|
||||
<circle cx="24" cy="24" fill="none" r={radius} stroke="hsl(var(--muted))" strokeWidth="6" />
|
||||
<circle cx="24" cy="24" fill="none" r={radius} stroke="var(--muted)" strokeWidth="6" />
|
||||
<circle
|
||||
cx="24"
|
||||
cy="24"
|
||||
fill="none"
|
||||
r={radius}
|
||||
stroke={metricToneStroke(tone)}
|
||||
stroke={overviewMetricToneColor(tone)}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={circumference * (1 - clamped)}
|
||||
strokeLinecap="round"
|
||||
@@ -1043,6 +1147,20 @@ function OverviewRingMetric({ ratio, tone }: { ratio: number; tone: MetricTone }
|
||||
);
|
||||
}
|
||||
|
||||
function overviewMetricToneColor(tone: MetricTone): string {
|
||||
if (tone === "blue") return "#007aff";
|
||||
if (tone === "indigo") return "#5856d6";
|
||||
if (tone === "amber") return "#ff9f0a";
|
||||
if (tone === "rose") return "#ff3b30";
|
||||
if (tone === "slate") return "#8e8e93";
|
||||
return "#30b0c7";
|
||||
}
|
||||
|
||||
function overviewMetricShowsRatio(metric: OverviewMetricKind): boolean {
|
||||
return metric === "cache-ratio" || metric === "success-rate" || metric === "errors" ||
|
||||
metric === "input-tokens" || metric === "output-tokens" || metric === "cache-tokens";
|
||||
}
|
||||
|
||||
function UsageTrendWidget({
|
||||
dimensions,
|
||||
usageRange,
|
||||
@@ -1058,25 +1176,38 @@ function UsageTrendWidget({
|
||||
const chartMargin = dimensions.height <= 1
|
||||
? { bottom: 0, left: 0, right: 4, top: 8 }
|
||||
: { bottom: 4, left: 0, right: 8, top: 8 };
|
||||
const legendItems = variant === "composed"
|
||||
? [
|
||||
{ color: "#007aff", label: t("Total tokens") },
|
||||
{ color: "#34c759", label: t("Requests") },
|
||||
{ color: overviewCacheColor, label: t("Cache tokens") }
|
||||
]
|
||||
: variant === "bar"
|
||||
? [
|
||||
{ color: "#007aff", label: t("Total tokens") },
|
||||
{ color: "#34c759", label: t("Requests") }
|
||||
]
|
||||
: [
|
||||
{ color: "#007aff", label: t("Total tokens") },
|
||||
{ color: overviewCacheColor, label: t("Cache tokens") }
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className="flex h-full min-h-0 min-w-0 flex-col">
|
||||
<CardHeader className="shrink-0 flex-row items-center justify-between">
|
||||
<CardTitle>{t("Usage Trend")}</CardTitle>
|
||||
</CardHeader>
|
||||
<Card className="overview-card flex h-full min-h-0 min-w-0 flex-col">
|
||||
<OverviewCardHeading icon={ChartNoAxesCombined} title={t("Usage Trend")} trailing={dimensions.width >= 2 ? <OverviewChartLegend items={legendItems} /> : null} />
|
||||
<CardContent className="min-h-0 flex-1">
|
||||
<ChartFrame fill>
|
||||
{({ height, width }) => (
|
||||
<ComposedChart data={usageStats.series} height={height} margin={chartMargin} width={width}>
|
||||
<CartesianGrid stroke="#dfe3e8" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis axisLine={false} dataKey="label" hide={dimensions.height <= 1} tick={{ fill: "#5f6b7a", fontSize: 11 }} tickLine={false} />
|
||||
<YAxis axisLine={false} hide={dimensions.width <= 1} tick={{ fill: "#5f6b7a", fontSize: 11 }} tickFormatter={formatAxisNumber} tickLine={false} yAxisId="tokens" />
|
||||
<CartesianGrid stroke="var(--overview-chart-grid)" strokeDasharray="2 5" vertical={false} />
|
||||
<XAxis axisLine={false} dataKey="label" hide={dimensions.height <= 1} tick={{ fill: "var(--muted-foreground)", fontSize: 11 }} tickLine={false} />
|
||||
<YAxis axisLine={false} hide={dimensions.width <= 1} tick={{ fill: "var(--muted-foreground)", fontSize: 11 }} tickFormatter={formatAxisNumber} tickLine={false} yAxisId="tokens" />
|
||||
<YAxis axisLine={false} hide orientation="right" yAxisId="requests" />
|
||||
<Tooltip content={<UsageTooltip />} />
|
||||
{variant === "composed" ? (
|
||||
<>
|
||||
<Area dataKey="totalTokens" fill="#0f766e" fillOpacity={0.14} name={t("Total tokens")} stroke="#0f766e" strokeWidth={2} type="monotone" yAxisId="tokens" />
|
||||
<Bar barSize={12} dataKey="requestCount" fill="#2563eb" name={t("Requests")} radius={[3, 3, 0, 0]} yAxisId="requests">
|
||||
<Area dataKey="totalTokens" fill="#007aff" fillOpacity={0.12} name={t("Total tokens")} stroke="#007aff" strokeWidth={2.25} type="monotone" yAxisId="tokens" />
|
||||
<Bar barSize={12} dataKey="requestCount" fill="#34c759" name={t("Requests")} radius={[4, 4, 0, 0]} yAxisId="requests">
|
||||
<LabelList content={<RequestHealthBarLabel />} dataKey="requestCount" />
|
||||
</Bar>
|
||||
<Line dataKey="cacheTokens" dot={false} name={t("Cache tokens")} stroke={overviewCacheColor} strokeWidth={2} type="monotone" yAxisId="tokens" />
|
||||
@@ -1084,20 +1215,20 @@ function UsageTrendWidget({
|
||||
) : null}
|
||||
{variant === "area" ? (
|
||||
<>
|
||||
<Area dataKey="totalTokens" fill="#0f766e" fillOpacity={0.18} name={t("Total tokens")} stroke="#0f766e" strokeWidth={2} type="monotone" yAxisId="tokens" />
|
||||
<Area dataKey="totalTokens" fill="#007aff" fillOpacity={0.14} name={t("Total tokens")} stroke="#007aff" strokeWidth={2.25} type="monotone" yAxisId="tokens" />
|
||||
<Area dataKey="cacheTokens" fill={overviewCacheColor} fillOpacity={0.12} name={t("Cache tokens")} stroke={overviewCacheColor} strokeWidth={2} type="monotone" yAxisId="tokens" />
|
||||
</>
|
||||
) : null}
|
||||
{variant === "line" ? (
|
||||
<>
|
||||
<Line dataKey="totalTokens" dot={false} name={t("Total tokens")} stroke="#0f766e" strokeWidth={2.5} type="monotone" yAxisId="tokens" />
|
||||
<Line dataKey="totalTokens" dot={false} name={t("Total tokens")} stroke="#007aff" strokeWidth={2.5} type="monotone" yAxisId="tokens" />
|
||||
<Line dataKey="cacheTokens" dot={false} name={t("Cache tokens")} stroke={overviewCacheColor} strokeWidth={2} type="monotone" yAxisId="tokens" />
|
||||
</>
|
||||
) : null}
|
||||
{variant === "bar" ? (
|
||||
<>
|
||||
<Bar barSize={14} dataKey="totalTokens" fill="#0f766e" name={t("Total tokens")} radius={[4, 4, 0, 0]} yAxisId="tokens" />
|
||||
<Line dataKey="requestCount" dot={false} name={t("Requests")} stroke="#2563eb" strokeWidth={2} type="monotone" yAxisId="requests" />
|
||||
<Bar barSize={14} dataKey="totalTokens" fill="#007aff" name={t("Total tokens")} radius={[4, 4, 0, 0]} yAxisId="tokens" />
|
||||
<Line dataKey="requestCount" dot={false} name={t("Requests")} stroke="#34c759" strokeWidth={2} type="monotone" yAxisId="requests" />
|
||||
</>
|
||||
) : null}
|
||||
</ComposedChart>
|
||||
@@ -1125,14 +1256,11 @@ function TokenActivityOverviewWidget({
|
||||
const showLegend = dimensions.height >= 2 && dimensions.width >= 2;
|
||||
|
||||
return (
|
||||
<Card className="flex h-full min-h-0 min-w-0 flex-col">
|
||||
<CardHeader className="shrink-0 flex-row items-center justify-between">
|
||||
<CardTitle>{t("Activity")}</CardTitle>
|
||||
<Badge variant="outline">{t("Tokens")}</Badge>
|
||||
</CardHeader>
|
||||
<Card className="overview-card flex h-full min-h-0 min-w-0 flex-col">
|
||||
<OverviewCardHeading icon={CalendarDays} title={t("Activity")} tone="green" trailing={<Badge variant="outline">{t("Tokens")}</Badge>} />
|
||||
<CardContent className="flex min-h-0 flex-1 flex-col overflow-visible p-3">
|
||||
{showSummary ? (
|
||||
<div className={cn("mb-3 grid overflow-hidden rounded-lg border border-border bg-muted/20", dimensions.width >= 2 ? "grid-cols-4" : "grid-cols-2")}>
|
||||
<div className={cn("overview-nested-surface mb-3 grid overflow-hidden border", dimensions.width >= 2 ? "grid-cols-4" : "grid-cols-2")}>
|
||||
<OverviewActivityStat label={t("Longest streak")} value={formatCompactNumber(activity.longestStreak)} unit={t(activity.longestStreak === 1 ? "day" : "days")} />
|
||||
<OverviewActivityStat label={t("Avg / day")} value={formatCompactNumber(Math.round(activity.avgPerDay))} />
|
||||
<OverviewActivityStat label={t("Avg / week")} value={formatCompactNumber(Math.round(activity.avgPerWeek))} />
|
||||
@@ -1148,7 +1276,7 @@ function TokenActivityOverviewWidget({
|
||||
{[0, 1, 2, 3, 4].map((intensity) => (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-3 w-3 rounded-[3px]"
|
||||
className="overview-activity-cell h-3 w-3 rounded-[3px]"
|
||||
key={intensity}
|
||||
style={{ backgroundColor: overviewActivityColor(intensity as TokenActivityCell["intensity"], true) }}
|
||||
/>
|
||||
@@ -1184,7 +1312,7 @@ function OverviewActivityStat({
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0 border-r border-border bg-card/60 px-3 py-2 last:border-r-0">
|
||||
<div className="overview-activity-stat min-w-0 border-r border-border/60 bg-transparent px-3 py-2 last:border-r-0">
|
||||
<div className="truncate text-[11px] font-medium text-muted-foreground">{label}</div>
|
||||
<div className="mt-0.5 flex min-w-0 items-baseline gap-1">
|
||||
<span className="truncate text-[17px] font-semibold tracking-tight text-foreground">{value}</span>
|
||||
@@ -1254,7 +1382,7 @@ function OverviewActivityGrid({
|
||||
{activity.cells.map((cell) => (
|
||||
<span
|
||||
aria-label={`${cell.dateLabel}: ${formatActivityTokenCount(cell.totalTokens)} ${t("tokens")}`}
|
||||
className="group relative aspect-square w-full rounded-[4px]"
|
||||
className="overview-activity-cell group relative aspect-square w-full rounded-[4px]"
|
||||
key={cell.dateKey}
|
||||
style={{
|
||||
backgroundColor: overviewActivityColor(cell.intensity, cell.inObservedRange),
|
||||
@@ -1291,12 +1419,12 @@ function overviewActivityTooltipPositionClass(cell: TokenActivityCell, weekCount
|
||||
}
|
||||
|
||||
function overviewActivityColor(intensity: TokenActivityCell["intensity"], inRange: boolean): string {
|
||||
if (!inRange) return "rgba(99,102,241,.06)";
|
||||
if (intensity === 0) return "rgba(99,102,241,.12)";
|
||||
if (intensity === 1) return "rgba(99,102,241,.30)";
|
||||
if (intensity === 2) return "rgba(99,102,241,.50)";
|
||||
if (intensity === 3) return "rgba(99,102,241,.70)";
|
||||
return "rgba(99,102,241,.92)";
|
||||
if (!inRange) return "rgba(0,122,255,.05)";
|
||||
if (intensity === 0) return "rgba(0,122,255,.12)";
|
||||
if (intensity === 1) return "rgba(0,122,255,.30)";
|
||||
if (intensity === 2) return "rgba(0,122,255,.50)";
|
||||
if (intensity === 3) return "rgba(0,122,255,.72)";
|
||||
return "rgba(0,122,255,.94)";
|
||||
}
|
||||
|
||||
function TokenMixOverviewWidget({
|
||||
@@ -1310,8 +1438,8 @@ function TokenMixOverviewWidget({
|
||||
}) {
|
||||
const t = useAppText();
|
||||
const tokenMix = [
|
||||
{ color: "#2563eb", name: t("Input"), value: totals.inputTokens },
|
||||
{ color: "#d97706", name: t("Output"), value: totals.outputTokens },
|
||||
{ color: "#007aff", name: t("Input"), value: totals.inputTokens },
|
||||
{ color: "#ff9f0a", name: t("Output"), value: totals.outputTokens },
|
||||
{ color: overviewCacheColor, name: t("Cache"), value: totals.cacheTokens }
|
||||
];
|
||||
const total = tokenMix.reduce((sum, item) => sum + item.value, 0);
|
||||
@@ -1321,11 +1449,8 @@ function TokenMixOverviewWidget({
|
||||
: { bottom: 8, left: 8, right: 12, top: 8 };
|
||||
|
||||
return (
|
||||
<Card className="flex h-full min-h-0 min-w-0 flex-col">
|
||||
<CardHeader className="shrink-0 flex-row items-center justify-between">
|
||||
<CardTitle>{t("Token Mix")}</CardTitle>
|
||||
<Badge variant="outline">{formatCompactNumber(totals.totalTokens)}</Badge>
|
||||
</CardHeader>
|
||||
<Card className="overview-card flex h-full min-h-0 min-w-0 flex-col">
|
||||
<OverviewCardHeading icon={ChartPie} title={t("Token Mix")} tone="purple" trailing={<Badge variant="outline">{formatCompactNumber(totals.totalTokens)}</Badge>} />
|
||||
<CardContent className="min-h-0 flex-1 overflow-hidden">
|
||||
{variant === "stacked" ? (
|
||||
<div className="space-y-3">
|
||||
@@ -1338,35 +1463,41 @@ function TokenMixOverviewWidget({
|
||||
</div>
|
||||
) : null}
|
||||
{variant === "donut" || variant === "pie" ? (
|
||||
<ChartFrame fill>
|
||||
{({ height, width }) => (
|
||||
<PieChart height={height} width={width}>
|
||||
<Tooltip content={<TokenTooltip />} />
|
||||
<Pie
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
data={tokenMix}
|
||||
dataKey="value"
|
||||
innerRadius={variant === "donut" ? Math.min(height, width) * 0.22 : 0}
|
||||
nameKey="name"
|
||||
outerRadius={Math.min(height, width) * 0.34}
|
||||
paddingAngle={variant === "donut" ? 2 : 0}
|
||||
>
|
||||
{tokenMix.map((item) => (
|
||||
<Cell fill={item.color} key={item.name} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
)}
|
||||
</ChartFrame>
|
||||
<div className={cn("grid h-full min-h-0 items-center gap-3", showLegend && "grid-cols-[minmax(96px,1fr)_minmax(0,1fr)]")}>
|
||||
<div className="relative h-full min-h-0">
|
||||
<ChartFrame fill>
|
||||
{({ height, width }) => (
|
||||
<PieChart height={height} width={width}>
|
||||
<Tooltip content={<TokenTooltip />} />
|
||||
<Pie
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
data={tokenMix}
|
||||
dataKey="value"
|
||||
innerRadius={variant === "donut" ? Math.min(height, width) * 0.22 : 0}
|
||||
nameKey="name"
|
||||
outerRadius={Math.min(height, width) * 0.34}
|
||||
paddingAngle={variant === "donut" ? 2 : 0}
|
||||
>
|
||||
{tokenMix.map((item) => (
|
||||
<Cell fill={item.color} key={item.name} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
)}
|
||||
</ChartFrame>
|
||||
{variant === "donut" ? <OverviewDonutCenter label={t("Tokens")} value={formatCompactNumber(total)} /> : null}
|
||||
</div>
|
||||
{showLegend ? <OverviewTokenLegend rows={tokenMix} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{variant === "bars" ? (
|
||||
<ChartFrame fill>
|
||||
{({ height, width }) => (
|
||||
<BarChart data={tokenMix} height={height} layout="vertical" margin={chartMargin} width={width}>
|
||||
<CartesianGrid stroke="#dfe3e8" strokeDasharray="3 3" horizontal={false} />
|
||||
<XAxis axisLine={false} hide={dimensions.height <= 1} tick={{ fill: "#5f6b7a", fontSize: 11 }} tickFormatter={formatAxisNumber} tickLine={false} type="number" />
|
||||
<YAxis axisLine={false} dataKey="name" tick={{ fill: "#5f6b7a", fontSize: 11 }} tickLine={false} type="category" width={dimensions.width <= 1 ? 42 : 52} />
|
||||
<CartesianGrid stroke="var(--overview-chart-grid)" strokeDasharray="2 5" horizontal={false} />
|
||||
<XAxis axisLine={false} hide={dimensions.height <= 1} tick={{ fill: "var(--muted-foreground)", fontSize: 11 }} tickFormatter={formatAxisNumber} tickLine={false} type="number" />
|
||||
<YAxis axisLine={false} dataKey="name" tick={{ fill: "var(--muted-foreground)", fontSize: 11 }} tickLine={false} type="category" width={dimensions.width <= 1 ? 42 : 52} />
|
||||
<Tooltip content={<TokenTooltip />} />
|
||||
<Bar dataKey="value" radius={[0, 4, 4, 0]}>
|
||||
{tokenMix.map((item) => (
|
||||
@@ -1400,16 +1531,11 @@ function ModelDistributionOverviewWidget({
|
||||
: { bottom: 8, left: 8, right: 12, top: 8 };
|
||||
|
||||
return (
|
||||
<Card className="flex h-full min-h-0 min-w-0 flex-col">
|
||||
<CardHeader className="shrink-0 flex-row items-center justify-between">
|
||||
<CardTitle>{t("Model Distribution")}</CardTitle>
|
||||
<Badge variant="outline">{formatCompactNumber(total)}</Badge>
|
||||
</CardHeader>
|
||||
<Card className="overview-card flex h-full min-h-0 min-w-0 flex-col">
|
||||
<OverviewCardHeading icon={Layers3} title={t("Model Distribution")} tone="orange" trailing={<Badge variant="outline">{formatCompactNumber(total)}</Badge>} />
|
||||
<CardContent className="min-h-0 flex-1 overflow-hidden">
|
||||
{modelRows.length === 0 ? (
|
||||
<div className="flex h-full min-h-0 items-center justify-center rounded-lg border border-dashed border-border bg-muted/30 px-3 py-4 text-center text-[12px] text-muted-foreground">
|
||||
{t("No model activity")}
|
||||
</div>
|
||||
<OverviewEmptyState className="h-full py-4" compact label={t("No model activity")} />
|
||||
) : variant === "stacked" ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex h-3 overflow-hidden rounded-full bg-muted">
|
||||
@@ -1421,36 +1547,39 @@ function ModelDistributionOverviewWidget({
|
||||
</div>
|
||||
) : variant === "donut" || variant === "pie" ? (
|
||||
<div className={cn("grid h-full min-h-0 items-center gap-3", showLegend && "grid-cols-[minmax(96px,1fr)_minmax(0,1fr)]")}>
|
||||
<ChartFrame fill>
|
||||
{({ height, width }) => (
|
||||
<PieChart height={height} width={width}>
|
||||
<Tooltip content={<TokenTooltip />} />
|
||||
<Pie
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
data={modelRows}
|
||||
dataKey="value"
|
||||
innerRadius={variant === "donut" ? Math.min(height, width) * 0.22 : 0}
|
||||
nameKey="name"
|
||||
outerRadius={Math.min(height, width) * 0.34}
|
||||
paddingAngle={variant === "donut" ? 2 : 0}
|
||||
>
|
||||
{modelRows.map((item) => (
|
||||
<Cell fill={item.color} key={item.name} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
)}
|
||||
</ChartFrame>
|
||||
<div className="relative h-full min-h-0">
|
||||
<ChartFrame fill>
|
||||
{({ height, width }) => (
|
||||
<PieChart height={height} width={width}>
|
||||
<Tooltip content={<TokenTooltip />} />
|
||||
<Pie
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
data={modelRows}
|
||||
dataKey="value"
|
||||
innerRadius={variant === "donut" ? Math.min(height, width) * 0.22 : 0}
|
||||
nameKey="name"
|
||||
outerRadius={Math.min(height, width) * 0.34}
|
||||
paddingAngle={variant === "donut" ? 2 : 0}
|
||||
>
|
||||
{modelRows.map((item) => (
|
||||
<Cell fill={item.color} key={item.name} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
)}
|
||||
</ChartFrame>
|
||||
{variant === "donut" ? <OverviewDonutCenter label={t("Tokens")} value={formatCompactNumber(total)} /> : null}
|
||||
</div>
|
||||
{showLegend ? <OverviewTokenLegend rows={modelRows} /> : null}
|
||||
</div>
|
||||
) : (
|
||||
<ChartFrame fill>
|
||||
{({ height, width }) => (
|
||||
<BarChart data={modelRows} height={height} layout="vertical" margin={chartMargin} width={width}>
|
||||
<CartesianGrid stroke="#dfe3e8" strokeDasharray="3 3" horizontal={false} />
|
||||
<XAxis axisLine={false} hide={dimensions.height <= 1} tick={{ fill: "#5f6b7a", fontSize: 11 }} tickFormatter={formatAxisNumber} tickLine={false} type="number" />
|
||||
<YAxis axisLine={false} dataKey="name" tick={{ fill: "#5f6b7a", fontSize: 11 }} tickLine={false} type="category" width={dimensions.width <= 1 ? 58 : 88} />
|
||||
<CartesianGrid stroke="var(--overview-chart-grid)" strokeDasharray="2 5" horizontal={false} />
|
||||
<XAxis axisLine={false} hide={dimensions.height <= 1} tick={{ fill: "var(--muted-foreground)", fontSize: 11 }} tickFormatter={formatAxisNumber} tickLine={false} type="number" />
|
||||
<YAxis axisLine={false} dataKey="name" tick={{ fill: "var(--muted-foreground)", fontSize: 11 }} tickLine={false} type="category" width={dimensions.width <= 1 ? 58 : 88} />
|
||||
<Tooltip content={<TokenTooltip />} />
|
||||
<Bar dataKey="value" radius={[0, 4, 4, 0]}>
|
||||
{modelRows.map((item) => (
|
||||
@@ -1467,7 +1596,7 @@ function ModelDistributionOverviewWidget({
|
||||
}
|
||||
|
||||
function overviewModelDistributionRows(rows: UsageComparisonRow[], translate: (value: string) => string): Array<{ color: string; name: string; value: number }> {
|
||||
const colors = ["#2563eb", "#0f766e", "#d97706", "#be123c", "#7c3aed", "#64748b"];
|
||||
const colors = ["#007aff", "#34c759", "#ff9f0a", "#ff3b30", "#af52de", "#8e8e93"];
|
||||
const positiveRows = rows
|
||||
.filter((row) => row.totalTokens > 0)
|
||||
.sort((a, b) => b.totalTokens - a.totalTokens);
|
||||
@@ -1489,9 +1618,9 @@ function overviewModelDistributionRows(rows: UsageComparisonRow[], translate: (v
|
||||
|
||||
function OverviewTokenLegend({ rows }: { rows: Array<{ color: string; name: string; value: number }> }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<div className="grid grid-cols-1 gap-1.5">
|
||||
{rows.map((row) => (
|
||||
<div className="flex min-w-0 items-center gap-2 text-[12px]" key={row.name}>
|
||||
<div className="overview-legend-row flex min-w-0 items-center gap-2 rounded-[8px] px-2 py-1.5 text-[11px]" key={row.name}>
|
||||
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: row.color }} />
|
||||
<span className="min-w-0 flex-1 truncate text-muted-foreground">{row.name}</span>
|
||||
<span className="shrink-0 font-semibold">{formatCompactNumber(row.value)}</span>
|
||||
@@ -1532,18 +1661,15 @@ function OverviewAnalysisWidget({
|
||||
|
||||
if (shouldUseCompact) {
|
||||
return (
|
||||
<Card className="flex h-full min-h-0 min-w-0 flex-col">
|
||||
<CardHeader className="shrink-0 flex-row items-center justify-between">
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<Badge variant="outline">{rows.length}</Badge>
|
||||
</CardHeader>
|
||||
<Card className="overview-card flex h-full min-h-0 min-w-0 flex-col">
|
||||
<OverviewCardHeading icon={UsersRound} title={title} tone="slate" trailing={<Badge variant="outline">{rows.length}</Badge>} />
|
||||
<CardContent className="min-h-0 flex-1 overflow-hidden">
|
||||
{rows.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border bg-muted/30 px-3 py-7 text-center text-[12px] text-muted-foreground">{emptyLabel}</div>
|
||||
<OverviewEmptyState compact label={emptyLabel} />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{rows.slice(0, rowLimit).map((row) => (
|
||||
<div className="flex min-w-0 items-center justify-between gap-3 rounded-md border border-border bg-muted/20 px-3 py-2" key={row.key}>
|
||||
<div className="overview-nested-surface flex min-w-0 items-center justify-between gap-3 border px-3 py-2" key={row.key}>
|
||||
<span className="min-w-0 truncate text-[12px] font-medium">{row.label}</span>
|
||||
<span className="shrink-0 text-[12px] font-semibold">{formatCompactNumber(row.totalTokens)}</span>
|
||||
</div>
|
||||
@@ -1861,7 +1987,7 @@ function overviewWidgetOverlaySizeClass(size: OverviewWidgetSize): string {
|
||||
|
||||
type OverviewWidgetDimensions = { height: 1 | 2 | 3 | 4; width: 1 | 2 | 3 | 4 };
|
||||
|
||||
const overviewCacheColor = "#6366f1";
|
||||
const overviewCacheColor = "#af52de";
|
||||
|
||||
function overviewWidgetDimensions(size: OverviewWidgetSize): OverviewWidgetDimensions {
|
||||
const [widthText, heightText] = size.split(":");
|
||||
@@ -2004,10 +2130,10 @@ function SystemStatusBar({
|
||||
|
||||
if (variant === "compact") {
|
||||
return (
|
||||
<Card className="flex h-full min-h-0 min-w-0 flex-col border-border/70 bg-card">
|
||||
<Card className="overview-card flex h-full min-h-0 min-w-0 flex-col">
|
||||
<CardContent className="flex min-h-0 min-w-0 flex-1 items-center justify-between gap-3 p-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className={cn("flex h-5 w-5 shrink-0 items-center justify-center rounded-full", systemStatusIconClass(overallTone))}>
|
||||
<span className="overview-status-icon flex h-5 w-5 shrink-0 items-center justify-center rounded-full" data-tone={overallTone}>
|
||||
<StatusIcon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
@@ -2024,28 +2150,25 @@ function SystemStatusBar({
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="flex h-full min-h-0 min-w-0 flex-col border-border/70 bg-card">
|
||||
<CardContent className="min-h-0 flex-1 space-y-4 overflow-hidden p-4">
|
||||
<div className="flex min-w-0 items-center justify-between gap-3">
|
||||
<h2 className="truncate text-[15px] font-semibold tracking-tight">{t("System status")}</h2>
|
||||
<div className="flex shrink-0 items-center gap-2 text-[12px] font-medium text-muted-foreground">
|
||||
<ChevronLeft aria-hidden="true" className="h-3.5 w-3.5 opacity-60" />
|
||||
<span>{rangeLabel}</span>
|
||||
<ChevronRight aria-hidden="true" className="h-3.5 w-3.5 opacity-60" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="overview-card flex h-full min-h-0 min-w-0 flex-col">
|
||||
<OverviewCardHeading
|
||||
icon={Server}
|
||||
title={t("System status")}
|
||||
tone={overallTone === "ok" ? "green" : overallTone === "warn" ? "orange" : overallTone === "error" ? "red" : "slate"}
|
||||
trailing={<span className="overview-date-pill block max-w-[320px] truncate">{rangeLabel}</span>}
|
||||
/>
|
||||
<CardContent className="min-h-0 flex-1 overflow-hidden p-3">
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex min-w-0 items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className={cn("flex h-4 w-4 shrink-0 items-center justify-center rounded-full", systemStatusIconClass(overallTone))}>
|
||||
<span className="overview-status-icon flex h-4 w-4 shrink-0 items-center justify-center rounded-full" data-tone={overallTone}>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-[13px] font-semibold">{t("API Service")}</span>
|
||||
</div>
|
||||
<div className="shrink-0 text-[12px] font-medium text-muted-foreground">
|
||||
<Badge variant={overallTone === "ok" ? "success" : overallTone === "warn" ? "warning" : overallTone === "error" ? "danger" : "outline"}>
|
||||
{formatPercent(availability)} {t("Availability")}
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 gap-1" aria-label={t("System status")}>
|
||||
@@ -2055,8 +2178,9 @@ function SystemStatusBar({
|
||||
key={`${segment.point.bucket}-${index}`}
|
||||
>
|
||||
<span
|
||||
className={cn("h-full w-full rounded-[3px]", systemStatusSegmentClass(segment.tone))}
|
||||
aria-label={systemStatusPointTooltip(segment, t)}
|
||||
className="overview-status-segment h-full w-full rounded-[4px]"
|
||||
data-tone={segment.tone}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
@@ -2114,22 +2238,29 @@ function ProviderAccountsOverview({
|
||||
: sortedAccounts
|
||||
.filter((account) => account.meters.length > 0 || account.status === "error");
|
||||
const isSingleAccount = visibleAccounts.length === 1;
|
||||
const showHeading = dimensions.height >= 2 && dimensions.width >= 2;
|
||||
|
||||
return (
|
||||
<Card className="flex h-full min-h-0 min-w-0 flex-col">
|
||||
<Card className="overview-card flex h-full min-h-0 min-w-0 flex-col">
|
||||
{showHeading ? (
|
||||
<OverviewCardHeading
|
||||
icon={WalletCards}
|
||||
title={t("Account Balance")}
|
||||
tone="green"
|
||||
trailing={<Badge variant="outline">{visibleAccounts.length}</Badge>}
|
||||
/>
|
||||
) : null}
|
||||
<CardContent className={cn("min-h-0 flex-1 overflow-hidden", providerAccountContentPaddingClass(dimensions))}>
|
||||
{visibleAccounts.length === 0 ? (
|
||||
<div className="flex h-full min-h-0 items-center justify-center rounded-lg border border-dashed border-border bg-muted/30 px-3 py-4 text-center text-[12px] text-muted-foreground">
|
||||
{t("No account balance connectors configured")}
|
||||
</div>
|
||||
<OverviewEmptyState className="h-full py-4" compact label={t("No account balance connectors configured")} />
|
||||
) : isSingleAccount ? (
|
||||
<ProviderAccountSinglePanel account={visibleAccounts[0]} dimensions={dimensions} refreshing={refreshing} variant={variant} onRefresh={onRefresh} />
|
||||
) : variant === "compact" ? (
|
||||
<div className={cn("grid h-full min-h-0 grid-cols-1 overflow-y-auto pr-1", providerAccountGapClass(dimensions), providerAccountGridClass(dimensions))}>
|
||||
<div className={cn("grid h-full min-h-0 grid-cols-1 overflow-y-auto pr-1", providerAccountGapClass(dimensions), providerAccountGridClass(dimensions, visibleAccounts.length))}>
|
||||
{visibleAccounts.map((account) => {
|
||||
const meter = primaryProviderAccountDisplayMeter(account);
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 items-center justify-between gap-3 overflow-hidden rounded-lg border border-border bg-muted/20 px-3 py-2" key={providerAccountSnapshotKey(account)}>
|
||||
<div className="overview-nested-surface flex min-h-0 min-w-0 items-center justify-between gap-3 overflow-hidden border px-3 py-2" key={providerAccountSnapshotKey(account)}>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[12px] font-semibold">{providerAccountSnapshotLabel(account)}</div>
|
||||
{providerAccountShowSource(dimensions) && meter ? <div className="truncate text-[11px] text-muted-foreground">{t(meter.label)}</div> : null}
|
||||
@@ -2171,7 +2302,7 @@ function ProviderAccountsOverview({
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn("grid h-full min-h-0 grid-cols-1 overflow-y-auto pr-1", providerAccountGapClass(dimensions), providerAccountGridClass(dimensions))}>
|
||||
<div className={cn("grid h-full min-h-0 grid-cols-1 overflow-y-auto pr-1", providerAccountGapClass(dimensions), providerAccountGridClass(dimensions, visibleAccounts.length))}>
|
||||
{visibleAccounts.map((account) => {
|
||||
return <ProviderAccountSummaryCard account={account} dimensions={dimensions} key={providerAccountSnapshotKey(account)} refreshing={refreshing} variant={variant} onRefresh={onRefresh} />;
|
||||
})}
|
||||
@@ -2250,7 +2381,7 @@ function ProviderAccountSummaryCard({
|
||||
const showQuotaVisual = providerAccountUsesQuotaVisual(variant) && quotaMeters.length > 0;
|
||||
|
||||
return (
|
||||
<div className={cn("min-h-0 min-w-0 overflow-hidden rounded-lg border border-border bg-muted/20", providerAccountCardPaddingClass(dimensions))}>
|
||||
<div className={cn("overview-nested-surface min-h-0 min-w-0 overflow-hidden border", providerAccountCardPaddingClass(dimensions))}>
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[13px] font-semibold">{providerAccountSnapshotLabel(account)}</div>
|
||||
@@ -3028,7 +3159,7 @@ function ProviderAccountQuotaGauge({
|
||||
const path = describeSvgArc(60, 66, 42, start, end);
|
||||
return (
|
||||
<svg aria-hidden="true" className={sizeClass} viewBox="0 0 120 120">
|
||||
<path d={path} fill="none" pathLength={100} stroke="hsl(var(--muted))" strokeLinecap="round" strokeWidth="11" />
|
||||
<path d={path} fill="none" pathLength={100} stroke="var(--muted)" strokeLinecap="round" strokeWidth="11" />
|
||||
<path d={path} fill="none" pathLength={100} stroke={stroke} strokeDasharray={`${Math.round(primaryRatio * 100)} 100`} strokeLinecap="round" strokeWidth="11" />
|
||||
<text className="fill-foreground text-[18px] font-semibold" dy="0.35em" textAnchor="middle" x="60" y="60">{formatProviderAccountMeterValue(primary)}</text>
|
||||
</svg>
|
||||
@@ -3075,7 +3206,7 @@ function ProviderAccountQuotaCircle({
|
||||
|
||||
return (
|
||||
<>
|
||||
<circle cx={cx} cy={cy} fill="none" r={radius} stroke="hsl(var(--muted))" strokeWidth={strokeWidth} />
|
||||
<circle cx={cx} cy={cy} fill="none" r={radius} stroke="var(--muted)" strokeWidth={strokeWidth} />
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
@@ -3241,8 +3372,8 @@ function providerAccountStackClass(dimensions: OverviewWidgetDimensions): string
|
||||
return dimensions.height <= 1 ? "space-y-1.5" : "space-y-2.5";
|
||||
}
|
||||
|
||||
function providerAccountGridClass(dimensions: OverviewWidgetDimensions): string {
|
||||
if (dimensions.width >= 3) return "md:grid-cols-2 xl:grid-cols-3";
|
||||
function providerAccountGridClass(dimensions: OverviewWidgetDimensions, itemCount: number): string {
|
||||
if (dimensions.width >= 3) return itemCount <= 2 ? "md:grid-cols-2" : "md:grid-cols-2 xl:grid-cols-3";
|
||||
if (dimensions.width >= 2) return "md:grid-cols-2";
|
||||
return "";
|
||||
}
|
||||
@@ -4378,14 +4509,11 @@ function UsageAnalysisCard({
|
||||
const showCacheRate = dimensions.width >= 4 && dimensions.height >= 3;
|
||||
|
||||
return (
|
||||
<Card className="flex h-full min-h-0 min-w-0 flex-col">
|
||||
<CardHeader className="shrink-0 flex-row items-center justify-between">
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<Badge variant="outline">{rows.length}</Badge>
|
||||
</CardHeader>
|
||||
<Card className="overview-card flex h-full min-h-0 min-w-0 flex-col">
|
||||
<OverviewCardHeading icon={UsersRound} title={title} tone="slate" trailing={<Badge variant="outline">{rows.length}</Badge>} />
|
||||
<CardContent className="min-h-0 flex-1 overflow-hidden">
|
||||
{rows.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border bg-muted/30 px-3 py-8 text-center text-[12px] text-muted-foreground">{emptyLabel}</div>
|
||||
<OverviewEmptyState compact label={emptyLabel} />
|
||||
) : (
|
||||
<div className={cn("h-full overflow-hidden", agentListSurfaceClassName)}>
|
||||
<table className={cn("table-fixed", agentListTableClassName)}>
|
||||
@@ -4485,7 +4613,7 @@ function UsageTooltip({
|
||||
const point = payload.find((item) => item.payload)?.payload;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-card/95 glass-surface px-3 py-2.5 text-[11px] shadow-card-elevated">
|
||||
<div className="overview-tooltip rounded-xl border px-3 py-2.5 text-[11px]">
|
||||
<div className="mb-1 font-semibold">{label}</div>
|
||||
<div className="space-y-1">
|
||||
{payload.map((item) => (
|
||||
@@ -4572,7 +4700,7 @@ function TokenTooltip({
|
||||
const title = label || payload[0]?.name || "";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-card/95 glass-surface px-3 py-2.5 text-[11px] shadow-card-elevated">
|
||||
<div className="overview-tooltip rounded-xl border px-3 py-2.5 text-[11px]">
|
||||
<div className="font-semibold">{title}</div>
|
||||
<div className="mt-1 text-muted-foreground">{formatCompactNumber(Number(payload[0]?.value) || 0)} tokens</div>
|
||||
</div>
|
||||
|
||||
@@ -536,9 +536,9 @@ export function Toggle({ checked, disabled = false, onChange, title }: { checked
|
||||
|
||||
export type MetricTone = "amber" | "blue" | "indigo" | "rose" | "slate" | "teal";
|
||||
|
||||
export function MetricCard({ label, tone, value }: { label: string; tone: MetricTone; value: string }) {
|
||||
export function MetricCard({ className, label, tone, value }: { className?: string; label: string; tone: MetricTone; value: string }) {
|
||||
return (
|
||||
<Card className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
|
||||
<Card className={cn("flex h-full min-h-0 min-w-0 flex-col overflow-hidden", className)}>
|
||||
<div className={cn("h-1", metricToneBar(tone))} />
|
||||
<CardContent className="flex min-h-[88px] flex-1 flex-col justify-center">
|
||||
<div className="min-w-0">
|
||||
@@ -586,6 +586,12 @@ export function formatStatusBucketDate(bucket: string, range: UsageStatsRange):
|
||||
}
|
||||
|
||||
export function parseStatusBucketDate(bucket: string): Date | undefined {
|
||||
if (/^\d{4}-\d{1,2}-\d{1,2}T\d{1,2}:\d{2}/.test(bucket)) {
|
||||
const isoDate = new Date(bucket);
|
||||
if (Number.isFinite(isoDate.getTime())) {
|
||||
return isoDate;
|
||||
}
|
||||
}
|
||||
const match = bucket.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:\s+(\d{1,2})(?::00)?)?$/);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
AppConfig, createSourceTabs, DEFAULT_TRAY_WIDGETS, defaultTrayWidgetVariant, emptySnapshots, formatCompactNumber, formatProviderName,
|
||||
AppConfig, applyTrayThemePreference, createSourceTabs, DEFAULT_TRAY_WIDGETS, defaultTrayWidgetVariant, emptySnapshots, formatCompactNumber, formatProviderName,
|
||||
formatPercent, formatUpdated, formatUsdCost, normalizeTrayWidgets, ProviderAccountSnapshot, rangeLabel,
|
||||
SnapshotMap, SourceTab, TrayComponentVariants, TrayWidgetConfig, UsageComparisonRow, UsageStatsFilter, UsageStatsRange, UsageTotals, useCallback, useEffect,
|
||||
useMemo, useState, useTrayErrorText, useTrayText
|
||||
useMemo, useState, useTrayErrorText, useTrayText, useTrayThemePreference
|
||||
} from "./shared";
|
||||
import {
|
||||
AccountSummaryPanel, AnimatedUsageChart, ChartShell, ModelShareChart, RingMetrics,
|
||||
@@ -16,8 +16,9 @@ const trayHeaderRanges: TrayHeaderRange[] = ["24h", "7d", "30d"];
|
||||
export function TrayApp() {
|
||||
const t = useTrayText();
|
||||
const formatError = useTrayErrorText();
|
||||
useTrayThemePreference();
|
||||
const [allSnapshots, setAllSnapshots] = useState<SnapshotMap>(emptySnapshots);
|
||||
const [configuredProviders, setConfiguredProviders] = useState<string[]>([]);
|
||||
const [configuredProviders, setConfiguredProviders] = useState<AppConfig["Providers"]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>();
|
||||
@@ -52,8 +53,9 @@ export function TrayApp() {
|
||||
setSnapshots({ today, "24h": day, "7d": week, "30d": month });
|
||||
setAllSnapshots((current) => ({ ...current, "30d": allMonth ?? month }));
|
||||
setAccountSnapshots(accounts);
|
||||
setConfiguredProviders(config.Providers.map((provider) => provider.name.trim()).filter(Boolean));
|
||||
setConfiguredProviders(config.Providers.filter((provider) => provider.name.trim()));
|
||||
setTrayWidgets(normalizeTrayWidgets(config.trayWidgets, config.trayWindowModules, config.trayComponentVariants));
|
||||
applyTrayThemePreference(config.theme);
|
||||
} catch (nextError) {
|
||||
setError(formatError(nextError));
|
||||
} finally {
|
||||
@@ -129,7 +131,7 @@ export function TrayApp() {
|
||||
|
||||
return (
|
||||
<main className="h-screen w-screen overflow-hidden bg-transparent text-slate-100">
|
||||
<aside className="flex h-full min-h-0 flex-col overflow-y-auto rounded-[14px] border border-slate-950/15 bg-slate-950 p-3 text-slate-50 shadow-[0_18px_42px_rgba(15,23,42,.28)]">
|
||||
<aside className="tray-shell flex h-full min-h-0 flex-col overflow-y-auto p-3">
|
||||
<TrayStatusStrip totalTokens={activeTotals.totalTokens} />
|
||||
|
||||
<section className="space-y-2">
|
||||
@@ -153,12 +155,12 @@ export function TrayApp() {
|
||||
))}
|
||||
</section>
|
||||
|
||||
{loading ? <div className="mt-1.5 text-[11px] font-medium text-slate-200/60">{t("Syncing usage...")}</div> : null}
|
||||
{loading ? <div className="mt-1.5 text-[11px] font-medium text-slate-300/55">{t("Syncing usage...")}</div> : null}
|
||||
|
||||
{error ? <div className="mt-3 rounded-lg border border-rose-400/24 bg-rose-500/18 px-3 py-2 text-[12px] font-medium text-rose-100">{error}</div> : null}
|
||||
{error ? <div className="mt-3 rounded-[12px] border border-rose-400/20 bg-rose-500/15 px-3 py-2 text-[12px] font-medium text-rose-100">{error}</div> : null}
|
||||
|
||||
{!hasAnyVisibleModule && !error ? (
|
||||
<div className="flex min-h-[260px] items-center justify-center rounded-[10px] border border-white/10 bg-white/[.03] px-4 text-center text-[12px] font-medium text-slate-400">
|
||||
<div className="tray-panel-subtle flex min-h-[260px] items-center justify-center px-4 text-center text-[12px] font-medium text-slate-400">
|
||||
{t("No tray modules enabled")}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -204,7 +206,7 @@ function TrayRuntimeWidget({
|
||||
|
||||
if (widget.type === "header") {
|
||||
return (
|
||||
<div className="flex min-w-0 items-start justify-between gap-2 rounded-[8px] border border-white/10 bg-white/[.04] px-2.5 py-2">
|
||||
<div className="tray-panel flex min-w-0 items-start justify-between gap-2 px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-[13px] font-bold text-slate-50">{selectedProvider ? formatProviderName(selectedProvider) : t("Usage Overview")}</h1>
|
||||
<p className="mt-0.5 truncate text-[10px] font-medium text-slate-400">{formatUpdated(activeStats.generatedAt, t)}</p>
|
||||
@@ -265,10 +267,11 @@ function TrayHeaderRangeSwitch({
|
||||
const t = useTrayText();
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 rounded-md border border-white/10 bg-slate-900/70 p-0.5">
|
||||
<div className="tray-segmented flex shrink-0">
|
||||
{trayHeaderRanges.map((item) => (
|
||||
<button
|
||||
className={`h-5 rounded-[5px] px-1.5 text-[10px] font-bold transition ${range === item ? "bg-white/14 text-slate-50" : "text-slate-400 hover:text-slate-100"}`}
|
||||
className="tray-segmented-item h-5 px-1.5 text-[10px] font-semibold"
|
||||
data-active={range === item}
|
||||
key={item}
|
||||
type="button"
|
||||
onClick={() => onChange(item)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
DEFAULT_TRAY_WIDGETS, emptySnapshots,
|
||||
applyTrayThemePreference, DEFAULT_TRAY_WIDGETS, emptySnapshots,
|
||||
normalizeTrayWidgets, ProviderAccountSnapshot, SnapshotMap, TrayWidgetConfig, UsageStatsFilter,
|
||||
UsageStatsRange, useCallback, useEffect, useState, useTrayErrorText, useTrayText
|
||||
UsageStatsRange, useCallback, useEffect, useState, useTrayErrorText, useTrayText, useTrayThemePreference
|
||||
} from "./shared";
|
||||
import {
|
||||
TrayStatusStrip, UsageDetailPanel
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
export function TrayDetailApp({ provider }: { provider?: string }) {
|
||||
const t = useTrayText();
|
||||
const formatError = useTrayErrorText();
|
||||
useTrayThemePreference();
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [range, setRange] = useState<UsageStatsRange>("30d");
|
||||
@@ -40,6 +41,7 @@ export function TrayDetailApp({ provider }: { provider?: string }) {
|
||||
setSnapshots({ today, "24h": day, "7d": week, "30d": month });
|
||||
setAccountSnapshots(accounts);
|
||||
setTrayWidgets(normalizeTrayWidgets(config.trayWidgets, config.trayWindowModules, config.trayComponentVariants));
|
||||
applyTrayThemePreference(config.theme);
|
||||
} catch (nextError) {
|
||||
setError(formatError(nextError));
|
||||
} finally {
|
||||
@@ -91,12 +93,12 @@ export function TrayDetailApp({ provider }: { provider?: string }) {
|
||||
|
||||
return (
|
||||
<main
|
||||
className="h-screen w-screen overflow-y-auto rounded-[14px] border border-slate-950/15 bg-slate-950 p-3 text-slate-100 shadow-[0_18px_42px_rgba(15,23,42,.28)]"
|
||||
className="tray-shell h-screen w-screen overflow-y-auto p-3"
|
||||
>
|
||||
<TrayStatusStrip totalTokens={snapshots[range].totals.totalTokens} />
|
||||
<UsageDetailPanel activeStats={snapshots[range]} accountRefreshing={accountRefreshing} accountSnapshots={accountSnapshots} provider={provider} range={range} widgets={trayWidgets} onRefreshAccount={refreshAccountSnapshots} onRangeChange={setRange} />
|
||||
{loading ? <div className="mt-2 text-[11px] font-medium text-slate-200/60">{t("Syncing usage...")}</div> : null}
|
||||
{error ? <div className="mt-3 rounded-lg border border-rose-400/24 bg-rose-500/18 px-3 py-2 text-[12px] font-medium text-rose-100">{error}</div> : null}
|
||||
{loading ? <div className="mt-2 text-[11px] font-medium text-slate-300/55">{t("Syncing usage...")}</div> : null}
|
||||
{error ? <div className="mt-3 rounded-[12px] border border-rose-400/20 bg-rose-500/15 px-3 py-2 text-[12px] font-medium text-rose-100">{error}</div> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export function AccountSummaryPanel({
|
||||
|
||||
if (!snapshot) {
|
||||
return (
|
||||
<div className="rounded-[8px] border border-white/10 bg-white/[.03] px-3 py-2 text-[11px] font-medium text-slate-400">
|
||||
<div className="tray-panel-subtle px-3 py-2 text-[11px] font-medium text-slate-400">
|
||||
{t("No account data configured")}
|
||||
</div>
|
||||
);
|
||||
@@ -33,12 +33,12 @@ export function AccountSummaryPanel({
|
||||
const meters = accountMetersForDisplay(snapshot, variant === "stacked" ? 3 : 2);
|
||||
|
||||
return (
|
||||
<div className="rounded-[8px] border border-white/10 bg-white/[.04] p-2">
|
||||
<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>
|
||||
<button
|
||||
aria-label={t("Refresh")}
|
||||
className={`m-0 inline-flex shrink-0 appearance-none items-center justify-center border-0 bg-transparent p-0 shadow-none transition-colors 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(snapshot.status)}`}
|
||||
disabled={refreshing || !onRefresh}
|
||||
onClick={() => {
|
||||
void onRefresh?.();
|
||||
@@ -87,7 +87,7 @@ function AccountMeters({
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{meters.map((meter) => (
|
||||
<div className="min-w-0 rounded-md bg-white/[.04] px-2 py-1" key={meter.id}>
|
||||
<div className="tray-stat-cell min-w-0 px-2 py-1" key={meter.id}>
|
||||
<div className="truncate text-[9px] font-medium text-slate-400">{formatAccountMeterTitle(meter, t)}</div>
|
||||
<div className="truncate text-[12px] font-bold text-slate-50">{formatAccountMeterValue(meter, t)}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,35 @@
|
||||
import {
|
||||
SourceTab, useTrayText
|
||||
SourceTab, useState, useTrayText
|
||||
} from "../shared";
|
||||
import { CircleHelp, Layers3 } from "lucide-react";
|
||||
|
||||
function SourceTabIcon({ tab }: { tab: SourceTab }) {
|
||||
const [failedIconUrl, setFailedIconUrl] = useState("");
|
||||
const isAll = !tab.provider;
|
||||
const showProviderIcon = Boolean(tab.iconUrl && tab.iconUrl !== failedIconUrl);
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="tray-source-icon flex h-4 w-4 shrink-0 items-center justify-center"
|
||||
data-icon-kind={isAll ? "all" : showProviderIcon ? "provider" : "fallback"}
|
||||
>
|
||||
{isAll ? (
|
||||
<Layers3 size={11} strokeWidth={2} />
|
||||
) : showProviderIcon ? (
|
||||
<img
|
||||
alt=""
|
||||
className="h-3.5 w-3.5 rounded-[3px] object-contain"
|
||||
src={tab.iconUrl}
|
||||
onError={() => setFailedIconUrl(tab.iconUrl ?? "")}
|
||||
/>
|
||||
) : (
|
||||
<CircleHelp size={11} strokeWidth={2} />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function SourceGrid({
|
||||
selectedProvider,
|
||||
tabs,
|
||||
@@ -18,17 +47,16 @@ export function SourceGrid({
|
||||
const active = tab.provider === selectedProvider || (!tab.provider && !selectedProvider);
|
||||
return (
|
||||
<button
|
||||
className={[
|
||||
"min-w-0 truncate rounded-md border px-2 py-1 text-center text-[10px] font-semibold",
|
||||
active
|
||||
? "border-teal-300/35 bg-teal-300/16 text-teal-50"
|
||||
: "border-white/10 bg-white/[.04] text-slate-300 hover:border-white/16 hover:bg-white/[.07] hover:text-slate-50"
|
||||
].join(" ")}
|
||||
aria-pressed={active}
|
||||
className="tray-source-tab flex min-w-0 items-center justify-center gap-1 px-1.5 py-1.5 text-center text-[10px] font-semibold"
|
||||
data-active={active}
|
||||
key={tab.id}
|
||||
title={tab.provider ?? t(tab.label)}
|
||||
type="button"
|
||||
onClick={() => onSelect(tab.provider)}
|
||||
>
|
||||
{t(tab.label)}
|
||||
<SourceTabIcon tab={tab} />
|
||||
<span className="min-w-0 truncate">{t(tab.label)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -6,10 +6,10 @@ export function TrayStatusStrip({ totalTokens }: { totalTokens: number }) {
|
||||
const t = useTrayText();
|
||||
|
||||
return (
|
||||
<div className="mb-3 flex min-w-0 items-center justify-between gap-3 border-b border-white/10 pb-2">
|
||||
<div className="tray-status-strip mb-3 flex min-w-0 items-center justify-between gap-3 border-b pb-2.5">
|
||||
<button
|
||||
aria-label={t("Open CCR")}
|
||||
className="-ml-1 flex min-w-0 items-center gap-2 rounded-md px-1 py-0.5 text-left transition hover:bg-white/[.06] focus:outline-none focus:ring-2 focus:ring-cyan-300/35"
|
||||
className="tray-header-action -ml-1 flex min-w-0 items-center gap-2 rounded-[9px] px-1 py-0.5 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/35"
|
||||
title={t("Open CCR")}
|
||||
type="button"
|
||||
onClick={() => void window.ccr?.showMainWindow()}
|
||||
@@ -22,7 +22,7 @@ export function TrayStatusStrip({ totalTokens }: { totalTokens: number }) {
|
||||
</button>
|
||||
<button
|
||||
aria-label={t("Quit")}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-white/10 bg-white/[.04] text-slate-300 hover:border-white/16 hover:bg-white/[.08] hover:text-slate-50"
|
||||
className="tray-icon-button flex h-7 w-7 shrink-0 items-center justify-center"
|
||||
title={t("Quit")}
|
||||
type="button"
|
||||
onClick={() => void window.ccr?.quitApp()}
|
||||
@@ -37,7 +37,7 @@ function TrayWindowHeaderIcon() {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-md border border-white/15 bg-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.12)]"
|
||||
className="tray-header-icon flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-[8px] border"
|
||||
>
|
||||
<img alt="" className="h-[72%] w-[72%] object-contain" src={appLogoUrl} />
|
||||
</span>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function UsageDetailPanel({
|
||||
}
|
||||
if (widget.type === "header") {
|
||||
return (
|
||||
<div className="flex min-w-0 items-start justify-between gap-2 rounded-[8px] border border-white/10 bg-white/[.04] px-2.5 py-2" key={`${widget.id}-${index}`}>
|
||||
<div className="tray-panel flex min-w-0 items-start justify-between gap-2 px-3 py-2.5" key={`${widget.id}-${index}`}>
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-[13px] font-bold text-slate-50">{t("Usage Detail")}</h2>
|
||||
<p className="mt-0.5 truncate text-[10px] font-medium text-slate-400">{rangeLabel(range, t)} - {provider ? formatProviderName(provider) : t("All providers")}</p>
|
||||
@@ -83,7 +83,7 @@ export function UsageDetailPanel({
|
||||
})}
|
||||
</div>
|
||||
{!hasDetailModule ? (
|
||||
<div className="flex min-h-[260px] items-center justify-center rounded-[10px] border border-white/10 bg-white/[.03] px-4 text-center text-[12px] font-medium text-slate-400">
|
||||
<div className="tray-panel-subtle flex min-h-[260px] items-center justify-center px-4 text-center text-[12px] font-medium text-slate-400">
|
||||
{t("No tray modules enabled")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -7,10 +7,11 @@ export function RangeSwitch({ range, onChange }: { range: UsageStatsRange; onCha
|
||||
const t = useTrayText();
|
||||
|
||||
return (
|
||||
<div className="flex rounded-lg border border-white/8 bg-slate-900/28 p-0.5">
|
||||
<div className="tray-segmented flex">
|
||||
{ranges.map((item) => (
|
||||
<button
|
||||
className={`h-7 rounded-[7px] px-2.5 text-[11px] font-bold ${range === item ? "bg-white/14 text-slate-50 shadow-[inset_0_1px_0_rgba(255,255,255,.18)]" : "text-slate-300/72 hover:text-slate-100"}`}
|
||||
className="tray-segmented-item h-7 px-2.5 text-[11px] font-semibold"
|
||||
data-active={range === item}
|
||||
key={item}
|
||||
type="button"
|
||||
onClick={() => onChange(item)}
|
||||
@@ -24,7 +25,7 @@ export function RangeSwitch({ range, onChange }: { range: UsageStatsRange; onCha
|
||||
|
||||
export function ChartShell({ children, meta, title }: { children: ReactNode; meta?: string; title: string }) {
|
||||
return (
|
||||
<div className="relative min-w-0 overflow-hidden rounded-[8px] border border-white/10 bg-white/[.04] p-2">
|
||||
<div className="tray-panel relative min-w-0 overflow-hidden p-2.5">
|
||||
<div className="relative z-10 flex min-w-0 items-center justify-between gap-2">
|
||||
<h3 className="truncate text-[11px] font-bold text-slate-100">{title}</h3>
|
||||
{meta ? <span className="min-w-0 truncate text-[10px] font-medium text-slate-400">{meta}</span> : null}
|
||||
@@ -43,7 +44,7 @@ export function StatsGrid({
|
||||
}) {
|
||||
if (variant === "compact") {
|
||||
return (
|
||||
<div className="mb-2 rounded-[8px] border border-white/10 bg-white/[.04] p-2">
|
||||
<div className="tray-panel mb-2 p-2.5">
|
||||
{items.map((item) => (
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 py-0.5 text-[10px]" key={item.label}>
|
||||
<span className="truncate font-medium text-slate-400">{item.label}</span>
|
||||
@@ -58,7 +59,7 @@ export function StatsGrid({
|
||||
return (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{items.map((item) => (
|
||||
<div className="rounded-full border border-white/10 bg-white/[.05] px-2 py-1 text-[10px] font-bold text-slate-100" key={item.label}>
|
||||
<div className="tray-stat-cell rounded-full px-2 py-1 text-[10px] font-semibold text-slate-100" key={item.label}>
|
||||
<span className="text-slate-400">{item.label}</span> {item.value}
|
||||
</div>
|
||||
))}
|
||||
@@ -77,7 +78,7 @@ export function StatsGrid({
|
||||
|
||||
function StatChip({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="min-w-0 rounded-[7px] border border-white/10 bg-white/[.04] px-2 py-1.5">
|
||||
<div className="tray-stat-cell min-w-0 px-2 py-1.5">
|
||||
<div className="truncate text-[10px] font-medium text-slate-400">{label}</div>
|
||||
<div className="truncate text-[13px] font-bold text-slate-50">{value}</div>
|
||||
</div>
|
||||
@@ -102,37 +103,38 @@ export function AnimatedUsageChart({
|
||||
<div className="min-w-0">
|
||||
<svg className="mt-2 h-16 w-full overflow-visible" preserveAspectRatio="none" role="img" viewBox="0 0 260 72" aria-label={t("Usage chart")}>
|
||||
<defs>
|
||||
<filter id={`${chartId}-glow`} x="-20%" y="-40%" width="140%" height="180%">
|
||||
<feGaussianBlur stdDeviation="2.8" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<linearGradient id={`${chartId}-primary-fill`} x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0" stopColor="rgba(10,132,255,.32)" />
|
||||
<stop offset="1" stopColor="rgba(10,132,255,.015)" />
|
||||
</linearGradient>
|
||||
<linearGradient id={`${chartId}-secondary-fill`} x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0" stopColor="rgba(191,90,242,.2)" />
|
||||
<stop offset="1" stopColor="rgba(191,90,242,.01)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{[20, 68, 116, 164, 212].map((x) => (
|
||||
<line key={x} stroke="rgba(148,163,184,.12)" strokeWidth="1" x1={x} x2={x} y1="0" y2="72" />
|
||||
<line key={x} stroke="rgba(235,235,245,.075)" strokeWidth="1" x1={x} x2={x} y1="0" y2="72" />
|
||||
))}
|
||||
{variant === "bar" ? (
|
||||
<>
|
||||
{tokenGeometry.bars.map((bar) => (
|
||||
<rect fill="rgba(45,212,191,.9)" height={bar.height} key={`token-${bar.x}`} rx="3" width={bar.width} x={bar.x} y={bar.y} />
|
||||
<rect fill="rgba(10,132,255,.92)" height={bar.height} key={`token-${bar.x}`} rx="3" width={bar.width} x={bar.x} y={bar.y} />
|
||||
))}
|
||||
{cacheGeometry.bars.map((bar) => (
|
||||
<rect fill="rgba(167,139,250,.72)" height={bar.height} key={`cache-${bar.x}`} rx="3" width={Math.max(2, bar.width * 0.52)} x={bar.x + bar.width * 0.24} y={bar.y} />
|
||||
<rect fill="rgba(191,90,242,.72)" height={bar.height} key={`cache-${bar.x}`} rx="3" width={Math.max(2, bar.width * 0.52)} x={bar.x + bar.width * 0.24} y={bar.y} />
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
{variant === "area" ? (
|
||||
<>
|
||||
<path d={tokenGeometry.areaPath} fill="rgba(45,212,191,.18)" />
|
||||
<path d={cacheGeometry.areaPath} fill="rgba(167,139,250,.12)" />
|
||||
<path d={tokenGeometry.areaPath} fill={`url(#${chartId}-primary-fill)`} />
|
||||
<path d={cacheGeometry.areaPath} fill={`url(#${chartId}-secondary-fill)`} />
|
||||
</>
|
||||
) : null}
|
||||
{variant !== "bar" ? (
|
||||
<>
|
||||
<path className="tray-line-draw" d={tokenGeometry.linePath} fill="none" filter={`url(#${chartId}-glow)`} stroke="rgba(45,212,191,.95)" strokeLinecap="round" strokeLinejoin="round" strokeWidth={variant === "sparkline" ? "3" : "4"} vectorEffect="non-scaling-stroke" />
|
||||
{variant === "sparkline" ? null : <path className="tray-line-draw" d={cacheGeometry.linePath} fill="none" stroke="rgba(167,139,250,.72)" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" vectorEffect="non-scaling-stroke" />}
|
||||
<path className="tray-line-draw" d={tokenGeometry.linePath} fill="none" stroke="rgba(10,132,255,.98)" strokeLinecap="round" strokeLinejoin="round" strokeWidth={variant === "sparkline" ? "3" : "3.5"} vectorEffect="non-scaling-stroke" />
|
||||
{variant === "sparkline" ? null : <path className="tray-line-draw" d={cacheGeometry.linePath} fill="none" stroke="rgba(191,90,242,.78)" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.25" vectorEffect="non-scaling-stroke" />}
|
||||
</>
|
||||
) : null}
|
||||
</svg>
|
||||
@@ -149,13 +151,13 @@ export function TokenActivityPanel({
|
||||
const activity = buildTokenActivity(series, { maxWeeks: 14, minWeeks: 10 });
|
||||
|
||||
return (
|
||||
<div className="min-w-0 rounded-[8px] border border-white/10 bg-white/[.04] p-2">
|
||||
<div className="tray-panel min-w-0 p-2.5">
|
||||
<div className="mb-2 flex min-w-0 items-center justify-between gap-2">
|
||||
<div className="truncate text-[11px] font-bold text-slate-100">{t("Activity")}</div>
|
||||
<div className="shrink-0 text-[10px] font-medium text-slate-400">{t("Tokens")}</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 grid grid-cols-4 gap-px overflow-hidden rounded-[7px] border border-white/8 bg-white/[.08]">
|
||||
<div className="tray-panel-subtle mb-2 grid grid-cols-4 gap-px overflow-hidden">
|
||||
<TokenActivityStat label={t("Longest streak")} value={formatCompactNumber(activity.longestStreak)} unit={t(activity.longestStreak === 1 ? "day" : "days")} />
|
||||
<TokenActivityStat label={t("Avg / day")} value={formatCompactNumber(Math.round(activity.avgPerDay))} />
|
||||
<TokenActivityStat label={t("Avg / week")} value={formatCompactNumber(Math.round(activity.avgPerWeek))} />
|
||||
@@ -190,7 +192,7 @@ function TokenActivityStat({
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0 bg-slate-950/35 px-1.5 py-1">
|
||||
<div className="min-w-0 bg-black/10 px-1.5 py-1">
|
||||
<div className="truncate text-[8px] font-semibold text-slate-400">{label}</div>
|
||||
<div className="flex min-w-0 items-baseline gap-1">
|
||||
<span className="truncate text-[11px] font-bold text-slate-50">{value}</span>
|
||||
@@ -290,12 +292,12 @@ function trayActivityTooltipPositionClass(cell: TokenActivityCell, weekCount: nu
|
||||
}
|
||||
|
||||
function trayActivityColor(intensity: TokenActivityCell["intensity"], inRange: boolean): string {
|
||||
if (!inRange) return "rgba(129,140,248,.06)";
|
||||
if (intensity === 0) return "rgba(129,140,248,.14)";
|
||||
if (intensity === 1) return "rgba(129,140,248,.32)";
|
||||
if (intensity === 2) return "rgba(129,140,248,.52)";
|
||||
if (intensity === 3) return "rgba(129,140,248,.72)";
|
||||
return "rgba(129,140,248,.94)";
|
||||
if (!inRange) return "rgba(10,132,255,.045)";
|
||||
if (intensity === 0) return "rgba(10,132,255,.12)";
|
||||
if (intensity === 1) return "rgba(10,132,255,.3)";
|
||||
if (intensity === 2) return "rgba(10,132,255,.5)";
|
||||
if (intensity === 3) return "rgba(10,132,255,.72)";
|
||||
return "rgba(10,132,255,.96)";
|
||||
}
|
||||
|
||||
export function TokenMixPanel({
|
||||
@@ -307,14 +309,14 @@ export function TokenMixPanel({
|
||||
}) {
|
||||
const t = useTrayText();
|
||||
const rows = [
|
||||
{ className: "bg-blue-400", color: "rgb(96,165,250)", label: t("Input"), value: totals.inputTokens },
|
||||
{ className: "bg-amber-300", color: "rgb(252,211,77)", label: t("Output"), value: totals.outputTokens },
|
||||
{ className: "bg-rose-300", color: "rgb(253,164,175)", label: t("Cache"), value: totals.cacheTokens }
|
||||
{ className: "bg-[#0a84ff]", color: "rgb(10,132,255)", label: t("Input"), value: totals.inputTokens },
|
||||
{ className: "bg-[#ff9f0a]", color: "rgb(255,159,10)", label: t("Output"), value: totals.outputTokens },
|
||||
{ className: "bg-[#bf5af2]", color: "rgb(191,90,242)", label: t("Cache"), value: totals.cacheTokens }
|
||||
];
|
||||
const max = Math.max(...rows.map((row) => row.value), 1);
|
||||
|
||||
return (
|
||||
<div className="min-w-0 rounded-[8px] border border-white/10 bg-white/[.04] p-2">
|
||||
<div className="tray-panel min-w-0 p-2.5">
|
||||
<div className="mb-2 truncate text-[11px] font-bold text-slate-100">{t("Token Mix")}</div>
|
||||
{variant === "donut" || variant === "pie" ? (
|
||||
<div className="grid grid-cols-[64px_minmax(0,1fr)] items-center gap-2">
|
||||
@@ -364,7 +366,7 @@ export function RingMetrics({
|
||||
const successRequests = Math.round(totals.requestCount * Math.max(0, Math.min(1, totals.successRate)));
|
||||
|
||||
return (
|
||||
<div className="min-w-0 rounded-[8px] border border-white/10 bg-white/[.04] p-2">
|
||||
<div className="tray-panel min-w-0 p-2.5">
|
||||
<div className="mb-2 truncate text-[11px] font-bold text-slate-100">{t("Circular metrics")}</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<RingMetric centerUnit={t("requests")} centerValue={formatCompactNumber(successRequests)} label={t("Success")} value={totals.successRate} variant={variant} />
|
||||
@@ -388,7 +390,7 @@ function RingMetric({
|
||||
variant: TrayComponentVariants["rings"];
|
||||
}) {
|
||||
const clamped = Math.max(0, Math.min(1, value));
|
||||
const stroke = clamped > 0.8 ? "rgb(45,212,191)" : "rgb(129,140,248)";
|
||||
const stroke = clamped > 0.8 ? "rgb(48,209,88)" : "rgb(10,132,255)";
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col items-center text-center">
|
||||
<div className="aspect-square w-full min-w-0">
|
||||
@@ -417,21 +419,21 @@ export function ModelShareChart({
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="mb-2 rounded-[8px] border border-white/10 bg-white/[.03] px-3 py-8 text-center text-[12px] font-medium text-slate-400">
|
||||
<div className="tray-panel-subtle mb-2 px-3 py-8 text-center text-[12px] font-medium text-slate-400">
|
||||
{t("No usage captured yet")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const chartRows = rows.slice(0, 4).map((row, index) => ({
|
||||
className: ["bg-teal-300", "bg-indigo-400", "bg-amber-300", "bg-rose-300"][index] ?? "bg-slate-300",
|
||||
color: ["rgb(45,212,191)", "rgb(129,140,248)", "rgb(251,191,36)", "rgb(253,164,175)"][index] ?? "rgb(203,213,225)",
|
||||
className: ["bg-[#0a84ff]", "bg-[#bf5af2]", "bg-[#ff9f0a]", "bg-[#ff375f]"][index] ?? "bg-slate-300",
|
||||
color: ["rgb(10,132,255)", "rgb(191,90,242)", "rgb(255,159,10)", "rgb(255,55,95)"][index] ?? "rgb(203,213,225)",
|
||||
label: row.label,
|
||||
value: row.totalTokens
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="mb-2 min-w-0 rounded-[8px] border border-white/10 bg-white/[.04] p-2">
|
||||
<div className="tray-panel mb-2 min-w-0 p-2.5">
|
||||
<div className="mb-2 truncate text-[11px] font-bold text-slate-100">{t("Model Share")}</div>
|
||||
{variant === "donut" || variant === "pie" ? (
|
||||
<div className="grid grid-cols-[64px_minmax(0,1fr)] items-center gap-2">
|
||||
@@ -457,7 +459,7 @@ export function ModelShareChart({
|
||||
<div className="min-w-0 flex-1 truncate text-[10px] font-medium text-slate-300">{row.label}</div>
|
||||
<div className="h-1.5 w-14 shrink-0 overflow-hidden rounded-full bg-white/10">
|
||||
<div
|
||||
className="h-full rounded-full bg-teal-300"
|
||||
className="h-full rounded-full bg-[#0a84ff]"
|
||||
style={{ width: `${Math.max(3, row.maxShare * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -514,7 +516,7 @@ function ShareChart({
|
||||
transform="rotate(-90 20 20)"
|
||||
/>
|
||||
))}
|
||||
{variant === "donut" ? <circle cx="20" cy="20" fill="rgb(15,23,42)" r="8" /> : null}
|
||||
{variant === "donut" ? <circle cx="20" cy="20" fill="rgb(36,36,38)" r="8" /> : null}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,13 +2,17 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState, t
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { LoaderCircle, Power, RefreshCw } from "lucide-react";
|
||||
import appLogoUrl from "@/assets/logo.png";
|
||||
import codexLogoUrl from "@/assets/agent-logos/codex.png";
|
||||
import trayCyanIconUrl from "@/assets/tray-cyan.png";
|
||||
import trayOrangeIconUrl from "@/assets/tray-orange.png";
|
||||
import trayVioletIconUrl from "@/assets/tray-violet.png";
|
||||
import { DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS } from "@ccr/core/contracts/app";
|
||||
import { formatLocalizedErrorMessage } from "@ccr/core/contracts/i18n";
|
||||
import { findProviderPreset, findProviderPresetByBaseUrl, providerPresets } from "@ccr/core/providers/presets";
|
||||
import { providerPresetIconUrls } from "../home/shared/options";
|
||||
import type {
|
||||
AppConfig,
|
||||
GatewayProviderConfig,
|
||||
ProviderAccountMeter,
|
||||
ProviderAccountSnapshot,
|
||||
TrayBalanceProgressConfig,
|
||||
@@ -37,6 +41,7 @@ export type SnapshotMap = Record<UsageStatsRange, UsageStatsSnapshot>;
|
||||
|
||||
export type SourceTab = {
|
||||
id: string;
|
||||
iconUrl?: string;
|
||||
label: string;
|
||||
provider?: string;
|
||||
};
|
||||
@@ -138,6 +143,32 @@ export function useTrayText() {
|
||||
return useContext(TrayI18nContext);
|
||||
}
|
||||
|
||||
export function applyTrayThemePreference(theme: AppConfig["theme"] | undefined): void {
|
||||
const root = document.documentElement;
|
||||
if (theme === "light" || theme === "dark") {
|
||||
root.dataset.theme = theme;
|
||||
return;
|
||||
}
|
||||
root.removeAttribute("data-theme");
|
||||
}
|
||||
|
||||
export function useTrayThemePreference(): void {
|
||||
useEffect(() => {
|
||||
const syncTheme = () => {
|
||||
if (!window.ccr) {
|
||||
return;
|
||||
}
|
||||
void window.ccr.getConfig()
|
||||
.then((config) => applyTrayThemePreference(config.theme))
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
syncTheme();
|
||||
window.addEventListener("focus", syncTheme);
|
||||
return () => window.removeEventListener("focus", syncTheme);
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function useTrayErrorText() {
|
||||
const language = useResolvedTrayLanguage();
|
||||
return useMemo(() => (error: unknown) => formatLocalizedErrorMessage(language, error), [language]);
|
||||
@@ -203,10 +234,18 @@ export function useResolvedTrayLanguage(): ResolvedLanguage {
|
||||
return languagePreference === "system" ? systemLanguage : languagePreference;
|
||||
}
|
||||
|
||||
export function createSourceTabs(rows: UsageComparisonRow[], configuredProviders: string[]): SourceTab[] {
|
||||
const providers = new Map<string, { index: number; score: number }>();
|
||||
export function createSourceTabs(rows: UsageComparisonRow[], configuredProviders: GatewayProviderConfig[]): SourceTab[] {
|
||||
const providers = new Map<string, { iconUrl?: string; index: number; score: number }>();
|
||||
configuredProviders.forEach((provider, index) => {
|
||||
providers.set(provider, { index, score: 0 });
|
||||
const name = provider.name.trim();
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
providers.set(name, {
|
||||
iconUrl: resolveTrayProviderIcon(provider),
|
||||
index,
|
||||
score: 0
|
||||
});
|
||||
});
|
||||
|
||||
for (const row of rows) {
|
||||
@@ -214,8 +253,13 @@ export function createSourceTabs(rows: UsageComparisonRow[], configuredProviders
|
||||
if (!provider) {
|
||||
continue;
|
||||
}
|
||||
const current = providers.get(provider) ?? { index: providers.size, score: 0 };
|
||||
const current = providers.get(provider) ?? {
|
||||
iconUrl: resolveTrayProviderIcon({ models: [], name: provider }),
|
||||
index: providers.size,
|
||||
score: 0
|
||||
};
|
||||
providers.set(provider, {
|
||||
iconUrl: current.iconUrl,
|
||||
index: current.index,
|
||||
score: current.score + row.totalTokens + row.requestCount
|
||||
});
|
||||
@@ -224,8 +268,9 @@ export function createSourceTabs(rows: UsageComparisonRow[], configuredProviders
|
||||
const providerTabs = Array.from(providers.entries())
|
||||
.sort((a, b) => b[1].score - a[1].score || a[1].index - b[1].index)
|
||||
.slice(0, 7)
|
||||
.map(([provider]) => ({
|
||||
.map(([provider, metadata]) => ({
|
||||
id: `provider:${provider}`,
|
||||
iconUrl: metadata.iconUrl,
|
||||
label: formatProviderName(provider),
|
||||
provider
|
||||
}));
|
||||
@@ -239,6 +284,58 @@ export function createSourceTabs(rows: UsageComparisonRow[], configuredProviders
|
||||
];
|
||||
}
|
||||
|
||||
export function resolveTrayProviderIcon(provider: GatewayProviderConfig): string | undefined {
|
||||
const explicitIcon = provider.icon?.trim();
|
||||
if (explicitIcon) {
|
||||
return explicitIcon;
|
||||
}
|
||||
|
||||
const name = provider.name.trim();
|
||||
const normalizedName = normalizeProviderIdentity(name);
|
||||
const baseUrl = providerBaseUrl(provider);
|
||||
if (normalizedName.includes("codex") || baseUrl.toLowerCase().includes("/codex")) {
|
||||
return codexLogoUrl;
|
||||
}
|
||||
|
||||
const preset = findProviderPreset(provider.id)
|
||||
?? (baseUrl ? findProviderPresetByBaseUrl(baseUrl) : undefined)
|
||||
?? providerPresets.find((candidate) => providerNameMatchesPreset(normalizedName, candidate.name, candidate.aliases));
|
||||
if (preset) {
|
||||
return providerPresetIconUrls[preset.id];
|
||||
}
|
||||
|
||||
if (normalizedName.includes("智谱")) {
|
||||
const presetId = normalizedName.includes("通用") || normalizedName.includes("general")
|
||||
? "zhipu-cn-general"
|
||||
: "zhipu-cn-coding";
|
||||
return providerPresetIconUrls[presetId];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeProviderIdentity(value: string): string {
|
||||
return value.trim().toLocaleLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function providerNameMatchesPreset(normalizedName: string, presetName: string, aliases: string[] = []): boolean {
|
||||
if (!normalizedName) {
|
||||
return false;
|
||||
}
|
||||
const identities = [presetName, ...aliases]
|
||||
.map(normalizeProviderIdentity)
|
||||
.filter((identity) => identity.length >= 4)
|
||||
.sort((a, b) => b.length - a.length);
|
||||
return identities.some((identity) => normalizedName === identity || normalizedName.startsWith(`${identity} `));
|
||||
}
|
||||
|
||||
function providerBaseUrl(provider: GatewayProviderConfig): string {
|
||||
return provider.baseUrl?.trim()
|
||||
|| provider.baseurl?.trim()
|
||||
|| provider.api_base_url?.trim()
|
||||
|| "";
|
||||
}
|
||||
|
||||
export function normalizeTrayWindowModules(value: AppConfig["trayWindowModules"] | undefined): TrayWindowModuleId[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return DEFAULT_TRAY_WINDOW_MODULES;
|
||||
@@ -787,7 +884,7 @@ export function accountStatusClass(status: ProviderAccountSnapshot["status"]): s
|
||||
return "bg-amber-300/15 text-amber-100";
|
||||
}
|
||||
if (status === "ok") {
|
||||
return "bg-teal-300/15 text-teal-100";
|
||||
return "bg-[#30d158]/15 text-[#b7f7c6]";
|
||||
}
|
||||
return "bg-slate-400/15 text-slate-200";
|
||||
}
|
||||
@@ -799,7 +896,7 @@ export function accountProgressClass(status: ProviderAccountSnapshot["status"]):
|
||||
if (status === "warning") {
|
||||
return "bg-amber-300";
|
||||
}
|
||||
return "bg-teal-300";
|
||||
return "bg-[#30d158]";
|
||||
}
|
||||
|
||||
export function accountProgressColor(status: ProviderAccountSnapshot["status"]): string {
|
||||
@@ -809,7 +906,7 @@ export function accountProgressColor(status: ProviderAccountSnapshot["status"]):
|
||||
if (status === "warning") {
|
||||
return "rgb(252,211,77)";
|
||||
}
|
||||
return "rgb(45,212,191)";
|
||||
return "rgb(48,209,88)";
|
||||
}
|
||||
|
||||
export function readLanguagePreference(): AppLanguagePreference {
|
||||
|
||||
@@ -229,10 +229,43 @@
|
||||
}
|
||||
|
||||
body.tray-window {
|
||||
/* Keep these as tints only. The actual blur is the macOS vibrancy view. */
|
||||
--tray-material-fill-top: light-dark(rgba(255, 255, 255, .12), rgba(28, 28, 30, .22));
|
||||
--tray-material-fill-bottom: light-dark(rgba(242, 242, 247, .045), rgba(18, 18, 20, .12));
|
||||
--tray-material-glow: light-dark(rgba(255, 255, 255, .3), rgba(255, 255, 255, .075));
|
||||
--tray-material-sheen: light-dark(rgba(255, 255, 255, .22), rgba(255, 255, 255, .055));
|
||||
--tray-text-primary: light-dark(rgba(28, 28, 30, .96), rgba(255, 255, 255, .96));
|
||||
--tray-text-secondary: light-dark(rgba(60, 60, 67, .72), rgba(235, 235, 245, .68));
|
||||
--tray-text-tertiary: light-dark(rgba(60, 60, 67, .52), rgba(235, 235, 245, .48));
|
||||
--tray-panel-fill: light-dark(rgba(255, 255, 255, .16), rgba(255, 255, 255, .065));
|
||||
--tray-panel-subtle-fill: light-dark(rgba(255, 255, 255, .09), rgba(255, 255, 255, .04));
|
||||
--tray-panel-border: light-dark(rgba(60, 60, 67, .15), rgba(255, 255, 255, .105));
|
||||
--tray-panel-subtle-border: light-dark(rgba(60, 60, 67, .1), rgba(255, 255, 255, .085));
|
||||
--tray-panel-shadow: light-dark(rgba(0, 0, 0, .08), rgba(0, 0, 0, .12));
|
||||
--tray-panel-highlight: light-dark(rgba(255, 255, 255, .62), rgba(255, 255, 255, .07));
|
||||
--tray-segmented-fill: light-dark(rgba(118, 118, 128, .14), rgba(0, 0, 0, .18));
|
||||
--tray-segmented-border: light-dark(rgba(60, 60, 67, .1), rgba(255, 255, 255, .065));
|
||||
--tray-segmented-active-fill: light-dark(rgba(255, 255, 255, .38), rgba(255, 255, 255, .14));
|
||||
--tray-segmented-active-shadow: light-dark(rgba(0, 0, 0, .12), rgba(0, 0, 0, .28));
|
||||
--tray-stat-fill: light-dark(rgba(255, 255, 255, .11), rgba(255, 255, 255, .045));
|
||||
--tray-stat-border: light-dark(rgba(60, 60, 67, .1), rgba(255, 255, 255, .065));
|
||||
--tray-control-fill: light-dark(rgba(255, 255, 255, .09), rgba(255, 255, 255, .04));
|
||||
--tray-control-hover-fill: light-dark(rgba(255, 255, 255, .2), rgba(255, 255, 255, .075));
|
||||
--tray-control-border: light-dark(rgba(60, 60, 67, .12), rgba(255, 255, 255, .07));
|
||||
--tray-divider: light-dark(rgba(60, 60, 67, .14), rgba(255, 255, 255, .08));
|
||||
--tray-track: light-dark(rgba(60, 60, 67, .13), rgba(255, 255, 255, .1));
|
||||
--tray-inset-fill: light-dark(rgba(60, 60, 67, .06), rgba(0, 0, 0, .1));
|
||||
--tray-accent-fill: light-dark(rgba(0, 122, 255, .14), rgba(10, 132, 255, .2));
|
||||
--tray-accent-border: light-dark(rgba(0, 122, 255, .28), rgba(10, 132, 255, .34));
|
||||
--tray-accent-text: light-dark(#004f9e, #fff);
|
||||
--tray-status-error: light-dark(#b4232d, #fecdd3);
|
||||
--tray-status-warning: light-dark(#8a4b00, #fef3c7);
|
||||
--tray-status-ok: light-dark(#006b5f, #ccfbf1);
|
||||
background: transparent;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif;
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: subpixel-antialiased;
|
||||
text-rendering: geometricPrecision;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body.tray-window #root {
|
||||
@@ -243,9 +276,9 @@
|
||||
body.tray-window {
|
||||
--scrollbar-size: 10px;
|
||||
--scrollbar-border: 3px;
|
||||
--scrollbar-thumb: rgba(248, 250, 252, .26);
|
||||
--scrollbar-thumb-hover: rgba(248, 250, 252, .42);
|
||||
--scrollbar-thumb-active: rgba(248, 250, 252, .56);
|
||||
--scrollbar-thumb: light-dark(rgba(60, 60, 67, .28), rgba(248, 250, 252, .26));
|
||||
--scrollbar-thumb-hover: light-dark(rgba(60, 60, 67, .42), rgba(248, 250, 252, .42));
|
||||
--scrollbar-thumb-active: light-dark(rgba(60, 60, 67, .56), rgba(248, 250, 252, .56));
|
||||
}
|
||||
|
||||
body.tray-window * {
|
||||
@@ -374,42 +407,503 @@
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.tray-glass-panel {
|
||||
background: rgba(2, 6, 23, .18);
|
||||
border-radius: 14px;
|
||||
border: 0;
|
||||
.overview-view {
|
||||
--overview-chart-grid: rgba(60, 60, 67, .13);
|
||||
--primary: #007aff;
|
||||
--ring: #007aff;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.overview-toolbar-glyph {
|
||||
background: color-mix(in oklab, var(--muted) 78%, var(--card));
|
||||
box-shadow:
|
||||
inset 0 0 0 1px color-mix(in oklab, var(--border) 54%, transparent),
|
||||
inset 0 1px 0 rgba(255, 255, 255, .42);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.overview-toolbar {
|
||||
background: color-mix(in oklab, var(--card) 88%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--border) 72%, transparent);
|
||||
border-radius: 16px;
|
||||
box-shadow: none;
|
||||
color: rgba(248, 250, 252, .98);
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
padding: 10px;
|
||||
backdrop-filter: blur(22px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(150%);
|
||||
}
|
||||
|
||||
.tray-chart-panel {
|
||||
.overview-segmented {
|
||||
background: color-mix(in oklab, var(--muted) 88%, var(--background));
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--border) 55%, transparent);
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.overview-segmented-item {
|
||||
border-radius: 8px;
|
||||
transition: background-color 140ms ease, color 140ms ease, box-shadow 140ms ease;
|
||||
}
|
||||
|
||||
.overview-segmented-item[data-active="true"] {
|
||||
background: var(--card);
|
||||
box-shadow:
|
||||
0 1px 3px rgba(15, 23, 42, .12),
|
||||
inset 0 1px 0 rgba(255, 255, 255, .65);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.overview-editor-panel {
|
||||
background: color-mix(in oklab, var(--card) 92%, var(--background));
|
||||
border-color: color-mix(in oklab, var(--border) 76%, transparent);
|
||||
border-radius: 16px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.overview-card {
|
||||
background: color-mix(in oklab, var(--card) 96%, var(--background));
|
||||
border-color: color-mix(in oklab, var(--border) 70%, transparent);
|
||||
border-radius: 16px;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.overview-card-header {
|
||||
min-height: 50px;
|
||||
padding: 9px 12px;
|
||||
}
|
||||
|
||||
.overview-heading-icon {
|
||||
align-items: center;
|
||||
background: color-mix(in oklab, #007aff 12%, var(--card));
|
||||
border: 1px solid color-mix(in oklab, #007aff 18%, transparent);
|
||||
border-radius: 8px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .46);
|
||||
color: #007aff;
|
||||
display: inline-flex;
|
||||
height: 27px;
|
||||
justify-content: center;
|
||||
width: 27px;
|
||||
}
|
||||
|
||||
.overview-heading-icon[data-tone="green"] {
|
||||
background: color-mix(in oklab, #34c759 12%, var(--card));
|
||||
border-color: color-mix(in oklab, #34c759 18%, transparent);
|
||||
color: #248a3d;
|
||||
}
|
||||
|
||||
.overview-heading-icon[data-tone="orange"] {
|
||||
background: color-mix(in oklab, #ff9f0a 13%, var(--card));
|
||||
border-color: color-mix(in oklab, #ff9f0a 20%, transparent);
|
||||
color: #c66b00;
|
||||
}
|
||||
|
||||
.overview-heading-icon[data-tone="purple"] {
|
||||
background: color-mix(in oklab, #af52de 12%, var(--card));
|
||||
border-color: color-mix(in oklab, #af52de 18%, transparent);
|
||||
color: #8944ab;
|
||||
}
|
||||
|
||||
.overview-heading-icon[data-tone="red"] {
|
||||
background: color-mix(in oklab, #ff3b30 11%, var(--card));
|
||||
border-color: color-mix(in oklab, #ff3b30 18%, transparent);
|
||||
color: #d70015;
|
||||
}
|
||||
|
||||
.overview-heading-icon[data-tone="slate"] {
|
||||
background: color-mix(in oklab, #8e8e93 11%, var(--card));
|
||||
border-color: color-mix(in oklab, #8e8e93 18%, transparent);
|
||||
color: #636366;
|
||||
}
|
||||
|
||||
.overview-date-pill {
|
||||
background: color-mix(in oklab, var(--muted) 74%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--border) 48%, transparent);
|
||||
border-radius: 999px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .01em;
|
||||
max-width: min(34vw, 320px);
|
||||
padding: 4px 9px;
|
||||
}
|
||||
|
||||
.overview-chart-legend {
|
||||
background: color-mix(in oklab, var(--muted) 48%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--border) 40%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.overview-status-icon[data-tone="ok"] {
|
||||
background: #34c759;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.overview-status-icon[data-tone="warn"] {
|
||||
background: #ffcc00;
|
||||
color: #5c3b00;
|
||||
}
|
||||
|
||||
.overview-status-icon[data-tone="error"] {
|
||||
background: #ff3b30;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.overview-status-icon[data-tone="idle"] {
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.overview-status-segment {
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .24);
|
||||
transition: filter 140ms ease, transform 140ms ease;
|
||||
}
|
||||
|
||||
.overview-status-segment[data-tone="ok"] {
|
||||
background: #34c759;
|
||||
}
|
||||
|
||||
.overview-status-segment[data-tone="warn"] {
|
||||
background: #ffcc00;
|
||||
}
|
||||
|
||||
.overview-status-segment[data-tone="error"] {
|
||||
background: #ff3b30;
|
||||
}
|
||||
|
||||
.overview-status-segment[data-tone="idle"] {
|
||||
background: color-mix(in oklab, var(--muted-foreground) 22%, transparent);
|
||||
}
|
||||
|
||||
.overview-status-segment:hover {
|
||||
filter: brightness(1.04) saturate(1.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.overview-metric-card {
|
||||
--overview-metric-accent: #30b0c7;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(15, 23, 42, .56), rgba(6, 78, 59, .24) 52%, rgba(88, 28, 135, .16)),
|
||||
linear-gradient(rgba(148, 163, 184, .075) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(148, 163, 184, .065) 1px, transparent 1px);
|
||||
background-size: auto, 28px 28px, 28px 28px;
|
||||
radial-gradient(circle at 100% 0%, color-mix(in oklab, var(--overview-metric-accent) 12%, transparent), transparent 47%),
|
||||
color-mix(in oklab, var(--card) 96%, var(--background));
|
||||
}
|
||||
|
||||
.tray-chart-panel::before {
|
||||
background: linear-gradient(90deg, transparent, rgba(45, 212, 191, .08), transparent);
|
||||
content: "";
|
||||
inset: 0;
|
||||
opacity: .85;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
.overview-metric-card[data-tone="blue"] { --overview-metric-accent: #007aff; }
|
||||
.overview-metric-card[data-tone="indigo"] { --overview-metric-accent: #5856d6; }
|
||||
.overview-metric-card[data-tone="amber"] { --overview-metric-accent: #ff9f0a; }
|
||||
.overview-metric-card[data-tone="rose"] { --overview-metric-accent: #ff3b30; }
|
||||
.overview-metric-card[data-tone="slate"] { --overview-metric-accent: #8e8e93; }
|
||||
|
||||
.overview-metric-dot {
|
||||
background: var(--overview-metric-accent);
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 0 0 4px color-mix(in oklab, var(--overview-metric-accent) 13%, transparent);
|
||||
display: inline-block;
|
||||
height: 7px;
|
||||
width: 7px;
|
||||
}
|
||||
|
||||
.tray-chart-scan {
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, .2), transparent);
|
||||
bottom: 0;
|
||||
.overview-metric-track {
|
||||
background: color-mix(in oklab, var(--overview-metric-accent) 10%, var(--muted));
|
||||
border-radius: 999px;
|
||||
height: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.overview-metric-fill {
|
||||
background: var(--overview-metric-accent);
|
||||
border-radius: inherit;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .24);
|
||||
height: 100%;
|
||||
min-width: 3px;
|
||||
}
|
||||
|
||||
.overview-empty-state {
|
||||
background:
|
||||
radial-gradient(circle at 50% 0%, color-mix(in oklab, var(--primary) 7%, transparent), transparent 52%),
|
||||
color-mix(in oklab, var(--muted) 46%, transparent);
|
||||
}
|
||||
|
||||
.overview-empty-state-icon {
|
||||
align-items: center;
|
||||
background: color-mix(in oklab, var(--card) 76%, var(--muted));
|
||||
border: 1px solid color-mix(in oklab, var(--border) 54%, transparent);
|
||||
border-radius: 10px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .45);
|
||||
color: var(--muted-foreground);
|
||||
display: inline-flex;
|
||||
height: 34px;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
}
|
||||
|
||||
.overview-widget-frame.is-editing {
|
||||
border-radius: 18px;
|
||||
outline: 1px solid color-mix(in oklab, var(--primary) 34%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.overview-widget-frame.is-selected {
|
||||
box-shadow: 0 0 0 4px color-mix(in oklab, var(--primary) 11%, transparent);
|
||||
outline: 2px solid var(--primary);
|
||||
}
|
||||
|
||||
.overview-widget-drag-handle {
|
||||
align-items: center;
|
||||
background: color-mix(in oklab, var(--card) 92%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--primary) 24%, var(--border));
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, .12);
|
||||
color: var(--muted-foreground);
|
||||
display: flex;
|
||||
height: 18px;
|
||||
justify-content: center;
|
||||
left: 50%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translateX(-130%);
|
||||
width: 45%;
|
||||
display: none;
|
||||
top: -9px;
|
||||
transform: translateX(-50%);
|
||||
transition: opacity 140ms ease, color 140ms ease;
|
||||
width: 34px;
|
||||
z-index: 45;
|
||||
}
|
||||
|
||||
.overview-widget-frame:hover .overview-widget-drag-handle,
|
||||
.overview-widget-drag-handle.is-selected {
|
||||
color: var(--primary);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.overview-activity-stat {
|
||||
transition: background-color 140ms ease;
|
||||
}
|
||||
|
||||
.overview-activity-stat:hover,
|
||||
.overview-legend-row:hover {
|
||||
background: color-mix(in oklab, var(--accent) 44%, transparent);
|
||||
}
|
||||
|
||||
.overview-activity-cell {
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .16);
|
||||
transition: filter 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.overview-activity-cell:hover {
|
||||
filter: saturate(1.1) brightness(1.04);
|
||||
transform: scale(1.12);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.overview-legend-row {
|
||||
transition: background-color 140ms ease;
|
||||
}
|
||||
|
||||
.overview-tooltip {
|
||||
-webkit-backdrop-filter: blur(24px) saturate(150%);
|
||||
backdrop-filter: blur(24px) saturate(150%);
|
||||
background: color-mix(in oklab, var(--popover) 90%, transparent);
|
||||
border-color: color-mix(in oklab, var(--border) 68%, transparent);
|
||||
box-shadow:
|
||||
0 12px 34px rgba(15, 23, 42, .16),
|
||||
inset 0 1px 0 rgba(255, 255, 255, .34);
|
||||
}
|
||||
|
||||
.overview-card > [class*="border-b"] {
|
||||
border-color: color-mix(in oklab, var(--border) 52%, transparent);
|
||||
}
|
||||
|
||||
.overview-nested-surface {
|
||||
background: color-mix(in oklab, var(--muted) 52%, transparent);
|
||||
border-color: color-mix(in oklab, var(--border) 54%, transparent);
|
||||
border-radius: 12px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.overview-palette-item {
|
||||
background: color-mix(in oklab, var(--background) 72%, var(--card));
|
||||
border-color: color-mix(in oklab, var(--border) 66%, transparent);
|
||||
border-radius: 12px;
|
||||
box-shadow: none;
|
||||
transition: background-color 140ms ease;
|
||||
}
|
||||
|
||||
.overview-palette-item:hover {
|
||||
background: color-mix(in oklab, var(--accent) 58%, var(--card));
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .overview-view {
|
||||
--overview-chart-grid: rgba(235, 235, 245, .13);
|
||||
--primary: #0a84ff;
|
||||
--ring: #0a84ff;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .overview-heading-icon[data-tone="green"] { color: #30d158; }
|
||||
:root[data-theme="dark"] .overview-heading-icon[data-tone="orange"] { color: #ff9f0a; }
|
||||
:root[data-theme="dark"] .overview-heading-icon[data-tone="purple"] { color: #bf5af2; }
|
||||
:root[data-theme="dark"] .overview-heading-icon[data-tone="red"] { color: #ff453a; }
|
||||
:root[data-theme="dark"] .overview-heading-icon[data-tone="slate"] { color: #aeaeb2; }
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .overview-view {
|
||||
--overview-chart-grid: rgba(235, 235, 245, .13);
|
||||
--primary: #0a84ff;
|
||||
--ring: #0a84ff;
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .overview-heading-icon[data-tone="green"] { color: #30d158; }
|
||||
:root:not([data-theme="light"]) .overview-heading-icon[data-tone="orange"] { color: #ff9f0a; }
|
||||
:root:not([data-theme="light"]) .overview-heading-icon[data-tone="purple"] { color: #bf5af2; }
|
||||
:root:not([data-theme="light"]) .overview-heading-icon[data-tone="red"] { color: #ff453a; }
|
||||
:root:not([data-theme="light"]) .overview-heading-icon[data-tone="slate"] { color: #aeaeb2; }
|
||||
|
||||
}
|
||||
|
||||
.tray-shell {
|
||||
-webkit-backdrop-filter: blur(36px) saturate(170%);
|
||||
backdrop-filter: blur(36px) saturate(170%);
|
||||
background:
|
||||
linear-gradient(135deg, var(--tray-material-sheen), transparent 28%, transparent 72%, var(--tray-material-sheen)),
|
||||
radial-gradient(circle at 18% 0%, var(--tray-material-glow), transparent 38%),
|
||||
linear-gradient(180deg, var(--tray-material-fill-top), var(--tray-material-fill-bottom));
|
||||
/* BrowserWindow owns the outer radius and material edge. Drawing another
|
||||
CSS border here created the bright two-pixel ring on Retina displays. */
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
color: var(--tray-text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
body.tray-window .tray-shell :where(.text-slate-50, .text-slate-100, .text-slate-200) {
|
||||
color: var(--tray-text-primary);
|
||||
}
|
||||
|
||||
body.tray-window .tray-shell :where(.text-slate-300, .text-slate-400) {
|
||||
color: var(--tray-text-secondary);
|
||||
}
|
||||
|
||||
body.tray-window .tray-shell :where(.text-slate-500, .text-slate-200\/60, .text-slate-300\/55) {
|
||||
color: var(--tray-text-tertiary);
|
||||
}
|
||||
|
||||
body.tray-window .tray-shell :where(.bg-white\/10) {
|
||||
background: var(--tray-track);
|
||||
}
|
||||
|
||||
body.tray-window .tray-shell :where(.bg-black\/10) {
|
||||
background: var(--tray-inset-fill);
|
||||
}
|
||||
|
||||
body.tray-window .tray-shell :where(.border-white\/10) {
|
||||
border-color: var(--tray-divider);
|
||||
}
|
||||
|
||||
body.tray-window .tray-shell :where(.text-rose-50, .text-rose-100) { color: var(--tray-status-error); }
|
||||
body.tray-window .tray-shell :where(.text-amber-50, .text-amber-100) { color: var(--tray-status-warning); }
|
||||
body.tray-window .tray-shell :where(.text-teal-50, .text-teal-100) { color: var(--tray-status-ok); }
|
||||
|
||||
.tray-panel {
|
||||
background: var(--tray-panel-fill);
|
||||
border: 1px solid var(--tray-panel-border);
|
||||
border-radius: 13px;
|
||||
box-shadow:
|
||||
0 1px 2px var(--tray-panel-shadow),
|
||||
inset 0 1px 0 var(--tray-panel-highlight);
|
||||
}
|
||||
|
||||
.tray-panel-subtle {
|
||||
background: var(--tray-panel-subtle-fill);
|
||||
border: 1px solid var(--tray-panel-subtle-border);
|
||||
border-radius: 11px;
|
||||
}
|
||||
|
||||
.tray-segmented {
|
||||
background: var(--tray-segmented-fill);
|
||||
border: 1px solid var(--tray-segmented-border);
|
||||
border-radius: 10px;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, .14);
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.tray-segmented-item {
|
||||
border-radius: 8px;
|
||||
color: var(--tray-text-secondary);
|
||||
transition: background-color 140ms ease, color 140ms ease, box-shadow 140ms ease;
|
||||
}
|
||||
|
||||
.tray-segmented-item:hover {
|
||||
color: var(--tray-text-primary);
|
||||
}
|
||||
|
||||
.tray-segmented-item[data-active="true"] {
|
||||
background: var(--tray-segmented-active-fill);
|
||||
box-shadow:
|
||||
0 1px 3px var(--tray-segmented-active-shadow),
|
||||
inset 0 1px 0 var(--tray-panel-highlight);
|
||||
color: var(--tray-text-primary);
|
||||
}
|
||||
|
||||
.tray-stat-cell {
|
||||
background: var(--tray-stat-fill);
|
||||
border: 1px solid var(--tray-stat-border);
|
||||
border-radius: 11px;
|
||||
box-shadow: inset 0 1px 0 var(--tray-panel-highlight);
|
||||
}
|
||||
|
||||
.tray-source-tab {
|
||||
background: var(--tray-control-fill);
|
||||
border: 1px solid var(--tray-control-border);
|
||||
border-radius: 9px;
|
||||
color: var(--tray-text-secondary);
|
||||
transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease;
|
||||
}
|
||||
|
||||
.tray-source-tab:hover {
|
||||
background: var(--tray-control-hover-fill);
|
||||
color: var(--tray-text-primary);
|
||||
}
|
||||
|
||||
.tray-source-tab[data-active="true"] {
|
||||
background: var(--tray-accent-fill);
|
||||
border-color: var(--tray-accent-border);
|
||||
color: var(--tray-accent-text);
|
||||
}
|
||||
|
||||
.tray-source-icon {
|
||||
background: color-mix(in srgb, var(--tray-control-fill) 82%, transparent);
|
||||
border: 1px solid color-mix(in srgb, currentColor 16%, transparent);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.tray-source-tab[data-active="true"] .tray-source-icon {
|
||||
background: color-mix(in srgb, var(--tray-accent-fill) 68%, white 12%);
|
||||
}
|
||||
|
||||
.tray-icon-button {
|
||||
background: var(--tray-control-fill);
|
||||
border: 1px solid var(--tray-control-border);
|
||||
border-radius: 9px;
|
||||
color: var(--tray-text-secondary);
|
||||
transition: background-color 140ms ease, color 140ms ease;
|
||||
}
|
||||
|
||||
.tray-icon-button:hover {
|
||||
background: var(--tray-control-hover-fill);
|
||||
color: var(--tray-text-primary);
|
||||
}
|
||||
|
||||
body.tray-window .tray-status-strip {
|
||||
border-color: var(--tray-divider);
|
||||
}
|
||||
|
||||
.tray-header-action:hover,
|
||||
.tray-header-icon {
|
||||
background: var(--tray-control-fill);
|
||||
}
|
||||
|
||||
.tray-header-icon {
|
||||
border-color: var(--tray-control-border);
|
||||
box-shadow: inset 0 1px 0 var(--tray-panel-highlight);
|
||||
}
|
||||
|
||||
.tray-line-draw {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from "../../packages/core/src/contracts/app.ts";
|
||||
|
||||
export function installBrowserGlobals() {
|
||||
const documentThemeDataset: Record<string, string> = {};
|
||||
const localStorage = {
|
||||
getItem: () => null,
|
||||
removeItem: () => undefined,
|
||||
@@ -48,7 +49,13 @@ export function installBrowserGlobals() {
|
||||
style: {}
|
||||
},
|
||||
documentElement: {
|
||||
lang: "en"
|
||||
dataset: documentThemeDataset,
|
||||
lang: "en",
|
||||
removeAttribute: (name: string) => {
|
||||
if (name === "data-theme") {
|
||||
delete documentThemeDataset.theme;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from "node:test";
|
||||
import * as React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { OverviewView } from "../../packages/ui/src/pages/home/components/dashboard.tsx";
|
||||
import { parseStatusBucketDate } from "../../packages/ui/src/pages/home/shared/controls.tsx";
|
||||
import { providerAccountMeterDetailValidityProgress } from "../../packages/ui/src/pages/home/shared/provider-accounts.ts";
|
||||
import type { OverviewWidgetConfig, ProviderAccountSnapshot } from "../../packages/core/src/contracts/app.ts";
|
||||
import { accountSnapshots, installBrowserGlobals, usageStats } from "./fixtures.ts";
|
||||
@@ -46,6 +47,10 @@ test("OverviewView renders every overview widget type", () => {
|
||||
assert.match(html, /All providers/);
|
||||
assert.match(html, /All models/);
|
||||
assert.match(html, /aria-label="Edit widgets"/);
|
||||
assert.match(html, /aria-pressed="true"/);
|
||||
assert.match(html, /overview-heading-icon/);
|
||||
assert.match(html, /overview-metric-card/);
|
||||
assert.doesNotMatch(html, /2026-06-20T00:00:00\.000Z/);
|
||||
assert.match(html, /System status/);
|
||||
assert.match(html, /API Service/);
|
||||
assert.match(html, /openai \/ Primary Key/);
|
||||
@@ -69,6 +74,27 @@ test("OverviewView renders every overview widget type", () => {
|
||||
assert.match(html, /Spend Receipt/);
|
||||
});
|
||||
|
||||
test("overview status dates accept ISO usage buckets", () => {
|
||||
assert.equal(parseStatusBucketDate("2026-06-20T00:00:00.000Z")?.toISOString(), "2026-06-20T00:00:00.000Z");
|
||||
});
|
||||
|
||||
test("overview metric cards only show progress for ratio-based data", () => {
|
||||
const renderMetric = (metric: "cache-ratio" | "requests", variant: "bar" | "card") => renderToStaticMarkup(
|
||||
<OverviewView
|
||||
overviewWidgets={[{ enabled: true, id: `metric-${metric}-${variant}`, metric, size: "1:1", type: "metric", variant }]}
|
||||
providerAccounts={[]}
|
||||
setUsageRange={() => undefined}
|
||||
usageRange="30d"
|
||||
usageStats={usageStats("30d")}
|
||||
onWidgetsChange={() => undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
assert.doesNotMatch(renderMetric("requests", "card"), /overview-metric-track/);
|
||||
assert.match(renderMetric("cache-ratio", "card"), /overview-metric-track/);
|
||||
assert.match(renderMetric("requests", "bar"), /overview-metric-track/);
|
||||
});
|
||||
|
||||
test("OverviewView renders the empty widget layout state", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<OverviewView
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "../../packages/ui/src/pages/tray/components/index.ts";
|
||||
import { TrayApp } from "../../packages/ui/src/pages/tray/TrayApp.tsx";
|
||||
import { TrayDetailApp } from "../../packages/ui/src/pages/tray/TrayDetailApp.tsx";
|
||||
import { applyTrayThemePreference, createSourceTabs } from "../../packages/ui/src/pages/tray/shared.tsx";
|
||||
import { accountSnapshots, installBrowserGlobals, usageStats, usageTotals } from "./fixtures.ts";
|
||||
|
||||
installBrowserGlobals();
|
||||
@@ -33,6 +34,17 @@ const componentVariants = {
|
||||
tokenMix: "bars"
|
||||
} as const;
|
||||
|
||||
test("Tray theme follows the explicit app preference and resets to system", () => {
|
||||
applyTrayThemePreference("dark");
|
||||
assert.equal(document.documentElement.dataset.theme, "dark");
|
||||
|
||||
applyTrayThemePreference("light");
|
||||
assert.equal(document.documentElement.dataset.theme, "light");
|
||||
|
||||
applyTrayThemePreference("system");
|
||||
assert.equal(document.documentElement.dataset.theme, undefined);
|
||||
});
|
||||
|
||||
test("UsageOverviewPanel renders every enabled overview tray module", () => {
|
||||
const activeStats = usageStats("30d");
|
||||
const html = renderToStaticMarkup(
|
||||
@@ -118,17 +130,36 @@ test("SourceGrid renders provider tabs with the selected state", () => {
|
||||
selectedProvider="openai"
|
||||
tabs={[
|
||||
{ id: "all", label: "All" },
|
||||
{ id: "provider:openai", label: "OpenAI", provider: "openai" },
|
||||
{ id: "provider:openai", iconUrl: "data:image/png;base64,AA==", label: "OpenAI", provider: "openai" },
|
||||
{ id: "provider:anthropic", label: "Anthropic", provider: "anthropic" }
|
||||
]}
|
||||
onSelect={() => undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
assert.match(html, />All<\/button>/);
|
||||
assert.match(html, /border-teal-300\/35 bg-teal-300\/16 text-teal-50/);
|
||||
assert.match(html, />OpenAI<\/button>/);
|
||||
assert.match(html, />Anthropic<\/button>/);
|
||||
assert.match(html, /data-icon-kind="all"/);
|
||||
assert.match(html, /data-icon-kind="provider"/);
|
||||
assert.match(html, /data-icon-kind="fallback"/);
|
||||
assert.match(html, /class="tray-source-tab[^"]*" data-active="true"/);
|
||||
assert.match(html, />All<\/span>/);
|
||||
assert.match(html, />OpenAI<\/span>/);
|
||||
assert.match(html, />Anthropic<\/span>/);
|
||||
});
|
||||
|
||||
test("Tray source tabs resolve configured, preset, local, and fallback provider icons", () => {
|
||||
const tabs = createSourceTabs([], [
|
||||
{ icon: "data:image/png;base64,custom", models: [], name: "Custom Provider" },
|
||||
{ baseUrl: "https://generativelanguage.googleapis.com", models: [], name: "Google Gemini" },
|
||||
{ baseUrl: "https://chatgpt.com/backend-api/codex", models: [], name: "Codex API" },
|
||||
{ models: [], name: "unknown" }
|
||||
]);
|
||||
const tabByProvider = new Map(tabs.map((tab) => [tab.provider, tab]));
|
||||
|
||||
assert.equal(tabByProvider.get("Custom Provider")?.iconUrl, "data:image/png;base64,custom");
|
||||
assert.ok(tabByProvider.get("Google Gemini")?.iconUrl);
|
||||
assert.ok(tabByProvider.get("Codex API")?.iconUrl);
|
||||
assert.notEqual(tabByProvider.get("Google Gemini")?.iconUrl, tabByProvider.get("Codex API")?.iconUrl);
|
||||
assert.equal(tabByProvider.get("unknown")?.iconUrl, undefined);
|
||||
});
|
||||
|
||||
test("AccountSummaryPanel covers empty and metered account states", () => {
|
||||
@@ -211,7 +242,7 @@ test("RangeSwitch renders every usage range option", () => {
|
||||
|
||||
assert.match(html, />Today<\/button>/);
|
||||
assert.match(html, />24h<\/button>/);
|
||||
assert.match(html, /bg-white\/14 text-slate-50/);
|
||||
assert.match(html, /class="tray-segmented-item[^"]*" data-active="true"/);
|
||||
assert.match(html, />7d<\/button>/);
|
||||
assert.match(html, />30d<\/button>/);
|
||||
});
|
||||
@@ -252,8 +283,9 @@ test("AnimatedUsageChart renders line, area, bar, and sparkline output", () => {
|
||||
const sparkHtml = renderToStaticMarkup(<AnimatedUsageChart chartId="spark-chart" series={series} variant="sparkline" />);
|
||||
|
||||
assert.match(lineHtml, /aria-label="Usage chart"/);
|
||||
assert.match(lineHtml, /line-chart-glow/);
|
||||
assert.match(areaHtml, /fill="rgba\(45,212,191,.18\)"/);
|
||||
assert.match(lineHtml, /line-chart-primary-fill/);
|
||||
assert.match(lineHtml, /stroke="rgba\(10,132,255,.98\)"/);
|
||||
assert.match(areaHtml, /fill="url\(#area-chart-primary-fill\)"/);
|
||||
assert.match(barHtml, /<rect /);
|
||||
assert.match(sparkHtml, /stroke-width="3"/);
|
||||
});
|
||||
@@ -276,7 +308,7 @@ test("TokenMixPanel renders bars, stacked, and share chart variants", () => {
|
||||
|
||||
assert.match(barsHtml, /Token Mix/);
|
||||
assert.match(barsHtml, /Input/);
|
||||
assert.match(stackedHtml, /bg-blue-400/);
|
||||
assert.match(stackedHtml, /bg-\[#0a84ff\]/);
|
||||
assert.match(donutHtml, /aria-label="Share chart"/);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user