diff --git a/ui/src/ui/app-render-usage-tab.ts b/ui/src/ui/app-render-usage-tab.ts index fa1374b83c4..349abd9e279 100644 --- a/ui/src/ui/app-render-usage-tab.ts +++ b/ui/src/ui/app-render-usage-tab.ts @@ -37,6 +37,8 @@ export function renderUsageTab(state: AppViewState) { timeSeriesBreakdownMode: state.usageTimeSeriesBreakdownMode, timeSeries: state.usageTimeSeries, timeSeriesLoading: state.usageTimeSeriesLoading, + timeSeriesCursorStart: state.usageTimeSeriesCursorStart, + timeSeriesCursorEnd: state.usageTimeSeriesCursorEnd, sessionLogs: state.usageSessionLogs, sessionLogsLoading: state.usageSessionLogsLoading, sessionLogsExpanded: state.usageSessionLogsExpanded, @@ -196,6 +198,10 @@ export function renderUsageTab(state: AppViewState) { } } + // Reset range selection when switching sessions + state.usageTimeSeriesCursorStart = null; + state.usageTimeSeriesCursorEnd = null; + // Load timeseries/logs only if exactly one session selected if (state.usageSelectedSessions.length === 1) { void loadSessionTimeSeries(state, state.usageSelectedSessions[0]); @@ -237,6 +243,10 @@ export function renderUsageTab(state: AppViewState) { onTimeSeriesBreakdownChange: (mode) => { state.usageTimeSeriesBreakdownMode = mode; }, + onTimeSeriesCursorRangeChange: (start, end) => { + state.usageTimeSeriesCursorStart = start; + state.usageTimeSeriesCursorEnd = end; + }, onClearDays: () => { state.usageSelectedDays = []; }, diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index f01b4f9150e..ea41b4b073c 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -164,6 +164,8 @@ export type AppViewState = { usageTimeSeriesBreakdownMode: "total" | "by-type"; usageTimeSeries: SessionUsageTimeSeries | null; usageTimeSeriesLoading: boolean; + usageTimeSeriesCursorStart: number | null; + usageTimeSeriesCursorEnd: number | null; usageSessionLogs: SessionLogEntry[] | null; usageSessionLogsLoading: boolean; usageSessionLogsExpanded: boolean; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 57cfed9e1eb..7ec9b5e434f 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -260,6 +260,8 @@ export class OpenClawApp extends LitElement { @state() usageTimeSeriesBreakdownMode: "total" | "by-type" = "by-type"; @state() usageTimeSeries: import("./types.js").SessionUsageTimeSeries | null = null; @state() usageTimeSeriesLoading = false; + @state() usageTimeSeriesCursorStart: number | null = null; + @state() usageTimeSeriesCursorEnd: number | null = null; @state() usageSessionLogs: import("./views/usage.js").SessionLogEntry[] | null = null; @state() usageSessionLogsLoading = false; @state() usageSessionLogsExpanded = false; diff --git a/ui/src/ui/controllers/usage.ts b/ui/src/ui/controllers/usage.ts index 6c159005734..c7c964e4302 100644 --- a/ui/src/ui/controllers/usage.ts +++ b/ui/src/ui/controllers/usage.ts @@ -15,6 +15,8 @@ export type UsageState = { usageSelectedDays: string[]; usageTimeSeries: SessionUsageTimeSeries | null; usageTimeSeriesLoading: boolean; + usageTimeSeriesCursorStart: number | null; + usageTimeSeriesCursorEnd: number | null; usageSessionLogs: SessionLogEntry[] | null; usageSessionLogsLoading: boolean; }; @@ -94,7 +96,10 @@ export async function loadSessionLogs(state: UsageState, sessionKey: string) { state.usageSessionLogsLoading = true; state.usageSessionLogs = null; try { - const res = await state.client.request("sessions.usage.logs", { key: sessionKey, limit: 500 }); + const res = await state.client.request("sessions.usage.logs", { + key: sessionKey, + limit: 2000, + }); if (res && Array.isArray((res as { logs: SessionLogEntry[] }).logs)) { state.usageSessionLogs = (res as { logs: SessionLogEntry[] }).logs; } diff --git a/ui/src/ui/views/usage-render-details.test.ts b/ui/src/ui/views/usage-render-details.test.ts new file mode 100644 index 00000000000..084a15c4d1c --- /dev/null +++ b/ui/src/ui/views/usage-render-details.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from "vitest"; +import type { TimeSeriesPoint, UsageSessionEntry } from "./usageTypes.ts"; +import { + computeFilteredUsage, + CHART_BAR_WIDTH_RATIO, + CHART_MAX_BAR_WIDTH, +} from "./usage-render-details.ts"; + +function makePoint(overrides: Partial = {}): TimeSeriesPoint { + return { + timestamp: 1000, + totalTokens: 100, + cost: 0.01, + input: 30, + output: 40, + cacheRead: 20, + cacheWrite: 10, + cumulativeTokens: 0, + cumulativeCost: 0, + ...overrides, + }; +} + +const baseUsage = { + totalTokens: 1000, + totalCost: 1.0, + input: 300, + output: 400, + cacheRead: 200, + cacheWrite: 100, + durationMs: 60000, + firstActivity: 0, + lastActivity: 60000, + missingCostEntries: 0, + messageCounts: { + total: 10, + user: 5, + assistant: 5, + toolCalls: 0, + toolResults: 0, + errors: 0, + }, +} satisfies NonNullable; + +describe("computeFilteredUsage", () => { + it("returns undefined when no points match the range", () => { + const points = [makePoint({ timestamp: 1000 }), makePoint({ timestamp: 2000 })]; + const result = computeFilteredUsage(baseUsage, points, 3000, 4000); + expect(result).toBeUndefined(); + }); + + it("aggregates tokens and cost for points within range", () => { + const points = [ + makePoint({ timestamp: 1000, totalTokens: 100, cost: 0.1 }), + makePoint({ timestamp: 2000, totalTokens: 200, cost: 0.2 }), + makePoint({ timestamp: 3000, totalTokens: 300, cost: 0.3 }), + ]; + const result = computeFilteredUsage(baseUsage, points, 1000, 2000); + expect(result).toBeDefined(); + expect(result!.totalTokens).toBe(300); // 100 + 200 + expect(result!.totalCost).toBeCloseTo(0.3); // 0.1 + 0.2 + }); + + it("handles reversed range (end < start)", () => { + const points = [ + makePoint({ timestamp: 1000, totalTokens: 50 }), + makePoint({ timestamp: 2000, totalTokens: 75 }), + ]; + const result = computeFilteredUsage(baseUsage, points, 2000, 1000); + expect(result).toBeDefined(); + expect(result!.totalTokens).toBe(125); + }); + + it("counts message types based on input/output presence", () => { + const points = [ + makePoint({ timestamp: 1000, input: 10, output: 0 }), + makePoint({ timestamp: 2000, input: 0, output: 20 }), + makePoint({ timestamp: 3000, input: 5, output: 15 }), + ]; + const result = computeFilteredUsage(baseUsage, points, 1000, 3000); + expect(result!.messageCounts!.user).toBe(2); // points with input > 0 + expect(result!.messageCounts!.assistant).toBe(2); // points with output > 0 + expect(result!.messageCounts!.total).toBe(3); + }); + + it("computes duration from first to last filtered point", () => { + const points = [makePoint({ timestamp: 1000 }), makePoint({ timestamp: 5000 })]; + const result = computeFilteredUsage(baseUsage, points, 1000, 5000); + expect(result!.durationMs).toBe(4000); + expect(result!.firstActivity).toBe(1000); + expect(result!.lastActivity).toBe(5000); + }); + + it("aggregates token types (input, output, cacheRead, cacheWrite)", () => { + const points = [ + makePoint({ timestamp: 1000, input: 10, output: 20, cacheRead: 30, cacheWrite: 40 }), + makePoint({ timestamp: 2000, input: 5, output: 15, cacheRead: 25, cacheWrite: 35 }), + ]; + const result = computeFilteredUsage(baseUsage, points, 1000, 2000); + expect(result!.input).toBe(15); + expect(result!.output).toBe(35); + expect(result!.cacheRead).toBe(55); + expect(result!.cacheWrite).toBe(75); + }); +}); + +describe("chart bar sizing", () => { + it("bar width ratio and max are reasonable", () => { + expect(CHART_BAR_WIDTH_RATIO).toBeGreaterThan(0); + expect(CHART_BAR_WIDTH_RATIO).toBeLessThan(1); + expect(CHART_MAX_BAR_WIDTH).toBeGreaterThan(0); + }); + + it("bars fit within chart width for typical point counts", () => { + const chartWidth = 366; // typical: 400 - padding.left(30) - padding.right(4) + // For reasonable point counts (up to ~300), bars should fit + for (const n of [1, 2, 10, 50, 100, 200]) { + const slotWidth = chartWidth / n; + const barWidth = Math.min( + CHART_MAX_BAR_WIDTH, + Math.max(1, slotWidth * CHART_BAR_WIDTH_RATIO), + ); + const barGap = slotWidth - barWidth; + // Slot-based sizing guarantees total = n * slotWidth = chartWidth + expect(n * slotWidth).toBeCloseTo(chartWidth); + // Bar gap is non-negative when slotWidth >= 1 / CHART_BAR_WIDTH_RATIO + if (slotWidth >= 1 / CHART_BAR_WIDTH_RATIO) { + expect(barGap).toBeGreaterThanOrEqual(0); + } + } + }); +}); diff --git a/ui/src/ui/views/usage-render-details.ts b/ui/src/ui/views/usage-render-details.ts index a429b2bbd93..b33e624d39c 100644 --- a/ui/src/ui/views/usage-render-details.ts +++ b/ui/src/ui/views/usage-render-details.ts @@ -10,6 +10,14 @@ import { UsageSessionEntry, } from "./usageTypes.ts"; +// Chart constants +const CHART_BAR_WIDTH_RATIO = 0.75; // Fraction of slot used for bar (rest is gap) +const CHART_MAX_BAR_WIDTH = 8; // Max bar width in SVG viewBox units +const CHART_SELECTION_OPACITY = 0.06; // Opacity of range selection overlay +const HANDLE_WIDTH = 5; // Width of drag handle in SVG units +const HANDLE_HEIGHT = 12; // Height of drag handle +const HANDLE_GRIP_OFFSET = 0.7; // Offset of grip lines inside handle + function pct(part: number, total: number): number { if (!total || total <= 0) { return 0; @@ -21,8 +29,34 @@ function renderEmptyDetailState() { return nothing; } -function renderSessionSummary(session: UsageSessionEntry) { - const usage = session.usage; +/** Normalize a log timestamp to milliseconds (handles seconds vs ms). */ +function normalizeLogTimestamp(ts: number): number { + return ts < 1e12 ? ts * 1000 : ts; +} + +/** Filter session logs by a timestamp range. */ +function filterLogsByRange( + logs: SessionLogEntry[], + rangeStart: number, + rangeEnd: number, +): SessionLogEntry[] { + const lo = Math.min(rangeStart, rangeEnd); + const hi = Math.max(rangeStart, rangeEnd); + return logs.filter((log) => { + if (log.timestamp <= 0) { + return true; + } + const ts = normalizeLogTimestamp(log.timestamp); + return ts >= lo && ts <= hi; + }); +} + +function renderSessionSummary( + session: UsageSessionEntry, + filteredUsage?: UsageSessionEntry["usage"], + filteredLogs?: SessionLogEntry[], +) { + const usage = filteredUsage || session.usage; if (!usage) { return html`
No usage data for this session.
@@ -45,12 +79,37 @@ function renderSessionSummary(session: UsageSessionEntry) { badges.push(`model:${session.model}`); } - const toolItems = - usage.toolUsage?.tools.slice(0, 6).map((tool) => ({ + // Always use the full tool list for stable layout; update counts when filtering + const baseTools = usage.toolUsage?.tools.slice(0, 6) ?? []; + let toolCallCount: number; + let uniqueToolCount: number; + let toolItems: Array<{ label: string; value: string; sub: string }>; + + if (filteredLogs) { + const toolCounts = new Map(); + for (const log of filteredLogs) { + const { tools } = parseToolSummary(log.content); + for (const [name] of tools) { + toolCounts.set(name, (toolCounts.get(name) || 0) + 1); + } + } + // Keep the same tool order as the full session, just update counts + toolItems = baseTools.map((tool) => ({ + label: tool.name, + value: `${toolCounts.get(tool.name) ?? 0}`, + sub: "calls", + })); + toolCallCount = [...toolCounts.values()].reduce((sum, c) => sum + c, 0); + uniqueToolCount = toolCounts.size; + } else { + toolItems = baseTools.map((tool) => ({ label: tool.name, value: `${tool.count}`, sub: "calls", - })) ?? []; + })); + toolCallCount = usage.toolUsage?.totalCalls ?? 0; + uniqueToolCount = usage.toolUsage?.uniqueTools ?? 0; + } const modelItems = usage.modelUsage?.slice(0, 6).map((entry) => ({ label: entry.model ?? "unknown", @@ -68,8 +127,8 @@ function renderSessionSummary(session: UsageSessionEntry) {
Tool Calls
-
${usage.toolUsage?.totalCalls ?? 0}
-
${usage.toolUsage?.uniqueTools ?? 0} tools
+
${toolCallCount}
+
${uniqueToolCount} tools
Errors
@@ -89,6 +148,66 @@ function renderSessionSummary(session: UsageSessionEntry) { `; } +/** Aggregate usage stats from time series points within a timestamp range. */ +function computeFilteredUsage( + baseUsage: NonNullable, + points: TimeSeriesPoint[], + rangeStart: number, + rangeEnd: number, +): UsageSessionEntry["usage"] | undefined { + const lo = Math.min(rangeStart, rangeEnd); + const hi = Math.max(rangeStart, rangeEnd); + const filtered = points.filter((p) => p.timestamp >= lo && p.timestamp <= hi); + if (filtered.length === 0) { + return undefined; + } + + let totalTokens = 0; + let totalCost = 0; + let userMessages = 0; + let assistantMessages = 0; + let totalInput = 0; + let totalOutput = 0; + let totalCacheRead = 0; + let totalCacheWrite = 0; + + for (const p of filtered) { + totalTokens += p.totalTokens || 0; + totalCost += p.cost || 0; + totalInput += p.input || 0; + totalOutput += p.output || 0; + totalCacheRead += p.cacheRead || 0; + totalCacheWrite += p.cacheWrite || 0; + if (p.output > 0) { + assistantMessages++; + } + if (p.input > 0) { + userMessages++; + } + } + + return { + ...baseUsage, + totalTokens, + totalCost, + input: totalInput, + output: totalOutput, + cacheRead: totalCacheRead, + cacheWrite: totalCacheWrite, + durationMs: filtered[filtered.length - 1].timestamp - filtered[0].timestamp, + firstActivity: filtered[0].timestamp, + lastActivity: filtered[filtered.length - 1].timestamp, + messageCounts: { + total: filtered.length, + user: userMessages, + assistant: assistantMessages, + toolCalls: 0, + toolResults: 0, + errors: 0, + }, + }; +} + function renderSessionDetailPanel( session: UsageSessionEntry, timeSeries: { points: TimeSeriesPoint[] } | null, @@ -97,6 +216,9 @@ function renderSessionDetailPanel( onTimeSeriesModeChange: (mode: "cumulative" | "per-turn") => void, timeSeriesBreakdownMode: "total" | "by-type", onTimeSeriesBreakdownChange: (mode: "total" | "by-type") => void, + timeSeriesCursorStart: number | null, + timeSeriesCursorEnd: number | null, + onTimeSeriesCursorRangeChange: (start: number | null, end: number | null) => void, startDate: string, endDate: string, selectedDays: string[], @@ -123,18 +245,31 @@ function renderSessionDetailPanel( const displayLabel = label.length > 50 ? label.slice(0, 50) + "…" : label; const usage = session.usage; + const hasRange = timeSeriesCursorStart !== null && timeSeriesCursorEnd !== null; + const filteredUsage = + timeSeriesCursorStart !== null && timeSeriesCursorEnd !== null && timeSeries?.points && usage + ? computeFilteredUsage(usage, timeSeries.points, timeSeriesCursorStart, timeSeriesCursorEnd) + : undefined; + const headerStats = filteredUsage + ? { totalTokens: filteredUsage.totalTokens, totalCost: filteredUsage.totalCost } + : { totalTokens: usage?.totalTokens ?? 0, totalCost: usage?.totalCost ?? 0 }; + const cursorIndicator = filteredUsage ? " (filtered)" : ""; + return html`
-
${displayLabel}
+
+ ${displayLabel} + ${cursorIndicator ? html`${cursorIndicator}` : nothing} +
${ usage ? html` - ${formatTokens(usage.totalTokens)} tokens - ${formatCost(usage.totalCost)} + ${formatTokens(headerStats.totalTokens)} tokens${cursorIndicator} + ${formatCost(headerStats.totalCost)}${cursorIndicator} ` : nothing } @@ -142,7 +277,13 @@ function renderSessionDetailPanel(
- ${renderSessionSummary(session)} + ${renderSessionSummary( + session, + filteredUsage, + timeSeriesCursorStart != null && timeSeriesCursorEnd != null && sessionLogs + ? filterLogsByRange(sessionLogs, timeSeriesCursorStart, timeSeriesCursorEnd) + : undefined, + )}
${renderTimeSeriesCompact( timeSeries, @@ -154,6 +295,9 @@ function renderSessionDetailPanel( startDate, endDate, selectedDays, + timeSeriesCursorStart, + timeSeriesCursorEnd, + onTimeSeriesCursorRangeChange, )}
@@ -168,6 +312,8 @@ function renderSessionDetailPanel( onLogFilterHasToolsChange, onLogFilterQueryChange, onLogFilterClear, + hasRange ? timeSeriesCursorStart : null, + hasRange ? timeSeriesCursorEnd : null, )} ${renderContextPanel(session.contextWeight, usage, contextExpanded, onToggleContextExpanded)}
@@ -186,6 +332,9 @@ function renderTimeSeriesCompact( startDate?: string, endDate?: string, selectedDays?: string[], + cursorStart?: number | null, + cursorEnd?: number | null, + onCursorRangeChange?: (start: number | null, end: number | null) => void, ) { if (loading) { return html` @@ -242,14 +391,44 @@ function renderTimeSeriesCompact( return { ...p, cumulativeTokens: cumTokens, cumulativeCost: cumCost }; }); + // Compute range-filtered sums for "Tokens by Type" + const hasSelection = cursorStart != null && cursorEnd != null; + const rangeStartTs = hasSelection ? Math.min(cursorStart, cursorEnd) : 0; + const rangeEndTs = hasSelection ? Math.max(cursorStart, cursorEnd) : Infinity; + + // Find start/end indices for dimming + let rangeStartIdx = 0; + let rangeEndIdx = points.length; + if (hasSelection) { + rangeStartIdx = points.findIndex((p) => p.timestamp >= rangeStartTs); + if (rangeStartIdx === -1) { + rangeStartIdx = points.length; + } + const endIdx = points.findIndex((p) => p.timestamp > rangeEndTs); + rangeEndIdx = endIdx === -1 ? points.length : endIdx; + } + + const filteredPoints = hasSelection ? points.slice(rangeStartIdx, rangeEndIdx) : points; + let filteredOutput = 0, + filteredInput = 0, + filteredCacheRead = 0, + filteredCacheWrite = 0; + for (const p of filteredPoints) { + filteredOutput += p.output; + filteredInput += p.input; + filteredCacheRead += p.cacheRead; + filteredCacheWrite += p.cacheWrite; + } + const width = 400, - height = 80; - const padding = { top: 16, right: 10, bottom: 20, left: 40 }; + height = 100; + const padding = { top: 8, right: 4, bottom: 14, left: 30 }; const chartWidth = width - padding.left - padding.right; const chartHeight = height - padding.top - padding.bottom; const isCumulative = mode === "cumulative"; const breakdownByType = mode === "per-turn" && breakdownMode === "by-type"; - const totalTypeTokens = sumOutput + sumInput + sumCacheRead + sumCacheWrite; + + const totalTypeTokens = filteredOutput + filteredInput + filteredCacheRead + filteredCacheWrite; const barTotals = points.map((p) => isCumulative ? p.cumulativeTokens @@ -258,14 +437,32 @@ function renderTimeSeriesCompact( : p.totalTokens, ); const maxValue = Math.max(...barTotals, 1); - const barWidth = Math.max(2, Math.min(8, (chartWidth / points.length) * 0.7)); - const barGap = Math.max(1, (chartWidth - barWidth * points.length) / (points.length - 1 || 1)); + // Ensure bars + gaps fit exactly within chartWidth + const slotWidth = chartWidth / points.length; // space per bar including gap + const barWidth = Math.min(CHART_MAX_BAR_WIDTH, Math.max(1, slotWidth * CHART_BAR_WIDTH_RATIO)); + const barGap = slotWidth - barWidth; + + // Pre-compute handle X positions in SVG viewBox coordinates + const leftHandleX = padding.left + rangeStartIdx * (barWidth + barGap); + const rightHandleX = + rangeEndIdx >= points.length + ? padding.left + (points.length - 1) * (barWidth + barGap) + barWidth // right edge of last bar + : padding.left + (rangeEndIdx - 1) * (barWidth + barGap) + barWidth; // right edge of last selected bar return html`
-
Usage Over Time
+
Usage Over Time
+ ${ + hasSelection + ? html` +
+ +
+ ` + : nothing + }
- - - - - - - ${formatTokens(maxValue)} - 0 - - ${ - points.length > 0 - ? svg` - ${new Date(points[0].timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" })} - ${new Date(points[points.length - 1].timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" })} - ` - : nothing - } - - ${points.map((p, i) => { - const val = barTotals[i]; - const x = padding.left + i * (barWidth + barGap); - const barHeight = (val / maxValue) * chartHeight; - const y = padding.top + chartHeight - barHeight; - const date = new Date(p.timestamp); - const tooltipLines = [ - date.toLocaleDateString(undefined, { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }), - `${formatTokens(val)} tokens`, - ]; - if (breakdownByType) { - tooltipLines.push(`Output ${formatTokens(p.output)}`); - tooltipLines.push(`Input ${formatTokens(p.input)}`); - tooltipLines.push(`Cache write ${formatTokens(p.cacheWrite)}`); - tooltipLines.push(`Cache read ${formatTokens(p.cacheRead)}`); +
+ + + + + + + ${formatTokens(maxValue)} + 0 + + ${ + points.length > 0 + ? svg` + ${new Date(points[0].timestamp).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })} + ${new Date(points[points.length - 1].timestamp).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })} + ` + : nothing } - const tooltip = tooltipLines.join(" · "); - if (!breakdownByType) { - return svg`${tooltip}`; - } - const segments = [ - { value: p.output, class: "output" }, - { value: p.input, class: "input" }, - { value: p.cacheWrite, class: "cache-write" }, - { value: p.cacheRead, class: "cache-read" }, - ]; - let yCursor = padding.top + chartHeight; - return svg` - ${segments.map((seg) => { - if (seg.value <= 0 || val <= 0) { - return nothing; + + ${points.map((p, i) => { + const val = barTotals[i]; + const x = padding.left + i * (barWidth + barGap); + const bh = (val / maxValue) * chartHeight; + const y = padding.top + chartHeight - bh; + const date = new Date(p.timestamp); + const tooltipLines = [ + date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }), + `${formatTokens(val)} tokens`, + ]; + if (breakdownByType) { + tooltipLines.push(`Out ${formatTokens(p.output)}`); + tooltipLines.push(`In ${formatTokens(p.input)}`); + tooltipLines.push(`CW ${formatTokens(p.cacheWrite)}`); + tooltipLines.push(`CR ${formatTokens(p.cacheRead)}`); + } + const tooltip = tooltipLines.join(" · "); + const isOutside = hasSelection && (i < rangeStartIdx || i >= rangeEndIdx); + + if (!breakdownByType) { + return svg`${tooltip}`; + } + const segments = [ + { value: p.output, cls: "output" }, + { value: p.input, cls: "input" }, + { value: p.cacheWrite, cls: "cache-write" }, + { value: p.cacheRead, cls: "cache-read" }, + ]; + let yC = padding.top + chartHeight; + const dim = isOutside ? " dimmed" : ""; + return svg` + ${segments.map((seg) => { + if (seg.value <= 0 || val <= 0) { + return nothing; + } + const sh = bh * (seg.value / val); + yC -= sh; + return svg`${tooltip}`; + })} + `; + })} + + ${svg` + + `} + + ${svg` + + + + + `} + + ${svg` + + + + + `} + + + ${(() => { + const leftHandlePos = `${((leftHandleX / width) * 100).toFixed(1)}%`; + const rightHandlePos = `${((rightHandleX / width) * 100).toFixed(1)}%`; + + const makeDragHandler = (side: "left" | "right") => (e: MouseEvent) => { + if (!onCursorRangeChange) { + return; + } + e.preventDefault(); + e.stopPropagation(); + // Find the wrapper, then the SVG inside it + const wrapper = (e.currentTarget as HTMLElement).closest(".timeseries-chart-wrapper"); + const svgEl = wrapper?.querySelector("svg") as SVGSVGElement; + if (!svgEl) { + return; + } + // Capture rect once at mousedown to avoid re-render offset shifts + const rect = svgEl.getBoundingClientRect(); + const svgWidth = rect.width; + const chartLeftPx = (padding.left / width) * svgWidth; + const chartRightPx = ((width - padding.right) / width) * svgWidth; + const chartW = chartRightPx - chartLeftPx; + + const posToIdx = (clientX: number) => { + const x = Math.max(0, Math.min(1, (clientX - rect.left - chartLeftPx) / chartW)); + return Math.min(Math.floor(x * points.length), points.length - 1); + }; + + // Compute click offset: where on the handle the user grabbed + const handleSvgX = side === "left" ? leftHandleX : rightHandleX; + const handleClientX = rect.left + (handleSvgX / width) * svgWidth; + const grabOffset = e.clientX - handleClientX; + + document.body.style.cursor = "col-resize"; + + const handleMove = (me: MouseEvent) => { + const adjustedX = me.clientX - grabOffset; + const idx = posToIdx(adjustedX); + const pt = points[idx]; + if (!pt) { + return; } - const segHeight = barHeight * (seg.value / val); - yCursor -= segHeight; - return svg`${tooltip}`; - })} + if (side === "left") { + const endTs = cursorEnd ?? points[points.length - 1].timestamp; + // Don't let left go past right + onCursorRangeChange(Math.min(pt.timestamp, endTs), endTs); + } else { + const startTs = cursorStart ?? points[0].timestamp; + // Don't let right go past left + onCursorRangeChange(startTs, Math.max(pt.timestamp, startTs)); + } + }; + + const handleUp = () => { + document.body.style.cursor = ""; + document.removeEventListener("mousemove", handleMove); + document.removeEventListener("mouseup", handleUp); + }; + + document.addEventListener("mousemove", handleMove); + document.addEventListener("mouseup", handleUp); + }; + + return html` +
+
`; - })} - -
${points.length} msgs · ${formatTokens(cumTokens)} · ${formatCost(cumCost)}
+ })()} +
+
+ ${ + hasSelection + ? html` + ▶ Turns ${rangeStartIdx + 1}–${rangeEndIdx} of ${points.length} · + ${new Date(rangeStartTs).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}–${new Date(rangeEndTs).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })} · + ${formatTokens(filteredOutput + filteredInput + filteredCacheRead + filteredCacheWrite)} · + ${formatCost(filteredPoints.reduce((s, p) => s + (p.cost || 0), 0))} + ` + : html`${points.length} msgs · ${formatTokens(cumTokens)} · ${formatCost(cumCost)}` + } +
${ breakdownByType ? html`
-
Tokens by Type
+
Tokens by Type
-
-
-
-
+
+
+
+
- Output ${formatTokens(sumOutput)} + Output ${formatTokens(filteredOutput)}
- Input ${formatTokens(sumInput)} + Input ${formatTokens(filteredInput)}
- Cache Write ${formatTokens(sumCacheWrite)} + Cache Write ${formatTokens(filteredCacheWrite)}
- Cache Read ${formatTokens(sumCacheRead)} + Cache Read ${formatTokens(filteredCacheRead)}
Total: ${formatTokens(totalTypeTokens)}
@@ -404,6 +720,7 @@ function renderContextPanel( usage: UsageSessionEntry["usage"], expanded: boolean, onToggleExpanded: () => void, + timeSeriesCursor?: number | null, ) { if (!contextWeight) { return html` @@ -450,7 +767,7 @@ function renderContextPanel( return html`
-
System Prompt Breakdown
+
System Prompt Breakdown
${ hasMore ? html`
-

${contextPct || "Base context per message"}

+

+ ${ + timeSeriesCursor !== null && timeSeriesCursor !== undefined + ? "Current state (not filtered by timeline cursor)" + : contextPct || "Base context per message" + } +

@@ -576,6 +899,8 @@ function renderSessionLogsCompact( onFilterHasToolsChange: (next: boolean) => void, onFilterQueryChange: (next: string) => void, onFilterClear: () => void, + cursorStart?: number | null, + cursorEnd?: number | null, ) { if (loading) { return html` @@ -604,6 +929,18 @@ function renderSessionLogsCompact( new Set(entries.flatMap((entry) => entry.toolInfo.tools.map(([name]) => name))), ).toSorted((a, b) => a.localeCompare(b)); const filteredEntries = entries.filter((entry) => { + // Filter by cursor timeline range (only if logs cover the range) + if (cursorStart != null && cursorEnd != null) { + const ts = entry.log.timestamp; + if (ts > 0) { + const lo = Math.min(cursorStart, cursorEnd); + const hi = Math.max(cursorStart, cursorEnd); + const normalizedTs = normalizeLogTimestamp(ts); + if (normalizedTs < lo || normalizedTs > hi) { + return false; + } + } + } if (filters.roles.length > 0 && !filters.roles.includes(entry.log.role)) { return false; } @@ -624,9 +961,12 @@ function renderSessionLogsCompact( } return true; }); + const hasActiveFilters = + filters.roles.length > 0 || filters.tools.length > 0 || filters.hasTools || normalizedQuery; + const hasCursorFilter = cursorStart != null && cursorEnd != null; const displayedCount = - filters.roles.length > 0 || filters.tools.length > 0 || filters.hasTools || normalizedQuery - ? `${filteredEntries.length} of ${logs.length}` + hasActiveFilters || hasCursorFilter + ? `${filteredEntries.length} of ${logs.length} ${hasCursorFilter ? "(timeline filtered)" : ""}` : `${logs.length}`; const roleSelected = new Set(filters.roles); @@ -635,7 +975,7 @@ function renderSessionLogsCompact( return html`
- Conversation (${displayedCount} messages) + Conversation (${displayedCount} messages) @@ -736,10 +1076,13 @@ function renderSessionLogsCompact( } export { + computeFilteredUsage, renderContextPanel, renderEmptyDetailState, renderSessionDetailPanel, renderSessionLogsCompact, renderSessionSummary, renderTimeSeriesCompact, + CHART_BAR_WIDTH_RATIO, + CHART_MAX_BAR_WIDTH, }; diff --git a/ui/src/ui/views/usage-styles/usageStyles-part3.ts b/ui/src/ui/views/usage-styles/usageStyles-part3.ts index 5507126d9e6..9a7b60572bb 100644 --- a/ui/src/ui/views/usage-styles/usageStyles-part3.ts +++ b/ui/src/ui/views/usage-styles/usageStyles-part3.ts @@ -509,4 +509,43 @@ export const usageStylesPart3 = ` height: 22px; } } + + /* ===== CHART AXIS ===== */ + .ts-axis-label { + font-size: 5px; + fill: var(--text-muted); + } + + /* ===== RANGE SELECTION HANDLES ===== */ + .chart-handle-zone { + position: absolute; + top: 0; + width: 16px; + height: 100%; + cursor: col-resize; + z-index: 10; + transform: translateX(-50%); + } + + .timeseries-chart-wrapper { + position: relative; + } + + .timeseries-reset-btn { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 999px; + padding: 2px 10px; + font-size: 11px; + color: var(--text-muted); + cursor: pointer; + transition: all 0.15s ease; + margin-left: 8px; + } + + .timeseries-reset-btn:hover { + background: var(--bg-hover); + color: var(--text); + border-color: var(--border-strong); + } `; diff --git a/ui/src/ui/views/usage.ts b/ui/src/ui/views/usage.ts index 303bd15258d..7a2e3d1beda 100644 --- a/ui/src/ui/views/usage.ts +++ b/ui/src/ui/views/usage.ts @@ -811,6 +811,9 @@ export function renderUsage(props: UsageProps) { props.onTimeSeriesModeChange, props.timeSeriesBreakdownMode, props.onTimeSeriesBreakdownChange, + props.timeSeriesCursorStart, + props.timeSeriesCursorEnd, + props.onTimeSeriesCursorRangeChange, props.startDate, props.endDate, props.selectedDays, diff --git a/ui/src/ui/views/usageTypes.ts b/ui/src/ui/views/usageTypes.ts index d25ae16fba9..86a7cea4c73 100644 --- a/ui/src/ui/views/usageTypes.ts +++ b/ui/src/ui/views/usageTypes.ts @@ -42,6 +42,8 @@ export type UsageProps = { timeSeriesBreakdownMode: "total" | "by-type"; timeSeries: { points: TimeSeriesPoint[] } | null; timeSeriesLoading: boolean; + timeSeriesCursorStart: number | null; // Start of selected range (null = no selection) + timeSeriesCursorEnd: number | null; // End of selected range (null = no selection) sessionLogs: SessionLogEntry[] | null; sessionLogsLoading: boolean; sessionLogsExpanded: boolean; @@ -76,6 +78,7 @@ export type UsageProps = { onDailyChartModeChange: (mode: "total" | "by-type") => void; onTimeSeriesModeChange: (mode: "cumulative" | "per-turn") => void; onTimeSeriesBreakdownChange: (mode: "total" | "by-type") => void; + onTimeSeriesCursorRangeChange: (start: number | null, end: number | null) => void; onSelectDay: (day: string, shiftKey: boolean) => void; // Support shift-click onSelectHour: (hour: number, shiftKey: boolean) => void; onClearDays: () => void;