mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-18 00:34:06 +00:00
fix(webui): align token usage heatmap
This commit is contained in:
parent
631fdb4a46
commit
7510918610
@ -1666,7 +1666,7 @@ function OverviewSettings({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-7">
|
<div className="space-y-7">
|
||||||
<section>
|
<section>
|
||||||
<TokenUsageHeatmap usage={settings.usage} />
|
<TokenUsageHeatmap usage={settings.usage} timeZone={settings.agent.timezone} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
@ -24,15 +24,16 @@ type TokenUsageMonthLabel = {
|
|||||||
label: string;
|
label: string;
|
||||||
column: number;
|
column: number;
|
||||||
};
|
};
|
||||||
|
type CalendarDayParts = {
|
||||||
|
year: string;
|
||||||
|
month: string;
|
||||||
|
day: string;
|
||||||
|
};
|
||||||
|
|
||||||
const TOKEN_HEATMAP_CELLS = 371;
|
const TOKEN_HEATMAP_CELLS = 371;
|
||||||
const TOKEN_HEATMAP_COLUMNS = Math.ceil(TOKEN_HEATMAP_CELLS / 7);
|
const TOKEN_HEATMAP_COLUMNS = Math.ceil(TOKEN_HEATMAP_CELLS / 7);
|
||||||
const TOKEN_USAGE_SOURCE_ORDER = ["user", "api", "cron", "dream", "system"] as const;
|
const TOKEN_USAGE_SOURCE_ORDER = ["user", "api", "cron", "dream", "system"] as const;
|
||||||
|
|
||||||
function startOfUtcDay(date: Date): Date {
|
|
||||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
|
||||||
}
|
|
||||||
|
|
||||||
function addUtcDays(date: Date, days: number): Date {
|
function addUtcDays(date: Date, days: number): Date {
|
||||||
const next = new Date(date);
|
const next = new Date(date);
|
||||||
next.setUTCDate(next.getUTCDate() + days);
|
next.setUTCDate(next.getUTCDate() + days);
|
||||||
@ -43,12 +44,56 @@ function isoDay(date: Date): string {
|
|||||||
return date.toISOString().slice(0, 10);
|
return date.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function utcDateFromIsoDay(day: string): Date {
|
||||||
|
const [year, month, date] = day.split("-").map(Number);
|
||||||
|
return new Date(Date.UTC(year, month - 1, date));
|
||||||
|
}
|
||||||
|
|
||||||
|
function utcDayParts(date: Date): CalendarDayParts {
|
||||||
|
return {
|
||||||
|
year: String(date.getUTCFullYear()).padStart(4, "0"),
|
||||||
|
month: String(date.getUTCMonth() + 1).padStart(2, "0"),
|
||||||
|
day: String(date.getUTCDate()).padStart(2, "0"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayPartsForTimeZone(date: Date, timeZone: string | undefined): CalendarDayParts {
|
||||||
|
if (!timeZone) return utcDayParts(date);
|
||||||
|
try {
|
||||||
|
const parts = new Intl.DateTimeFormat("en", {
|
||||||
|
calendar: "gregory",
|
||||||
|
numberingSystem: "latn",
|
||||||
|
timeZone,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
}).formatToParts(date);
|
||||||
|
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
||||||
|
if (values.year && values.month && values.day) {
|
||||||
|
return {
|
||||||
|
year: values.year.padStart(4, "0"),
|
||||||
|
month: values.month.padStart(2, "0"),
|
||||||
|
day: values.day.padStart(2, "0"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to UTC when the browser cannot resolve the configured timezone.
|
||||||
|
}
|
||||||
|
return utcDayParts(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayIsoDay(timeZone: string | undefined): string {
|
||||||
|
const parts = dayPartsForTimeZone(new Date(), timeZone);
|
||||||
|
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||||
|
}
|
||||||
|
|
||||||
function buildTokenUsageCalendar(
|
function buildTokenUsageCalendar(
|
||||||
days: TokenUsageDay[] | undefined,
|
days: TokenUsageDay[] | undefined,
|
||||||
monthFormatter: Intl.DateTimeFormat,
|
monthFormatter: Intl.DateTimeFormat,
|
||||||
|
timeZone: string | undefined,
|
||||||
): { cells: TokenUsageCell[]; monthLabels: TokenUsageMonthLabel[] } {
|
): { cells: TokenUsageCell[]; monthLabels: TokenUsageMonthLabel[] } {
|
||||||
const byDate = new Map((days ?? []).map((day) => [day.date, day]));
|
const byDate = new Map((days ?? []).map((day) => [day.date, day]));
|
||||||
const today = startOfUtcDay(new Date());
|
const today = utcDateFromIsoDay(todayIsoDay(timeZone));
|
||||||
const end = addUtcDays(today, 6 - today.getUTCDay());
|
const end = addUtcDays(today, 6 - today.getUTCDay());
|
||||||
const start = addUtcDays(end, -(TOKEN_HEATMAP_CELLS - 1));
|
const start = addUtcDays(end, -(TOKEN_HEATMAP_CELLS - 1));
|
||||||
const seenMonths = new Set<string>();
|
const seenMonths = new Set<string>();
|
||||||
@ -131,7 +176,13 @@ function tokenUsageCellClass(level: number, future: boolean): string {
|
|||||||
return "bg-neutral-200/70 ring-1 ring-black/[0.025] dark:bg-white/[0.08] dark:ring-white/[0.035]";
|
return "bg-neutral-200/70 ring-1 ring-black/[0.025] dark:bg-white/[0.08] dark:ring-white/[0.035]";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TokenUsageHeatmap({ usage }: { usage?: TokenUsagePayload }) {
|
export function TokenUsageHeatmap({
|
||||||
|
usage,
|
||||||
|
timeZone,
|
||||||
|
}: {
|
||||||
|
usage?: TokenUsagePayload;
|
||||||
|
timeZone?: string;
|
||||||
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
||||||
t(key, { defaultValue: fallback, ...(values ?? {}) });
|
t(key, { defaultValue: fallback, ...(values ?? {}) });
|
||||||
@ -140,8 +191,8 @@ export function TokenUsageHeatmap({ usage }: { usage?: TokenUsagePayload }) {
|
|||||||
[i18n.language],
|
[i18n.language],
|
||||||
);
|
);
|
||||||
const { cells, monthLabels } = useMemo(
|
const { cells, monthLabels } = useMemo(
|
||||||
() => buildTokenUsageCalendar(usage?.days, monthFormatter),
|
() => buildTokenUsageCalendar(usage?.days, monthFormatter, timeZone),
|
||||||
[monthFormatter, usage?.days],
|
[monthFormatter, timeZone, usage?.days],
|
||||||
);
|
);
|
||||||
const maxTokens = Math.max(0, ...cells.map((cell) => cell.total));
|
const maxTokens = Math.max(0, ...cells.map((cell) => cell.total));
|
||||||
|
|
||||||
@ -154,14 +205,14 @@ export function TokenUsageHeatmap({ usage }: { usage?: TokenUsagePayload }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="mb-2 grid h-4 gap-1.5 text-[10px] font-normal leading-4 text-muted-foreground/62"
|
className="mb-2 grid min-h-4 gap-1.5 text-[10px] font-normal leading-4 text-muted-foreground/62"
|
||||||
style={{ gridTemplateColumns: `repeat(${TOKEN_HEATMAP_COLUMNS}, minmax(0, 1fr))` }}
|
style={{ gridTemplateColumns: `repeat(${TOKEN_HEATMAP_COLUMNS}, minmax(0, 1fr))` }}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
{monthLabels.map((month) => (
|
{monthLabels.map((month) => (
|
||||||
<span
|
<span
|
||||||
key={`${month.label}-${month.column}`}
|
key={`${month.label}-${month.column}`}
|
||||||
className="truncate"
|
className="overflow-visible whitespace-nowrap"
|
||||||
style={{ gridColumnStart: month.column, gridColumnEnd: "span 4" }}
|
style={{ gridColumnStart: month.column, gridColumnEnd: "span 4" }}
|
||||||
>
|
>
|
||||||
{month.label}
|
{month.label}
|
||||||
|
|||||||
@ -119,6 +119,7 @@ const installedAnyGen = {
|
|||||||
function renderSettingsView(
|
function renderSettingsView(
|
||||||
options: {
|
options: {
|
||||||
initialSection?: "overview" | "apps" | "advanced" | "models";
|
initialSection?: "overview" | "apps" | "advanced" | "models";
|
||||||
|
initialSettings?: SettingsPayload;
|
||||||
onSettingsChange?: (payload: SettingsPayload) => void;
|
onSettingsChange?: (payload: SettingsPayload) => void;
|
||||||
onNativeEngineRestart?: () => Promise<string>;
|
onNativeEngineRestart?: () => Promise<string>;
|
||||||
} = {},
|
} = {},
|
||||||
@ -128,6 +129,7 @@ function renderSettingsView(
|
|||||||
<SettingsView
|
<SettingsView
|
||||||
theme="light"
|
theme="light"
|
||||||
initialSection={options.initialSection ?? "apps"}
|
initialSection={options.initialSection ?? "apps"}
|
||||||
|
initialSettings={options.initialSettings}
|
||||||
onToggleTheme={() => {}}
|
onToggleTheme={() => {}}
|
||||||
onBackToChat={() => {}}
|
onBackToChat={() => {}}
|
||||||
onModelNameChange={() => {}}
|
onModelNameChange={() => {}}
|
||||||
@ -140,6 +142,7 @@ function renderSettingsView(
|
|||||||
|
|
||||||
describe("SettingsView Apps catalog", () => {
|
describe("SettingsView Apps catalog", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -270,6 +273,44 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
expect(screen.queryByText("Peak tokens")).not.toBeInTheDocument();
|
expect(screen.queryByText("Peak tokens")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("aligns token activity days with the configured timezone", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-06-02T18:00:00Z"));
|
||||||
|
const payload: SettingsPayload = {
|
||||||
|
...settingsPayload(),
|
||||||
|
agent: {
|
||||||
|
...settingsPayload().agent,
|
||||||
|
timezone: "Asia/Shanghai",
|
||||||
|
},
|
||||||
|
usage: {
|
||||||
|
days: [
|
||||||
|
{
|
||||||
|
date: "2026-06-03",
|
||||||
|
prompt_tokens: 1200,
|
||||||
|
completion_tokens: 300,
|
||||||
|
cached_tokens: 500,
|
||||||
|
total_tokens: 1500,
|
||||||
|
requests: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total_tokens: 1500,
|
||||||
|
total_tokens_30d: 1500,
|
||||||
|
total_tokens_365d: 1500,
|
||||||
|
peak_day_tokens: 1500,
|
||||||
|
current_streak_days: 1,
|
||||||
|
longest_streak_days: 1,
|
||||||
|
active_days_30d: 1,
|
||||||
|
requests_30d: 2,
|
||||||
|
updated_at: "2026-06-03T00:00:00Z",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
|
||||||
|
|
||||||
|
renderSettingsView({ initialSection: "overview", initialSettings: payload });
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("2026-06-03: 1.5K tokens, 2 requests")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("shows context window options in model settings", async () => {
|
it("shows context window options in model settings", async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user