blob: 21c1614d8fe9505f5ac2935565632d3eb63cd675 [file]
// 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 type {
DataSource,
DataSourceModel,
DataSourceRows,
} from '../../components/widgets/datagrid/data_source';
import type {Filter} from '../../components/widgets/datagrid/model';
import type {Row, SqlValue} from '../../trace_processor/query_result';
import type {QueryResult} from '../../base/query_slot';
import {
type 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 = default order.
private currentOrderBy = '';
// Aliases pre-resolved to field names. `currentFilterKey` is the JSON
// form for cheap equality checks.
private currentFilter: ReadonlyArray<Filter> = [];
private currentFilterKey = '';
// `useRows` falls back to `getTotalRows()` when undefined.
private _filteredTotalRows: number | undefined;
// Field-mask shipped as `:fetch_results` `columns`. Tracks the visible
// results-grid columns so a column toggle refetches a narrower page (and
// pulls in a metadata column when the user just enabled it).
private currentColumns: readonly string[] = [];
private currentColumnsKey = '';
// availableColumnNames from the last fetch — the results-page column picker
// reads this to know what's selectable.
private _availableColumnNames: ReadonlyArray<string> | undefined;
get filteredTotalRows(): number | undefined {
return this._filteredTotalRows;
}
get availableColumnNames(): ReadonlyArray<string> | undefined {
return this._availableColumnNames;
}
// `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;
// Columns the grid is currently displaying; shipped as the `:fetch_results`
// `columns` field-mask.
const wantedColumns = (model.columns ?? []).map((c) => c.field);
const wantedColumnsKey = JSON.stringify(wantedColumns);
// Fetch on sort/filter/range/columns/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 columnsChanged =
this.hasInitialFetchCompleted &&
wantedColumnsKey !== this.currentColumnsKey;
const needsInitial = !this.hasInitialFetchCompleted && wantedLimit > 0;
if (
(sortChanged ||
filterChanged ||
rangeChanged ||
columnsChanged ||
needsInitial) &&
!this.isFetching
) {
this.currentOrderBy = wantedOrderBy;
if (filterChanged) {
this.currentFilter = wantedFilter;
this.currentFilterKey = wantedFilterKey;
// Briefly oversized scrollbar > briefly collapsed while refetching.
this._filteredTotalRows = undefined;
}
this.currentColumns = wantedColumns;
this.currentColumnsKey = wantedColumnsKey;
// 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 → backend field name for the order_by wire string.
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<Filter> {
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<void> {
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;
m.redraw();
try {
const result = await this.queryClient.fetchResults(
this.queryUuid,
limit,
offset,
this.signal,
this.currentOrderBy,
this.currentFilter,
this.currentColumns.length > 0 ? this.currentColumns : undefined,
);
this.loadedRows = [...result.rows];
this.loadedOffset = offset;
this.loadedLimit = limit;
this.hasInitialFetchCompleted = true;
this._filteredTotalRows = result.totalFilteredRows;
this._availableColumnNames = result.availableColumnNames;
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<void> {
if (this.hasInitialFetchCompleted) return;
await this.fetchMoreRows(0, 100);
}
getError(): string | null {
return this.error;
}
getColumns(): string[] {
return this.columns;
}
useAggregateSummaries(_model: DataSourceModel): QueryResult<Row> {
return {data: undefined, isPending: false, isFresh: true};
}
useDistinctValues(
_column: string | undefined,
): QueryResult<readonly SqlValue[]> {
// `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<readonly string[]> {
return {data: undefined, isPending: false, isFresh: true};
}
async exportData(_model: DataSourceModel): Promise<readonly Row[]> {
return this.loadedRows;
}
}