* 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>
350 lines
10 KiB
TypeScript
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>
|
|
`;
|
|
}
|