diff --git a/ui/src/assets/bigtrace.scss b/ui/src/assets/bigtrace.scss index c59c23d414f..c1fc38794ba 100644 --- a/ui/src/assets/bigtrace.scss +++ b/ui/src/assets/bigtrace.scss @@ -31,8 +31,7 @@ @import "../gen/all_plugins"; @import "../gen/all_core_plugins"; -// BigTrace-specific styles. Classes use the .pf-bt- prefix to avoid -// collisions with existing Perfetto UI classes. +// BigTrace-specific styles; `.pf-bt-` prefix avoids collisions. // ---------- Settings page ---------- @@ -72,3 +71,424 @@ border-color: var(--pf-color-primary); } } + +// Scroll once tabs overflow; without this the close X collapses behind the title. +.pf-query-page__editor-tabs > .pf-tabs__tabs { + overflow-x: auto; + scrollbar-width: thin; + scrollbar-color: var(--pf-color-border-secondary) transparent; + + // Cap tab width so long names don't push the "+" off-screen. + > .pf-tabs__tab { + max-width: 220px; + } +} + +.pf-query-history__item { + margin-bottom: 10px; + max-height: none !important; + overflow-y: visible !important; +} + +// Absolute-position buttons; the shared float created a BFC that squeezed meta. +.pf-query-history__item { + position: relative; +} + +.pf-query-history__item-buttons { + position: absolute !important; + top: 4px; + right: 4px; + float: none !important; + z-index: 1; +} + +// Status-colored left bar; on the meta card only (running it down the pre +// makes it look uncomfortably tall and clashes with the pre's own border). +.pf-query-history__item-meta { + border-left: 3px solid transparent; +} + +.pf-query-history__item:has(.pf-status-success) .pf-query-history__item-meta { + border-left-color: var(--pf-color-success); +} + +.pf-query-history__item:has(.pf-status-failed) .pf-query-history__item-meta { + border-left-color: var(--pf-color-danger); +} + +.pf-query-history__item:has(.pf-status-cancelled) .pf-query-history__item-meta { + border-left-color: var(--pf-color-warning); +} + +.pf-query-history__item:has(.pf-status-in-progress) + .pf-query-history__item-meta { + border-left-color: var(--pf-color-primary); +} + +.pf-query-history__item:has(.pf-status-queued) .pf-query-history__item-meta, +.pf-query-history__item:has(.pf-status-unknown) .pf-query-history__item-meta { + border-left-color: var(--pf-color-text-muted); +} + +// Dim 0-row entries so the eye skips to entries that have data. +.pf-query-history__item-rows--empty { + opacity: 0.55; +} + +// Async-query status bar (under the editor). Two groups split by +// space-between: left = [Refresh] [Status pill] [Duration], +// right = [Traces n/m] [Rows N]. +.pf-query-page__status-bar { + padding: 12px 16px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; +} + +.pf-query-page__status-bar-group { + display: inline-flex; + align-items: center; + gap: 16px; +} + +// Give the rows stat (the rightmost element of the right group) some +// breathing room from the bar's right edge — without this, `~1.2M` butts +// right up against the bar's border. +.pf-query-page__status-bar-stat--rows { + margin-right: 16px; +} + +// While the query is running, the right group collapses to just the Rows +// inline progress bar — labels, numeric values, and the Traces stat hide. +// A perfetto Tooltip wraps the bar; hovering it shows Traces n/m and Rows n. +.pf-query-page__status-bar--running { + .pf-query-page__status-bar-stat--traces, + .pf-query-page__status-bar-stat-label, + .pf-query-page__status-bar-stat-value { + display: none; + } +} + +// Refresh button + notification-dot wrapper. +.pf-query-page__status-bar-refresh { + position: relative; + display: inline-block; +} + +.pf-query-page__status-bar-notif { + position: absolute; + top: -2px; + right: -2px; + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--pf-color-success); + z-index: 10; +} + +.pf-query-page__status-bar-stat { + display: inline-flex; + align-items: baseline; + gap: 6px; + font-variant-numeric: tabular-nums; +} + +.pf-query-page__status-bar-stat--empty { + opacity: 0.55; +} + +// Duration stat — flat treatment matches the peer TRACES / ROWS stats. +.pf-query-page__status-bar-duration { + display: inline-flex; + // Baseline so the value aligns with the SUCCESS pill and peer stats; + // `center` would synthesize a first-baseline ~2px above. + align-items: baseline; + gap: 6px; + font-variant-numeric: tabular-nums; +} + +// At <520px container width, drop the Ctrl+Enter hint first; the hotkey +// still works and ~90px lets "Persistent" stay on one line. +.pf-query-page__toolbar { + container-type: inline-size; + + // Without nowrap, "Persistent" wraps below its toggle when the toolbar + // is squeezed. The @container rule below drops the hint first. + .pf-checkbox { + white-space: nowrap; + } +} +@container (max-width: 520px) { + .pf-query-page__hotkeys { + display: none; + } +} + +// Vertical rule between toolbar param groups. +.pf-query-page__toolbar-divider { + display: inline-block; + width: 1px; + align-self: stretch; + margin: 4px 4px; + background-color: var(--pf-color-border-secondary); +} + +// Inline progress bar next to N/M in status-bar stats. width = done/total. +// Wider than a typical inline mini-bar because during a running query it is +// the sole on-screen progress signal (labels/numbers are hover-revealed). +.pf-query-page__inline-progress { + display: inline-block; + width: 140px; + height: 6px; + margin-left: 6px; + border-radius: 2px; + border: 1px solid var(--pf-color-border); + background-color: rgba(255, 255, 255, 0.04); + overflow: hidden; + vertical-align: middle; +} + +.pf-query-page__inline-progress-fill { + display: block; + height: 100%; + background-color: var(--pf-color-accent); + transition: width 0.3s ease-out; + will-change: width; +} + +// Meta + pre form one unified card via matching L/R borders. `padding-right` +// reserves ~56px for the absolute-positioned Open/Delete buttons. +// Visual hierarchy: meta header is the loudest (bg-tertiary, darkest of the +// three); the details band sits in the middle (bg-secondary); the SQL pre is +// the quietest (bg-void = page bg in light theme). +.pf-query-history__item-meta { + // Anchor for the absolute-positioned Open/Delete buttons inside the meta. + position: relative; + background: var(--pf-color-background-tertiary); + padding: 6px 8px; + padding-right: 56px; + // Top + right only; the earlier `border-left: 3px solid ` rule + // owns the left edge — `border` shorthand would shrink it to 1px. + border-top: 1px solid var(--pf-color-border-secondary); + border-right: 1px solid var(--pf-color-border-secondary); + border-radius: 4px 4px 0 0; + display: flex; + flex-direction: column; + gap: 2px; + font-size: 11px; + color: var(--pf-color-text); +} + +.pf-query-history__item pre { + // `void` = page bg — blends the SQL preview so only meta has weight. + background: var(--pf-color-void); + padding: 8px; + border: solid 1px var(--pf-color-border-secondary); + border-top: none; + border-radius: 0 0 4px 4px; + margin: 0; + font-family: var(--pf-font-monospace, monospace); + font-size: 11px; + white-space: pre-wrap; +} + +.pf-query-history__item pre:hover::after { + content: none !important; +} + +// Clamped SQL preview: ~4 lines with a fade-out mask; click to expand. +// Uses `pre.` prefix so specificity (0,1,1) matches `.pf-query-history__item pre`. +pre.pf-query-history__item-query { + cursor: pointer; + position: relative; + max-height: 4.5em; + overflow: hidden; + mask-image: linear-gradient(to bottom, black 70%, transparent 100%); + -webkit-mask-image: linear-gradient(to bottom, black 70%, transparent 100%); +} + +pre.pf-query-history__item-query.pf-query-history__item-query--expanded { + // Cap so a 1000-line query doesn't blow out the sidebar layout. + max-height: 50vh; + overflow: auto; + mask-image: none; + -webkit-mask-image: none; +} + +// Standalone frame for the delete-confirm modal (no .pf-query-history__item parent). +pre.pf-query-history__item-query--standalone { + font-family: var(--pf-font-monospace, monospace); + background: var(--pf-color-void); + border: solid 1px var(--pf-color-border-secondary); + border-radius: 4px; + padding: 8px; + margin: 0; + white-space: pre-wrap; + cursor: pointer; + position: relative; + max-height: 4.5em; + overflow: hidden; + mask-image: linear-gradient(to bottom, black 70%, transparent 100%); + -webkit-mask-image: linear-gradient(to bottom, black 70%, transparent 100%); +} + +pre.pf-query-history__item-query--standalone.pf-query-history__item-query--expanded { + max-height: 50vh; + overflow: auto; + mask-image: none; + -webkit-mask-image: none; +} + +.pf-query-history__item-query--empty { + font-style: italic; + opacity: 0.5; +} + +.pf-query-page__error-tab-title { + color: var(--pf-color-danger); +} + +// Error tab content: scrollable pre with the full error text. +.pf-query-page__error-content { + overflow: auto; + max-height: 100%; + padding: 12px; + margin: 0; + font-size: 0.85em; + white-space: pre-wrap; +} + +// Propagate height down so DataGrid `fillHeight` has a parent to fill +// and only the table body scrolls. +.pf-query-page__results-panel { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.pf-query-page__results-container { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.pf-query-page__results-container > .pf-data-grid { + flex: 1; + min-height: 0; +} + +.pf-status-success { + color: var(--pf-color-success); +} + +.pf-status-failed { + color: var(--pf-color-danger); +} + +.pf-status-cancelled { + color: var(--pf-color-warning); +} + +.pf-status-in-progress { + color: var(--pf-color-primary); +} + +.pf-status-queued, +.pf-status-unknown { + color: var(--pf-color-text-muted); + font-style: italic; +} + +// Row 1: status pill (intrinsic) on the left, start date (ellipsis-shrinks) +// on the right; fixed slots keep edges aligned across rows. +.pf-query-history__item-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + width: 100%; + + > span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + } +} + +// Pill never shrinks — date absorbs all the squeeze (else "SUCCESS"→"S..."). +.pf-query-history__item-status { + flex-shrink: 0 !important; +} + +.pf-query-history__item-date { + text-align: right; +} + +// Materialized-only "details" band — a distinct middle section sandwiched +// between the meta header (above) and the SQL pre (below). Its own background +// shade + horizontal separators on top and bottom make it visibly a separate +// section, not a row inside the header card. +.pf-query-history__item-details { + background: var(--pf-color-background-secondary); + padding: 4px 8px; + border-left: 1px solid var(--pf-color-border-secondary); + border-right: 1px solid var(--pf-color-border-secondary); + border-top: 1px solid var(--pf-color-border-secondary); + border-bottom: 1px solid var(--pf-color-border-secondary); + display: flex; + align-items: baseline; + gap: 4px; + min-width: 0; + overflow: hidden; + font-size: var(--pf-font-size-xs); + font-variant-numeric: tabular-nums; + color: var(--pf-color-text-muted); +} + +.pf-query-history__item-details--empty { + opacity: 0.55; +} + +.pf-query-history__item-rows-value { + flex-shrink: 0; +} + +.pf-query-history__item-table-link { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex: 1 1 auto; +} + +.pf-query-history__item-table-link--disabled { + // Theme-aware muted color. + color: var(--pf-color-text-muted); + pointer-events: none; + text-decoration: none; +} + +.pf-query-history__item-table-link--active { + color: var(--pf-color-primary); + pointer-events: auto; + text-decoration: underline; +} + +// Truncate long column names so the type badge stays readable at +// narrow sidebar widths. +.pf-simple-table-list__column-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +// Keep the badge at intrinsic width when the row is squeezed. +.pf-simple-table-list__column-type { + flex-shrink: 0; +} diff --git a/ui/src/bigtrace/help_modal.ts b/ui/src/bigtrace/help_modal.ts index 8cdcc9df8bc..dda1e51a232 100644 --- a/ui/src/bigtrace/help_modal.ts +++ b/ui/src/bigtrace/help_modal.ts @@ -43,8 +43,8 @@ class BigTraceHelpContent implements m.ClassComponent { ), m( 'tr', - m('td', keycap('Ctrl'), ' + ', keycap('Enter'), ' (with selection)'), - m('td', 'Execute selection'), + m('td', keycap('Ctrl'), ' + ', keycap('Enter')), + m('td', 'Execute selected text (when text is selected)'), ), ), m('h2', 'Running commands'), diff --git a/ui/src/bigtrace/index.ts b/ui/src/bigtrace/index.ts index 465af2f04ba..0013275e2ca 100644 --- a/ui/src/bigtrace/index.ts +++ b/ui/src/bigtrace/index.ts @@ -21,10 +21,9 @@ import {initLiveReload} from '../core/live_reload'; import {settingsStorage} from './settings/settings_storage'; import {ThemeProvider} from '../frontend/theme_provider'; import {OverlayContainer} from '../widgets/overlay_container'; -import {QueryPage} from './pages/query_page'; +import {QueryPage, queryRightSidebarToggleFn} from './pages/query_page'; import {HomePage} from './pages/home_page'; import {bigTraceSettingsStorage} from './settings/bigtrace_settings_storage'; -import {queryState} from './query/query_state'; import {SettingsPage} from './pages/settings_page'; import {Topbar} from './layout/topbar'; import {BigTraceApp as BigTraceAppSingleton} from './bigtrace_app'; @@ -38,11 +37,10 @@ import {toggleHelp} from './help_modal'; import {Routes} from './routes'; function getRoot() { - // Works out the root directory where the content should be served from - // e.g. `http://origin/v1.2.3/`. + // Root for serving content, e.g. `http://origin/v1.2.3/`. const script = document.currentScript as HTMLScriptElement; - // Needed for DOM tests, that do not have script element. + // DOM tests have no script element. if (script === null) { return ''; } @@ -62,6 +60,9 @@ function setupContentSecurityPolicy() { `'self'`, 'https://autopush-brush-googleapis.corp.google.com', 'https://brush-googleapis.corp.google.com', + // Local reference / TP backends for dev (perfetto_2/tools/). + 'http://localhost:*', + 'http://127.0.0.1:*', ], 'img-src': [`'self'`, 'data:', 'blob:'], 'style-src': [ @@ -177,25 +178,11 @@ class BigTraceLayout implements m.ClassComponent { } } -// Root component: routing, theme, hotkeys, and layout. -// Uses m.mount (not m.route) so that all rendering goes through the raf -// scheduler's mount system. m.route() caches the original m.mount and -// bypasses the raf scheduler, which breaks cross-tree redraws for -// portal-based popups (e.g. the omnibox dropdown). +// Root: routing + theme + hotkeys. Uses m.mount (not m.route) because +// m.route bypasses the raf scheduler and breaks portal-based popups. class BigTraceRoot implements m.ClassComponent { - private prevRoute = ''; - private queryInitialQuery: string | undefined; - view(): m.Children { const route = getCurrentRoute(); - - // Capture initialQuery on first navigation to /query. - if (route === Routes.QUERY && this.prevRoute !== Routes.QUERY) { - this.queryInitialQuery = queryState.initialQuery; - queryState.initialQuery = undefined; - } - this.prevRoute = route; - const page = this.resolvePage(route); const theme = settingsStorage.get('theme'); @@ -222,17 +209,17 @@ class BigTraceRoot implements m.ClassComponent { } private resolvePage(route: string): m.Children { - switch (route) { - case Routes.QUERY: - return m(QueryPage, { - useBrushBackend: true, - initialQuery: this.queryInitialQuery, - }); - case Routes.SETTINGS: - return m(SettingsPage); - default: - return m(HomePage); - } + return [ + // QueryPage stays mounted across route changes to preserve DataGrid + // state (filters, scroll position, sort order). + m( + 'div', + {style: {display: route === Routes.QUERY ? 'contents' : 'none'}}, + m(QueryPage, {useBigtraceBackend: true}), + ), + route === Routes.SETTINGS && m(SettingsPage), + route !== Routes.QUERY && route !== Routes.SETTINGS && m(HomePage), + ]; } } @@ -264,11 +251,21 @@ function registerCommands() { defaultHotkey: '!Mod+B', }); + app.commands.registerCommand({ + id: 'bigtrace.ToggleQueryRightSidebar', + name: 'Toggle query right sidebar (History / Stdlib Schemas)', + callback: () => { + queryRightSidebarToggleFn?.(); + }, + defaultHotkey: '!Mod+Shift+B', + }); + app.commands.registerCommand({ id: 'bigtrace.ShowHelp', name: 'Show help', callback: () => toggleHelp(), - defaultHotkey: '?', + // '!' prefix fires even when the omnibox has focus. + defaultHotkey: '!?', }); } diff --git a/ui/src/bigtrace/layout/omnibox.ts b/ui/src/bigtrace/layout/omnibox.ts index 8536445ca6c..84543492551 100644 --- a/ui/src/bigtrace/layout/omnibox.ts +++ b/ui/src/bigtrace/layout/omnibox.ts @@ -108,7 +108,10 @@ export class Omnibox implements m.ClassComponent { private renderCommandOmnibox(): m.Children { const {commands, omnibox} = BigTraceApp.instance; - const allCmds = commands.getCommands(); + // OpenCommandPalette is a no-op when invoked from inside itself. + const allCmds = commands + .getCommands() + .filter((c) => c.id !== 'bigtrace.OpenCommandPalette'); const filteredCmds = fuzzyFilterCommands(allCmds, omnibox.text); const commandsWithHeuristics = filteredCmds.map((cmd) => { @@ -118,13 +121,11 @@ export class Omnibox implements m.ClassComponent { }; }); - const sorted = commandsWithHeuristics.sort((a, b) => { - if (b.recentsIndex === a.recentsIndex) { - return 0; - } else { - return b.recentsIndex - a.recentsIndex; - } - }); + // Sort by recentsIndex descending — used commands (>=0) above + // never-used (-1). + const sorted = commandsWithHeuristics.sort( + (a, b) => b.recentsIndex - a.recentsIndex, + ); const options = sorted.map(({recentsIndex, cmd}): OmniboxOption => { const {segments, id, defaultHotkey, source} = cmd; @@ -223,13 +224,15 @@ export class Omnibox implements m.ClassComponent { } return m(OmniboxWidget, { value: omnibox.text, - placeholder: `Search or type ${hints.join(', ')}`, + // Don't say "Search" — search submit is a no-op in BigTrace. + placeholder: `Type ${hints.join(', ')}`, inputRef: OMNIBOX_INPUT_REF, onInput: (value, _prev) => { if (value === '>') { omnibox.setMode(OmniboxMode.Command); return; } + // Check registered mode triggers. if (value.length === 1 && omnibox.registeredModes.has(value)) { omnibox.activateRegisteredMode(value); return; @@ -242,6 +245,8 @@ export class Omnibox implements m.ClassComponent { } }, onSubmit: (_value, _mod, _shift) => { + // BigTrace has no trace-level search; submitting from the search + // omnibox is a no-op other than blurring the input. if (this.omniboxInputEl) { this.omniboxInputEl.blur(); } @@ -591,7 +596,11 @@ class OmniboxWidget implements m.ClassComponent { } } -function fuzzyFilterCommands(commands: readonly Command[], searchTerm: string) { +// Returns Commands annotated with `segments` for highlighted rendering. +function fuzzyFilterCommands( + commands: readonly Command[], + searchTerm: string, +): Array { const finder = new FuzzyFinder(commands, ({name}) => name); return finder.find(searchTerm).map((result) => { return {segments: result.segments, ...result.item}; diff --git a/ui/src/bigtrace/layout/sidebar.ts b/ui/src/bigtrace/layout/sidebar.ts index 587d9c9cce2..ca7126cfd3c 100644 --- a/ui/src/bigtrace/layout/sidebar.ts +++ b/ui/src/bigtrace/layout/sidebar.ts @@ -17,11 +17,12 @@ import {assetSrc} from '../../base/assets'; import {Icon} from '../../widgets/icon'; import {getOrCreate} from '../../base/utils'; import {classNames} from '../../base/classnames'; +import {setRoute} from '../router'; +import {Routes} from '../routes'; const SIDEBAR_SECTIONS = { bigtrace: { title: 'BigTrace', - summary: 'Query and analyze large traces', defaultCollapsed: false, }, } as const; @@ -57,14 +58,16 @@ export class Sidebar implements m.ClassComponent { m( 'h1', { + // Title clicks go home; setRoute keeps history/back working. style: { margin: 0, - fontSize: '18px', - fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: '8px', + cursor: 'pointer', }, + title: 'Go to BigTrace home', + onclick: () => setRoute(Routes.HOME), }, m('img', { src: assetSrc('assets/logo-128.png'), diff --git a/ui/src/bigtrace/pages/editor_tab_view.ts b/ui/src/bigtrace/pages/editor_tab_view.ts new file mode 100644 index 00000000000..e676c50dc6e --- /dev/null +++ b/ui/src/bigtrace/pages/editor_tab_view.ts @@ -0,0 +1,764 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import m from 'mithril'; +import {Box} from '../../widgets/box'; +import {Button, ButtonVariant} from '../../widgets/button'; +import {Callout} from '../../widgets/callout'; +import {Intent} from '../../widgets/common'; +import {CopyToClipboardButton} from '../../widgets/copy_to_clipboard_button'; +import {Editor} from '../../widgets/editor'; +import {EmptyState} from '../../widgets/empty_state'; +import {HotkeyGlyphs} from '../../widgets/hotkey_glyphs'; +import {linkify} from '../../widgets/anchor'; +import {Spinner} from '../../widgets/spinner'; +import {SplitPanel} from '../../widgets/split_panel'; +import {PopupPosition} from '../../widgets/popup'; +import {Stack, StackAuto} from '../../widgets/stack'; +import {Tooltip} from '../../widgets/tooltip'; +import {Switch} from '../../widgets/switch'; +import {Tabs} from '../../widgets/tabs'; +import {TextInput} from '../../widgets/text_input'; +import {DataGrid} from '../../components/widgets/datagrid/datagrid'; +import {DataSource} from '../../components/widgets/datagrid/data_source'; +import {InMemoryDataSource} from '../../components/widgets/datagrid/in_memory_data_source'; +import { + ColumnSchema, + SchemaRegistry, +} from '../../components/widgets/datagrid/datagrid_schema'; +import {Duration} from '../../base/time'; +import {endpointStorage} from '../settings/endpoint_storage'; +import {SettingFilter} from '../settings/settings_types'; +import {BigtraceAsyncDataSource} from '../query/bigtrace_async_data_source'; +import {setHistoryActiveTab} from '../query/query_history'; +import {BigtraceQueryClient} from '../query/bigtrace_query_client'; +import {QueryRunner} from '../query/query_runner'; +import { + formatCompact, + queryStore, + statusDisplayLabel, + TERMINAL_STATUSES, +} from '../query/query_store'; +import { + BigTraceEditorTab, + QueryResponse, + QueryTabsState, + deriveTitleFromQuery, +} from './query_tabs_state'; + +export interface EditorTabViewAttrs { + readonly tab: BigTraceEditorTab; + readonly tabsState: QueryTabsState; + readonly runner: QueryRunner; + readonly useBigtraceBackend: boolean; +} + +// Purely presentational; state on `tab`, side-effects via `runner`/`tabsState`. +export class EditorTabView implements m.ClassComponent { + view({attrs}: m.Vnode): m.Children { + const {tab, tabsState, runner, useBigtraceBackend} = attrs; + + // Tabs reopened from history wire up their dataSource on first render. + if (tab.queryUuid && !tab.dataSource) { + attachAsyncDataSource(tab, runner); + } + + if (tab.dataSource && tab.queryResult && tab.materialize && tab.execution) { + tab.queryResult.totalRowCount = tab.execution.processedRows; + } + + return m(SplitPanel, { + direction: 'vertical', + // Most BigTrace queries are short; bias the split toward results. + initialSplit: {percent: 22}, + minSize: 100, + firstPanel: renderEditorPanel(tab, tabsState, runner, useBigtraceBackend), + secondPanel: renderResultsPanel(tab, tabsState, runner), + }); + } +} + +// --------------------------------------------------------------------------- +// Editor panel: toolbar (Run/Cancel + limit + Materialize) and the editor. +// --------------------------------------------------------------------------- + +function renderEditorPanel( + tab: BigTraceEditorTab, + tabsState: QueryTabsState, + runner: QueryRunner, + useBigtraceBackend: boolean, +): m.Children { + return m('.pf-query-page__editor-panel', [ + m(Box, {className: 'pf-query-page__toolbar'}, [ + m(Stack, {orientation: 'horizontal'}, [ + tab.isLoading + ? m(Button, { + label: 'Cancel', + icon: 'stop', + intent: Intent.Warning, + variant: ButtonVariant.Filled, + onclick: () => runner.cancel(tab), + }) + : m(Button, { + label: 'Run Query', + icon: 'play_arrow', + intent: Intent.Primary, + variant: ButtonVariant.Filled, + // Whitespace + SQL line comments → nothing to run. + disabled: deriveTitleFromQuery(tab.editorText) === undefined, + onclick: () => { + setHistoryActiveTab(tab.materialize); + tabsState.maybeAutoNameTab(tab.id, tab.editorText); + runner.run(tab, tab.editorText); + }, + }), + m( + Stack, + {orientation: 'horizontal', className: 'pf-query-page__hotkeys'}, + 'or press', + m(HotkeyGlyphs, {hotkey: 'Mod+Enter'}), + ), + m(StackAuto), + useBigtraceBackend && [ + m(Switch, { + label: 'Persistent', + title: + 'ON: results saved to History (Persistent tab) — reopen later. ' + + 'OFF: results shown inline and discarded when the tab closes.', + checked: tab.materialize, + // Mode captured at submit; disable mid-run so it isn't a false affordance. + disabled: tab.isLoading, + onchange: (e: Event) => { + tab.materialize = (e.target as HTMLInputElement).checked; + setHistoryActiveTab(tab.materialize); + tabsState.markDirty(); + }, + }), + m('span.pf-query-page__toolbar-divider', {'aria-hidden': 'true'}), + m('span', 'Limit:'), + m(TextInput, { + type: 'number', + value: String(tab.limit), + placeholder: 'Limit', + // Captured at submit; disable mid-run. + disabled: tab.isLoading, + onInput: (value: string) => { + const newLimit = parseInt(value, 10); + if (!isNaN(newLimit) && newLimit > 0) { + tab.limit = newLimit; + } + }, + }), + ], + ]), + ]), + tab.editorText.includes('"') && + m( + Callout, + {icon: 'warning', intent: Intent.None}, + `" (double quote) character observed in query; if this is being used to ` + + `define a string, please use ' (single quote) instead. Using double quotes ` + + `can cause subtle problems which are very hard to debug.`, + ), + m(Editor, { + text: tab.editorText, + language: 'perfetto-sql', + autofocus: true, + // Claim Ctrl/Cmd+S so the browser's "Save Page As…" doesn't fire. + onSave: () => {}, + onUpdate: (text: string) => { + tab.editorText = text; + tabsState.markDirty(); + }, + onExecute: (query: string) => { + setHistoryActiveTab(tab.materialize); + tabsState.maybeAutoNameTab(tab.id, query); + runner.run(tab, query); + }, + }), + ]); +} + +// "<1s" for sub-500ms runs so the user sees the query actually ran. +function formatDurationS(ms: number): string { + if (ms < 500) return '<1s'; + return Duration.format(Duration.fromMillis(Math.round(ms / 1000) * 1000)); +} + +// Owns its own setInterval since sync queries don't drive periodic redraws. +class RunningQuerySpinner implements m.ClassComponent<{startMs: number}> { + private timer: number | null = null; + + oncreate(): void { + this.timer = window.setInterval(() => m.redraw(), 100); + } + + onremove(): void { + if (this.timer !== null) { + window.clearInterval(this.timer); + this.timer = null; + } + } + + view({attrs: {startMs}}: m.Vnode<{startMs: number}>): m.Children { + const elapsedMs = Math.max(0, Date.now() - startMs); + const durationStr = formatDurationS(elapsedMs); + return m( + EmptyState, + { + title: `Running query… ${durationStr}`, + icon: 'hourglass_empty', + fillHeight: true, + }, + m(Spinner), + ); + } +} + +// --------------------------------------------------------------------------- +// Results panel: status box (async only) + result tabs (Error/Table/Chart). +// --------------------------------------------------------------------------- + +function renderResultsPanel( + tab: BigTraceEditorTab, + tabsState: QueryTabsState, + runner: QueryRunner, +): m.Children { + const status = renderStatusBox(tab); + + if (!tab.dataSource || !tab.queryResult) { + return m( + '.pf-query-page__results-panel', + status, + tab.isLoading + ? m(RunningQuerySpinner, {startMs: tab.clientStartTime ?? Date.now()}) + : m(EmptyState, { + title: 'Run a query to see results', + icon: 'search', + fillHeight: true, + }), + ); + } + + const processedRows = tab.execution?.processedRows ?? 0; + + // Sync re-open from history (rows not persisted): show a re-run hint + // instead of the misleading "no rows" empty state. + const isSyncReopenNoRerun = + !tab.materialize && + Boolean(tab.queryUuid) && + tab.queryResult.rows.length === 0 && + !tab.queryResult.error; + + // Materialized table gone (TTL / cancellation / post-failure) but + // metadata still claims rows; without this we'd spin on "Loading + // schema…". Only after terminal; mid-run the table is still building. + const isTerminalStatus = + tab.execution?.status !== undefined && + TERMINAL_STATUSES.has(tab.execution.status); + const isAsyncTableCleared = + tab.materialize && + Boolean(tab.queryUuid) && + !tab.execution?.tableName && + !tab.queryResult.error && + isTerminalStatus; + + // Async: live processedRows. Sync: inline rows (processedRows is 0 server-side). + const hasRowsToShow = tab.materialize + ? processedRows > 0 + : tab.queryResult.rows.length > 0; + + const hasError = tab.queryResult.error !== undefined; + + // Table content: the primary result display. + let tableContent: m.Children; + if (isSyncReopenNoRerun) { + tableContent = m(EmptyState, { + title: 'Re-run the query to see results', + icon: 'refresh', + fillHeight: true, + }); + } else if (isAsyncTableCleared) { + tableContent = m(EmptyState, { + title: + tab.execution?.status === 'CANCELLED' + ? 'Query was cancelled' + : 'Results no longer available', + icon: 'refresh', + fillHeight: true, + }); + } else if (hasRowsToShow) { + tableContent = renderResultsGrid(tab, tabsState, runner); + } else if (tab.isLoading) { + tableContent = m('div'); + } else { + tableContent = m(EmptyState, { + title: 'Query returned no rows', + icon: 'search', + fillHeight: true, + }); + } + + // Auto-select Error tab when it's the only signal; user override sticks. + // Reset stale key if the Error tab no longer exists. + if (tab.resultsTabKey === 'error' && !hasError) { + tab.resultsTabKey = undefined; + } + const defaultTab = hasError && !hasRowsToShow ? 'error' : 'table'; + const activeTab = tab.resultsTabKey ?? defaultTab; + + return m( + '.pf-query-page__results-panel', + status, + m( + '.pf-query-page__results-container', + renderResultsTabs(tab, tableContent, activeTab), + ), + ); +} + +function renderStatusBox(tab: BigTraceEditorTab): m.Children { + if (!tab.materialize || !tab.queryUuid) return false; + + const isTerminal = + tab.execution?.status !== undefined && + TERMINAL_STATUSES.has(tab.execution.status); + const processedRows = tab.execution?.processedRows ?? 0; + const hasNewData = !isTerminal && processedRows > tab.lastProcessedRows; + + let durationMs = 0; + if ( + isTerminal && + tab.execution?.endTime !== undefined && + tab.execution?.startTime !== undefined + ) { + durationMs = tab.execution.endTime - tab.execution.startTime; + } else if (!isTerminal) { + const start = + tab.execution?.startTime !== undefined + ? tab.execution.startTime + : tab.clientStartTime; + if (start !== undefined) { + durationMs = Date.now() - start; + } + } + + const status = tab.execution?.status ?? 'UNKNOWN'; + const processedTraces = tab.execution?.processedTraces ?? 0; + const totalTraces = tab.execution?.totalTraces ?? 0; + const durationStr = formatDurationS(durationMs); + + // Left group: refresh, status pill, duration. + const leftGroup = m( + '.pf-query-page__status-bar-group', + m( + 'div.pf-query-page__status-bar-refresh', + m(Button, { + icon: 'refresh', + title: hasNewData + ? 'New data available. Click to refresh.' + : 'Refresh data', + onclick: () => refreshAsyncStatus(tab), + }), + hasNewData && + m('span.pf-query-page__status-bar-notif', { + 'aria-label': 'New data available', + }), + ), + m( + 'span.pf-query-page__status-bar-pill', + {className: `pf-status-${status.toLowerCase().replace(/_/g, '-')}`}, + statusDisplayLabel(status), + ), + m( + 'span.pf-query-page__status-bar-duration', + m('span.pf-query-page__status-bar-duration-value', durationStr), + ), + ); + + // Right group: progress bars (Traces and Rows). While running, the CSS + // collapses labels/values to opacity 0 and hides the Traces bar so the + // user just sees the Rows progress bar; hovering reveals the numbers. + const rowsStatClasses = [ + 'pf-query-page__status-bar-stat', + 'pf-query-page__status-bar-stat--rows', + processedRows === 0 && 'pf-query-page__status-bar-stat--empty', + ] + .filter(Boolean) + .join(' '); + const rightGroupContent = m( + '.pf-query-page__status-bar-group', + m( + 'span.pf-query-page__status-bar-stat.pf-query-page__status-bar-stat--traces', + m('span.pf-query-page__status-bar-stat-label', 'Traces:'), + m( + 'span.pf-query-page__status-bar-stat-value', + { + // Tooltip preserves exact counts; the displayed values are compact. + title: + `${processedTraces.toLocaleString()} of ` + + `${totalTraces.toLocaleString()}` + + (!isTerminal + ? ' — numerator lags the poll (≤3s); denominator is exact.' + : ''), + }, + formatCompact(processedTraces), + ), + renderInlineProgressBar(processedTraces, totalTraces, !isTerminal), + ), + m( + 'span', + {className: rowsStatClasses}, + m('span.pf-query-page__status-bar-stat-label', 'Rows:'), + m( + 'span.pf-query-page__status-bar-stat-value', + { + title: `${processedRows.toLocaleString()} of result limit ${tab.limit.toLocaleString()}`, + }, + formatCompact(processedRows), + ), + renderInlineProgressBar(processedRows, tab.limit, !isTerminal), + ), + ); + + // While running, wrap the whole right group in a Tooltip — hovering + // anywhere on the right side reveals TRACES + ROWS counts that are + // collapsed to just the progress bar by default. + const rightGroup = !isTerminal + ? m( + Tooltip, + { + trigger: rightGroupContent, + position: PopupPosition.Top, + }, + m( + '.pf-query-page__status-bar-progress-tooltip', + m('div', `Traces: ${formatCompact(processedTraces)}`), + m('div', `Rows: ${formatCompact(processedRows)}`), + ), + ) + : rightGroupContent; + + return m( + Box, + { + className: isTerminal + ? 'pf-query-page__status-bar' + : 'pf-query-page__status-bar pf-query-page__status-bar--running', + }, + leftGroup, + rightGroup, + ); +} + +// Hidden on terminal states — a static fraction adds no information. +function renderInlineProgressBar( + done: number, + total: number, + live: boolean, +): m.Children { + if (!live) return null; + if (total <= 0) return null; + const pct = Math.max(0, Math.min(100, (done / total) * 100)); + return m( + 'span.pf-query-page__inline-progress', + m('span.pf-query-page__inline-progress-fill', { + style: {width: `${pct}%`}, + }), + ); +} + +async function refreshAsyncStatus(tab: BigTraceEditorTab): Promise { + if (!tab.queryUuid) return; + try { + const status = await tab.queryClient?.getStatus( + tab.queryUuid, + tab.lifecycle.signal, + ); + if (status) { + queryStore.update(tab.queryUuid, { + processedRows: status.processedRows ?? 0, + processedTraces: status.processedTraces ?? 0, + totalTraces: status.totalTraces ?? 0, + status: status.status ?? 'N/A', + }); + } + } catch (e) { + console.error('Failed to fetch query status on refresh:', e); + } + if (tab.dataSource instanceof BigtraceAsyncDataSource) { + tab.dataSource.refresh(); + tab.lastProcessedRows = tab.execution?.processedRows ?? 0; + } + m.redraw(); +} + +// --------------------------------------------------------------------------- +// Result grid (Table tab) + Error tab + Chart placeholder. +// --------------------------------------------------------------------------- + +function renderErrorTab(tab: BigTraceEditorTab): m.Children { + const errorStr = tab.queryResult?.error ?? ''; + const fullText = errorStr + .replaceAll('\\n', '\n') + .replaceAll('\\t', ' ') + .replaceAll('\\u003e', '>'); + return m('pre.pf-query-page__error-content', fullText); +} + +function renderResultsTabs( + tab: BigTraceEditorTab, + tableContent: m.Children, + activeTab: string, +): m.Children { + const hasError = tab.queryResult?.error !== undefined; + const tabs = [ + ...(hasError + ? [ + { + key: 'error', + title: m('span.pf-query-page__error-tab-title', 'Error'), + content: renderErrorTab(tab), + }, + ] + : []), + {key: 'table', title: 'Table', content: tableContent}, + { + key: 'chart', + title: 'Chart', + content: m(EmptyState, { + title: 'Charts are coming soon', + icon: 'bar_chart', + }), + }, + ]; + + return m('.pf-query-page__results', [ + m(Tabs, { + tabs, + activeTabKey: activeTab, + onTabChange: (key) => { + tab.resultsTabKey = key; + }, + }), + ]); +} + +function renderResultsGrid( + tab: BigTraceEditorTab, + tabsState: QueryTabsState, + runner: QueryRunner, +): m.Children { + const queryResult = tab.queryResult!; + const dataSource = tab.dataSource!; + + const isInitialLoad = + tab.queryUuid !== undefined && + tab.queryUuid !== '' && + (tab.execution === undefined || tab.execution.status === 'UNKNOWN'); + if (isInitialLoad) { + return m( + EmptyState, + { + title: 'Loading query status...', + icon: 'hourglass_empty', + fillHeight: true, + }, + m(Spinner), + ); + } + + // Caller gated on hasRowsToShow, so partial-success failures + // (quota cut-off, mid-stream sqlite error) still render the grid. + const isTerminal = + tab.execution?.status !== undefined && + TERMINAL_STATUSES.has(tab.execution.status); + + const tableContent: m.Children[] = []; + + // Sync uses static columns; async fills in once schema arrives. + let columns = queryResult.columns; + if (columns.length === 0 && dataSource instanceof BigtraceAsyncDataSource) { + columns = dataSource.getColumns() ?? []; + } + + if (dataSource instanceof BigtraceAsyncDataSource) { + const error = dataSource.getError(); + // Suppress mid-stream 400s — those are the backend's transient + // FAILED_PRECONDITION ("no rows yet"). Surface anything terminal. + if ( + error !== null && + error !== '' && + (isTerminal || error.includes('status: 400') === false) + ) { + tableContent.push( + m(EmptyState, { + title: `Failed to load schema: ${error}`, + icon: 'error', + fillHeight: true, + }), + ); + return tableContent; + } + } + + if (columns.length === 0) { + // Async mid-flight: kick `useRows` so the data source starts + // fetching, then spin. (Sync re-opens intercepted upstream.) + dataSource.useRows({mode: 'flat', columns: []}); + tableContent.push( + m( + EmptyState, + { + title: 'Loading schema...', + icon: 'hourglass_empty', + fillHeight: true, + }, + m(Spinner), + ), + ); + return tableContent; + } + + tableContent.push( + renderDataGrid(tab, tabsState, runner, columns, queryResult, dataSource), + ); + return tableContent; +} + +function renderDataGrid( + tab: BigTraceEditorTab, + _tabsState: QueryTabsState, + _runner: QueryRunner, + columns: ReadonlyArray, + queryResult: QueryResponse, + dataSource: DataSource, +): m.Children { + const querySettings: SettingFilter[] = tab.querySettings; + + const columnSchema: ColumnSchema = {}; + for (const column of columns) { + if (column === 'link') { + columnSchema[column] = { + cellRenderer: (value) => { + if (value === null || value === undefined) return ''; + return linkify(String(value)); + }, + }; + } else { + columnSchema[column] = {cellRenderer: undefined}; + } + } + const schema: SchemaRegistry = {data: columnSchema}; + + return m(DataGrid, { + schema, + rootSchema: 'data', + enablePivotControls: false, // In-memory datasource doesn't support pivoting. + initialColumns: columns + .filter((col) => { + if (!col.startsWith('_')) return true; + if (col === '_trace_id') return true; + const settingId = col.substring(1); + return querySettings.some( + (s) => s.settingId === settingId && s.category === 'TRACE_METADATA', + ); + }) + .map((col) => ({id: col, field: col})), + className: 'pf-query-page__results', + data: dataSource, + // Without this, the entire results panel scrolls instead of just + // the grid body — toolbar and sticky header detach. + fillHeight: true, + showExportButton: true, + emptyStateMessage: 'Query returned no rows', + toolbarItemsLeft: [ + m( + 'span.pf-query-page__results-summary', + renderResultsSummary(tab, queryResult), + ), + ], + toolbarItemsRight: [ + m(CopyToClipboardButton, { + textToCopy: queryResult.query, + title: 'Copy executed query to clipboard', + label: 'Copy Query', + }), + ], + }); +} + +// No "Showing X–Y" — the loaded window is a prefetch buffer, not the viewport. +function renderResultsSummary( + tab: BigTraceEditorTab, + queryResult: QueryResponse, +): string { + if (!tab.materialize) { + const durationStr = formatDurationS(Math.max(0, queryResult.durationMs)); + return `Returned ${queryResult.totalRowCount.toLocaleString()} rows in ${durationStr}`; + } + // Prefer post-filter count; fall back to live progress pre-first-fetch. + const asyncDs = + tab.dataSource instanceof BigtraceAsyncDataSource + ? tab.dataSource + : undefined; + const count = asyncDs?.filteredTotalRows ?? tab.execution?.processedRows ?? 0; + const isTerminal = + tab.execution?.status !== undefined && + TERMINAL_STATUSES.has(tab.execution.status); + const text = `${count.toLocaleString()} rows`; + return isTerminal ? text : `${text} · running…`; +} + +// --------------------------------------------------------------------------- +// Lazily build the async data source for tabs restored from localStorage. +// --------------------------------------------------------------------------- + +function attachAsyncDataSource( + tab: BigTraceEditorTab, + runner: QueryRunner, +): void { + if (!tab.queryUuid) return; + const endpointSetting = endpointStorage.get('bigtraceEndpoint'); + const endpoint = endpointSetting ? (endpointSetting.get() as string) : ''; + const queryClient = new BigtraceQueryClient(endpoint); + tab.queryClient = queryClient; + // Sync isn't persisted; empty source → "re-run to see results" hint. + if (!tab.materialize) { + tab.dataSource = new InMemoryDataSource([]); + return; + } + tab.dataSource = new BigtraceAsyncDataSource( + tab.queryUuid, + queryClient, + () => tab.execution?.processedRows ?? 0, + tab.lifecycle.signal, + ); + tab.isLoading = true; + runner.startPolling(tab); + + if (tab.queryResult === undefined) { + tab.queryResult = { + rows: [], + columns: [], + error: undefined, + totalRowCount: 0, + durationMs: 0, + statementWithOutputCount: 0, + statementCount: 1, + lastStatementSql: tab.editorText, + query: tab.editorText, + }; + } +} diff --git a/ui/src/bigtrace/pages/home_page.ts b/ui/src/bigtrace/pages/home_page.ts index b2586a77428..5351670d0a4 100644 --- a/ui/src/bigtrace/pages/home_page.ts +++ b/ui/src/bigtrace/pages/home_page.ts @@ -15,7 +15,6 @@ import m from 'mithril'; import {assetSrc} from '../../base/assets'; import {Icon} from '../../widgets/icon'; -import {HotkeyGlyphs} from '../../widgets/hotkey_glyphs'; import {Switch} from '../../widgets/switch'; import {queryState} from '../query/query_state'; import {settingsStorage} from '../settings/settings_storage'; @@ -41,6 +40,34 @@ GROUP BY p.name ORDER BY cpu_sec DESC LIMIT 10`; +// One of the four landing-page action buttons (Quick start + +// Example queries rows). Differs only by icon, label, and click. +function homeButton( + label: string, + icon: string, + onclick: () => void, +): m.Children { + return m( + '.pf-home-page__button', + {onclick}, + m(Icon, {icon, className: 'pf-left-icon'}), + m('span.pf-button__label', label), + ); +} + +// Example-query button: stash the query for the new tab to pick up, +// then navigate to the editor. +function exampleQueryButton( + label: string, + icon: string, + query: string, +): m.Children { + return homeButton(label, icon, () => { + queryState.initialQuery = query; + setRoute(Routes.QUERY); + }); +} + export class HomePage implements m.ClassComponent { view() { const themeSetting = settingsStorage.get('theme'); @@ -50,6 +77,14 @@ export class HomePage implements m.ClassComponent { '.pf-home-page', m( '.pf-home-page__center', + // Override shared `justify-content: space-around` (shared SCSS out of scope). + { + style: { + justifyContent: 'flex-start', + paddingTop: '15vh', + gap: '24px', + }, + }, m( '.pf-home-page__title', m(`img.logo[src=${assetSrc('assets/logo-3d.png')}]`), @@ -65,17 +100,11 @@ export class HomePage implements m.ClassComponent { '.pf-home-page__section-content', m( '.pf-home-page__getting-started-buttons', - m( - '.pf-home-page__button', - {onclick: () => setRoute(Routes.SETTINGS)}, - m(Icon, {icon: 'settings', className: 'pf-left-icon'}), - m('span.pf-button__label', 'Configure targets'), + homeButton('Configure backend', 'settings', () => + setRoute(Routes.SETTINGS), ), - m( - '.pf-home-page__button', - {onclick: () => setRoute(Routes.QUERY)}, - m(Icon, {icon: 'edit', className: 'pf-left-icon'}), - m('span.pf-button__label', 'Open query editor'), + homeButton('Open query editor', 'edit', () => + setRoute(Routes.QUERY), ), ), ), @@ -88,50 +117,16 @@ export class HomePage implements m.ClassComponent { '.pf-home-page__section-content', m( '.pf-home-page__getting-started-buttons', - m( - '.pf-home-page__button', - { - onclick: () => { - queryState.initialQuery = LMK_QUERY; - setRoute(Routes.QUERY); - }, - }, - m(Icon, {icon: 'search', className: 'pf-left-icon'}), - m('span.pf-button__label', 'LMK events'), + exampleQueryButton('LMK events', 'search', LMK_QUERY), + exampleQueryButton( + 'Top CPU consumers', + 'timer', + CPU_TIME_QUERY, ), - m( - '.pf-home-page__button', - { - onclick: () => { - queryState.initialQuery = CPU_TIME_QUERY; - setRoute(Routes.QUERY); - }, - }, - m(Icon, {icon: 'timer', className: 'pf-left-icon'}), - m('span.pf-button__label', 'Top CPU consumers'), - ), - ), - ), - ), - // Shortcuts section - m( - '.pf-home-page__section', - m('.pf-home-page__section-title', 'Shortcuts'), - m( - '.pf-home-page__section-content', - m( - '.pf-home-page__shortcut', - m('span.pf-home-page__shortcut-label', 'Commands'), - m(HotkeyGlyphs, {hotkey: '!Mod+Shift+P'}), - ), - m( - '.pf-home-page__shortcut', - m('span.pf-home-page__shortcut-label', 'Toggle sidebar'), - m(HotkeyGlyphs, {hotkey: '!Mod+B'}), ), ), ), - // Links below the cards + // Footer: theme toggle; full shortcut list lives in the help modal (?). m( '.pf-home-page__links', m(Switch, { diff --git a/ui/src/bigtrace/pages/query_page.ts b/ui/src/bigtrace/pages/query_page.ts index 7d561c79a61..a551dc677ca 100644 --- a/ui/src/bigtrace/pages/query_page.ts +++ b/ui/src/bigtrace/pages/query_page.ts @@ -13,255 +13,133 @@ // limitations under the License. import m from 'mithril'; -import {Button, ButtonVariant} from '../../widgets/button'; -import {TextInput} from '../../widgets/text_input'; -import {Editor} from '../../widgets/editor'; -import {DataGrid} from '../../components/widgets/datagrid/datagrid'; -import { - SchemaRegistry, - ColumnSchema, -} from '../../components/widgets/datagrid/datagrid_schema'; -import {InMemoryDataSource} from '../../components/widgets/datagrid/in_memory_data_source'; -import {SplitPanel} from '../../widgets/split_panel'; +import {Button} from '../../widgets/button'; import {EmptyState} from '../../widgets/empty_state'; -import {Callout} from '../../widgets/callout'; -import {Intent} from '../../widgets/common'; -import {Box} from '../../widgets/box'; -import {Stack, StackAuto} from '../../widgets/stack'; -import {HotkeyGlyphs} from '../../widgets/hotkey_glyphs'; -import {CopyToClipboardButton} from '../../widgets/copy_to_clipboard_button'; -import {DataSource} from '../../components/widgets/datagrid/data_source'; -import {queryHistoryStorage} from '../query/query_history_storage'; +import {SplitPanel} from '../../widgets/split_panel'; +import {Spinner} from '../../widgets/spinner'; +import {Tabs, TabsTab} from '../../widgets/tabs'; import {QueryHistoryComponent} from '../query/query_history'; +import {QueryRunner} from '../query/query_runner'; +import {bigTraceSettingsStorage} from '../settings/bigtrace_settings_storage'; import {sqlTablesLoader} from '../query/sql_tables'; import {TableList} from '../query/table_list'; -import {Spinner} from '../../widgets/spinner'; -import {SettingFilter} from '../settings/settings_types'; -import {bigTraceSettingsStorage} from '../settings/bigtrace_settings_storage'; -import {endpointStorage} from '../settings/endpoint_storage'; -import {HttpDataSource} from '../query/http_data_source'; -import {Tabs, TabsTab} from '../../widgets/tabs'; -import {linkify} from '../../widgets/anchor'; -import {shortUuid} from '../../base/uuid'; -import {Row as DataGridRow} from '../../trace_processor/query_result'; -import {debounce} from '../../base/rate_limiters'; - -interface QueryResponse { - query: string; - error?: string; - totalRowCount: number; - durationMs: number; - columns: string[]; - rows: DataGridRow[]; - statementCount: number; - statementWithOutputCount: number; - lastStatementSql: string; -} +import {showModal} from '../../widgets/modal'; +import {EditorTabView} from './editor_tab_view'; +import {QueryTabsState} from './query_tabs_state'; +import {queryState} from '../query/query_state'; interface QueryPageAttrs { - useBrushBackend?: boolean; - initialQuery?: string; + useBigtraceBackend?: boolean; } -const DEFAULT_SQL = ''; -const QUERY_TABS_STORAGE_KEY = 'bigtraceQueryTabs'; - -// Per-tab state for each editor tab. -interface BigTraceEditorTab { - readonly id: string; - title: string; - editorText: string; - limit: number; - queryResult?: QueryResponse; - isLoading: boolean; - dataSource?: DataSource; - querySettings: SettingFilter[]; - activeHttpDataSource?: HttpDataSource; -} - -// Manages the collection of editor tabs. Survives component re-mounts. -class QueryTabsState { - tabs: BigTraceEditorTab[] = []; - activeTabId = ''; - private tabCounter = 0; - private readonly debouncedSave = debounce(() => this.saveToStorage(), 1000); - - constructor() { - if (!this.loadFromStorage()) { - this.addNewTab(undefined, DEFAULT_SQL); - } - } - - private saveToStorage(): void { - const state = { - tabs: this.tabs.map((t) => ({ - id: t.id, - title: t.title, - editorText: t.editorText, - limit: t.limit, - })), - activeTabId: this.activeTabId, - }; - localStorage.setItem(QUERY_TABS_STORAGE_KEY, JSON.stringify(state)); - } - - private loadFromStorage(): boolean { - const stored = localStorage.getItem(QUERY_TABS_STORAGE_KEY); - if (!stored) return false; - try { - const parsed = JSON.parse(stored); - if (!Array.isArray(parsed.tabs) || parsed.tabs.length === 0) return false; - for (const t of parsed.tabs) { - this.addNewTab(t.title, t.editorText, t.limit); - } - if (typeof parsed.activeTabId === 'string') { - const found = this.tabs.find((t) => t.id === parsed.activeTabId); - if (!found) { - // Restored tabs get new IDs, so activate by index instead. - const idx = parsed.tabs.findIndex( - (t: {id: string}) => t.id === parsed.activeTabId, - ); - if (idx >= 0 && idx < this.tabs.length) { - this.activeTabId = this.tabs[idx].id; - } - } - } - return true; - } catch { - return false; - } - } - - markDirty(): void { - this.debouncedSave(); - } - - private nextTabName(): string { - const existingNames = new Set(this.tabs.map((t) => t.title)); - let count = ++this.tabCounter; - while (existingNames.has(`Query ${count}`)) { - count = ++this.tabCounter; - } - return `Query ${count}`; - } - - addNewTab( - title?: string, - initialQuery?: string, - limit?: number, - ): BigTraceEditorTab { - const tab: BigTraceEditorTab = { - id: shortUuid(), - title: title ?? this.nextTabName(), - editorText: initialQuery ?? '', - limit: limit ?? 100, - queryResult: undefined, - isLoading: false, - dataSource: undefined, - querySettings: [], - activeHttpDataSource: undefined, - }; - this.tabs.push(tab); - this.activeTabId = tab.id; - this.markDirty(); - return tab; - } - - getActiveTab(): BigTraceEditorTab | undefined { - return this.tabs.find((t) => t.id === this.activeTabId); - } - - closeTab(tabId: string): void { - if (this.tabs.length <= 1) return; - const index = this.tabs.findIndex((t) => t.id === tabId); - if (index === -1) return; - this.tabs[index].activeHttpDataSource?.abort(); - this.tabs.splice(index, 1); - if (this.activeTabId === tabId) { - const newIndex = Math.min(index, this.tabs.length - 1); - this.activeTabId = this.tabs[newIndex].id; - } - this.markDirty(); - } - - renameTab(tabId: string, newTitle: string): void { - const tab = this.tabs.find((t) => t.id === tabId); - if (tab) { - tab.title = newTitle; - this.markDirty(); - } - } - - reorderTab(draggedId: string, beforeId: string | undefined): void { - const draggedIndex = this.tabs.findIndex((t) => t.id === draggedId); - if (draggedIndex === -1) return; - const [dragged] = this.tabs.splice(draggedIndex, 1); - if (beforeId === undefined) { - this.tabs.push(dragged); - } else { - const beforeIndex = this.tabs.findIndex((t) => t.id === beforeId); - if (beforeIndex === -1) { - this.tabs.push(dragged); - } else { - this.tabs.splice(beforeIndex, 0, dragged); - } - } - } -} - -const tabsState = new QueryTabsState(); +// Lets the globally-registered keyboard command reach into the active +// QueryPage instance. Same pattern as sidebarToggleFn in index.ts. +export let queryRightSidebarToggleFn: (() => void) | undefined; export class QueryPage implements m.ClassComponent { - private useBrushBackend = false; + private useBigtraceBackend = false; private sidebarVisible = true; + private readonly tabsState = new QueryTabsState(); + private historyRefreshSignal = 0; + private readonly runner = new QueryRunner({ + onHistoryChanged: () => { + this.historyRefreshSignal++; + }, + markDirty: () => this.tabsState.markDirty(), + }); oninit({attrs}: m.Vnode) { - this.useBrushBackend = attrs.useBrushBackend || false; - if (attrs.initialQuery) { - const activeTab = tabsState.getActiveTab(); - if (activeTab && activeTab.editorText.trim() === '') { - activeTab.editorText = attrs.initialQuery; - } else { - tabsState.addNewTab(undefined, attrs.initialQuery); - } - tabsState.markDirty(); - } - if (this.useBrushBackend) { + this.useBigtraceBackend = attrs.useBigtraceBackend || false; + queryRightSidebarToggleFn = () => { + this.sidebarVisible = !this.sidebarVisible; + m.redraw(); + }; + if (this.useBigtraceBackend) { bigTraceSettingsStorage.loadSettings(); } sqlTablesLoader.load(); } view() { - const activeTab = tabsState.getActiveTab(); + // Process initialQuery set by home-page example buttons. + // Read-and-clear: each value is consumed exactly once. + const initialQuery = queryState.initialQuery; + if (initialQuery !== undefined) { + queryState.initialQuery = undefined; + const activeTab = this.tabsState.getActiveTab(); + if (activeTab && activeTab.editorText.trim() === '') { + activeTab.editorText = initialQuery; + this.tabsState.maybeAutoNameTab(activeTab.id, initialQuery); + } else { + this.tabsState.addNewTab(undefined, initialQuery); + } + this.tabsState.markDirty(); + } // Build editor tabs for the Tabs widget. - const editorTabs: TabsTab[] = tabsState.tabs.map((tab) => ({ + const editorTabs: TabsTab[] = this.tabsState.tabs.map((tab) => ({ key: tab.id, title: tab.title, - leftIcon: 'code', - closeButton: tabsState.tabs.length > 1, - content: this.renderEditorTabContent(tab), + // Spinner on tabs with a query in flight, so tab-switching + // doesn't make the running query "disappear". + leftIcon: tab.isLoading ? 'progress_activity' : 'code', + closeButton: this.tabsState.tabs.length > 1, + content: m(EditorTabView, { + tab, + tabsState: this.tabsState, + runner: this.runner, + useBigtraceBackend: this.useBigtraceBackend, + }), })); const leftPanel = m(Tabs, { className: 'pf-query-page__editor-tabs', tabs: editorTabs, - activeTabKey: tabsState.activeTabId, + activeTabKey: this.tabsState.activeTabId, reorderable: true, onTabChange: (key) => { - tabsState.activeTabId = key; - tabsState.markDirty(); + this.tabsState.activeTabId = key; + this.tabsState.markDirty(); + }, + onTabRename: (key, newTitle) => this.tabsState.renameTab(key, newTitle), + onTabClose: async (key) => { + // closeTab is a no-op when only one tab remains; bail before + // the confirm so middle-click doesn't dead-end. + if (this.tabsState.tabs.length <= 1) return; + // Confirm only for ephemeral queries — closing loses the results. + // Persistent queries keep running on the backend (reopen from History). + const tab = this.tabsState.tabs.find((t) => t.id === key); + if (tab?.isLoading && !tab.materialize) { + let confirmed = false; + await showModal({ + title: 'Close tab?', + content: m( + 'div', + 'A query is still running. Closing this tab will lose the results.', + ), + buttons: [ + {text: 'Keep open'}, + { + text: 'Close', + primary: true, + action: () => { + confirmed = true; + }, + }, + ], + }); + if (!confirmed) return; + } + this.tabsState.closeTab(key); + m.redraw(); }, - onTabRename: (key, newTitle) => tabsState.renameTab(key, newTitle), - onTabClose: (key) => tabsState.closeTab(key), onTabReorder: (draggedKey, beforeKey) => - tabsState.reorderTab(draggedKey, beforeKey), + this.tabsState.reorderTab(draggedKey, beforeKey), newTabContent: [ m(Button, { icon: 'add', className: 'pf-tabs__new-tab-btn', - onclick: () => tabsState.addNewTab(), + onclick: () => this.tabsState.addNewTab(), }), m('div', {style: {flex: '1'}}), m(Button, { @@ -280,22 +158,45 @@ export class QueryPage implements m.ClassComponent { tabs: [ { key: 'history', + // No leftIcon — the ~20px is better spent on the label at + // narrow viewports. title: 'History', - leftIcon: 'history', content: m(QueryHistoryComponent, { className: 'pf-query-page__history', - runQuery: (query: string) => { - if (activeTab) this.runQueryOnTab(activeTab, query); - }, - setQuery: (query: string) => { - if (activeTab) activeTab.editorText = query; + refreshSignal: this.historyRefreshSignal, + openQuery: async ( + query: string, + uuid: string, + materialize: boolean, + forceNew?: boolean, + limit?: number, + startTime?: number, + ) => { + const tab = this.tabsState.addNewTab( + undefined, + query, + limit, + uuid, + materialize, + forceNew, + ); + this.tabsState.activeTabId = tab.id; + this.tabsState.markDirty(); + if (startTime !== undefined && tab.execution) { + tab.execution.startTime = startTime; + } + await this.runner.resumeFromHistory(tab, query); }, }), }, { key: 'tables', - title: 'Tables', - leftIcon: 'table_chart', + // Hide the count until the loader settles so we don't + // flash "(0)" on mount. + title: + sqlTablesLoader.modules && !sqlTablesLoader.isLoading + ? `Stdlib Schemas (${sqlTablesLoader.modules.listTables().length})` + : 'Stdlib Schemas', content: this.renderTablesTab(), }, ], @@ -311,107 +212,15 @@ export class QueryPage implements m.ClassComponent { direction: 'horizontal', initialSplit: {percent: 25}, controlledPanel: 'second', - minSize: 100, + // Floor for the History meta-band layout; dismiss the + // sidebar entirely (Ctrl+Shift+B) for narrower screens. + minSize: 280, firstPanel: leftPanel, secondPanel: sidebarPanel, }), ); } - private renderEditorTabContent(tab: BigTraceEditorTab): m.Children { - const editorPanel = m('.pf-query-page__editor-panel', [ - m(Box, {className: 'pf-query-page__toolbar'}, [ - m(Stack, {orientation: 'horizontal'}, [ - tab.isLoading - ? m(Button, { - label: 'Cancel', - icon: 'stop', - intent: Intent.Warning, - variant: ButtonVariant.Filled, - onclick: () => this.cancelQueryOnTab(tab), - }) - : m(Button, { - label: 'Run Query', - icon: 'play_arrow', - intent: Intent.Primary, - variant: ButtonVariant.Filled, - onclick: () => this.runQueryOnTab(tab, tab.editorText), - }), - m( - Stack, - { - orientation: 'horizontal', - className: 'pf-query-page__hotkeys', - }, - 'or press', - m(HotkeyGlyphs, {hotkey: 'Mod+Enter'}), - ), - m(StackAuto), - this.useBrushBackend && [ - m('span', 'Result limit:'), - m(TextInput, { - type: 'number', - value: String(tab.limit), - placeholder: 'Limit', - onChange: (value: string) => { - const newLimit = parseInt(value, 10); - if (!isNaN(newLimit) && newLimit > 0) { - tab.limit = newLimit; - } - }, - }), - ], - ]), - ]), - tab.editorText.includes('"') && - m( - Callout, - {icon: 'warning', intent: Intent.None}, - `" (double quote) character observed in query; if this is being used to ` + - `define a string, please use ' (single quote) instead. Using double quotes ` + - `can cause subtle problems which are very hard to debug.`, - ), - m(Editor, { - text: tab.editorText, - language: 'perfetto-sql', - onUpdate: (text: string) => { - tab.editorText = text; - tabsState.markDirty(); - }, - onExecute: (query: string) => this.runQueryOnTab(tab, query), - }), - ]); - - const resultsPanel = m( - '.pf-query-page__results-panel', - tab.dataSource && tab.queryResult - ? this.renderQueryResult( - tab.queryResult, - tab.dataSource, - tab.querySettings, - ) - : tab.isLoading - ? m(EmptyState, { - title: 'Running query...', - icon: 'hourglass_empty', - fillHeight: true, - }) - : m(EmptyState, { - title: 'Run a query to see results', - icon: 'search', - fillHeight: true, - }), - ); - - return m(SplitPanel, { - direction: 'vertical', - initialSplit: {percent: 35}, - minSize: 100, - firstPanel: editorPanel, - secondPanel: resultsPanel, - }); - } - private renderTablesTab(): m.Children { if (sqlTablesLoader.loadError) { return m(EmptyState, { @@ -435,188 +244,8 @@ export class QueryPage implements m.ClassComponent { return m(TableList, { sqlModules: modules, onQueryTable: (tableName, query) => { - tabsState.addNewTab(tableName, query); + this.tabsState.addNewTab(tableName, query); }, }); } - - private cancelQueryOnTab(tab: BigTraceEditorTab) { - tab.activeHttpDataSource?.abort(); - tab.activeHttpDataSource = undefined; - tab.isLoading = false; - m.redraw(); - } - - private async runQueryOnTab(tab: BigTraceEditorTab, query: string) { - if (!query) return; - - // Abort any in-flight query on this tab. - tab.activeHttpDataSource?.abort(); - - queryHistoryStorage.saveQuery(query); - - tab.isLoading = true; - tab.queryResult = undefined; - m.redraw(); - - if (this.useBrushBackend) { - const endpointSetting = endpointStorage.get('bigtraceEndpoint'); - const endpoint = endpointSetting ? (endpointSetting.get() as string) : ''; - - await bigTraceSettingsStorage.loadSettings(); - - const settings = bigTraceSettingsStorage.buildSettingFilters(); - tab.querySettings = settings; - - const httpDataSource = new HttpDataSource( - endpoint, - query, - tab.limit, - settings, - ); - tab.activeHttpDataSource = httpDataSource; - const startMs = performance.now(); - try { - const data = await httpDataSource.query(); - tab.queryResult = { - rows: data, - columns: data.length > 0 ? Object.keys(data[0]) : [], - error: undefined, - totalRowCount: data.length, - durationMs: performance.now() - startMs, - statementWithOutputCount: 1, - statementCount: 1, - lastStatementSql: query, - query, - }; - } catch (e) { - // Don't show an error for user-initiated cancellation. - if (e instanceof Error && e.message === 'Query was cancelled.') { - return; - } - const error = e instanceof Error ? e.message : String(e); - tab.queryResult = { - rows: [], - columns: [], - error, - totalRowCount: 0, - durationMs: performance.now() - startMs, - statementWithOutputCount: 0, - statementCount: 1, - lastStatementSql: query, - query, - }; - } finally { - tab.activeHttpDataSource = undefined; - } - } else { - throw new Error( - 'Local query execution is unsupported in bigtrace context.', - ); - } - - if (tab.queryResult !== undefined) { - tab.dataSource = new InMemoryDataSource(tab.queryResult.rows); - } - - tab.isLoading = false; - m.redraw(); - } - - private renderQueryResult( - queryResult: QueryResponse, - dataSource: DataSource, - querySettings: SettingFilter[], - ) { - if (queryResult.error) { - return m( - '.pf-query-page__query-error', - `Error (after ${Math.round(queryResult.durationMs).toLocaleString()} ms): ${queryResult.error}`, - ); - } else { - const tableContent = [ - queryResult.statementWithOutputCount > 1 && - m(Box, [ - m(Callout, {icon: 'warning', intent: Intent.None}, [ - `${queryResult.statementWithOutputCount} out of ${queryResult.statementCount} `, - 'statements returned a result. ', - 'Only the results for the last statement are displayed.', - ]), - ]), - (() => { - // Build schema directly - const columnSchema: ColumnSchema = {}; - for (const column of queryResult.columns) { - if (column === 'link') { - columnSchema[column] = { - cellRenderer: (value) => { - if (value === null || value === undefined) { - return ''; - } - return linkify(String(value)); - }, - }; - } else { - columnSchema[column] = {cellRenderer: undefined}; - } - } - const schema: SchemaRegistry = {data: columnSchema}; - - return m(DataGrid, { - schema, - rootSchema: 'data', - enablePivotControls: false, // In-memory datasource does not support pivoting - initialColumns: queryResult.columns - .filter((col) => { - if (!col.startsWith('_')) return true; - if (col === '_trace_id') return true; - const settingId = col.substring(1); - return querySettings.some( - (s) => - s.settingId === settingId && - s.category === 'TRACE_METADATA', - ); - }) - .map((col) => ({ - id: col, - field: col, - })), - className: 'pf-query-page__results', - data: dataSource, - showExportButton: true, - emptyStateMessage: 'Query returned no rows', - toolbarItemsLeft: m( - 'span.pf-query-page__results-summary', - `Returned ${queryResult.totalRowCount.toLocaleString()} rows in ${Math.round(queryResult.durationMs).toLocaleString()} ms`, - ), - toolbarItemsRight: [ - m(CopyToClipboardButton, { - textToCopy: queryResult.query, - title: 'Copy executed query to clipboard', - label: 'Copy Query', - }), - ], - }); - })(), - ]; - - return m(Tabs, { - tabs: [ - { - key: 'table', - title: 'Table', - content: tableContent, - }, - { - key: 'chart', - title: 'Chart', - content: m(EmptyState, { - title: 'Charts are coming soon', - icon: 'bar_chart', - }), - }, - ], - }); - } - } } diff --git a/ui/src/bigtrace/pages/query_tabs_state.ts b/ui/src/bigtrace/pages/query_tabs_state.ts new file mode 100644 index 00000000000..e0f98929a8d --- /dev/null +++ b/ui/src/bigtrace/pages/query_tabs_state.ts @@ -0,0 +1,317 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {DataSource} from '../../components/widgets/datagrid/data_source'; +import {Row as DataGridRow} from '../../trace_processor/query_result'; +import {debounce} from '../../base/rate_limiters'; +import {shortUuid} from '../../base/uuid'; +import {BigtraceQueryClient} from '../query/bigtrace_query_client'; +import {queryStore, QueryExecution} from '../query/query_store'; +import {SettingFilter} from '../settings/settings_types'; + +const QUERY_TABS_STORAGE_KEY = 'bigtraceQueryTabs'; +const DEFAULT_SQL = ''; +const DEFAULT_LIMIT = 100; +const TAB_TITLE_MAX_CHARS = 32; + +// First non-empty `--`-stripped line, clipped. `/* */` blocks not handled. +export function deriveTitleFromQuery(sql: string): string | undefined { + const stripped = sql + .split('\n') + .map((line) => { + const idx = line.indexOf('--'); + return idx === -1 ? line : line.slice(0, idx); + }) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + if (stripped.length === 0) return undefined; + const firstLine = stripped[0]; + if (firstLine.length <= TAB_TITLE_MAX_CHARS) return firstLine; + return firstLine.slice(0, TAB_TITLE_MAX_CHARS - 1) + '…'; +} + +// Sync populates rows/columns; async leaves them empty (reads via `tab.dataSource`). +export interface QueryResponse { + query: string; + error?: string; + totalRowCount: number; + durationMs: number; + columns: string[]; + rows: DataGridRow[]; + statementCount: number; + statementWithOutputCount: number; + lastStatementSql: string; +} + +// QueryResponse with sensible defaults; callers spread real values via `partial`. +export function makeQueryResponse( + query: string, + partial: Partial> = {}, +): QueryResponse { + return { + query, + lastStatementSql: query, + statementCount: 1, + statementWithOutputCount: 0, + totalRowCount: 0, + durationMs: 0, + columns: [], + rows: [], + error: undefined, + ...partial, + }; +} + +// Mutated in-place by the runner; only QueryTabsState creates/destroys. +export interface BigTraceEditorTab { + readonly id: string; + title: string; + editorText: string; + limit: number; + queryResult?: QueryResponse; + isLoading: boolean; + dataSource?: DataSource; + querySettings: SettingFilter[]; + // Tab-lifetime: every backend request plumbs `signal`; aborts on close. + readonly lifecycle: AbortController; + // Per-execute request: Cancel aborts this without tearing down the tab. + activeRequest?: AbortController; + queryClient?: BigtraceQueryClient; + materialize: boolean; + queryUuid?: string; + pollInterval?: number; + lastProcessedRows: number; + clientStartTime?: number; + execution?: QueryExecution; + // Stale-poll guard: bumped on each startPolling() call. + pollGeneration: number; + // Active results tab (Table / Error / Chart). Undefined = auto-select: + // Error when the query failed with no rows, Table otherwise. Set once + // the user clicks a tab, so their choice sticks across redraws. + resultsTabKey?: string; +} + +// Persisted subset of BigTraceEditorTab. Transient state is rebuilt on load. +interface StoredTab { + readonly id: string; + readonly title: string; + readonly editorText: string; + readonly limit: number; + readonly materialize: boolean; + readonly queryUuid?: string; + readonly error?: string; +} + +interface StoredState { + readonly tabs: ReadonlyArray; + readonly activeTabId?: string; +} + +// Manages editor tabs + localStorage persistence across page reloads. +export class QueryTabsState { + tabs: BigTraceEditorTab[] = []; + activeTabId = ''; + + private tabCounter = 0; + private readonly debouncedSave = debounce(() => this.saveToStorage(), 1000); + + constructor() { + if (!this.loadFromStorage()) { + this.addNewTab(undefined, DEFAULT_SQL); + } + } + + markDirty(): void { + this.debouncedSave(); + } + + getActiveTab(): BigTraceEditorTab | undefined { + return this.tabs.find((t) => t.id === this.activeTabId); + } + + // Create and activate. Without `forceNew`, reactivates an existing tab + // matching by `queryUuid` (preferred) or `initialQuery`. + addNewTab( + title?: string, + initialQuery?: string, + limit?: number, + queryUuid?: string, + materialize?: boolean, + forceNew?: boolean, + ): BigTraceEditorTab { + if (!forceNew) { + const existingTab = this.tabs.find((t) => { + if (queryUuid && t.queryUuid === queryUuid) return true; + if (!queryUuid && initialQuery && t.editorText === initialQuery) { + return true; + } + return false; + }); + + if (existingTab) { + this.activeTabId = existingTab.id; + this.markDirty(); + return existingTab; + } + } + + // Caller title wins; else derive from SQL so History opens have meaningful + // labels instead of "Query N". maybeAutoNameTab refines on first run. + const derivedTitle = + title ?? (initialQuery && deriveTitleFromQuery(initialQuery)); + const tab: BigTraceEditorTab = { + id: shortUuid(), + title: derivedTitle || this.nextTabName(), + editorText: initialQuery ?? '', + limit: limit ?? DEFAULT_LIMIT, + queryResult: undefined, + isLoading: false, + dataSource: undefined, + querySettings: [], + lifecycle: new AbortController(), + activeRequest: undefined, + // History-reopen → Persistent; new tab → sync; caller overrides. + materialize: materialize ?? Boolean(queryUuid), + lastProcessedRows: 0, + queryUuid, + pollGeneration: 0, + }; + tab.execution = queryStore.getOrCreate(queryUuid || tab.id, { + materialized: tab.materialize, + }); + this.tabs.push(tab); + this.activeTabId = tab.id; + this.markDirty(); + return tab; + } + + closeTab(tabId: string): void { + if (this.tabs.length <= 1) return; + const index = this.tabs.findIndex((t) => t.id === tabId); + if (index === -1) return; + const tabToClose = this.tabs[index]; + if (tabToClose.pollInterval !== undefined) { + window.clearTimeout(tabToClose.pollInterval); + tabToClose.pollInterval = undefined; + } + // Aborts execute_* and any one-off request holding `lifecycle.signal`. + tabToClose.activeRequest?.abort(); + tabToClose.lifecycle.abort(); + this.tabs.splice(index, 1); + if (this.activeTabId === tabId) { + const newIndex = Math.min(index, this.tabs.length - 1); + this.activeTabId = this.tabs[newIndex].id; + } + this.markDirty(); + } + + renameTab(tabId: string, newTitle: string): void { + const tab = this.tabs.find((t) => t.id === tabId); + if (tab) { + tab.title = newTitle; + this.markDirty(); + } + } + + // Replace "Query N" with a SQL-derived title before submit; + // user-renamed tabs are skipped. + maybeAutoNameTab(tabId: string, queryText: string): void { + const tab = this.tabs.find((t) => t.id === tabId); + if (!tab) return; + if (!/^Query \d+$/.test(tab.title)) return; + const derived = deriveTitleFromQuery(queryText); + if (derived === undefined) return; + tab.title = derived; + this.markDirty(); + } + + reorderTab(draggedId: string, beforeId: string | undefined): void { + const draggedIndex = this.tabs.findIndex((t) => t.id === draggedId); + if (draggedIndex === -1) return; + const [dragged] = this.tabs.splice(draggedIndex, 1); + if (beforeId === undefined) { + this.tabs.push(dragged); + return; + } + const beforeIndex = this.tabs.findIndex((t) => t.id === beforeId); + if (beforeIndex === -1) { + this.tabs.push(dragged); + } else { + this.tabs.splice(beforeIndex, 0, dragged); + } + } + + // ----- Persistence ----- + + private saveToStorage(): void { + const state: StoredState = { + tabs: this.tabs.map((t) => ({ + id: t.id, + title: t.title, + editorText: t.editorText, + limit: t.limit, + materialize: t.materialize, + queryUuid: t.queryUuid, + error: t.queryResult?.error, + })), + activeTabId: this.activeTabId, + }; + localStorage.setItem(QUERY_TABS_STORAGE_KEY, JSON.stringify(state)); + } + + private loadFromStorage(): boolean { + const stored = localStorage.getItem(QUERY_TABS_STORAGE_KEY); + if (!stored) return false; + let parsed: StoredState; + try { + parsed = JSON.parse(stored) as StoredState; + } catch { + return false; + } + if (!Array.isArray(parsed.tabs) || parsed.tabs.length === 0) return false; + + for (const t of parsed.tabs) { + const tab = this.addNewTab( + t.title, + t.editorText, + t.limit, + t.queryUuid, + t.materialize, + ); + if (t.error !== undefined && t.error !== '') { + tab.queryResult = makeQueryResponse(tab.editorText, {error: t.error}); + } + } + if (typeof parsed.activeTabId === 'string') { + const found = this.tabs.find((t) => t.id === parsed.activeTabId); + if (!found) { + // Restored tabs get new IDs, so activate by index instead. + const idx = parsed.tabs.findIndex((t) => t.id === parsed.activeTabId); + if (idx >= 0 && idx < this.tabs.length) { + this.activeTabId = this.tabs[idx].id; + } + } + } + return true; + } + + private nextTabName(): string { + const existingNames = new Set(this.tabs.map((t) => t.title)); + let count = ++this.tabCounter; + while (existingNames.has(`Query ${count}`)) { + count = ++this.tabCounter; + } + return `Query ${count}`; + } +} diff --git a/ui/src/bigtrace/pages/settings_page.ts b/ui/src/bigtrace/pages/settings_page.ts index fcca1b3819d..2cb1b7afe0b 100644 --- a/ui/src/bigtrace/pages/settings_page.ts +++ b/ui/src/bigtrace/pages/settings_page.ts @@ -19,6 +19,7 @@ import m from 'mithril'; import {SettingsShell} from '../../widgets/settings_shell'; import {Switch} from '../../widgets/switch'; import {Card, CardStack} from '../../widgets/card'; +import {Icon} from '../../widgets/icon'; import {classNames} from '../../base/classnames'; import {bigTraceSettingsStorage} from '../settings/bigtrace_settings_storage'; import {Setting as BigTraceSetting} from '../settings/settings_types'; @@ -64,6 +65,9 @@ class BigTraceSettingsCard className: 'pf-settings-card__toggle', style: {marginRight: '8px'}, checked: !disabled, + title: + 'Turn off to skip this filter — its value will not be ' + + 'sent to the backend with subsequent queries.', onchange: (e: Event) => { const target = e.target as HTMLInputElement; onChange?.(!target.checked); @@ -71,7 +75,6 @@ class BigTraceSettingsCard }), title, ]), - id && m('.pf-settings-card__id', id), description !== undefined && m('.pf-settings-card__description', description), ); @@ -157,10 +160,22 @@ export class SettingsPage implements m.ClassComponent { categories.get(categoryName)!.push(setting); } + // Show a "no matches" hint when search hides everything except + // the always-shown General card. + const hasOtherMatches = settings.length > 0; + const showNoMatchesHint = + this.searchQuery !== '' && + !hasOtherMatches && + !bigTraceSettingsStorage.execConfigLoadError; + + // Only force-create the Trace Metadata section while loading or on error; + // an empty metadata response collapses the section entirely. if ( this.searchQuery === '' && !categories.has('Trace Metadata') && - !bigTraceSettingsStorage.execConfigLoadError + !bigTraceSettingsStorage.execConfigLoadError && + (bigTraceSettingsStorage.isMetadataLoading || + bigTraceSettingsStorage.metadataLoadError) ) { categories.set('Trace Metadata', []); } @@ -170,19 +185,11 @@ export class SettingsPage implements m.ClassComponent { { title: 'Settings', className: 'page', + // Reload-required affordance lives next to the endpoint + // input (renderEndpointControl), not in the header. stickyHeaderContent: m( Stack, {orientation: 'horizontal'}, - endpointStorage.isReloadRequired() && - m(Button, { - label: 'Reload required', - icon: 'refresh', - intent: Intent.Primary, - variant: ButtonVariant.Filled, - onclick: () => { - window.location.reload(); - }, - }), m(StackAuto), m(TextInput, { placeholder: 'Search...', @@ -201,16 +208,6 @@ export class SettingsPage implements m.ClassComponent { icon: 'hourglass_empty', fillHeight: true, }), - bigTraceSettingsStorage.execConfigLoadError && - m( - Callout, - { - intent: Intent.Danger, - icon: 'error', - title: 'Failed to Load Execution Configuration', - }, - bigTraceSettingsStorage.execConfigLoadError, - ), Array.from(categories.entries()).map(([category, catSettings]) => { let categoryHeader: m.Children = m( 'h2.pf-settings-page__plugin-title', @@ -284,20 +281,57 @@ export class SettingsPage implements m.ClassComponent { categoryContent, ); }), + // After the General card, so the callout's "Set the + // Endpoint above" copy points at a field above it. + bigTraceSettingsStorage.execConfigLoadError && + m( + Callout, + { + intent: Intent.Danger, + icon: 'error', + title: 'Failed to Load Execution Configuration', + }, + bigTraceSettingsStorage.execConfigLoadError, + ), + showNoMatchesHint && + m(EmptyState, { + title: `No settings match "${this.searchQuery}"`, + icon: 'search_off', + }), ]), ); } private renderEndpointControl(setting: Setting) { const currentValue = setting.get() as string; - return m(TextInput, { - value: currentValue, - style: {width: 'min(300px, 30vw)'}, - oninput: (e: Event) => { - const target = e.target as HTMLInputElement; - setting.set(target.value); + return m( + Stack, + { + orientation: 'horizontal', + gap: '8px', + alignItems: 'center', + style: {flexWrap: 'wrap', justifyContent: 'flex-end'}, }, - }); + m(TextInput, { + value: currentValue, + placeholder: 'https://your-bigtrace-backend/v1', + style: {width: 'min(300px, 30vw)'}, + oninput: (e: Event) => { + const target = e.target as HTMLInputElement; + setting.set(target.value); + }, + }), + // Endpoint is cached at module init; force a reload to apply + // changes. + endpointStorage.isReloadRequired() && + m(Button, { + label: 'Reload to apply', + icon: 'refresh', + intent: Intent.Primary, + variant: ButtonVariant.Filled, + onclick: () => window.location.reload(), + }), + ); } private renderBigTraceSettingCard(setting: BigTraceSetting) { @@ -305,10 +339,73 @@ export class SettingsPage implements m.ClassComponent { const fullWidth = setting.type === 'string-array' || (setting.type === 'string' && setting.format === 'sql'); + // Flag enabled-but-empty filters upfront. Numeric settings are + // excluded because 0 is legit (= unlimited). + const needsValue = + !disabled && + (setting.type === 'string' || setting.type === 'string-array'); + let warning: string | undefined; + if (needsValue) { + const value = setting.get(); + if (setting.type === 'string') { + if (typeof value === 'string' && value.trim() === '') { + warning = 'Required when this filter is enabled.'; + } + } else if (setting.type === 'string-array') { + if ( + !Array.isArray(value) || + value.length === 0 || + value.every((v) => typeof v === 'string' && v.trim() === '') + ) { + warning = 'Required when this filter is enabled.'; + } + } + } + // "(unlimited)" hint on numeric settings whose description says + // "ignored if 0" — works for any setting following the convention. + let hint: string | undefined; + if ( + !disabled && + setting.type === 'number' && + setting.get() === 0 && + /ignored if 0/i.test(setting.description) + ) { + hint = '(unlimited)'; + } + const description: m.Children = warning + ? [ + setting.description, + m( + '.pf-settings-card__warning', + { + style: { + color: 'var(--pf-color-danger, #b00020)', + marginTop: '4px', + }, + }, + m(Icon, { + icon: 'warning', + style: {fontSize: '14px', verticalAlign: 'middle'}, + }), + ' ', + warning, + ), + ] + : hint + ? [ + setting.description, + ' ', + m( + 'span.pf-settings-card__hint', + {style: {opacity: 0.7, fontStyle: 'italic'}}, + hint, + ), + ] + : setting.description; return m(BigTraceSettingsCard, { id: setting.id, title: setting.name, - description: setting.description, + description, controls: renderSetting(setting), disabled, fullWidthControls: fullWidth, diff --git a/ui/src/bigtrace/query/abort_utils.ts b/ui/src/bigtrace/query/abort_utils.ts new file mode 100644 index 00000000000..17cedf78bc6 --- /dev/null +++ b/ui/src/bigtrace/query/abort_utils.ts @@ -0,0 +1,28 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Forward `parent` aborts to `child`; returns a detacher (call in `finally` +// to avoid leaking on long-lived parents). Already-aborted parent fires now. +export function forwardAbort( + parent: AbortSignal, + child: AbortController, +): () => void { + if (parent.aborted) { + child.abort(parent.reason); + return () => {}; + } + const handler = () => child.abort(parent.reason); + parent.addEventListener('abort', handler, {once: true}); + return () => parent.removeEventListener('abort', handler); +} diff --git a/ui/src/bigtrace/query/abort_utils_unittest.ts b/ui/src/bigtrace/query/abort_utils_unittest.ts new file mode 100644 index 00000000000..596215372e7 --- /dev/null +++ b/ui/src/bigtrace/query/abort_utils_unittest.ts @@ -0,0 +1,54 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {forwardAbort} from './abort_utils'; + +describe('forwardAbort', () => { + test('aborts child when parent aborts', () => { + const parent = new AbortController(); + const child = new AbortController(); + forwardAbort(parent.signal, child); + + expect(child.signal.aborted).toBe(false); + parent.abort(); + expect(child.signal.aborted).toBe(true); + }); + + test('aborts child immediately if parent is already aborted', () => { + const parent = new AbortController(); + parent.abort(); + const child = new AbortController(); + forwardAbort(parent.signal, child); + expect(child.signal.aborted).toBe(true); + }); + + test('detacher prevents future child aborts on parent abort', () => { + const parent = new AbortController(); + const child = new AbortController(); + const detach = forwardAbort(parent.signal, child); + + detach(); + parent.abort(); + expect(child.signal.aborted).toBe(false); + }); + + test('aborting child does not affect parent', () => { + const parent = new AbortController(); + const child = new AbortController(); + forwardAbort(parent.signal, child); + + child.abort(); + expect(parent.signal.aborted).toBe(false); + }); +}); diff --git a/ui/src/bigtrace/query/bigtrace_async_data_source.ts b/ui/src/bigtrace/query/bigtrace_async_data_source.ts new file mode 100644 index 00000000000..8b1138d6ba7 --- /dev/null +++ b/ui/src/bigtrace/query/bigtrace_async_data_source.ts @@ -0,0 +1,227 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + DataSource, + DataSourceModel, + DataSourceRows, +} from '../../components/widgets/datagrid/data_source'; +import {Filter} from '../../components/widgets/datagrid/model'; +import {Row, SqlValue} from '../../trace_processor/query_result'; +import {QueryResult} from '../../base/query_slot'; +import { + BigtraceQueryClient, + QueryCancelledError, +} from './bigtrace_query_client'; +import {encodeFilters} from './filter_encoding'; +import m from 'mithril'; + +type ModelWithColumns = DataSourceModel & { + columns?: Array<{field: string; alias?: string}>; +}; + +// DataSource adapter paging `:fetch_results` into the DataGrid widget. +export class BigtraceAsyncDataSource implements DataSource { + private loadedRows: Row[] = []; + private isFetching = false; + private columns: string[] = []; + private error: string | null = null; + private hasInitialFetchCompleted = false; + // Window in `loadedRows`, for range-change detection. + private loadedOffset = 0; + private loadedLimit = 0; + // AIP-132 §Ordering. Empty = materialization order. + private currentOrderBy = ''; + // Aliases pre-resolved to field names. `currentFilterKey` is the JSON + // form for cheap equality checks. + private currentFilter: ReadonlyArray = []; + private currentFilterKey = ''; + // `useRows` falls back to `getTotalRows()` when undefined. + private _filteredTotalRows: number | undefined; + + get filteredTotalRows(): number | undefined { + return this._filteredTotalRows; + } + + // `signal`: owner aborts on close. `getTotalRows`: scrollbar sizing. + constructor( + private readonly queryUuid: string, + private readonly queryClient: BigtraceQueryClient, + private readonly getTotalRows: () => number, + private readonly signal?: AbortSignal, + ) {} + + useRows(_model: DataSourceModel): DataSourceRows { + const model = _model as ModelWithColumns; + const wantedOrderBy = this.formatOrderBy(model); + const wantedFilter = this.formatFilter(model); + const wantedFilterKey = encodeFilters(wantedFilter); + const wantedOffset = model.pagination?.offset ?? 0; + const wantedLimit = model.pagination?.limit ?? 0; + + // Fetch on sort/filter/range/initial change; skip if in flight (avoids redraw storms). + const sortChanged = wantedOrderBy !== this.currentOrderBy; + const filterChanged = wantedFilterKey !== this.currentFilterKey; + const rangeChanged = + this.hasInitialFetchCompleted && + (wantedOffset !== this.loadedOffset || + (wantedLimit > 0 && wantedLimit !== this.loadedLimit)); + const needsInitial = !this.hasInitialFetchCompleted && wantedLimit > 0; + if ( + (sortChanged || filterChanged || rangeChanged || needsInitial) && + !this.isFetching + ) { + this.currentOrderBy = wantedOrderBy; + if (filterChanged) { + this.currentFilter = wantedFilter; + this.currentFilterKey = wantedFilterKey; + // Briefly oversized scrollbar > briefly collapsed while refetching. + this._filteredTotalRows = undefined; + } + // First render may have limit=0; fall back so the schema comes back. + const fetchLimit = wantedLimit > 0 ? wantedLimit : 100; + this.fetchMoreRows(wantedOffset, fetchLimit); + } + + const mappedRows = this.loadedRows.map((row) => { + const mappedRow: Row = {}; + for (const key in row) { + if (Object.prototype.hasOwnProperty.call(row, key)) { + const col = model.columns?.find((c) => c.field === key); + const alias = + col !== undefined && col.alias !== undefined ? col.alias : key; + mappedRow[alias] = row[key]; + } + } + return mappedRow; + }); + + return { + rows: mappedRows, + // Filtered total; falls back to unfiltered while undefined. + totalRows: this._filteredTotalRows ?? this.getTotalRows(), + rowOffset: this.loadedOffset, + isPending: this.isFetching, + }; + } + + // Resolve widget alias → SELECT field (backend whitelists fields). + private formatOrderBy(model: ModelWithColumns): string { + const sort = model.sort; + if (!sort) return ''; + const col = model.columns?.find((c) => c.alias === sort.alias); + const field = col?.field ?? sort.alias; + return `${field} ${sort.direction.toLowerCase()}`; + } + + // Same alias→field remap as formatOrderBy; `fetchResults` does encoding. + private formatFilter(model: ModelWithColumns): ReadonlyArray { + const filters = model.filters ?? []; + if (filters.length === 0) return []; + return filters.map((f) => { + const col = model.columns?.find((c) => c.alias === f.field); + const field = col?.field ?? f.field; + return {...f, field}; + }); + } + + // Re-fetch the currently-loaded window. No-op if a fetch is in flight. + async refresh(): Promise { + if (this.isFetching) return; + const offset = this.loadedOffset; + const limit = this.loadedLimit > 0 ? this.loadedLimit : 100; + await this.fetchMoreRows(offset, limit); + } + + private async fetchMoreRows(offset: number, limit: number) { + if (this.signal?.aborted) return; + this.error = null; + this.isFetching = true; + // `[bigtrace]` prefix is grep-friendly; filter truncated for long IN-lists. + const filterLog = + this.currentFilterKey.length > 80 + ? this.currentFilterKey.slice(0, 77) + '...' + : this.currentFilterKey; + console.log( + `[bigtrace] fetch_results uuid=${this.queryUuid.slice(0, 8)} ` + + `offset=${offset} limit=${limit} ` + + `order_by=${JSON.stringify(this.currentOrderBy)} ` + + `filter=${filterLog}`, + ); + m.redraw(); + try { + const result = await this.queryClient.fetchResults( + this.queryUuid, + limit, + offset, + this.signal, + this.currentOrderBy, + this.currentFilter, + ); + this.loadedRows = [...result.rows]; + this.loadedOffset = offset; + this.loadedLimit = limit; + this.hasInitialFetchCompleted = true; + this._filteredTotalRows = result.totalFilteredRows; + + if (this.columns.length === 0 && result.columns.length > 0) { + this.columns = [...result.columns]; + } + } catch (e) { + // Abort is expected when the owning tab closes; don't surface it. + if (e instanceof QueryCancelledError) return; + console.error('[bigtrace] fetch_results failed:', e); + this.error = e instanceof Error ? e.message : String(e); + } finally { + this.isFetching = false; + m.redraw(); + } + } + + // Force the first window after SUCCESS without waiting for a render. + async ensureResultsLoaded(): Promise { + if (this.hasInitialFetchCompleted) return; + await this.fetchMoreRows(0, 100); + } + + getError(): string | null { + return this.error; + } + + getColumns(): string[] { + return this.columns; + } + + useAggregateSummaries(_model: DataSourceModel): QueryResult { + return {data: undefined, isPending: false, isFresh: true}; + } + + useDistinctValues( + _column: string | undefined, + ): QueryResult { + // `data: []` (not `undefined`) avoids a permanent "Loading…" in the + // column-filter "Equals" submenu. Cell-context menu filtering still works. + return {data: [], isPending: false, isFresh: true}; + } + + useParameterKeys( + _prefix: string | undefined, + ): QueryResult { + return {data: undefined, isPending: false, isFresh: true}; + } + + async exportData(_model: DataSourceModel): Promise { + return this.loadedRows; + } +} diff --git a/ui/src/bigtrace/query/bigtrace_async_data_source_unittest.ts b/ui/src/bigtrace/query/bigtrace_async_data_source_unittest.ts new file mode 100644 index 00000000000..34ac32ec32e --- /dev/null +++ b/ui/src/bigtrace/query/bigtrace_async_data_source_unittest.ts @@ -0,0 +1,259 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {BigtraceAsyncDataSource} from './bigtrace_async_data_source'; +import {BigtraceQueryClient} from './bigtrace_query_client'; +import {DataSourceModel} from '../../components/widgets/datagrid/data_source'; +import {Filter} from '../../components/widgets/datagrid/model'; + +// One microtask is enough for fetchResults to settle and bookkeeping to update. +function flushAsync() { + return new Promise((r) => setTimeout(r, 0)); +} + +// Minimal model exposing only the fields the data source reads. +function fakeModel(opts: { + filters?: ReadonlyArray; + columns?: ReadonlyArray<{field: string; alias?: string}>; + offset?: number; + limit?: number; +}): DataSourceModel { + return { + pagination: {offset: opts.offset ?? 0, limit: opts.limit ?? 10}, + filters: opts.filters, + columns: opts.columns, + } as never; +} + +// Stub client recording every fetchResults call with a configurable response. +interface MockClient { + client: BigtraceQueryClient; + setNextResponse: (response: { + rows?: ReadonlyArray>; + columns?: ReadonlyArray; + totalFilteredRows?: number; + }) => void; + calls: () => Array<{ + uuid: string; + limit: number; + offset: number; + orderBy: string | undefined; + filter: ReadonlyArray | undefined; + }>; +} +function makeMockClient(): MockClient { + let next: { + rows: ReadonlyArray>; + columns: ReadonlyArray; + totalFilteredRows: number; + } = {rows: [], columns: [], totalFilteredRows: 0}; + const calls: Array<{ + uuid: string; + limit: number; + offset: number; + orderBy: string | undefined; + filter: ReadonlyArray | undefined; + }> = []; + const fetchResults = jest.fn( + async ( + uuid: string, + limit: number, + offset: number, + _signal?: AbortSignal, + orderBy?: string, + filter?: ReadonlyArray, + ) => { + calls.push({uuid, limit, offset, orderBy, filter}); + return { + rows: next.rows, + columns: next.columns, + totalFilteredRows: next.totalFilteredRows, + }; + }, + ); + return { + client: {fetchResults} as unknown as BigtraceQueryClient, + setNextResponse: (r) => + (next = { + rows: r.rows ?? [], + columns: r.columns ?? [], + totalFilteredRows: r.totalFilteredRows ?? 0, + }), + calls: () => calls, + }; +} + +describe('BigtraceAsyncDataSource — alias→field remap', () => { + test('formatFilter rewrites widget alias to backend field', async () => { + const mock = makeMockClient(); + mock.setNextResponse({totalFilteredRows: 7}); + const ds = new BigtraceAsyncDataSource('test-uuid', mock.client, () => 100); + ds.useRows( + fakeModel({ + filters: [{field: 'displayName', op: '=', value: 'kernel'}], + columns: [{alias: 'displayName', field: 'real_name'}], + }), + ); + await flushAsync(); + expect(mock.calls()).toHaveLength(1); + expect(mock.calls()[0].filter).toEqual([ + {field: 'real_name', op: '=', value: 'kernel'}, + ]); + }); + + test('formatFilter falls back to alias when no column mapping is provided', async () => { + // Missing column entry → pass alias through (don't drop the filter). + const mock = makeMockClient(); + const ds = new BigtraceAsyncDataSource('uid', mock.client, () => 0); + ds.useRows( + fakeModel({ + filters: [{field: 'orphan_field', op: '=', value: 1}], + columns: [], + }), + ); + await flushAsync(); + expect(mock.calls()[0].filter).toEqual([ + {field: 'orphan_field', op: '=', value: 1}, + ]); + }); +}); + +describe('BigtraceAsyncDataSource — useRows trigger logic', () => { + test('does not refetch when called twice with the same filter', async () => { + const mock = makeMockClient(); + const ds = new BigtraceAsyncDataSource('uid', mock.client, () => 0); + const model = fakeModel({ + filters: [{field: 'a', op: '=', value: 1}], + columns: [{alias: 'a', field: 'a'}], + }); + ds.useRows(model); + await flushAsync(); + ds.useRows(model); + await flushAsync(); + expect(mock.calls()).toHaveLength(1); + }); + + test('refetches with the new filter when filter changes', async () => { + const mock = makeMockClient(); + const ds = new BigtraceAsyncDataSource('uid', mock.client, () => 0); + ds.useRows( + fakeModel({ + filters: [{field: 'a', op: '=', value: 1}], + columns: [{alias: 'a', field: 'a'}], + }), + ); + await flushAsync(); + ds.useRows( + fakeModel({ + filters: [{field: 'a', op: '=', value: 2}], + columns: [{alias: 'a', field: 'a'}], + }), + ); + await flushAsync(); + expect(mock.calls()).toHaveLength(2); + expect(mock.calls()[1].filter).toEqual([{field: 'a', op: '=', value: 2}]); + }); + + test('semantically-equal filters built in different key orders do not refetch', async () => { + // Pins canonical key-sort at the data-source level. + const mock = makeMockClient(); + const ds = new BigtraceAsyncDataSource('uid', mock.client, () => 0); + ds.useRows( + fakeModel({ + filters: [{field: 'a', op: '=', value: 1}], + columns: [{alias: 'a', field: 'a'}], + }), + ); + await flushAsync(); + // JSON.parse preserves a non-natural insertion order. + const reordered = JSON.parse('{"value":1,"op":"=","field":"a"}'); + ds.useRows( + fakeModel({ + filters: [reordered], + columns: [{alias: 'a', field: 'a'}], + }), + ); + await flushAsync(); + expect(mock.calls()).toHaveLength(1); + }); +}); + +describe('BigtraceAsyncDataSource — filteredTotalRows flow', () => { + test('reports backend-supplied count after first fetch', async () => { + const mock = makeMockClient(); + mock.setNextResponse({totalFilteredRows: 42}); + const ds = new BigtraceAsyncDataSource( + 'uid', + mock.client, + () => 1000, // unfiltered fallback + ); + const model = fakeModel({ + filters: [{field: 'a', op: '=', value: 1}], + columns: [{alias: 'a', field: 'a'}], + }); + ds.useRows(model); + await flushAsync(); + const result = ds.useRows(model); + expect(result.totalRows).toBe(42); + }); + + test('falls back to getTotalRows() before any fetch completes', () => { + const mock = makeMockClient(); + const ds = new BigtraceAsyncDataSource('uid', mock.client, () => 1000); + // Pre-first-fetch: filteredTotalRows undefined → use fallback. + const result = ds.useRows(fakeModel({})); + expect(result.totalRows).toBe(1000); + }); + + test('clears filteredTotalRows on filter change so scrollbar reverts to fallback briefly', async () => { + const mock = makeMockClient(); + mock.setNextResponse({totalFilteredRows: 50}); + const ds = new BigtraceAsyncDataSource('uid', mock.client, () => 9999); + ds.useRows( + fakeModel({ + filters: [{field: 'a', op: '=', value: 1}], + columns: [{alias: 'a', field: 'a'}], + }), + ); + await flushAsync(); + expect( + ds.useRows( + fakeModel({ + filters: [{field: 'a', op: '=', value: 1}], + columns: [{alias: 'a', field: 'a'}], + }), + ).totalRows, + ).toBe(50); + // Don't flush — filter-change clears filteredTotalRows synchronously, + // so the next useRows() reports the fallback, not the stale 50. + mock.setNextResponse({totalFilteredRows: 7}); + const next = ds.useRows( + fakeModel({ + filters: [{field: 'a', op: '=', value: 2}], // different value → filter changed + columns: [{alias: 'a', field: 'a'}], + }), + ); + expect(next.totalRows).toBe(9999); + await flushAsync(); + // After the new response lands, totalRows reflects the new count. + expect( + ds.useRows( + fakeModel({ + filters: [{field: 'a', op: '=', value: 2}], + columns: [{alias: 'a', field: 'a'}], + }), + ).totalRows, + ).toBe(7); + }); +}); diff --git a/ui/src/bigtrace/query/bigtrace_query_client.ts b/ui/src/bigtrace/query/bigtrace_query_client.ts new file mode 100644 index 00000000000..ee5bc474595 --- /dev/null +++ b/ui/src/bigtrace/query/bigtrace_query_client.ts @@ -0,0 +1,274 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {Row as DataGridRow} from '../../trace_processor/query_result'; +import {Filter} from '../../components/widgets/datagrid/model'; +import {SettingFilter} from '../settings/settings_types'; +import {encodeFilters} from './filter_encoding'; +import {RawQueryExecution} from './query_history_storage'; + +// Tabular wire shape. Values are always strings (see CLAUDE.md +// "Response value contract"); `null` denotes SQL NULL. +interface QueryResponsePayload { + queryUuid?: string; + columnNames?: string[]; + rows?: Array<{values: Array}>; + // Filtered count for scrollbar sizing. + totalFilteredRows?: number; +} + +export interface QueryResultPage { + readonly rows: ReadonlyArray; + readonly columns: ReadonlyArray; + readonly queryUuid?: string; + // Post-filter count from `:fetch_results`; undefined elsewhere. + readonly totalFilteredRows?: number; +} + +// Request aborted via AbortSignal — treat as cancellation, not an error. +export class QueryCancelledError extends Error { + constructor() { + super('Query was cancelled.'); + this.name = 'QueryCancelledError'; + } +} + +// Backend returned 404 for a UUID; distinct from generic HTTP errors so +// callers can drop the dead reference instead of polling forever. +export class QueryNotFoundError extends Error { + constructor(uuid: string) { + super(`Query ${uuid} not found on the backend.`); + this.name = 'QueryNotFoundError'; + } +} + +// Single funnel for the BigTrace HTTP API (see CLAUDE.md for endpoints). +export class BigtraceQueryClient { + constructor(private readonly endpoint: string) {} + + // ----- Query execution ----- + + async executeSync( + query: string, + limit: number, + settings: ReadonlyArray, + signal?: AbortSignal, + ): Promise { + return this.executeAt( + '/execute_bigtrace_query', + query, + limit, + settings, + signal, + ); + } + + async executeAsync( + query: string, + limit: number, + settings: ReadonlyArray, + signal?: AbortSignal, + ): Promise { + return this.executeAt( + '/execute_bigtrace_query_async', + query, + limit, + settings, + signal, + ); + } + + async getStatus( + uuid: string, + signal?: AbortSignal, + ): Promise { + return this.requestJson( + `/query_executions/${uuid}:status`, + {signal}, + ); + } + + async getQueryExecution( + uuid: string, + signal?: AbortSignal, + ): Promise { + return this.requestJson(`/query_executions/${uuid}`, { + signal, + }); + } + + // Page the materialized table; `limit`/`offset` apply after orderBy/filter. + // `orderBy` is AIP-132; `filter` is the DataGrid `Filter[]` shape encoded + // via `encodeFilters`. Mid-flight calls return whatever rows have merged. + async fetchResults( + uuid: string, + limit: number, + offset: number, + signal?: AbortSignal, + orderBy?: string, + filter?: ReadonlyArray, + ): Promise { + let path = `/query_executions/${uuid}:fetch_results?limit=${limit}&offset=${offset}`; + if (orderBy && orderBy.length > 0) { + path += `&order_by=${encodeURIComponent(orderBy)}`; + } + if (filter && filter.length > 0) { + path += `&filter=${encodeURIComponent(encodeFilters(filter))}`; + } + const result = await this.requestJson(path, {signal}); + return parseQueryResponse(result); + } + + async cancelQuery(uuid: string, signal?: AbortSignal): Promise { + await this.request(`/query_executions/${uuid}:cancel`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({}), + signal, + }); + } + + async listQueryExecutions( + signal?: AbortSignal, + ): Promise> { + const result = await this.requestJson<{ + queryExecutions?: RawQueryExecution[]; + }>('/query_executions', {signal}); + return result.queryExecutions ?? []; + } + + async deleteQueryExecution( + uuid: string, + signal?: AbortSignal, + ): Promise { + await this.request(`/query_executions/${uuid}`, { + method: 'DELETE', + signal, + }); + } + + // ----- Internals ----- + + private async executeAt( + path: string, + query: string, + limit: number, + settings: ReadonlyArray, + signal: AbortSignal | undefined, + ): Promise { + const body = JSON.stringify({ + limit, + perfetto_sql: query, + settings: settings.map((s) => ({ + setting_id: s.settingId, + values: s.values, + category: s.category, + })), + }); + const result = await this.requestJson(path, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body, + signal, + }); + return parseQueryResponse(result); + } + + private async request(path: string, init?: RequestInit): Promise { + let response: Response; + try { + response = await fetch(`${this.endpoint}${path}`, { + credentials: 'include', + mode: 'cors', + ...init, + }); + } catch (e) { + // AbortSignal → DOMException, surface as our typed error. + if (e instanceof DOMException && e.name === 'AbortError') { + throw new QueryCancelledError(); + } + throw e; + } + if (!response.ok) { + const errorText = await response + .text() + .catch(() => 'Failed to read response body'); + // Surface backend `detail` field; fall back to raw body. + let detail = errorText; + try { + const parsed = JSON.parse(errorText); + if (typeof parsed?.detail === 'string') { + detail = parsed.detail; + } + } catch { + // Not JSON — use the body as-is. + } + if (response.status === 404) { + // Extract UUID from /query_executions/{uuid}[:action]; else use path. + const m = path.match(/\/query_executions\/([^/:?#]+)/); + throw new QueryNotFoundError(m ? m[1] : path); + } + if (response.status === 403) { + throw new Error( + `HTTP error! status: ${response.status}, message: ${detail}. ` + + `This might be an authentication issue. Please ensure you ` + + `are logged in with the correct credentials.`, + ); + } + throw new Error( + `HTTP error! status: ${response.status}, message: ${detail}`, + ); + } + return response; + } + + private async requestJson(path: string, init?: RequestInit): Promise { + const response = await this.request(path, init); + return (await response.json()) as T; + } +} + +// Preserves wire strings as-is (no numeric coercion — would corrupt 64-bit +// ids/timestamps). Only translates 'NULL' to JS null. Exported for unit tests. +export function parseQueryResponse( + result: QueryResponsePayload, +): QueryResultPage { + const colNames = result.columnNames; + if ( + colNames === undefined || + colNames === null || + result.rows === undefined || + result.rows === null + ) { + return {rows: [], columns: [], queryUuid: result.queryUuid}; + } + + const columns = colNames.filter((h): h is string => h !== null); + const rows = result.rows.map((row) => { + const out: DataGridRow = {}; + for (let i = 0; i < colNames.length; i++) { + const header = colNames[i]; + if (header === null) continue; + const value = row.values[i]; + out[header] = value === 'NULL' ? null : value; + } + return out; + }); + return { + rows, + columns, + queryUuid: result.queryUuid, + totalFilteredRows: result.totalFilteredRows, + }; +} diff --git a/ui/src/bigtrace/query/bigtrace_query_client_unittest.ts b/ui/src/bigtrace/query/bigtrace_query_client_unittest.ts new file mode 100644 index 00000000000..907e4d6c643 --- /dev/null +++ b/ui/src/bigtrace/query/bigtrace_query_client_unittest.ts @@ -0,0 +1,269 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + BigtraceQueryClient, + QueryNotFoundError, + parseQueryResponse, +} from './bigtrace_query_client'; +import {encodeFilters} from './filter_encoding'; + +describe('parseQueryResponse', () => { + test('returns empty result for null/undefined response', () => { + expect(parseQueryResponse({})).toEqual({rows: [], columns: []}); + expect(parseQueryResponse({columnNames: ['a'], rows: undefined})).toEqual({ + rows: [], + columns: [], + }); + }); + + test('aligns row values with column names', () => { + const result = parseQueryResponse({ + columnNames: ['a', 'b', 'c'], + rows: [{values: ['one', 'two', null]}], + }); + expect(result.columns).toEqual(['a', 'b', 'c']); + expect(result.rows).toEqual([{a: 'one', b: 'two', c: null}]); + }); + + test('preserves numeric strings as strings (no precision loss)', () => { + // The wire is always-strings so 64-bit values round-trip without going + // through JS Number (which would lose precision past 2^53). + const big = '9007199254740993'; + const result = parseQueryResponse({ + columnNames: ['id'], + rows: [{values: [big]}], + }); + expect(result.rows[0].id).toBe(big); + expect(typeof result.rows[0].id).toBe('string'); + }); + + test('preserves empty strings (does not coerce to 0)', () => { + const result = parseQueryResponse({ + columnNames: ['s'], + rows: [{values: ['']}], + }); + expect(result.rows[0].s).toBe(''); + }); + + test("translates the 'NULL' SQL marker into JS null", () => { + const result = parseQueryResponse({ + columnNames: ['x'], + rows: [{values: ['NULL']}], + }); + expect(result.rows[0].x).toBeNull(); + }); + + test('preserves explicit JSON null', () => { + const result = parseQueryResponse({ + columnNames: ['x'], + rows: [{values: [null]}], + }); + expect(result.rows[0].x).toBeNull(); + }); + + test('multiple rows in the right order', () => { + const result = parseQueryResponse({ + columnNames: ['n'], + rows: [{values: ['1']}, {values: ['2']}, {values: ['3']}], + }); + expect(result.rows.map((r) => r.n)).toEqual(['1', '2', '3']); + }); + + test('passes through totalFilteredRows when present', () => { + const result = parseQueryResponse({ + columnNames: ['n'], + rows: [{values: ['1']}], + totalFilteredRows: 42, + }); + expect(result.totalFilteredRows).toBe(42); + }); + + test('totalFilteredRows is undefined for non-fetchResults responses', () => { + const result = parseQueryResponse({ + columnNames: ['n'], + rows: [{values: ['1']}], + }); + expect(result.totalFilteredRows).toBeUndefined(); + }); +}); + +describe('encodeFilters', () => { + test('strings and null pass through unchanged', () => { + const out = encodeFilters([ + {field: 'name', op: 'glob', value: 'ui::*'}, + {field: 'kind', op: 'in', value: ['a', 'b']}, + {field: 'parent_id', op: 'is null'}, + ]); + expect(JSON.parse(out)).toEqual([ + {field: 'name', op: 'glob', value: 'ui::*'}, + {field: 'kind', op: 'in', value: ['a', 'b']}, + {field: 'parent_id', op: 'is null'}, + ]); + }); + + test('coerces non-string primitives so the wire is always-strings', () => { + // Always-strings: numbers, bigints, booleans all coerce losslessly. + // Booleans aren't in `SqlValue`; the encoder handles them anyway. + const out = encodeFilters([ + {field: 'count', op: '>=', value: 10}, + {field: 'dur', op: '>', value: 9223372036854775807n}, + JSON.parse('{"field":"flag","op":"=","value":true}'), + ]); + expect(JSON.parse(out)).toEqual([ + {field: 'count', op: '>=', value: '10'}, + {field: 'dur', op: '>', value: '9223372036854775807'}, + {field: 'flag', op: '=', value: 'true'}, + ]); + }); + + test('coerces every entry of an in-list', () => { + const out = encodeFilters([{field: 'tid', op: 'in', value: [1n, 2, 3n]}]); + expect(JSON.parse(out)).toEqual([ + {field: 'tid', op: 'in', value: ['1', '2', '3']}, + ]); + }); + + test('preserves JSON null distinct from the literal "null" string', () => { + // JSON null must NOT coerce to the string "null" (would match VARCHAR rows). + const out = encodeFilters([{field: 'a', op: '=', value: null}]); + expect(out).toBe('[{"field":"a","op":"=","value":null}]'); + }); + + test('empty array → "[]"', () => { + expect(encodeFilters([])).toBe('[]'); + }); + + test('produces canonical (key-sorted) output for stable equality', () => { + // Construction-order differences must not change the encoded string. + const a = encodeFilters([{field: 'x', op: '=', value: '1'}]); + const b = encodeFilters([JSON.parse('{"value":"1","op":"=","field":"x"}')]); + expect(a).toBe(b); + // And the canonical form is alphabetical. + expect(a).toBe('[{"field":"x","op":"=","value":"1"}]'); + }); +}); + +describe('BigtraceQueryClient.fetchResults URL construction', () => { + // Asserts the URL only; response handling is in parseQueryResponse tests. + const originalFetch = global.fetch; + afterEach(() => { + global.fetch = originalFetch; + }); + + function captureFetch(): jest.Mock { + const fakeResp = { + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify({columnNames: [], rows: []})), + json: () => Promise.resolve({columnNames: [], rows: []}), + }; + const fn = jest.fn().mockResolvedValue(fakeResp); + global.fetch = fn as unknown as typeof fetch; + return fn; + } + + test('omits filter param when none / empty array passed', async () => { + const fetchMock = captureFetch(); + const client = new BigtraceQueryClient('http://example/'); + await client.fetchResults('uid', 50, 0); + expect(fetchMock).toHaveBeenCalledTimes(1); + const url = (fetchMock.mock.calls[0] as unknown[])[0] as string; + expect(url).not.toContain('filter='); + + fetchMock.mockClear(); + await client.fetchResults('uid', 50, 0, undefined, undefined, []); + const url2 = (fetchMock.mock.calls[0] as unknown[])[0] as string; + expect(url2).not.toContain('filter='); + }); + + test('URL-encodes the JSON-encoded filter payload', async () => { + const fetchMock = captureFetch(); + const client = new BigtraceQueryClient('http://example/'); + await client.fetchResults('uid', 50, 0, undefined, undefined, [ + {field: 'name', op: 'glob', value: 'ui::*'}, + ]); + const url = (fetchMock.mock.calls[0] as unknown[])[0] as string; + expect(url).toContain('filter='); + // '*' / ':' / '"' percent-encoded via encodeURIComponent. + const want = encodeURIComponent( + JSON.stringify([{field: 'name', op: 'glob', value: 'ui::*'}]), + ); + expect(url).toContain(`filter=${want}`); + }); + + test('order_by and filter both appear in the URL when set', async () => { + const fetchMock = captureFetch(); + const client = new BigtraceQueryClient('http://example/'); + await client.fetchResults('uid', 50, 0, undefined, 'name desc', [ + {field: 'kind', op: '=', value: 'sched'}, + ]); + const url = (fetchMock.mock.calls[0] as unknown[])[0] as string; + expect(url).toContain('order_by=name%20desc'); + expect(url).toContain('filter='); + }); +}); + +describe('BigtraceQueryClient 404 handling', () => { + // resume-from-history relies on 404 → QueryNotFoundError to drop dead UUIDs. + // Jest runs in Node, so we hand-roll a minimal Response-shaped object. + const originalFetch = global.fetch; + afterEach(() => { + global.fetch = originalFetch; + }); + + function fakeResponse(status: number, body: string): unknown { + return { + ok: status >= 200 && status < 300, + status, + text: () => Promise.resolve(body), + json: () => Promise.resolve(JSON.parse(body)), + }; + } + + function mockStatus(status: number, body: string): void { + global.fetch = jest.fn().mockResolvedValue(fakeResponse(status, body)); + } + + test('getQueryExecution rejects with QueryNotFoundError on 404', async () => { + const uuid = 'abc-123'; + mockStatus(404, JSON.stringify({detail: `Query ${uuid} not found`})); + const client = new BigtraceQueryClient('http://example/'); + await expect(client.getQueryExecution(uuid)).rejects.toBeInstanceOf( + QueryNotFoundError, + ); + }); + + test('extracted UUID matches the request path', async () => { + const uuid = 'abc-123'; + mockStatus(404, JSON.stringify({detail: `Query ${uuid} not found`})); + const client = new BigtraceQueryClient('http://example/'); + let caught: unknown; + try { + await client.getStatus(uuid); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(QueryNotFoundError); + expect((caught as Error).message).toContain(uuid); + }); + + test('non-404 errors are not converted to QueryNotFoundError', async () => { + mockStatus(500, 'boom'); + const client = new BigtraceQueryClient('http://example/'); + await expect(client.getQueryExecution('any')).rejects.not.toBeInstanceOf( + QueryNotFoundError, + ); + }); +}); diff --git a/ui/src/bigtrace/query/filter_encoding.ts b/ui/src/bigtrace/query/filter_encoding.ts new file mode 100644 index 00000000000..15d94f2d26d --- /dev/null +++ b/ui/src/bigtrace/query/filter_encoding.ts @@ -0,0 +1,40 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {Filter} from '../../components/widgets/datagrid/model'; + +// Wire encoder for `:fetch_results?filter=...`, shared by the HTTP client +// and `BigtraceAsyncDataSource` (which compares the encoded string for +// change-detection — drift would silently break equality). +// +// Values are coerced to strings (preserves int64 precision; DuckDB's binder +// coerces back to the column's type). `null` passes through as JSON null. +// Object keys are sorted so equivalent filters hash to the same string. +export function encodeFilters(filters: ReadonlyArray): string { + return JSON.stringify(filters, (_key, value) => { + if (typeof value === 'object' && value !== null) { + if (Array.isArray(value)) return value; + // Sort keys for stable equality across construction order. + const sorted: Record = {}; + for (const k of Object.keys(value).sort()) { + sorted[k] = (value as Record)[k]; + } + return sorted; + } + if (value !== null && typeof value !== 'string') { + return String(value); + } + return value; + }); +} diff --git a/ui/src/bigtrace/query/http_data_source.ts b/ui/src/bigtrace/query/http_data_source.ts deleted file mode 100644 index d0aba9df47c..00000000000 --- a/ui/src/bigtrace/query/http_data_source.ts +++ /dev/null @@ -1,156 +0,0 @@ -// Copyright (C) 2026 The Android Open Source Project -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import {Row as DataGridRow} from '../../trace_processor/query_result'; -import {SettingFilter} from '../settings/settings_types'; - -export class HttpDataSource { - private static readonly DEFAULT_LIMIT = 1000000; - - private endpoint: string; - private baseQuery: string; - private limit: number; - private settings: SettingFilter[]; - private cachedData: DataGridRow[] | null = null; - private fetchPromise: Promise | null = null; - private abortController: AbortController | null = null; - - constructor( - endpoint: string, - baseQuery: string, - limit = HttpDataSource.DEFAULT_LIMIT, - settings: SettingFilter[], - ) { - this.endpoint = endpoint; - this.baseQuery = baseQuery; - this.limit = limit; - this.settings = settings; - } - - private async fetchData(forceRefresh = false): Promise { - if (forceRefresh) { - this.cachedData = null; - this.fetchPromise = null; - } - - if (this.cachedData !== null) { - return this.cachedData; - } - - if (this.fetchPromise !== null) { - return this.fetchPromise; - } - - this.fetchPromise = this.performFetch(); - try { - this.cachedData = await this.fetchPromise; - return this.cachedData; - } finally { - this.fetchPromise = null; - } - } - - private async performFetch(): Promise { - const url = `${this.endpoint}/execute_bigtrace_query`; - - const serializedSettings = this.settings.map((s) => ({ - setting_id: s.settingId, - values: s.values, - category: s.category, - })); - - const data = { - limit: this.limit, - perfetto_sql: this.baseQuery, - settings: serializedSettings, - }; - - this.abortController = new AbortController(); - - try { - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(data), - credentials: 'include', - mode: 'cors', - signal: this.abortController.signal, - }); - - if (!response.ok) { - let errorText = ''; - try { - errorText = await response.text(); - } catch (e) { - errorText = 'Could not read error body'; - } - if (response.status === 403) { - throw new Error( - `HTTP error! status: ${response.status}. This might be an authentication issue. Please make sure you are logged in to the correct Google account. Backend says: ${errorText}`, - ); - } - throw new Error( - `HTTP error! status: ${response.status}, backend says: ${errorText}`, - ); - } - - const result = await response.json(); - - if ( - result.columnNames !== undefined && - result.columnNames !== null && - result.rows !== undefined && - result.rows !== null - ) { - return result.rows.map( - (row: {values: Array}) => { - const rowObject: DataGridRow = {}; - result.columnNames.forEach((header: string, index: number) => { - if (header === null) return; - const value = row.values[index]; - const numValue = Number(value); - rowObject[header] = - value === null || value === 'NULL' || isNaN(numValue) - ? value - : numValue; - }); - return rowObject; - }, - ); - } - - return []; - } catch (error) { - if (error instanceof DOMException && error.name === 'AbortError') { - throw new Error('Query was cancelled.'); - } - throw error; - } - } - - abort(): void { - this.abortController?.abort(); - } - - async query(forceRefresh = false): Promise { - return this.fetchData(forceRefresh); - } - - clearCache(): void { - this.cachedData = null; - this.fetchPromise = null; - } -} diff --git a/ui/src/bigtrace/query/query_history.ts b/ui/src/bigtrace/query/query_history.ts index 9773ca88556..2d8339dcfd1 100644 --- a/ui/src/bigtrace/query/query_history.ts +++ b/ui/src/bigtrace/query/query_history.ts @@ -13,57 +13,336 @@ // limitations under the License. import m from 'mithril'; +import {classNames} from '../../base/classnames'; import {Icons} from '../../base/semantic_icons'; import {Button} from '../../widgets/button'; +import {Intent} from '../../widgets/common'; import {Stack} from '../../widgets/stack'; -import {queryHistoryStorage, QueryHistoryEntry} from './query_history_storage'; +import {queryHistoryStorage} from './query_history_storage'; +import { + formatCompact, + queryStore, + QueryExecution, + statusDisplayLabel, +} from './query_store'; +import {Tabs, TabsTab} from '../../widgets/tabs'; + +import {formatDate} from '../../base/time'; +import {Spinner} from '../../widgets/spinner'; +import {EmptyState} from '../../widgets/empty_state'; +import {showModal} from '../../widgets/modal'; + +// Open-an-existing-history-entry callback. +type OpenQueryFn = ( + query: string, + uuid: string, + materialize: boolean, + forceNew?: boolean, + limit?: number, + startTime?: number, +) => void; interface QueryHistoryComponentAttrs { readonly className?: string; - runQuery: (query: string) => void; - setQuery: (query: string) => void; + openQuery: OpenQueryFn; + readonly refreshSignal?: number; } -export class QueryHistoryComponent - implements m.ClassComponent -{ - view({attrs}: m.CVnode) { - const {runQuery, setQuery, ...rest} = attrs; - const unstarred: HistoryItemAttrs[] = []; - const starred: HistoryItemAttrs[] = []; - for (let i = 0; i < queryHistoryStorage.data.length; i++) { - const entry = queryHistoryStorage.data[i]; - const arr = entry.starred ? starred : unstarred; - arr.push({index: i, entry, runQuery, setQuery}); +// Refresh signal fires before the backend insert; wait out the round-trip. +const HISTORY_REFRESH_DEBOUNCE_MS = 1000; + +const MONTH_NAMES = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +] as const; + +// Sidebar history-row date format: "May 9, 2026, 6:01 PM". +function formatCompactDate(d: Date): string { + const month = MONTH_NAMES[d.getMonth()]; + const day = d.getDate(); + const year = d.getFullYear(); + let h = d.getHours(); + const m12 = h >= 12 ? 'PM' : 'AM'; + h = h % 12 || 12; + const mm = String(d.getMinutes()).padStart(2, '0'); + return `${month} ${day}, ${year}, ${h}:${mm} ${m12}`; +} + +// SQL block clamped to ~4 lines with a fade-out mask; click to expand. +// Used by both the sidebar history row and the delete-confirm modal. +// The sidebar inherits its frame from `.pf-query-history__item pre`; the +// modal uses `standalone: true` for the `--standalone` CSS class. +// Expand state is a Mithril instance field so it survives redraws. +interface ClampedQueryAttrs { + readonly queryText: string; + readonly standalone?: boolean; + readonly onExpand?: () => void; +} + +class ClampedQuery implements m.ClassComponent { + private expanded = false; + + view({attrs}: m.Vnode): m.Children { + const {queryText, standalone, onExpand} = attrs; + if (queryText === '') { + return m( + 'span.pf-query-history__item-query.pf-query-history__item-query--empty', + '(no query text)', + ); } return m( - '.pf-query-history', + 'pre.pf-query-history__item-query', { - ...rest, + className: classNames( + this.expanded && 'pf-query-history__item-query--expanded', + standalone && 'pf-query-history__item-query--standalone', + ), + onclick: () => { + this.expanded = !this.expanded; + if (this.expanded) { + onExpand?.(); + } + }, }, - m( - '.pf-query-history__header', - `Query history (${queryHistoryStorage.data.length} queries)`, - ), - starred.map((a) => m(HistoryItemComponent, a)), - unstarred.map((a) => m(HistoryItemComponent, a)), + queryText, + ); + } +} + +// UUIDs whose full SQL has already been fetched via the per-uuid endpoint. +const fetchedFullSql = new Set(); + +// Returns an onExpand callback that fetches the full SQL on first expand, +// or undefined if already fetched / no uuid. +function makeFullSqlExpander( + uuid: string | undefined, + currentText: string, +): (() => void) | undefined { + if (!uuid || fetchedFullSql.has(uuid)) return undefined; + return () => { + fetchedFullSql.add(uuid); + void queryHistoryStorage + .fetchFullSql(uuid) + .then((full) => { + if (full && full !== currentText) { + queryStore.update(uuid, {perfettoSql: full}); + m.redraw(); + } + }) + .catch((e) => { + fetchedFullSql.delete(uuid); + console.error('Failed to fetch full SQL:', e); + }); + }; +} + +// Module-level: survives sidebar toggles so we don't re-fetch on every show. +class HistoryStore { + history: QueryExecution[] = []; + isLoading = true; + error: string | null = null; + // Default to Ephemeral, matching the Persistent toggle's default-off. + activeTabKey = 'standard'; + private lastRefreshSignal = -1; + private debounceTimer?: number; + private hasEverLoaded = false; + + // No-op if signal unchanged; immediate fetch on first call; + // debounced on subsequent bumps. + requestRefresh(refreshSignal: number): void { + if (refreshSignal === this.lastRefreshSignal) return; + this.lastRefreshSignal = refreshSignal; + if (!this.hasEverLoaded) { + this.load(); + return; + } + if (this.debounceTimer !== undefined) { + window.clearTimeout(this.debounceTimer); + } + this.debounceTimer = window.setTimeout( + () => this.load(), + HISTORY_REFRESH_DEBOUNCE_MS, ); } + + // Bypass signal/debounce: explicit Refresh button + post-Delete. + refreshNow(): void { + if (this.debounceTimer !== undefined) { + window.clearTimeout(this.debounceTimer); + this.debounceTimer = undefined; + } + this.load(); + } + + private async load(): Promise { + this.hasEverLoaded = true; + this.isLoading = true; + this.error = null; + m.redraw(); + try { + const list = await queryHistoryStorage.getAllHistory(); + this.history = list.map((entry) => + queryStore.getOrCreate(entry.uuid, entry), + ); + } catch (e) { + this.error = e instanceof Error ? e.message : String(e); + } finally { + this.isLoading = false; + m.redraw(); + } + } } -interface HistoryItemAttrs { - index: number; - entry: QueryHistoryEntry; - runQuery: (query: string) => void; - setQuery: (query: string) => void; +const historyStore = new HistoryStore(); + +// Point the History sidebar at the tab matching the impending run. +export function setHistoryActiveTab(materialize: boolean): void { + const key = materialize ? 'materialized' : 'standard'; + if (historyStore.activeTabKey === key) return; + historyStore.activeTabKey = key; + m.redraw(); } -class HistoryItemComponent implements m.ClassComponent { - view(vnode: m.Vnode): m.Child { - const query = vnode.attrs.entry.query; +export class QueryHistoryComponent + implements m.ClassComponent +{ + oninit(vnode: m.CVnode) { + historyStore.requestRefresh(vnode.attrs.refreshSignal ?? 0); + } + + onbeforeupdate(vnode: m.CVnode) { + historyStore.requestRefresh(vnode.attrs.refreshSignal ?? 0); + return true; + } + + view({attrs}: m.CVnode) { + const {openQuery, ...rest} = attrs; + + if (historyStore.isLoading && historyStore.history.length === 0) { + return m( + EmptyState, + { + title: 'Loading history...', + icon: 'hourglass_empty', + fillHeight: true, + }, + m(Spinner), + ); + } + + if (historyStore.error) { + return m(EmptyState, { + title: `Failed to load history: ${historyStore.error}`, + icon: 'error', + fillHeight: true, + }); + } + + const standardQueries = historyStore.history.filter((h) => !h.materialized); + const materializedQueries = historyStore.history.filter( + (h) => h.materialized, + ); + + // Span-wrap titles so hover tooltips explain "Ephemeral"/"Persistent". + const tabs: TabsTab[] = [ + { + key: 'standard', + title: m( + 'span', + { + title: + 'Queries run with Persistent OFF — results were shown ' + + 'inline at run time and not saved. Reopen here to see the ' + + 'SQL again or rerun.', + }, + `Ephemeral (${standardQueries.length})`, + ), + content: this.renderHistoryList(standardQueries, false, openQuery), + }, + { + key: 'materialized', + title: m( + 'span', + { + title: + 'Queries run with Persistent ON — results saved to a ' + + 'temporary backend table you can reopen and browse here.', + }, + `Persistent (${materializedQueries.length})`, + ), + content: this.renderHistoryList(materializedQueries, true, openQuery), + }, + ]; + return m( - '.pf-query-history__item', - m( + '.pf-query-history', + rest, + m(Tabs, { + tabs: tabs, + activeTabKey: historyStore.activeTabKey, + onTabChange: (key) => { + historyStore.activeTabKey = key; + m.redraw(); + }, + rightContent: m(Button, { + icon: 'refresh', + title: 'Refresh history', + onclick: () => historyStore.refreshNow(), + }), + }), + ); + } + + private renderHistoryList( + queries: QueryExecution[], + isMaterialized: boolean, + openQuery?: OpenQueryFn, + ): m.Children { + if (queries.length === 0) { + return m( + EmptyState, + { + title: isMaterialized + ? 'No persistent queries yet' + : 'No ephemeral queries yet', + icon: 'search', + fillHeight: true, + }, + m( + 'div', + {style: {marginTop: '8px', opacity: 0.7}}, + isMaterialized + ? 'Run a query with Persistent on to see it here.' + : 'Run a query with Persistent off to see it here.', + ), + ); + } + + return queries.map((entry, index) => { + const queryText = entry.perfettoSql || ''; + const uuid = entry.uuid; + const startTime = entry.startTime; + const rows = entry.processedRows; + const link = entry.tableLink; + const dateObj = startTime !== undefined ? new Date(startTime) : null; + // Compact for the narrow sidebar; hover reveals the full UTC timestamp. + const localString = dateObj ? formatCompactDate(dateObj) : 'N/A'; + const utcString = + startTime !== undefined + ? formatDate(new Date(startTime), {printTimezone: false}) + : 'N/A'; + + const buttonsRow = m( Stack, { className: 'pf-query-history__item-buttons', @@ -72,38 +351,132 @@ class HistoryItemComponent implements m.ClassComponent { [ m(Button, { onclick: () => { - queryHistoryStorage.setStarred( - vnode.attrs.index, - !vnode.attrs.entry.starred, - ); + if (openQuery && uuid) { + openQuery( + queryText, + uuid, + isMaterialized, + false, + entry.limit, + startTime, + ); + } }, - icon: Icons.Star, - iconFilled: vnode.attrs.entry.starred, - }), - m(Button, { - onclick: () => vnode.attrs.setQuery(query), - icon: Icons.Edit, - }), - m(Button, { - onclick: () => vnode.attrs.runQuery(query), - icon: Icons.Play, + icon: Icons.ChangeTab, + title: 'Open', }), + m(Button, { - onclick: () => { - queryHistoryStorage.remove(vnode.attrs.index); + onclick: async () => { + if (!uuid) return; + let confirmed = false; + await showModal({ + title: 'Delete query from history?', + content: () => + m('div', [ + startTime !== undefined && + m( + 'div', + { + style: {marginBottom: '8px', opacity: '0.7'}, + title: `UTC: ${utcString}`, + }, + localString, + ), + m(ClampedQuery, { + queryText: entry.perfettoSql || '', + standalone: true, + onExpand: makeFullSqlExpander( + uuid, + entry.perfettoSql || '', + ), + }), + ]), + buttons: [ + {text: 'Cancel'}, + { + text: 'Delete', + primary: true, + action: () => { + confirmed = true; + }, + }, + ], + }); + if (!confirmed) return; + await queryHistoryStorage.deleteQuery(uuid); + historyStore.refreshNow(); }, icon: Icons.Delete, + // Red hover so destructive intent reads before click. + intent: Intent.Danger, + title: 'Delete query', }), ], - ), - m( - 'pre', - { - onclick: () => vnode.attrs.setQuery(query), - ondblclick: () => vnode.attrs.runQuery(query), - }, - query, - ), - ); + ); + + return m( + '.pf-query-history__item', + {key: `${uuid}-${index}`}, + m('.pf-query-history__item-meta', [ + buttonsRow, + m('div.pf-query-history__item-header', [ + m( + 'span.pf-query-history__item-status', + { + class: `pf-status-${entry.status.toLowerCase().replace(/_/g, '-')}`, + }, + statusDisplayLabel(entry.status), + ), + m( + 'span.pf-query-history__item-date', + {title: `UTC: ${utcString}`}, + localString, + ), + ]), + ]), + // Separate section (materialized only): a banded strip between the + // meta header and the SQL pre, with its own background and borders so + // it reads as a distinct section, not a row inside the header card. + isMaterialized && + m( + 'div.pf-query-history__item-details', + { + className: + rows === 0 + ? 'pf-query-history__item-details--empty' + : undefined, + }, + m( + 'a.pf-query-history__item-table-link', + { + class: + rows === 0 || link === undefined || link === '' + ? 'pf-query-history__item-table-link--disabled' + : 'pf-query-history__item-table-link--active', + href: link || '#', + target: '_blank', + title: + rows === 0 + ? 'No table created for empty results' + : entry.tableName || 'View Table', + }, + entry.tableName || '—', + ), + m( + 'span.pf-query-history__item-rows-value', + `${formatCompact(rows)} ${rows === 1 ? 'row' : 'rows'}`, + ), + ), + // Sidebar uses the .pf-query-history__item pre rule for the + // monospace look; modal callers opt in via `{standalone: true}`. + // /query_executions clips perfettoSql; first expand fetches the + // full text via the per-uuid endpoint. + m(ClampedQuery, { + queryText, + onExpand: makeFullSqlExpander(uuid, queryText), + }), + ); + }); } } diff --git a/ui/src/bigtrace/query/query_history_storage.ts b/ui/src/bigtrace/query/query_history_storage.ts index 6c8cfdaa6ce..bfdbd8e43f9 100644 --- a/ui/src/bigtrace/query/query_history_storage.ts +++ b/ui/src/bigtrace/query/query_history_storage.ts @@ -12,110 +12,96 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {z} from 'zod'; - -import {LocalStorage} from '../../core/local_storage'; -import {BIGTRACE_SETTINGS_STORAGE_KEY} from '../settings/settings_storage'; - -const QUERY_HISTORY_ENTRY_SCHEMA = z.object({ - query: z.string(), - timestamp: z.number(), - starred: z.boolean().default(false), -}); - -export type QueryHistoryEntry = z.infer; - -const QUERY_HISTORY_SCHEMA = z.array(QUERY_HISTORY_ENTRY_SCHEMA); +import {endpointStorage} from '../settings/endpoint_storage'; +import {BigtraceQueryClient} from './bigtrace_query_client'; +import {QueryExecution} from './query_store'; + +// Wire shape from /query_executions[*]; field list in CLAUDE.md. +// Times are ISO-8601; `readonly` marks the wire boundary. +export interface RawQueryExecution { + readonly queryUuid?: string; + readonly status?: string; + readonly startTime?: string; + readonly endTime?: string; + readonly processedRows?: number; + readonly processedTraces?: number; + readonly totalTraces?: number; + readonly error?: string; + readonly errorMessage?: string; + readonly perfettoSql?: string; + readonly limit?: number; + readonly materialized?: boolean; + readonly tableName?: string; + readonly tableLink?: string; +} -export type QueryHistory = z.infer; +// ISO-8601 → epoch ms; invalid/missing → undefined (never NaN). +export function isoToEpochMs(iso: string | undefined): number | undefined { + if (iso === undefined) return undefined; + const ms = new Date(iso).getTime(); + return Number.isFinite(ms) ? ms : undefined; +} export class QueryHistoryStorage { - private _data: QueryHistory; - maxItems = 50; - private storage: LocalStorage; - - constructor() { - this.storage = new LocalStorage(BIGTRACE_SETTINGS_STORAGE_KEY); - this._data = this.load(); + // Fresh client per call so endpoint changes apply without restart. + private client(): BigtraceQueryClient { + const setting = endpointStorage.get('bigtraceEndpoint'); + const endpoint = setting ? (setting.get() as string) : ''; + return new BigtraceQueryClient(endpoint); } - get data(): QueryHistory { - return this._data; + async getAllHistory(): Promise { + // No endpoint → empty, so the sidebar shows its empty state, not a 404. + const setting = endpointStorage.get('bigtraceEndpoint'); + const endpoint = setting ? (setting.get() as string) : ''; + if (endpoint.trim() === '') return []; + const list = await this.client().listQueryExecutions(); + const mapped = list.map(toQueryExecution); + mapped.sort((a, b) => (b.startTime ?? 0) - (a.startTime ?? 0)); + return mapped; } - saveQuery(query: string): void { - // If query already exists, move it to the front preserving starred status - const existingIndex = this._data.findIndex( - (entry) => entry.query === query, + async getMaterializedHistory(): Promise { + return (await this.getAllHistory()).filter( + (item) => item.materialized === true, ); - if (existingIndex !== -1) { - const existing = this._data[existingIndex]; - this._data.splice(existingIndex, 1); - this._data.unshift({ - query, - timestamp: Date.now(), - starred: existing.starred, - }); - this.save(); - return; - } - - // Count unstarred items and find the oldest one - let lastUnstarredIndex = -1; - let countUnstarred = 0; - for (let i = 0; i < this._data.length; i++) { - if (!this._data[i].starred) { - countUnstarred++; - lastUnstarredIndex = i; - } - } - - // Remove oldest unstarred if at capacity - if (countUnstarred >= this.maxItems && lastUnstarredIndex !== -1) { - this._data.splice(lastUnstarredIndex, 1); - } - - this._data.unshift({ - query, - timestamp: Date.now(), - starred: false, - }); - - this.save(); } - setStarred(index: number, starred: boolean): void { - if (index >= 0 && index < this._data.length) { - this._data[index].starred = starred; - this.save(); - } + async getNonMaterializedHistory(): Promise { + return (await this.getAllHistory()).filter( + (item) => item.materialized !== true, + ); } - remove(index: number): void { - if (index >= 0 && index < this._data.length) { - this._data.splice(index, 1); - this.save(); - } + async deleteQuery(uuid: string): Promise { + await this.client().deleteQueryExecution(uuid); } - private load(): QueryHistory { - const value = this.storage.load()['queries']; - if (value === undefined) { - return []; - } - const res = QUERY_HISTORY_SCHEMA.safeParse(value); - return res.success ? res.data : []; + // The listing endpoint clips perfettoSql; the per-uuid endpoint returns the + // full text. Use this on demand (e.g. when the user expands a clamped SQL + // preview in the history sidebar). + async fetchFullSql(uuid: string): Promise { + const raw = await this.client().getQueryExecution(uuid); + return raw.perfettoSql; } +} - private save(): void { - try { - const data = this.storage.load(); - data['queries'] = this._data; - this.storage.save(data); - } catch (e) { - console.warn('Failed to save query history to localStorage:', e); - } - } +function toQueryExecution(raw: RawQueryExecution): QueryExecution { + return { + uuid: raw.queryUuid ?? '', + status: raw.status ?? 'UNKNOWN', + startTime: isoToEpochMs(raw.startTime), + endTime: isoToEpochMs(raw.endTime), + processedRows: raw.processedRows ?? 0, + processedTraces: raw.processedTraces ?? 0, + totalTraces: raw.totalTraces ?? 0, + error: raw.error ?? raw.errorMessage, + perfettoSql: raw.perfettoSql, + limit: raw.limit, + materialized: raw.materialized, + tableName: raw.tableName, + tableLink: raw.tableLink, + }; } export const queryHistoryStorage = new QueryHistoryStorage(); diff --git a/ui/src/bigtrace/query/query_history_storage_unittest.ts b/ui/src/bigtrace/query/query_history_storage_unittest.ts new file mode 100644 index 00000000000..f177ed7e02f --- /dev/null +++ b/ui/src/bigtrace/query/query_history_storage_unittest.ts @@ -0,0 +1,39 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {isoToEpochMs} from './query_history_storage'; + +describe('isoToEpochMs', () => { + test('parses a valid ISO-8601 timestamp', () => { + const ms = isoToEpochMs('2026-01-02T03:04:05.000Z'); + expect(ms).toBe(Date.UTC(2026, 0, 2, 3, 4, 5)); + }); + + test('returns undefined for undefined input', () => { + expect(isoToEpochMs(undefined)).toBeUndefined(); + }); + + test('returns undefined for an unparseable string', () => { + expect(isoToEpochMs('not-a-date')).toBeUndefined(); + }); + + test('returns undefined for a digit-only string (regression)', () => { + // Regression: digit strings must not silently parse (Date(digits) → NaN). + expect(isoToEpochMs('1730000000000')).toBeUndefined(); + }); + + test('returns undefined for an empty string', () => { + expect(isoToEpochMs('')).toBeUndefined(); + }); +}); diff --git a/ui/src/bigtrace/query/query_runner.ts b/ui/src/bigtrace/query/query_runner.ts new file mode 100644 index 00000000000..27177869a4e --- /dev/null +++ b/ui/src/bigtrace/query/query_runner.ts @@ -0,0 +1,510 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import m from 'mithril'; +import {InMemoryDataSource} from '../../components/widgets/datagrid/in_memory_data_source'; +import {bigTraceSettingsStorage} from '../settings/bigtrace_settings_storage'; +import {endpointStorage} from '../settings/endpoint_storage'; +import {SettingFilter} from '../settings/settings_types'; +import {BigtraceAsyncDataSource} from './bigtrace_async_data_source'; +import { + BigtraceQueryClient, + QueryCancelledError, + QueryNotFoundError, +} from './bigtrace_query_client'; +import {forwardAbort} from './abort_utils'; +import {isoToEpochMs, RawQueryExecution} from './query_history_storage'; +import {queryStore, TERMINAL_STATUSES} from './query_store'; +import {makeQueryResponse} from '../pages/query_tabs_state'; +import type {BigTraceEditorTab} from '../pages/query_tabs_state'; + +const POLL_INTERVAL_MS = 3000; +const POLL_RETRY_MS = 1000; + +interface QueryRunnerCallbacks { + // History panel should refresh (start / finish / cancel). + readonly onHistoryChanged: () => void; + // Mithril redraw hook; tests pass a no-op. + readonly redraw?: () => void; + // Persist tab list when a tab gains a queryUuid mid-flight. + readonly markDirty?: () => void; +} + +// One instance per QueryPage; owns dispatch / polling / cancel for each tab. +export class QueryRunner { + constructor(private readonly cb: QueryRunnerCallbacks) {} + + // Run `query` on `tab`. Aborts any in-flight query on the tab first. + async run(tab: BigTraceEditorTab, query: string): Promise { + if (!query) return; + + // Abort any in-flight query on this tab. + tab.activeRequest?.abort(); + + tab.isLoading = true; + tab.queryResult = undefined; + tab.lastProcessedRows = 0; + tab.clientStartTime = Date.now(); + this.cb.markDirty?.(); + this.redraw(); + + this.cb.onHistoryChanged(); + const endpointSetting = endpointStorage.get('bigtraceEndpoint'); + const endpoint = endpointSetting ? (endpointSetting.get() as string) : ''; + + // Empty endpoint resolves to a 404 against the UI server; bail clearly. + if (endpoint.trim() === '') { + tab.queryResult = makeQueryResponse(query, { + error: 'Set the BigTrace Endpoint in Settings before running queries.', + }); + // renderResultsPanel needs both queryResult and dataSource for the banner. + tab.dataSource = new InMemoryDataSource([]); + tab.isLoading = false; + this.redraw(); + return; + } + + await bigTraceSettingsStorage.loadSettings(); + + const settings = bigTraceSettingsStorage.buildSettingFilters(); + tab.querySettings = settings; + + const queryClient = new BigtraceQueryClient(endpoint); + tab.queryClient = queryClient; + // Per-request controller; tab.lifecycle forwards into it on close. + const requestController = new AbortController(); + const cancelForward = forwardAbort(tab.lifecycle.signal, requestController); + tab.activeRequest = requestController; + const wallStartMs = performance.now(); + + try { + if (tab.materialize) { + await this.runAsync( + tab, + query, + queryClient, + settings, + requestController.signal, + wallStartMs, + ); + } else { + await this.runSync( + tab, + query, + queryClient, + settings, + requestController.signal, + wallStartMs, + ); + } + } catch (e) { + // User-initiated cancellation isn't an error worth surfacing. + if (e instanceof QueryCancelledError) { + tab.isLoading = false; + this.redraw(); + return; + } + tab.queryResult = makeQueryResponse(query, { + error: e instanceof Error ? e.message : String(e), + durationMs: performance.now() - wallStartMs, + }); + tab.isLoading = false; + } finally { + cancelForward(); + tab.activeRequest = undefined; + } + + if (tab.queryResult !== undefined && !tab.materialize) { + tab.dataSource = new InMemoryDataSource(tab.queryResult.rows); + tab.isLoading = false; + // Sync skips finalizePolling; flip the sidebar from IN_PROGRESS → SUCCESS here. + this.cb.onHistoryChanged(); + } + this.redraw(); + } + + // Aborts the local request and, for materialized queries, the backend too. + async cancel(tab: BigTraceEditorTab): Promise { + this.redraw(); // Update UI to show cancelling state. + + const queryUuid = tab.queryUuid; + tab.activeRequest?.abort(); + if (tab.pollInterval !== undefined) { + window.clearTimeout(tab.pollInterval); + tab.pollInterval = undefined; + } + + // Flip the pill immediately; next poll overwrites with server truth. + if (tab.execution && tab.execution.status === 'IN_PROGRESS') { + tab.execution.status = 'CANCELLED'; + tab.execution.endTime = Date.now(); + } + + if (tab.materialize && queryUuid && tab.queryClient) { + try { + await tab.queryClient.cancelQuery(queryUuid); + } catch (e) { + console.error(`Failed to cancel query ${queryUuid} on backend:`, e); + } + } + + tab.activeRequest = undefined; + tab.isLoading = false; + this.cb.onHistoryChanged(); + this.redraw(); + } + + // Pick up a tab whose `queryUuid` was set externally (e.g. history click). + async resumeFromHistory( + tab: BigTraceEditorTab, + fallbackQuery: string, + ): Promise { + if (!tab.queryUuid) return; + const endpointSetting = endpointStorage.get('bigtraceEndpoint'); + const endpoint = endpointSetting ? (endpointSetting.get() as string) : ''; + const queryClient = new BigtraceQueryClient(endpoint); + tab.queryClient = queryClient; + + if ( + !tab.dataSource || + (tab.materialize && !(tab.dataSource instanceof BigtraceAsyncDataSource)) + ) { + tab.dataSource = tab.materialize + ? new BigtraceAsyncDataSource( + tab.queryUuid, + queryClient, + () => tab.execution?.processedRows ?? 0, + tab.lifecycle.signal, + ) + : new InMemoryDataSource([]); + } + + let details: RawQueryExecution; + try { + details = await queryClient.getQueryExecution( + tab.queryUuid, + tab.lifecycle.signal, + ); + } catch (e) { + if (e instanceof QueryNotFoundError) { + // Dead UUID (entry deleted / backend restarted); otherwise polls forever. + this.dropStaleQueryUuid(tab); + return; + } + console.error('Failed to fetch query details on open:', e); + this.startPolling(tab); + return; + } + + if (!tab.execution) return; + const exec = tab.execution; + exec.status = details.status ?? 'N/A'; + exec.processedRows = details.processedRows ?? 0; + exec.processedTraces = details.processedTraces ?? 0; + exec.totalTraces = details.totalTraces ?? 0; + if (details.limit !== undefined) tab.limit = details.limit; + tab.editorText = details.perfettoSql || fallbackQuery; + const startMs = isoToEpochMs(details.startTime); + if (startMs !== undefined) exec.startTime = startMs; + + const isTerminal = TERMINAL_STATUSES.has(exec.status); + if (isTerminal) { + const endMs = isoToEpochMs(details.endTime); + if (endMs !== undefined) exec.endTime = endMs; + } + tab.isLoading = !isTerminal; + + const durationMs = + exec.endTime !== undefined && exec.startTime !== undefined + ? exec.endTime - exec.startTime + : 0; + + if (!tab.queryResult) { + tab.queryResult = makeQueryResponse(tab.editorText, { + totalRowCount: exec.processedRows, + durationMs, + statementWithOutputCount: 1, + }); + } else { + // Async only: sync's processedRows is 0 server-side. + if (tab.materialize) { + tab.queryResult.totalRowCount = exec.processedRows; + } + tab.queryResult.lastStatementSql = tab.editorText; + tab.queryResult.query = tab.editorText; + } + + if (!isTerminal) { + this.startPolling(tab); + } else if ( + (exec.status === 'SUCCESS' || exec.status === 'CANCELLED') && + tab.dataSource instanceof BigtraceAsyncDataSource && + // No table → skip the doomed :fetch_results round-trip. + (details.tableName ?? '') !== '' && + exec.processedRows > 0 + ) { + await tab.dataSource.ensureResultsLoaded(); + } else if (exec.status === 'FAILED') { + tab.queryResult.error = + details.errorMessage ?? + 'Query failed without a specific error message.'; + } + this.redraw(); + } + + // Poll an already-dispatched async query (tab restore, post-executeAsync). + startPolling(tab: BigTraceEditorTab): void { + if (!tab.queryUuid) return; + + // Bump generation so any prior poll self-terminates on next await. + const generation = ++tab.pollGeneration; + + const poll = async () => { + if (tab.pollGeneration !== generation) return; + if (!tab.queryUuid || !tab.isLoading) return; + + try { + const status = await tab.queryClient?.getStatus( + tab.queryUuid, + tab.lifecycle.signal, + ); + // Re-check: tab may have been cancelled / superseded during the await. + if (tab.pollGeneration !== generation || !tab.isLoading) return; + + if (status !== undefined && status !== null) { + this.applyStatus(tab, status); + await this.maybeAutoFetchProgress(tab); + } + + const isTerminal = + status !== undefined && + status.status !== undefined && + TERMINAL_STATUSES.has(status.status); + if (isTerminal) { + await this.finalizePolling(tab, status!); + } else if (tab.pollInterval !== undefined) { + tab.isLoading = true; + tab.pollInterval = window.setTimeout(poll, POLL_INTERVAL_MS); + } + this.redraw(); + } catch (e) { + if (e instanceof QueryNotFoundError) { + this.dropStaleQueryUuid(tab); + return; + } + console.error('Poll failed:', e); + if (tab.pollInterval !== undefined) { + tab.pollInterval = window.setTimeout(poll, POLL_RETRY_MS); + } + this.redraw(); + } + }; + + if (tab.pollInterval !== undefined) { + window.clearTimeout(tab.pollInterval); + } + tab.pollInterval = window.setTimeout(poll, 0); + } + + // Strip dead async metadata but keep the saved SQL for re-run. + private dropStaleQueryUuid(tab: BigTraceEditorTab): void { + if (tab.pollInterval !== undefined) { + window.clearTimeout(tab.pollInterval); + tab.pollInterval = undefined; + } + tab.pollGeneration++; + tab.queryUuid = undefined; + tab.execution = undefined; + tab.dataSource = undefined; + tab.queryResult = undefined; + tab.isLoading = false; + tab.lastProcessedRows = 0; + this.redraw(); + } + + // ----- Internals ----- + + private redraw(): void { + (this.cb.redraw ?? m.redraw)(); + } + + private async runAsync( + tab: BigTraceEditorTab, + query: string, + client: BigtraceQueryClient, + settings: ReadonlyArray, + signal: AbortSignal, + wallStartMs: number, + ): Promise { + const data = await client.executeAsync(query, tab.limit, settings, signal); + if (data.queryUuid === undefined || data.queryUuid === '') { + throw new Error('Backend did not return a queryUuid for async execute'); + } + tab.queryUuid = data.queryUuid; + // Always assign — otherwise the pill stays UNKNOWN after FAILED/SUCCESS. + // Seed with the SQL we just submitted (not the prior tab.execution which + // may carry a stale perfettoSql from a previously-opened query — that + // would surface as the wrong SQL in the new history entry). + tab.execution = queryStore.getOrCreate(tab.queryUuid, { + perfettoSql: query, + }); + + // Best-effort fetch for a precise server start_time. + try { + const details = await client.getQueryExecution( + tab.queryUuid, + tab.lifecycle.signal, + ); + const serverStartMs = isoToEpochMs(details?.startTime); + if (serverStartMs !== undefined) { + queryStore.update(tab.queryUuid, {startTime: serverStartMs}); + } + } catch (e) { + console.error('Failed to fetch query details after executeAsync:', e); + } + + this.startPolling(tab); + tab.dataSource = new BigtraceAsyncDataSource( + tab.queryUuid, + client, + () => tab.execution?.processedRows ?? 0, + tab.lifecycle.signal, + ); + tab.queryResult = makeQueryResponse(query, { + durationMs: performance.now() - wallStartMs, + }); + } + + private async runSync( + tab: BigTraceEditorTab, + query: string, + client: BigtraceQueryClient, + settings: ReadonlyArray, + signal: AbortSignal, + wallStartMs: number, + ): Promise { + const result = await client.executeSync(query, tab.limit, settings, signal); + if (result.queryUuid === undefined || result.queryUuid === '') { + throw new Error('Backend did not return a queryUuid for sync execute'); + } + tab.queryUuid = result.queryUuid; + // See runAsync: seed with the SQL we just submitted, not the prior + // tab.execution (which may carry stale perfettoSql from an earlier + // opened-from-history query). + tab.execution = queryStore.getOrCreate(tab.queryUuid, { + perfettoSql: query, + }); + tab.queryResult = makeQueryResponse(query, { + rows: [...result.rows], + columns: [...result.columns], + totalRowCount: result.rows.length, + durationMs: performance.now() - wallStartMs, + statementWithOutputCount: 1, + }); + queryStore.update(tab.queryUuid, { + processedRows: result.rows.length, + }); + tab.isLoading = false; + } + + private applyStatus(tab: BigTraceEditorTab, status: RawQueryExecution): void { + if (!tab.queryUuid) return; + queryStore.update(tab.queryUuid, { + processedRows: status.processedRows ?? 0, + processedTraces: status.processedTraces ?? 0, + totalTraces: status.totalTraces ?? 0, + status: status.status ?? 'N/A', + }); + this.redraw(); + } + + private async maybeAutoFetchProgress(tab: BigTraceEditorTab): Promise { + const isTerminal = + tab.execution?.status !== undefined && + TERMINAL_STATUSES.has(tab.execution.status); + if (isTerminal) return; + + const processedRows = tab.execution?.processedRows ?? 0; + if (processedRows <= tab.lastProcessedRows) return; + + if (tab.dataSource instanceof BigtraceAsyncDataSource) { + await tab.dataSource.refresh(); + tab.lastProcessedRows = processedRows; + } + } + + private async finalizePolling( + tab: BigTraceEditorTab, + status: RawQueryExecution, + ): Promise { + tab.pollInterval = undefined; + + // Fall back to the local clock if the backend didn't report endTime. + if ( + tab.execution !== undefined && + tab.execution.endTime === undefined && + tab.queryUuid + ) { + queryStore.update(tab.queryUuid, {endTime: Date.now()}); + } + + // Only refresh history if the query was actively running in the UI. + if (tab.isLoading) { + this.cb.onHistoryChanged(); + } + tab.isLoading = false; + + const isFailed = status.status === 'FAILED'; + const isSuccess = status.status === 'SUCCESS'; + + if (isFailed) { + const startMs = tab.execution?.startTime; + const endMs = tab.execution?.endTime; + tab.queryResult = makeQueryResponse(tab.editorText, { + error: 'Fetching error details...', + durationMs: + startMs !== undefined && endMs !== undefined ? endMs - startMs : 0, + }); + this.redraw(); + } + + // Fetch full execution details for timing + error message. + void tab.queryClient + ?.getQueryExecution(tab.queryUuid!, tab.lifecycle.signal) + .then((details: RawQueryExecution) => { + const endMs = isoToEpochMs(details.endTime); + if (endMs !== undefined && tab.queryUuid) { + queryStore.update(tab.queryUuid, {endTime: endMs}); + } + if (isFailed && tab.queryResult !== undefined) { + tab.queryResult.error = details.errorMessage || 'Query failed'; + this.redraw(); + } + }) + .catch((e: unknown) => { + console.error('Failed to fetch query execution details:', e); + if (isFailed && tab.queryResult !== undefined) { + tab.queryResult.error = `Failed to fetch error details: ${e instanceof Error ? e.message : String(e)}`; + this.redraw(); + } + }); + + // maybeAutoFetchProgress bails on terminal; without this, restored + // SUCCESS tabs stay on "Loading schema…". + if (isSuccess && tab.dataSource instanceof BigtraceAsyncDataSource) { + tab.dataSource.refresh(); + tab.lastProcessedRows = tab.execution?.processedRows ?? 0; + } + } +} diff --git a/ui/src/bigtrace/query/query_store.ts b/ui/src/bigtrace/query/query_store.ts new file mode 100644 index 00000000000..5f7ff36951b --- /dev/null +++ b/ui/src/bigtrace/query/query_store.ts @@ -0,0 +1,173 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Statuses after which polling stops. +export const TERMINAL_STATUSES: ReadonlySet = new Set([ + 'SUCCESS', + 'FAILED', + 'CANCELLED', +]); + +// UI-display label for a wire status. Backend uses IN_PROGRESS; the UI shows +// "Running" (shorter, no underscore). Transient UNKNOWN reads as "Starting". +export function statusDisplayLabel(status: string): string { + if (status === 'IN_PROGRESS') return 'Running'; + if (status === 'UNKNOWN') return 'Starting'; + const s = status.replace(/_/g, ' '); + return s.charAt(0) + s.slice(1).toLowerCase(); +} + +// Compact integer format (1.2K, 3.4M, 1.5B) for cramped UI spots — status bar, +// history sidebar. Precise value belongs in the surrounding tooltip. When +// rounding loses precision the result is prefixed "~" (e.g. 3,383,384 → +// "~3.4M") so users know to consult the tooltip. +const COMPACT_FORMATTER = new Intl.NumberFormat('en', { + notation: 'compact', + maximumFractionDigits: 1, +}); +const COMPACT_SUFFIX_MULTIPLIER: Readonly> = { + '': 1, + 'K': 1e3, + 'M': 1e6, + 'B': 1e9, + 'T': 1e12, +}; +export function formatCompact(n: number): string { + const compact = COMPACT_FORMATTER.format(n); + // Reconstruct the numeric value implied by the compact form and check + // whether it matches the input — if not, the compact form is rounded. + let numericPart = ''; + let suffix = ''; + for (const p of COMPACT_FORMATTER.formatToParts(n)) { + if (p.type === 'compact') { + suffix = p.value; + } else if ( + p.type === 'integer' || + p.type === 'decimal' || + p.type === 'fraction' || + p.type === 'minusSign' + ) { + numericPart += p.value; + } + } + const multiplier = COMPACT_SUFFIX_MULTIPLIER[suffix] ?? 1; + // Inputs are integer counts; round away float noise (3.4 * 1e6 ≠ 3.4e6). + const reconstructed = Math.round(parseFloat(numericPart) * multiplier); + return reconstructed === n ? compact : `~${compact}`; +} + +// UI-side execution record; times are epoch ms (ISO→epoch happens at the +// wire boundary in QueryHistoryStorage). +export interface QueryExecution { + uuid: string; + status: string; + startTime?: number; + endTime?: number; + processedRows: number; + processedTraces: number; + totalTraces: number; + error?: string; + perfettoSql?: string; + limit?: number; + materialized?: boolean; + tableName?: string; + tableLink?: string; +} + +// Merges live polling (`getStatus`) with bulk history (`listQueryExecutions`). +// Without the rule below, a history refresh during IN_PROGRESS would rewind +// processedRows: keep live progress unless incoming is terminal or higher. +export class QueryStore { + private queries = new Map(); + + getOrCreate( + uuid: string, + initialData?: Partial, + ): QueryExecution { + if (!this.queries.has(uuid)) { + this.queries.set(uuid, { + uuid, + status: 'UNKNOWN', + processedRows: 0, + processedTraces: 0, + totalTraces: 0, + ...initialData, + }); + } + const obj = this.queries.get(uuid)!; + if (initialData) { + this.mergeInto(obj, initialData); + } + return obj; + } + + // No-op if entry missing; getOrCreate first. + update(uuid: string, updates: Partial): void { + const obj = this.queries.get(uuid); + if (obj === undefined) return; + Object.assign(obj, updates); + } + + getAll(): QueryExecution[] { + return Array.from(this.queries.values()); + } + + // Test seam. + clear(): void { + this.queries.clear(); + } + + private mergeInto( + obj: QueryExecution, + incoming: Partial, + ): void { + const incomingIsTerminal = + incoming.status !== undefined && TERMINAL_STATUSES.has(incoming.status); + const objIsLive = obj.status === 'IN_PROGRESS' || obj.status === 'UNKNOWN'; + const rowCountIncreased = + (incoming.processedRows ?? 0) >= obj.processedRows; + + // Listing endpoint clips perfettoSql/error; never downgrade the held + // longer string with a shorter one. + const patch: Partial = {...incoming}; + if ( + patch.perfettoSql !== undefined && + obj.perfettoSql !== undefined && + patch.perfettoSql.length < obj.perfettoSql.length + ) { + delete patch.perfettoSql; + } + if ( + patch.error !== undefined && + obj.error !== undefined && + patch.error.length < obj.error.length + ) { + delete patch.error; + } + + if (!objIsLive || incomingIsTerminal || rowCountIncreased) { + Object.assign(obj, patch); + return; + } + + // Stale snapshot: carry over static metadata only; preserve live counters. + if (patch.tableLink !== undefined) obj.tableLink = patch.tableLink; + if (patch.tableName !== undefined) obj.tableName = patch.tableName; + if (patch.perfettoSql !== undefined) obj.perfettoSql = patch.perfettoSql; + if (patch.limit !== undefined) obj.limit = patch.limit; + if (patch.materialized !== undefined) obj.materialized = patch.materialized; + } +} + +export const queryStore = new QueryStore(); diff --git a/ui/src/bigtrace/query/query_store_unittest.ts b/ui/src/bigtrace/query/query_store_unittest.ts new file mode 100644 index 00000000000..47bb02448a1 --- /dev/null +++ b/ui/src/bigtrace/query/query_store_unittest.ts @@ -0,0 +1,184 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {QueryStore} from './query_store'; + +describe('QueryStore.getOrCreate', () => { + test('creates a new entry with sane defaults', () => { + const store = new QueryStore(); + const exec = store.getOrCreate('uuid-1'); + expect(exec.uuid).toBe('uuid-1'); + expect(exec.status).toBe('UNKNOWN'); + expect(exec.processedRows).toBe(0); + expect(exec.processedTraces).toBe(0); + expect(exec.totalTraces).toBe(0); + }); + + test('returns the same instance on subsequent calls (identity)', () => { + const store = new QueryStore(); + const a = store.getOrCreate('uuid-1'); + const b = store.getOrCreate('uuid-1'); + expect(b).toBe(a); + }); + + test('initialData on first call seeds the entry', () => { + const store = new QueryStore(); + const exec = store.getOrCreate('uuid-1', { + materialized: true, + perfettoSql: 'SELECT 1', + }); + expect(exec.materialized).toBe(true); + expect(exec.perfettoSql).toBe('SELECT 1'); + }); +}); + +describe('QueryStore merge rules (getOrCreate on existing entry)', () => { + test('overwrites everything when stored entry is terminal', () => { + const store = new QueryStore(); + store.getOrCreate('uuid-1', { + status: 'SUCCESS', + processedRows: 100, + }); + // Terminal entry trusts the incoming snapshot. + store.getOrCreate('uuid-1', { + status: 'CANCELLED', + processedRows: 50, + }); + const exec = store.getOrCreate('uuid-1'); + expect(exec.status).toBe('CANCELLED'); + expect(exec.processedRows).toBe(50); + }); + + test('overwrites everything when incoming snapshot is terminal', () => { + const store = new QueryStore(); + store.getOrCreate('uuid-1', { + status: 'IN_PROGRESS', + processedRows: 10, + }); + // Live entry, but the incoming snapshot is terminal — accept it. + store.getOrCreate('uuid-1', { + status: 'SUCCESS', + processedRows: 8, + endTime: 1000, + }); + const exec = store.getOrCreate('uuid-1'); + expect(exec.status).toBe('SUCCESS'); + expect(exec.processedRows).toBe(8); + expect(exec.endTime).toBe(1000); + }); + + test('overwrites when incoming row count is at least as high', () => { + const store = new QueryStore(); + store.getOrCreate('uuid-1', {status: 'IN_PROGRESS', processedRows: 10}); + store.getOrCreate('uuid-1', {status: 'IN_PROGRESS', processedRows: 25}); + const exec = store.getOrCreate('uuid-1'); + expect(exec.processedRows).toBe(25); + }); + + test('preserves live progress when incoming snapshot is staler', () => { + const store = new QueryStore(); + store.getOrCreate('uuid-1', { + status: 'IN_PROGRESS', + processedRows: 50, + processedTraces: 12, + }); + // History list arrives with old data; should NOT regress counters. + store.getOrCreate('uuid-1', { + status: 'IN_PROGRESS', + processedRows: 10, + processedTraces: 2, + tableLink: '/t/abc', + perfettoSql: 'SELECT 2', + }); + const exec = store.getOrCreate('uuid-1'); + expect(exec.processedRows).toBe(50); + expect(exec.processedTraces).toBe(12); + // Static metadata still gets carried over. + expect(exec.tableLink).toBe('/t/abc'); + expect(exec.perfettoSql).toBe('SELECT 2'); + }); + + test('UNKNOWN status counts as live', () => { + const store = new QueryStore(); + store.getOrCreate('uuid-1', {status: 'UNKNOWN', processedRows: 10}); + store.getOrCreate('uuid-1', {status: 'UNKNOWN', processedRows: 5}); + const exec = store.getOrCreate('uuid-1'); + expect(exec.processedRows).toBe(10); + }); +}); + +describe('QueryStore.update', () => { + test('partial updates preserve unrelated fields', () => { + const store = new QueryStore(); + store.getOrCreate('uuid-1', { + status: 'IN_PROGRESS', + processedRows: 10, + perfettoSql: 'SELECT 1', + }); + store.update('uuid-1', {processedRows: 20}); + const exec = store.getOrCreate('uuid-1'); + expect(exec.processedRows).toBe(20); + expect(exec.perfettoSql).toBe('SELECT 1'); + expect(exec.status).toBe('IN_PROGRESS'); + }); + + test('update on a missing entry is a no-op', () => { + const store = new QueryStore(); + expect(() => + store.update('does-not-exist', {processedRows: 1}), + ).not.toThrow(); + expect(store.getAll()).toHaveLength(0); + }); +}); + +describe('QueryStore truncation merge', () => { + // Listing clips perfettoSql/error; merge must not downgrade longer→shorter. + + test('shorter (truncated) SQL does not overwrite existing full SQL', () => { + const store = new QueryStore(); + const fullSql = 'SELECT * FROM slice WHERE name = "verbose..."'; + store.getOrCreate('uuid-1', {status: 'SUCCESS', perfettoSql: fullSql}); + store.getOrCreate('uuid-1', { + status: 'SUCCESS', + perfettoSql: 'SELECT * FROM slic…', + }); + expect(store.getOrCreate('uuid-1').perfettoSql).toBe(fullSql); + }); + + test('truncated SQL fills an empty slot', () => { + const store = new QueryStore(); + store.getOrCreate('uuid-1', {status: 'SUCCESS'}); + store.getOrCreate('uuid-1', {perfettoSql: 'SELECT * FROM slic…'}); + expect(store.getOrCreate('uuid-1').perfettoSql).toBe('SELECT * FROM slic…'); + }); + + test('longer (full) SQL upgrades a previously-truncated entry', () => { + const store = new QueryStore(); + store.getOrCreate('uuid-1', { + status: 'SUCCESS', + perfettoSql: 'SELECT * FROM slic…', + }); + const fullSql = 'SELECT * FROM slice WHERE name = "verbose..."'; + store.getOrCreate('uuid-1', {status: 'SUCCESS', perfettoSql: fullSql}); + expect(store.getOrCreate('uuid-1').perfettoSql).toBe(fullSql); + }); + + test('shorter error does not overwrite existing full error', () => { + const store = new QueryStore(); + const fullErr = 'no such table: events; line 12 col 4'; + store.getOrCreate('uuid-1', {status: 'FAILED', error: fullErr}); + store.getOrCreate('uuid-1', {status: 'FAILED', error: 'no such tabl…'}); + expect(store.getOrCreate('uuid-1').error).toBe(fullErr); + }); +}); diff --git a/ui/src/bigtrace/query/sql_modules.ts b/ui/src/bigtrace/query/sql_modules.ts index e3a6135a27a..6c8b7388888 100644 --- a/ui/src/bigtrace/query/sql_modules.ts +++ b/ui/src/bigtrace/query/sql_modules.ts @@ -16,65 +16,37 @@ import {TableColumn} from '../../components/widgets/sql/table/table_column'; import {SqlTableDefinition} from '../../components/widgets/sql/table/table_description'; import {PerfettoSqlType} from '../../trace_processor/perfetto_sql_type'; -// Handles the access to all of the Perfetto SQL modules accessible to Trace -// Processor. +// All Perfetto SQL modules accessible to Trace Processor. export interface SqlModules { - // Returns all tables/views between all loaded Perfetto SQL modules. listTables(): SqlTable[]; - - // Returns all loaded Perfetto SQL modules. listModules(): SqlModule[]; - - // Returns names of all tables/views between all loaded Perfetto SQL modules. listTablesNames(): string[]; - - // Returns Perfetto SQL table/view if it was loaded in one of the Perfetto - // SQL module. getTable(tableName: string): SqlTable | undefined; - - // Returns module that contains Perfetto SQL table/view if it was loaded in one of the Perfetto - // SQL module. getModuleForTable(tableName: string): SqlModule | undefined; - - // Returns true if the module is disabled due to missing data in the trace. isModuleDisabled(moduleName: string): boolean; - // Returns whether a specific table passed its own data availability check. - // Returns undefined if no per-table check exists for this table. - // When undefined, callers fall back to isModuleDisabled. + // undefined if no per-table check exists; callers fall back to isModuleDisabled. tablePassedDataCheck?(tableName: string): boolean | undefined; - // Returns the set of all disabled module names. getDisabledModules(): ReadonlySet; - // Triggers the data availability checks if not already started. - // Safe to call multiple times - subsequent calls are no-ops. - // Returns a promise that resolves when initialization is complete. + // Idempotent; resolves once availability checks complete. ensureInitialized(): Promise; } -// Handles the access to a specific Perfetto SQL Package. Package consists of -// Perfetto SQL modules. +// A Perfetto SQL package (a set of modules). export interface SqlPackage { readonly name: string; readonly modules: SqlModule[]; - // Returns all tables/views in this package. listTables(): SqlTable[]; - - // Returns names of all tables/views in this package. listTablesNames(): string[]; - getTable(tableName: string): SqlTable | undefined; - - // Returns sqlModule containing table with provided name. getModuleForTable(tableName: string): SqlModule | undefined; - - // Returns sqlTableDefinition of the table with provided name. getSqlTableDefinition(tableName: string): SqlTableDefinition | undefined; } -// Handles the access to a specific Perfetto SQL module. +// A single Perfetto SQL module. export interface SqlModule { readonly includeKey: string; readonly tags: string[]; @@ -83,14 +55,10 @@ export interface SqlModule { readonly tableFunctions: SqlTableFunction[]; readonly macros: SqlMacro[]; - // Returns sqlTable with provided name. getTable(tableName: string): SqlTable | undefined; - - // Returns sqlTableDefinition of the table with provided name. getSqlTableDefinition(tableName: string): SqlTableDefinition | undefined; } -// The definition of Perfetto SQL table/view. export interface SqlTable { readonly name: string; readonly includeKey?: string; @@ -100,11 +68,9 @@ export interface SqlTable { readonly dataCheckSql?: string; readonly columns: SqlColumn[]; - // Returns all columns as TableColumns. getTableColumns(): TableColumn[]; } -// The definition of Perfetto SQL function. export interface SqlFunction { readonly name: string; readonly description: string; @@ -113,7 +79,6 @@ export interface SqlFunction { readonly returnDesc: string; } -// The definition of Perfetto SQL table function. export interface SqlTableFunction { readonly name: string; readonly description: string; @@ -121,7 +86,6 @@ export interface SqlTableFunction { readonly returnCols: SqlColumn[]; } -// The definition of Perfetto SQL macro. export interface SqlMacro { readonly name: string; readonly description: string; @@ -129,15 +93,12 @@ export interface SqlMacro { readonly returnType: string; } -// The definition of Perfetto SQL column. export interface SqlColumn { readonly name: string; readonly description?: string; readonly type?: PerfettoSqlType; } -// The definition of Perfetto SQL argument. Can be used for functions, table -// functions or macros. export interface SqlArgument { readonly name: string; readonly description: string; @@ -151,8 +112,7 @@ export interface TableAndColumn { isEqual(o: TableAndColumn): boolean; } -// Returns true if a table should be considered disabled (no data). -// Uses table-level availability when known, falls back to module-level. +// Per-table availability if known; else falls back to module-level. export function isTableEffectivelyDisabled( sqlModules: SqlModules, tableName: string, diff --git a/ui/src/bigtrace/query/table_list.ts b/ui/src/bigtrace/query/table_list.ts index 7832abaaae5..7a2dbe84bd7 100644 --- a/ui/src/bigtrace/query/table_list.ts +++ b/ui/src/bigtrace/query/table_list.ts @@ -31,6 +31,11 @@ interface FilteredTable { segments: FuzzySegment[]; } +// Single source so run-query, body, title, and Copy text stay in sync. +function includeStatement(includeKey: string): string { + return `INCLUDE PERFETTO MODULE ${includeKey};`; +} + function renderHighlightedName(segments: FuzzySegment[]): m.Children { return segments.map(({matching, value}) => matching ? m('span.pf-simple-table-list__highlight', value) : value, @@ -81,7 +86,8 @@ export class TableList implements m.ClassComponent { '.pf-simple-table-list__items', m( Accordion, - {multi: false}, + // multi:true so users can expand several tables for schema compare. + {multi: true}, filteredTables.map(({table, segments}) => m( AccordionSection, @@ -89,6 +95,8 @@ export class TableList implements m.ClassComponent { key: table.name, summary: m( 'code.pf-simple-table-list__item-name', + // Ellipsis-truncated; tooltip reveals the full name. + {title: table.name}, renderHighlightedName(segments), ), }, @@ -103,12 +111,26 @@ export class TableList implements m.ClassComponent { ); } + private renderIncludeRow(includeKey: string): m.Children { + const include = includeStatement(includeKey); + return m( + '.pf-simple-table-list__detail-row', + m('span.pf-simple-table-list__detail-label', 'Include'), + m('code.pf-simple-table-list__detail-value', {title: include}, include), + m(CopyToClipboardButton, { + className: 'pf-show-on-hover', + textToCopy: include, + tooltip: 'Copy include string to clipboard', + }), + ); + } + private generateQuery(table: SqlTable): string { const lines: string[] = []; // Add INCLUDE statement if needed if (table.includeKey) { - lines.push(`INCLUDE PERFETTO MODULE ${table.includeKey};`); + lines.push(includeStatement(table.includeKey)); lines.push(''); } @@ -138,7 +160,12 @@ export class TableList implements m.ClassComponent { m( '.pf-simple-table-list__detail-row', m('span.pf-simple-table-list__detail-label', 'Table name'), - m('code.pf-simple-table-list__detail-value', table.name), + // Ellipsis-truncated; tooltip reveals the full name without copying. + m( + 'code.pf-simple-table-list__detail-value', + {title: table.name}, + table.name, + ), m(CopyToClipboardButton, { className: 'pf-show-on-hover', textToCopy: table.name, @@ -154,20 +181,7 @@ export class TableList implements m.ClassComponent { }), ), // Module - table.includeKey && - m( - '.pf-simple-table-list__detail-row', - m('span.pf-simple-table-list__detail-label', 'Include'), - m( - 'code.pf-simple-table-list__detail-value', - `INCLUDE PERFETTO MODULE ${table.includeKey};`, - ), - m(CopyToClipboardButton, { - className: 'pf-show-on-hover', - textToCopy: `INCLUDE PERFETTO MODULE ${table.includeKey};`, - tooltip: 'Copy include string to clipboard', - }), - ), + table.includeKey && this.renderIncludeRow(table.includeKey), // Columns table.columns.length > 0 && diff --git a/ui/src/bigtrace/router.ts b/ui/src/bigtrace/router.ts index d8b9dfd6666..f8b5ac86369 100644 --- a/ui/src/bigtrace/router.ts +++ b/ui/src/bigtrace/router.ts @@ -14,14 +14,8 @@ import m from 'mithril'; -// Simple hash-based router for BigTrace. -// Uses #! prefix (e.g., #!/query, #!/settings) to match the existing -// sidebar link format. -// -// This replaces Mithril's m.route() so that all rendering goes through -// the raf scheduler's mount system. m.route() bypasses the raf scheduler -// because it caches a reference to the original m.mount, which breaks -// cross-tree redraws (e.g. portal-based popups like the omnibox dropdown). +// Hash-based router (#!/query, #!/settings). Replaces m.route() because +// m.route bypasses the raf scheduler and breaks portal-based popups. export function getCurrentRoute(): string { const hash = window.location.hash; diff --git a/ui/src/bigtrace/settings/bigtrace_settings_service.ts b/ui/src/bigtrace/settings/bigtrace_settings_service.ts index 7c963a76102..433a57afc78 100644 --- a/ui/src/bigtrace/settings/bigtrace_settings_service.ts +++ b/ui/src/bigtrace/settings/bigtrace_settings_service.ts @@ -128,6 +128,14 @@ class BigTraceSettingsService { const endpointSetting = endpointStorage.get('bigtraceEndpoint'); const endpoint = endpointSetting ? (endpointSetting.get() as string) : ''; + // No endpoint → throw a clean message; the settings page renders + // it as a callout. (Otherwise a relative POST 404s on the UI server.) + if (endpoint.trim() === '') { + throw new Error( + 'Set the BigTrace Endpoint above to load backend settings.', + ); + } + this.execConfigAbortController?.abort(); this.execConfigAbortController = new AbortController(); @@ -183,6 +191,8 @@ class BigTraceSettingsService { const endpointSetting = endpointStorage.get('bigtraceEndpoint'); const endpoint = endpointSetting ? (endpointSetting.get() as string) : ''; + if (endpoint.trim() === '') return []; + this.metadataAbortController?.abort(); this.metadataAbortController = new AbortController(); diff --git a/ui/src/bigtrace/settings/bigtrace_settings_storage.ts b/ui/src/bigtrace/settings/bigtrace_settings_storage.ts index f788c4e8d10..932893cf3bd 100644 --- a/ui/src/bigtrace/settings/bigtrace_settings_storage.ts +++ b/ui/src/bigtrace/settings/bigtrace_settings_storage.ts @@ -95,8 +95,7 @@ class SettingImpl implements Setting { } isDisabled(): boolean { - const state = this.disabledStateStorage.load()[this.id]; - return state === undefined ? false : Boolean(state); + return Boolean(this.disabledStateStorage.load()[this.id]); } setDisabled(disabled: boolean): void { @@ -107,7 +106,8 @@ class SettingImpl implements Setting { } [Symbol.dispose](): void { - // Not implemented + // No resources owned — values live in LocalStorage. The method + // is required by `Setting extends Disposable`. } } diff --git a/ui/src/bigtrace/settings/settings_storage.ts b/ui/src/bigtrace/settings/settings_storage.ts index 934c0165f5f..fbd74c3fa50 100644 --- a/ui/src/bigtrace/settings/settings_storage.ts +++ b/ui/src/bigtrace/settings/settings_storage.ts @@ -61,7 +61,8 @@ export class SettingImpl implements Setting { } [Symbol.dispose](): void { - // Not implemented + // No resources owned — values live in LocalStorage. The method + // is required by `Setting extends Disposable`. } } diff --git a/ui/src/bigtrace/settings/settings_widgets.ts b/ui/src/bigtrace/settings/settings_widgets.ts index fef7f05fe90..4243e3f1095 100644 --- a/ui/src/bigtrace/settings/settings_widgets.ts +++ b/ui/src/bigtrace/settings/settings_widgets.ts @@ -30,17 +30,21 @@ export function renderSetting(setting: Setting): m.Children { switch (setting.type) { case 'number': + // Commit on every keystroke so an unrelated Mithril redraw doesn't + // wipe the user's typed value. + const commitNumber = (value: string) => { + const numValue = parseFloat(value); + if (!isNaN(numValue)) { + setting.set(numValue); + } + }; return m(TextInput, { type: 'number', value: String(currentValue), placeholder: setting.placeholder, disabled, - onChange: (value: string) => { - const numValue = parseFloat(value); - if (!isNaN(numValue)) { - setting.set(numValue); - } - }, + onInput: commitNumber, + onChange: commitNumber, }); case 'string': if (setting.format === 'sql') { @@ -57,6 +61,10 @@ export function renderSetting(setting: Setting): m.Children { value: String(currentValue), placeholder: setting.placeholder, disabled, + // Commit on every keystroke so Run-click doesn't race blur. + onInput: (value: string) => { + setting.set(value); + }, onChange: (value: string) => { setting.set(value); },