* 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>
322 lines
9.1 KiB
TypeScript
322 lines
9.1 KiB
TypeScript
export type UsageQueryTerm = {
|
||
key?: string;
|
||
value: string;
|
||
raw: string;
|
||
};
|
||
|
||
export type UsageQueryResult<TSession> = {
|
||
sessions: TSession[];
|
||
warnings: string[];
|
||
};
|
||
|
||
// Minimal shape required for query filtering. The usage view's real session type contains more fields.
|
||
export type UsageSessionQueryTarget = {
|
||
key: string;
|
||
label?: string;
|
||
sessionId?: string;
|
||
agentId?: string;
|
||
channel?: string;
|
||
chatType?: string;
|
||
modelProvider?: string;
|
||
providerOverride?: string;
|
||
origin?: { provider?: string };
|
||
model?: string;
|
||
contextWeight?: unknown;
|
||
usage?: {
|
||
totalTokens?: number;
|
||
totalCost?: number;
|
||
messageCounts?: { total?: number; errors?: number };
|
||
toolUsage?: { totalCalls?: number; tools?: Array<{ name: string }> };
|
||
modelUsage?: Array<{ provider?: string; model?: string }>;
|
||
} | null;
|
||
};
|
||
|
||
const QUERY_KEYS = new Set([
|
||
"agent",
|
||
"channel",
|
||
"chat",
|
||
"provider",
|
||
"model",
|
||
"tool",
|
||
"label",
|
||
"key",
|
||
"session",
|
||
"id",
|
||
"has",
|
||
"mintokens",
|
||
"maxtokens",
|
||
"mincost",
|
||
"maxcost",
|
||
"minmessages",
|
||
"maxmessages",
|
||
]);
|
||
|
||
const normalizeQueryText = (value: string): string => value.trim().toLowerCase();
|
||
|
||
const globToRegex = (pattern: string): RegExp => {
|
||
const escaped = pattern
|
||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||
.replace(/\*/g, ".*")
|
||
.replace(/\?/g, ".");
|
||
return new RegExp(`^${escaped}$`, "i");
|
||
};
|
||
|
||
const parseQueryNumber = (value: string): number | null => {
|
||
let raw = value.trim().toLowerCase();
|
||
if (!raw) {
|
||
return null;
|
||
}
|
||
if (raw.startsWith("$")) {
|
||
raw = raw.slice(1);
|
||
}
|
||
let multiplier = 1;
|
||
if (raw.endsWith("k")) {
|
||
multiplier = 1_000;
|
||
raw = raw.slice(0, -1);
|
||
} else if (raw.endsWith("m")) {
|
||
multiplier = 1_000_000;
|
||
raw = raw.slice(0, -1);
|
||
}
|
||
const parsed = Number(raw);
|
||
if (!Number.isFinite(parsed)) {
|
||
return null;
|
||
}
|
||
return parsed * multiplier;
|
||
};
|
||
|
||
export const extractQueryTerms = (query: string): UsageQueryTerm[] => {
|
||
// Tokenize by whitespace, but allow quoted values with spaces.
|
||
const rawTokens = query.match(/"[^"]+"|\S+/g) ?? [];
|
||
return rawTokens.map((token) => {
|
||
const cleaned = token.replace(/^"|"$/g, "");
|
||
const idx = cleaned.indexOf(":");
|
||
if (idx > 0) {
|
||
const key = cleaned.slice(0, idx);
|
||
const value = cleaned.slice(idx + 1);
|
||
return { key, value, raw: cleaned };
|
||
}
|
||
return { value: cleaned, raw: cleaned };
|
||
});
|
||
};
|
||
|
||
const getSessionText = (session: UsageSessionQueryTarget): string[] => {
|
||
const items: Array<string | undefined> = [session.label, session.key, session.sessionId];
|
||
return items.filter((item): item is string => Boolean(item)).map((item) => item.toLowerCase());
|
||
};
|
||
|
||
const getSessionProviders = (session: UsageSessionQueryTarget): string[] => {
|
||
const providers = new Set<string>();
|
||
if (session.modelProvider) {
|
||
providers.add(session.modelProvider.toLowerCase());
|
||
}
|
||
if (session.providerOverride) {
|
||
providers.add(session.providerOverride.toLowerCase());
|
||
}
|
||
if (session.origin?.provider) {
|
||
providers.add(session.origin.provider.toLowerCase());
|
||
}
|
||
for (const entry of session.usage?.modelUsage ?? []) {
|
||
if (entry.provider) {
|
||
providers.add(entry.provider.toLowerCase());
|
||
}
|
||
}
|
||
return Array.from(providers);
|
||
};
|
||
|
||
const getSessionModels = (session: UsageSessionQueryTarget): string[] => {
|
||
const models = new Set<string>();
|
||
if (session.model) {
|
||
models.add(session.model.toLowerCase());
|
||
}
|
||
for (const entry of session.usage?.modelUsage ?? []) {
|
||
if (entry.model) {
|
||
models.add(entry.model.toLowerCase());
|
||
}
|
||
}
|
||
return Array.from(models);
|
||
};
|
||
|
||
const getSessionTools = (session: UsageSessionQueryTarget): string[] =>
|
||
(session.usage?.toolUsage?.tools ?? []).map((tool) => tool.name.toLowerCase());
|
||
|
||
export const matchesUsageQuery = (
|
||
session: UsageSessionQueryTarget,
|
||
term: UsageQueryTerm,
|
||
): boolean => {
|
||
const value = normalizeQueryText(term.value ?? "");
|
||
if (!value) {
|
||
return true;
|
||
}
|
||
if (!term.key) {
|
||
return getSessionText(session).some((text) => text.includes(value));
|
||
}
|
||
|
||
const key = normalizeQueryText(term.key);
|
||
switch (key) {
|
||
case "agent":
|
||
return session.agentId?.toLowerCase().includes(value) ?? false;
|
||
case "channel":
|
||
return session.channel?.toLowerCase().includes(value) ?? false;
|
||
case "chat":
|
||
return session.chatType?.toLowerCase().includes(value) ?? false;
|
||
case "provider":
|
||
return getSessionProviders(session).some((provider) => provider.includes(value));
|
||
case "model":
|
||
return getSessionModels(session).some((model) => model.includes(value));
|
||
case "tool":
|
||
return getSessionTools(session).some((tool) => tool.includes(value));
|
||
case "label":
|
||
return session.label?.toLowerCase().includes(value) ?? false;
|
||
case "key":
|
||
case "session":
|
||
case "id":
|
||
if (value.includes("*") || value.includes("?")) {
|
||
const regex = globToRegex(value);
|
||
return (
|
||
regex.test(session.key) || (session.sessionId ? regex.test(session.sessionId) : false)
|
||
);
|
||
}
|
||
return (
|
||
session.key.toLowerCase().includes(value) ||
|
||
(session.sessionId?.toLowerCase().includes(value) ?? false)
|
||
);
|
||
case "has":
|
||
switch (value) {
|
||
case "tools":
|
||
return (session.usage?.toolUsage?.totalCalls ?? 0) > 0;
|
||
case "errors":
|
||
return (session.usage?.messageCounts?.errors ?? 0) > 0;
|
||
case "context":
|
||
return Boolean(session.contextWeight);
|
||
case "usage":
|
||
return Boolean(session.usage);
|
||
case "model":
|
||
return getSessionModels(session).length > 0;
|
||
case "provider":
|
||
return getSessionProviders(session).length > 0;
|
||
default:
|
||
return true;
|
||
}
|
||
case "mintokens": {
|
||
const threshold = parseQueryNumber(value);
|
||
if (threshold === null) {
|
||
return true;
|
||
}
|
||
return (session.usage?.totalTokens ?? 0) >= threshold;
|
||
}
|
||
case "maxtokens": {
|
||
const threshold = parseQueryNumber(value);
|
||
if (threshold === null) {
|
||
return true;
|
||
}
|
||
return (session.usage?.totalTokens ?? 0) <= threshold;
|
||
}
|
||
case "mincost": {
|
||
const threshold = parseQueryNumber(value);
|
||
if (threshold === null) {
|
||
return true;
|
||
}
|
||
return (session.usage?.totalCost ?? 0) >= threshold;
|
||
}
|
||
case "maxcost": {
|
||
const threshold = parseQueryNumber(value);
|
||
if (threshold === null) {
|
||
return true;
|
||
}
|
||
return (session.usage?.totalCost ?? 0) <= threshold;
|
||
}
|
||
case "minmessages": {
|
||
const threshold = parseQueryNumber(value);
|
||
if (threshold === null) {
|
||
return true;
|
||
}
|
||
return (session.usage?.messageCounts?.total ?? 0) >= threshold;
|
||
}
|
||
case "maxmessages": {
|
||
const threshold = parseQueryNumber(value);
|
||
if (threshold === null) {
|
||
return true;
|
||
}
|
||
return (session.usage?.messageCounts?.total ?? 0) <= threshold;
|
||
}
|
||
default:
|
||
return true;
|
||
}
|
||
};
|
||
|
||
export const filterSessionsByQuery = <TSession extends UsageSessionQueryTarget>(
|
||
sessions: TSession[],
|
||
query: string,
|
||
): UsageQueryResult<TSession> => {
|
||
const terms = extractQueryTerms(query);
|
||
if (terms.length === 0) {
|
||
return { sessions, warnings: [] };
|
||
}
|
||
|
||
const warnings: string[] = [];
|
||
for (const term of terms) {
|
||
if (!term.key) {
|
||
continue;
|
||
}
|
||
const normalizedKey = normalizeQueryText(term.key);
|
||
if (!QUERY_KEYS.has(normalizedKey)) {
|
||
warnings.push(`Unknown filter: ${term.key}`);
|
||
continue;
|
||
}
|
||
if (term.value === "") {
|
||
warnings.push(`Missing value for ${term.key}`);
|
||
}
|
||
if (normalizedKey === "has") {
|
||
const allowed = new Set(["tools", "errors", "context", "usage", "model", "provider"]);
|
||
if (term.value && !allowed.has(normalizeQueryText(term.value))) {
|
||
warnings.push(`Unknown has:${term.value}`);
|
||
}
|
||
}
|
||
if (
|
||
["mintokens", "maxtokens", "mincost", "maxcost", "minmessages", "maxmessages"].includes(
|
||
normalizedKey,
|
||
)
|
||
) {
|
||
if (term.value && parseQueryNumber(term.value) === null) {
|
||
warnings.push(`Invalid number for ${term.key}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
const filtered = sessions.filter((session) =>
|
||
terms.every((term) => matchesUsageQuery(session, term)),
|
||
);
|
||
return { sessions: filtered, warnings };
|
||
};
|
||
|
||
export function parseToolSummary(content: string) {
|
||
const lines = content.split("\n");
|
||
const toolCounts = new Map<string, number>();
|
||
const nonToolLines: string[] = [];
|
||
for (const line of lines) {
|
||
const match = /^\[Tool:\s*([^\]]+)\]/.exec(line.trim());
|
||
if (match) {
|
||
const name = match[1];
|
||
toolCounts.set(name, (toolCounts.get(name) ?? 0) + 1);
|
||
continue;
|
||
}
|
||
if (line.trim().startsWith("[Tool Result]")) {
|
||
continue;
|
||
}
|
||
nonToolLines.push(line);
|
||
}
|
||
const sortedTools = Array.from(toolCounts.entries()).toSorted((a, b) => b[1] - a[1]);
|
||
const totalCalls = sortedTools.reduce((sum, [, count]) => sum + count, 0);
|
||
const summary =
|
||
sortedTools.length > 0
|
||
? `Tools: ${sortedTools
|
||
.map(([name, count]) => `${name}×${count}`)
|
||
.join(", ")} (${totalCalls} calls)`
|
||
: "";
|
||
return {
|
||
tools: sortedTools,
|
||
summary,
|
||
cleanContent: nonToolLines.join("\n").trim(),
|
||
};
|
||
}
|