openclaw/ui/src/ui/app-render.helpers.ts
Tak Hoffman 8a352c8f9d
Web UI: add token usage dashboard (#10072)
* feat(ui): Token Usage dashboard with session analytics

Adds a comprehensive Token Usage view to the dashboard:

Backend:
- Extended session-cost-usage.ts with per-session daily breakdown
- Added date range filtering (startMs/endMs) to API endpoints
- New sessions.usage, sessions.usage.timeseries, sessions.usage.logs endpoints
- Cost breakdown by token type (input/output/cache read/write)

Frontend:
- Two-column layout: Daily chart + breakdown | Sessions list
- Interactive daily bar chart with click-to-filter and shift-click range select
- Session detail panel with usage timeline, conversation logs, context weight
- Filter chips for active day/session selections
- Toggle between tokens/cost view modes (default: cost)
- Responsive design for smaller screens

UX improvements:
- 21-day default date range
- Debounced date input (400ms)
- Session list shows filtered totals when days selected
- Context weight breakdown shows skills, tools, files contribution

* fix(ui): restore gatewayUrl validation and syncUrlWithSessionKey signature

- Restore normalizeGatewayUrl() to validate ws:/wss: protocol
- Restore isTopLevelWindow() guard for iframe security
- Revert syncUrlWithSessionKey signature (host param was unused)

* feat(ui): Token Usage dashboard with session analytics

Adds a comprehensive Token Usage view to the dashboard:

Backend:
- Extended session-cost-usage.ts with per-session daily breakdown
- Added date range filtering (startMs/endMs) to API endpoints
- New sessions.usage, sessions.usage.timeseries, sessions.usage.logs endpoints
- Cost breakdown by token type (input/output/cache read/write)

Frontend:
- Two-column layout: Daily chart + breakdown | Sessions list
- Interactive daily bar chart with click-to-filter and shift-click range select
- Session detail panel with usage timeline, conversation logs, context weight
- Filter chips for active day/session selections
- Toggle between tokens/cost view modes (default: cost)
- Responsive design for smaller screens

UX improvements:
- 21-day default date range
- Debounced date input (400ms)
- Session list shows filtered totals when days selected
- Context weight breakdown shows skills, tools, files contribution

* fix: usage dashboard data + cost handling (#8462) (thanks @mcinteerj)

* Usage: enrich metrics dashboard

* Usage: add latency + model trends

* Gateway: improve usage log parsing

* UI: add usage query helpers

* UI: client-side usage filter + debounce

* Build: harden write-cli-compat timing

* UI: add conversation log filters

* UI: fix usage dashboard lint + state

* Web UI: default usage dates to local day

* Protocol: sync session usage params (#8462) (thanks @mcinteerj, @TakHoffman)

---------

Co-authored-by: Jake McInteer <mcinteerj@gmail.com>
2026-02-05 22:35:46 -06:00

350 lines
10 KiB
TypeScript

import { html } from "lit";
import { repeat } from "lit/directives/repeat.js";
import type { AppViewState } from "./app-view-state.ts";
import type { ThemeTransitionContext } from "./theme-transition.ts";
import type { ThemeMode } from "./theme.ts";
import type { SessionsListResult } from "./types.ts";
import { refreshChat } from "./app-chat.ts";
import { syncUrlWithSessionKey } from "./app-settings.ts";
import { OpenClawApp } from "./app.ts";
import { ChatState, loadChatHistory } from "./controllers/chat.ts";
import { icons } from "./icons.ts";
import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation.ts";
export function renderTab(state: AppViewState, tab: Tab) {
const href = pathForTab(tab, state.basePath);
return html`
<a
href=${href}
class="nav-item ${state.tab === tab ? "active" : ""}"
@click=${(event: MouseEvent) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return;
}
event.preventDefault();
state.setTab(tab);
}}
title=${titleForTab(tab)}
>
<span class="nav-item__icon" aria-hidden="true">${icons[iconForTab(tab)]}</span>
<span class="nav-item__text">${titleForTab(tab)}</span>
</a>
`;
}
export function renderChatControls(state: AppViewState) {
const mainSessionKey = resolveMainSessionKey(state.hello, state.sessionsResult);
const sessionOptions = resolveSessionOptions(
state.sessionKey,
state.sessionsResult,
mainSessionKey,
);
const disableThinkingToggle = state.onboarding;
const disableFocusToggle = state.onboarding;
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
const focusActive = state.onboarding ? true : state.settings.chatFocusMode;
// Refresh icon
const refreshIcon = html`
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path>
<path d="M21 3v5h-5"></path>
</svg>
`;
const focusIcon = html`
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 7V4h3"></path>
<path d="M20 7V4h-3"></path>
<path d="M4 17v3h3"></path>
<path d="M20 17v3h-3"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
`;
return html`
<div class="chat-controls">
<label class="field chat-controls__session">
<select
.value=${state.sessionKey}
?disabled=${!state.connected}
@change=${(e: Event) => {
const next = (e.target as HTMLSelectElement).value;
state.sessionKey = next;
state.chatMessage = "";
state.chatStream = null;
(state as unknown as OpenClawApp).chatStreamStartedAt = null;
state.chatRunId = null;
(state as unknown as OpenClawApp).resetToolStream();
(state as unknown as OpenClawApp).resetChatScroll();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
void state.loadAssistantIdentity();
syncUrlWithSessionKey(
state as unknown as Parameters<typeof syncUrlWithSessionKey>[0],
next,
true,
);
void loadChatHistory(state as unknown as ChatState);
}}
>
${repeat(
sessionOptions,
(entry) => entry.key,
(entry) =>
html`<option value=${entry.key}>
${entry.displayName ?? entry.key}
</option>`,
)}
</select>
</label>
<button
class="btn btn--sm btn--icon"
?disabled=${state.chatLoading || !state.connected}
@click=${() => {
(state as unknown as OpenClawApp).resetToolStream();
void refreshChat(state as unknown as Parameters<typeof refreshChat>[0]);
}}
title="Refresh chat data"
>
${refreshIcon}
</button>
<span class="chat-controls__separator">|</span>
<button
class="btn btn--sm btn--icon ${showThinking ? "active" : ""}"
?disabled=${disableThinkingToggle}
@click=${() => {
if (disableThinkingToggle) {
return;
}
state.applySettings({
...state.settings,
chatShowThinking: !state.settings.chatShowThinking,
});
}}
aria-pressed=${showThinking}
title=${
disableThinkingToggle
? "Disabled during onboarding"
: "Toggle assistant thinking/working output"
}
>
${icons.brain}
</button>
<button
class="btn btn--sm btn--icon ${focusActive ? "active" : ""}"
?disabled=${disableFocusToggle}
@click=${() => {
if (disableFocusToggle) {
return;
}
state.applySettings({
...state.settings,
chatFocusMode: !state.settings.chatFocusMode,
});
}}
aria-pressed=${focusActive}
title=${
disableFocusToggle
? "Disabled during onboarding"
: "Toggle focus mode (hide sidebar + page header)"
}
>
${focusIcon}
</button>
</div>
`;
}
type SessionDefaultsSnapshot = {
mainSessionKey?: string;
mainKey?: string;
};
function resolveMainSessionKey(
hello: AppViewState["hello"],
sessions: SessionsListResult | null,
): string | null {
const snapshot = hello?.snapshot as { sessionDefaults?: SessionDefaultsSnapshot } | undefined;
const mainSessionKey = snapshot?.sessionDefaults?.mainSessionKey?.trim();
if (mainSessionKey) {
return mainSessionKey;
}
const mainKey = snapshot?.sessionDefaults?.mainKey?.trim();
if (mainKey) {
return mainKey;
}
if (sessions?.sessions?.some((row) => row.key === "main")) {
return "main";
}
return null;
}
function resolveSessionDisplayName(key: string, row?: SessionsListResult["sessions"][number]) {
const label = row?.label?.trim();
if (label) {
return `${label} (${key})`;
}
const displayName = row?.displayName?.trim();
if (displayName) {
return displayName;
}
return key;
}
function resolveSessionOptions(
sessionKey: string,
sessions: SessionsListResult | null,
mainSessionKey?: string | null,
) {
const seen = new Set<string>();
const options: Array<{ key: string; displayName?: string }> = [];
const resolvedMain = mainSessionKey && sessions?.sessions?.find((s) => s.key === mainSessionKey);
const resolvedCurrent = sessions?.sessions?.find((s) => s.key === sessionKey);
// Add main session key first
if (mainSessionKey) {
seen.add(mainSessionKey);
options.push({
key: mainSessionKey,
displayName: resolveSessionDisplayName(mainSessionKey, resolvedMain || undefined),
});
}
// Add current session key next
if (!seen.has(sessionKey)) {
seen.add(sessionKey);
options.push({
key: sessionKey,
displayName: resolveSessionDisplayName(sessionKey, resolvedCurrent),
});
}
// Add sessions from the result
if (sessions?.sessions) {
for (const s of sessions.sessions) {
if (!seen.has(s.key)) {
seen.add(s.key);
options.push({
key: s.key,
displayName: resolveSessionDisplayName(s.key, s),
});
}
}
}
return options;
}
const THEME_ORDER: ThemeMode[] = ["system", "light", "dark"];
export function renderThemeToggle(state: AppViewState) {
const index = Math.max(0, THEME_ORDER.indexOf(state.theme));
const applyTheme = (next: ThemeMode) => (event: MouseEvent) => {
const element = event.currentTarget as HTMLElement;
const context: ThemeTransitionContext = { element };
if (event.clientX || event.clientY) {
context.pointerClientX = event.clientX;
context.pointerClientY = event.clientY;
}
state.setTheme(next, context);
};
return html`
<div class="theme-toggle" style="--theme-index: ${index};">
<div class="theme-toggle__track" role="group" aria-label="Theme">
<span class="theme-toggle__indicator"></span>
<button
class="theme-toggle__button ${state.theme === "system" ? "active" : ""}"
@click=${applyTheme("system")}
aria-pressed=${state.theme === "system"}
aria-label="System theme"
title="System"
>
${renderMonitorIcon()}
</button>
<button
class="theme-toggle__button ${state.theme === "light" ? "active" : ""}"
@click=${applyTheme("light")}
aria-pressed=${state.theme === "light"}
aria-label="Light theme"
title="Light"
>
${renderSunIcon()}
</button>
<button
class="theme-toggle__button ${state.theme === "dark" ? "active" : ""}"
@click=${applyTheme("dark")}
aria-pressed=${state.theme === "dark"}
aria-label="Dark theme"
title="Dark"
>
${renderMoonIcon()}
</button>
</div>
</div>
`;
}
function renderSunIcon() {
return html`
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="4"></circle>
<path d="M12 2v2"></path>
<path d="M12 20v2"></path>
<path d="m4.93 4.93 1.41 1.41"></path>
<path d="m17.66 17.66 1.41 1.41"></path>
<path d="M2 12h2"></path>
<path d="M20 12h2"></path>
<path d="m6.34 17.66-1.41 1.41"></path>
<path d="m19.07 4.93-1.41 1.41"></path>
</svg>
`;
}
function renderMoonIcon() {
return html`
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"
></path>
</svg>
`;
}
function renderMonitorIcon() {
return html`
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
<rect width="20" height="14" x="2" y="3" rx="2"></rect>
<line x1="8" x2="16" y1="21" y2="21"></line>
<line x1="12" x2="12" y1="17" y2="21"></line>
</svg>
`;
}