diff --git a/ui/src/ui/app-render-usage-tab.ts b/ui/src/ui/app-render-usage-tab.ts new file mode 100644 index 0000000000..fa1374b83c --- /dev/null +++ b/ui/src/ui/app-render-usage-tab.ts @@ -0,0 +1,259 @@ +import { nothing } from "lit"; +import type { AppViewState } from "./app-view-state.ts"; +import type { UsageState } from "./controllers/usage.ts"; +import { loadUsage, loadSessionTimeSeries, loadSessionLogs } from "./controllers/usage.ts"; +import { renderUsage } from "./views/usage.ts"; + +// Module-scope debounce for usage date changes (avoids type-unsafe hacks on state object) +let usageDateDebounceTimeout: number | null = null; +const debouncedLoadUsage = (state: UsageState) => { + if (usageDateDebounceTimeout) { + clearTimeout(usageDateDebounceTimeout); + } + usageDateDebounceTimeout = window.setTimeout(() => void loadUsage(state), 400); +}; + +export function renderUsageTab(state: AppViewState) { + if (state.tab !== "usage") { + return nothing; + } + + return renderUsage({ + loading: state.usageLoading, + error: state.usageError, + startDate: state.usageStartDate, + endDate: state.usageEndDate, + sessions: state.usageResult?.sessions ?? [], + sessionsLimitReached: (state.usageResult?.sessions?.length ?? 0) >= 1000, + totals: state.usageResult?.totals ?? null, + aggregates: state.usageResult?.aggregates ?? null, + costDaily: state.usageCostSummary?.daily ?? [], + selectedSessions: state.usageSelectedSessions, + selectedDays: state.usageSelectedDays, + selectedHours: state.usageSelectedHours, + chartMode: state.usageChartMode, + dailyChartMode: state.usageDailyChartMode, + timeSeriesMode: state.usageTimeSeriesMode, + timeSeriesBreakdownMode: state.usageTimeSeriesBreakdownMode, + timeSeries: state.usageTimeSeries, + timeSeriesLoading: state.usageTimeSeriesLoading, + sessionLogs: state.usageSessionLogs, + sessionLogsLoading: state.usageSessionLogsLoading, + sessionLogsExpanded: state.usageSessionLogsExpanded, + logFilterRoles: state.usageLogFilterRoles, + logFilterTools: state.usageLogFilterTools, + logFilterHasTools: state.usageLogFilterHasTools, + logFilterQuery: state.usageLogFilterQuery, + query: state.usageQuery, + queryDraft: state.usageQueryDraft, + sessionSort: state.usageSessionSort, + sessionSortDir: state.usageSessionSortDir, + recentSessions: state.usageRecentSessions, + sessionsTab: state.usageSessionsTab, + visibleColumns: state.usageVisibleColumns as import("./views/usage.ts").UsageColumnId[], + timeZone: state.usageTimeZone, + contextExpanded: state.usageContextExpanded, + headerPinned: state.usageHeaderPinned, + onStartDateChange: (date) => { + state.usageStartDate = date; + state.usageSelectedDays = []; + state.usageSelectedHours = []; + state.usageSelectedSessions = []; + debouncedLoadUsage(state); + }, + onEndDateChange: (date) => { + state.usageEndDate = date; + state.usageSelectedDays = []; + state.usageSelectedHours = []; + state.usageSelectedSessions = []; + debouncedLoadUsage(state); + }, + onRefresh: () => loadUsage(state), + onTimeZoneChange: (zone) => { + state.usageTimeZone = zone; + }, + onToggleContextExpanded: () => { + state.usageContextExpanded = !state.usageContextExpanded; + }, + onToggleSessionLogsExpanded: () => { + state.usageSessionLogsExpanded = !state.usageSessionLogsExpanded; + }, + onLogFilterRolesChange: (next) => { + state.usageLogFilterRoles = next; + }, + onLogFilterToolsChange: (next) => { + state.usageLogFilterTools = next; + }, + onLogFilterHasToolsChange: (next) => { + state.usageLogFilterHasTools = next; + }, + onLogFilterQueryChange: (next) => { + state.usageLogFilterQuery = next; + }, + onLogFilterClear: () => { + state.usageLogFilterRoles = []; + state.usageLogFilterTools = []; + state.usageLogFilterHasTools = false; + state.usageLogFilterQuery = ""; + }, + onToggleHeaderPinned: () => { + state.usageHeaderPinned = !state.usageHeaderPinned; + }, + onSelectHour: (hour, shiftKey) => { + if (shiftKey && state.usageSelectedHours.length > 0) { + const allHours = Array.from({ length: 24 }, (_, i) => i); + const lastSelected = state.usageSelectedHours[state.usageSelectedHours.length - 1]; + const lastIdx = allHours.indexOf(lastSelected); + const thisIdx = allHours.indexOf(hour); + if (lastIdx !== -1 && thisIdx !== -1) { + const [start, end] = lastIdx < thisIdx ? [lastIdx, thisIdx] : [thisIdx, lastIdx]; + const range = allHours.slice(start, end + 1); + state.usageSelectedHours = [...new Set([...state.usageSelectedHours, ...range])]; + } + } else { + if (state.usageSelectedHours.includes(hour)) { + state.usageSelectedHours = state.usageSelectedHours.filter((h) => h !== hour); + } else { + state.usageSelectedHours = [...state.usageSelectedHours, hour]; + } + } + }, + onQueryDraftChange: (query) => { + state.usageQueryDraft = query; + if (state.usageQueryDebounceTimer) { + window.clearTimeout(state.usageQueryDebounceTimer); + } + state.usageQueryDebounceTimer = window.setTimeout(() => { + state.usageQuery = state.usageQueryDraft; + state.usageQueryDebounceTimer = null; + }, 250); + }, + onApplyQuery: () => { + if (state.usageQueryDebounceTimer) { + window.clearTimeout(state.usageQueryDebounceTimer); + state.usageQueryDebounceTimer = null; + } + state.usageQuery = state.usageQueryDraft; + }, + onClearQuery: () => { + if (state.usageQueryDebounceTimer) { + window.clearTimeout(state.usageQueryDebounceTimer); + state.usageQueryDebounceTimer = null; + } + state.usageQueryDraft = ""; + state.usageQuery = ""; + }, + onSessionSortChange: (sort) => { + state.usageSessionSort = sort; + }, + onSessionSortDirChange: (dir) => { + state.usageSessionSortDir = dir; + }, + onSessionsTabChange: (tab) => { + state.usageSessionsTab = tab; + }, + onToggleColumn: (column) => { + if (state.usageVisibleColumns.includes(column)) { + state.usageVisibleColumns = state.usageVisibleColumns.filter((entry) => entry !== column); + } else { + state.usageVisibleColumns = [...state.usageVisibleColumns, column]; + } + }, + onSelectSession: (key, shiftKey) => { + state.usageTimeSeries = null; + state.usageSessionLogs = null; + state.usageRecentSessions = [ + key, + ...state.usageRecentSessions.filter((entry) => entry !== key), + ].slice(0, 8); + + if (shiftKey && state.usageSelectedSessions.length > 0) { + // Shift-click: select range from last selected to this session + // Sort sessions same way as displayed (by tokens or cost descending) + const isTokenMode = state.usageChartMode === "tokens"; + const sortedSessions = [...(state.usageResult?.sessions ?? [])].toSorted((a, b) => { + const valA = isTokenMode ? (a.usage?.totalTokens ?? 0) : (a.usage?.totalCost ?? 0); + const valB = isTokenMode ? (b.usage?.totalTokens ?? 0) : (b.usage?.totalCost ?? 0); + return valB - valA; + }); + const allKeys = sortedSessions.map((s) => s.key); + const lastSelected = state.usageSelectedSessions[state.usageSelectedSessions.length - 1]; + const lastIdx = allKeys.indexOf(lastSelected); + const thisIdx = allKeys.indexOf(key); + if (lastIdx !== -1 && thisIdx !== -1) { + const [start, end] = lastIdx < thisIdx ? [lastIdx, thisIdx] : [thisIdx, lastIdx]; + const range = allKeys.slice(start, end + 1); + const newSelection = [...new Set([...state.usageSelectedSessions, ...range])]; + state.usageSelectedSessions = newSelection; + } + } else { + // Regular click: focus a single session (so details always open). + // Click the focused session again to clear selection. + if (state.usageSelectedSessions.length === 1 && state.usageSelectedSessions[0] === key) { + state.usageSelectedSessions = []; + } else { + state.usageSelectedSessions = [key]; + } + } + + // Load timeseries/logs only if exactly one session selected + if (state.usageSelectedSessions.length === 1) { + void loadSessionTimeSeries(state, state.usageSelectedSessions[0]); + void loadSessionLogs(state, state.usageSelectedSessions[0]); + } + }, + onSelectDay: (day, shiftKey) => { + if (shiftKey && state.usageSelectedDays.length > 0) { + // Shift-click: select range from last selected to this day + const allDays = (state.usageCostSummary?.daily ?? []).map((d) => d.date); + const lastSelected = state.usageSelectedDays[state.usageSelectedDays.length - 1]; + const lastIdx = allDays.indexOf(lastSelected); + const thisIdx = allDays.indexOf(day); + if (lastIdx !== -1 && thisIdx !== -1) { + const [start, end] = lastIdx < thisIdx ? [lastIdx, thisIdx] : [thisIdx, lastIdx]; + const range = allDays.slice(start, end + 1); + // Merge with existing selection + const newSelection = [...new Set([...state.usageSelectedDays, ...range])]; + state.usageSelectedDays = newSelection; + } + } else { + // Regular click: toggle single day + if (state.usageSelectedDays.includes(day)) { + state.usageSelectedDays = state.usageSelectedDays.filter((d) => d !== day); + } else { + state.usageSelectedDays = [day]; + } + } + }, + onChartModeChange: (mode) => { + state.usageChartMode = mode; + }, + onDailyChartModeChange: (mode) => { + state.usageDailyChartMode = mode; + }, + onTimeSeriesModeChange: (mode) => { + state.usageTimeSeriesMode = mode; + }, + onTimeSeriesBreakdownChange: (mode) => { + state.usageTimeSeriesBreakdownMode = mode; + }, + onClearDays: () => { + state.usageSelectedDays = []; + }, + onClearHours: () => { + state.usageSelectedHours = []; + }, + onClearSessions: () => { + state.usageSelectedSessions = []; + state.usageTimeSeries = null; + state.usageSessionLogs = null; + }, + onClearFilters: () => { + state.usageSelectedDays = []; + state.usageSelectedHours = []; + state.usageSelectedSessions = []; + state.usageTimeSeries = null; + state.usageSessionLogs = null; + }, + }); +} diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 5431627e03..3e9662b621 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -1,8 +1,8 @@ import { html, nothing } from "lit"; import type { AppViewState } from "./app-view-state.ts"; -import type { UsageState } from "./controllers/usage.ts"; import { parseAgentSessionKey } from "../../../src/routing/session-key.js"; import { refreshChatAvatar } from "./app-chat.ts"; +import { renderUsageTab } from "./app-render-usage-tab.ts"; import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers.ts"; import { loadAgentFileContent, loadAgentFiles, saveAgentFile } from "./controllers/agent-files.ts"; import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts"; @@ -50,18 +50,8 @@ import { updateSkillEdit, updateSkillEnabled, } from "./controllers/skills.ts"; -import { loadUsage, loadSessionTimeSeries, loadSessionLogs } from "./controllers/usage.ts"; import { icons } from "./icons.ts"; import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts"; - -// Module-scope debounce for usage date changes (avoids type-unsafe hacks on state object) -let usageDateDebounceTimeout: number | null = null; -const debouncedLoadUsage = (state: UsageState) => { - if (usageDateDebounceTimeout) { - clearTimeout(usageDateDebounceTimeout); - } - usageDateDebounceTimeout = window.setTimeout(() => void loadUsage(state), 400); -}; import { renderAgents } from "./views/agents.ts"; import { renderChannels } from "./views/channels.ts"; import { renderChat } from "./views/chat.ts"; @@ -76,7 +66,6 @@ import { renderNodes } from "./views/nodes.ts"; import { renderOverview } from "./views/overview.ts"; import { renderSessions } from "./views/sessions.ts"; import { renderSkills } from "./views/skills.ts"; -import { renderUsage } from "./views/usage.ts"; const AVATAR_DATA_RE = /^data:/i; const AVATAR_HTTP_RE = /^https?:\/\//i; @@ -315,268 +304,7 @@ export function renderApp(state: AppViewState) { : nothing } - ${ - state.tab === "usage" - ? renderUsage({ - loading: state.usageLoading, - error: state.usageError, - startDate: state.usageStartDate, - endDate: state.usageEndDate, - sessions: state.usageResult?.sessions ?? [], - sessionsLimitReached: (state.usageResult?.sessions?.length ?? 0) >= 1000, - totals: state.usageResult?.totals ?? null, - aggregates: state.usageResult?.aggregates ?? null, - costDaily: state.usageCostSummary?.daily ?? [], - selectedSessions: state.usageSelectedSessions, - selectedDays: state.usageSelectedDays, - selectedHours: state.usageSelectedHours, - chartMode: state.usageChartMode, - dailyChartMode: state.usageDailyChartMode, - timeSeriesMode: state.usageTimeSeriesMode, - timeSeriesBreakdownMode: state.usageTimeSeriesBreakdownMode, - timeSeries: state.usageTimeSeries, - timeSeriesLoading: state.usageTimeSeriesLoading, - sessionLogs: state.usageSessionLogs, - sessionLogsLoading: state.usageSessionLogsLoading, - sessionLogsExpanded: state.usageSessionLogsExpanded, - logFilterRoles: state.usageLogFilterRoles, - logFilterTools: state.usageLogFilterTools, - logFilterHasTools: state.usageLogFilterHasTools, - logFilterQuery: state.usageLogFilterQuery, - query: state.usageQuery, - queryDraft: state.usageQueryDraft, - sessionSort: state.usageSessionSort, - sessionSortDir: state.usageSessionSortDir, - recentSessions: state.usageRecentSessions, - sessionsTab: state.usageSessionsTab, - visibleColumns: - state.usageVisibleColumns as import("./views/usage.ts").UsageColumnId[], - timeZone: state.usageTimeZone, - contextExpanded: state.usageContextExpanded, - headerPinned: state.usageHeaderPinned, - onStartDateChange: (date) => { - state.usageStartDate = date; - state.usageSelectedDays = []; - state.usageSelectedHours = []; - state.usageSelectedSessions = []; - debouncedLoadUsage(state); - }, - onEndDateChange: (date) => { - state.usageEndDate = date; - state.usageSelectedDays = []; - state.usageSelectedHours = []; - state.usageSelectedSessions = []; - debouncedLoadUsage(state); - }, - onRefresh: () => loadUsage(state), - onTimeZoneChange: (zone) => { - state.usageTimeZone = zone; - }, - onToggleContextExpanded: () => { - state.usageContextExpanded = !state.usageContextExpanded; - }, - onToggleSessionLogsExpanded: () => { - state.usageSessionLogsExpanded = !state.usageSessionLogsExpanded; - }, - onLogFilterRolesChange: (next) => { - state.usageLogFilterRoles = next; - }, - onLogFilterToolsChange: (next) => { - state.usageLogFilterTools = next; - }, - onLogFilterHasToolsChange: (next) => { - state.usageLogFilterHasTools = next; - }, - onLogFilterQueryChange: (next) => { - state.usageLogFilterQuery = next; - }, - onLogFilterClear: () => { - state.usageLogFilterRoles = []; - state.usageLogFilterTools = []; - state.usageLogFilterHasTools = false; - state.usageLogFilterQuery = ""; - }, - onToggleHeaderPinned: () => { - state.usageHeaderPinned = !state.usageHeaderPinned; - }, - onSelectHour: (hour, shiftKey) => { - if (shiftKey && state.usageSelectedHours.length > 0) { - const allHours = Array.from({ length: 24 }, (_, i) => i); - const lastSelected = - state.usageSelectedHours[state.usageSelectedHours.length - 1]; - const lastIdx = allHours.indexOf(lastSelected); - const thisIdx = allHours.indexOf(hour); - if (lastIdx !== -1 && thisIdx !== -1) { - const [start, end] = - lastIdx < thisIdx ? [lastIdx, thisIdx] : [thisIdx, lastIdx]; - const range = allHours.slice(start, end + 1); - state.usageSelectedHours = [ - ...new Set([...state.usageSelectedHours, ...range]), - ]; - } - } else { - if (state.usageSelectedHours.includes(hour)) { - state.usageSelectedHours = state.usageSelectedHours.filter((h) => h !== hour); - } else { - state.usageSelectedHours = [...state.usageSelectedHours, hour]; - } - } - }, - onQueryDraftChange: (query) => { - state.usageQueryDraft = query; - if (state.usageQueryDebounceTimer) { - window.clearTimeout(state.usageQueryDebounceTimer); - } - state.usageQueryDebounceTimer = window.setTimeout(() => { - state.usageQuery = state.usageQueryDraft; - state.usageQueryDebounceTimer = null; - }, 250); - }, - onApplyQuery: () => { - if (state.usageQueryDebounceTimer) { - window.clearTimeout(state.usageQueryDebounceTimer); - state.usageQueryDebounceTimer = null; - } - state.usageQuery = state.usageQueryDraft; - }, - onClearQuery: () => { - if (state.usageQueryDebounceTimer) { - window.clearTimeout(state.usageQueryDebounceTimer); - state.usageQueryDebounceTimer = null; - } - state.usageQueryDraft = ""; - state.usageQuery = ""; - }, - onSessionSortChange: (sort) => { - state.usageSessionSort = sort; - }, - onSessionSortDirChange: (dir) => { - state.usageSessionSortDir = dir; - }, - onSessionsTabChange: (tab) => { - state.usageSessionsTab = tab; - }, - onToggleColumn: (column) => { - if (state.usageVisibleColumns.includes(column)) { - state.usageVisibleColumns = state.usageVisibleColumns.filter( - (entry) => entry !== column, - ); - } else { - state.usageVisibleColumns = [...state.usageVisibleColumns, column]; - } - }, - onSelectSession: (key, shiftKey) => { - state.usageTimeSeries = null; - state.usageSessionLogs = null; - state.usageRecentSessions = [ - key, - ...state.usageRecentSessions.filter((entry) => entry !== key), - ].slice(0, 8); - - if (shiftKey && state.usageSelectedSessions.length > 0) { - // Shift-click: select range from last selected to this session - // Sort sessions same way as displayed (by tokens or cost descending) - const isTokenMode = state.usageChartMode === "tokens"; - const sortedSessions = [...(state.usageResult?.sessions ?? [])].toSorted( - (a, b) => { - const valA = isTokenMode - ? (a.usage?.totalTokens ?? 0) - : (a.usage?.totalCost ?? 0); - const valB = isTokenMode - ? (b.usage?.totalTokens ?? 0) - : (b.usage?.totalCost ?? 0); - return valB - valA; - }, - ); - const allKeys = sortedSessions.map((s) => s.key); - const lastSelected = - state.usageSelectedSessions[state.usageSelectedSessions.length - 1]; - const lastIdx = allKeys.indexOf(lastSelected); - const thisIdx = allKeys.indexOf(key); - if (lastIdx !== -1 && thisIdx !== -1) { - const [start, end] = - lastIdx < thisIdx ? [lastIdx, thisIdx] : [thisIdx, lastIdx]; - const range = allKeys.slice(start, end + 1); - const newSelection = [...new Set([...state.usageSelectedSessions, ...range])]; - state.usageSelectedSessions = newSelection; - } - } else { - // Regular click: focus a single session (so details always open). - // Click the focused session again to clear selection. - if ( - state.usageSelectedSessions.length === 1 && - state.usageSelectedSessions[0] === key - ) { - state.usageSelectedSessions = []; - } else { - state.usageSelectedSessions = [key]; - } - } - - // Load timeseries/logs only if exactly one session selected - if (state.usageSelectedSessions.length === 1) { - void loadSessionTimeSeries(state, state.usageSelectedSessions[0]); - void loadSessionLogs(state, state.usageSelectedSessions[0]); - } - }, - onSelectDay: (day, shiftKey) => { - if (shiftKey && state.usageSelectedDays.length > 0) { - // Shift-click: select range from last selected to this day - const allDays = (state.usageCostSummary?.daily ?? []).map((d) => d.date); - const lastSelected = - state.usageSelectedDays[state.usageSelectedDays.length - 1]; - const lastIdx = allDays.indexOf(lastSelected); - const thisIdx = allDays.indexOf(day); - if (lastIdx !== -1 && thisIdx !== -1) { - const [start, end] = - lastIdx < thisIdx ? [lastIdx, thisIdx] : [thisIdx, lastIdx]; - const range = allDays.slice(start, end + 1); - // Merge with existing selection - const newSelection = [...new Set([...state.usageSelectedDays, ...range])]; - state.usageSelectedDays = newSelection; - } - } else { - // Regular click: toggle single day - if (state.usageSelectedDays.includes(day)) { - state.usageSelectedDays = state.usageSelectedDays.filter((d) => d !== day); - } else { - state.usageSelectedDays = [day]; - } - } - }, - onChartModeChange: (mode) => { - state.usageChartMode = mode; - }, - onDailyChartModeChange: (mode) => { - state.usageDailyChartMode = mode; - }, - onTimeSeriesModeChange: (mode) => { - state.usageTimeSeriesMode = mode; - }, - onTimeSeriesBreakdownChange: (mode) => { - state.usageTimeSeriesBreakdownMode = mode; - }, - onClearDays: () => { - state.usageSelectedDays = []; - }, - onClearHours: () => { - state.usageSelectedHours = []; - }, - onClearSessions: () => { - state.usageSelectedSessions = []; - state.usageTimeSeries = null; - state.usageSessionLogs = null; - }, - onClearFilters: () => { - state.usageSelectedDays = []; - state.usageSelectedHours = []; - state.usageSelectedSessions = []; - state.usageTimeSeries = null; - state.usageSessionLogs = null; - }, - }) - : nothing - } + ${renderUsageTab(state)} ${ state.tab === "cron"