Add DataGrid widget (#1429)
This commit adds a new `DataGrid` widget, a versatile component for
displaying tabular data.
It supports:
- Support for various data sources (e.g., SQL, in-memory).
- Pagination for handling large datasets.
- Sorting and filtering, with optional controlled mode for external
state management.
- Cell-level menus for value-based filtering.
- Column header menus for sorting and null-value filtering.
- A toolbar displaying active filters, pagination controls, and a reset
button.
- Customizable cell content rendering.
This widget replaces the current QueryTable implementation, so it can be
seen anywhere where the QueryTable was used (e.g. query & explore pages,
omnibox query results).
The intent is that this widget will eventually be used anywhere where we
need to display large tabular data sets.
Note: To avoid confusion, filtering is disabled for now. This could
cause confusion when used with e.g. the explore page and the query page
because it's not clear that filters applied to the data grid are
ephemeral and local to that data grid instance, rather than being
applied higher up to the explore page's node or the query page's query.
diff --git a/test/data/ui-screenshots/debug_tracks.test.ts/debug-tracks-pivot/debug-track-pivot.png.sha256 b/test/data/ui-screenshots/debug_tracks.test.ts/debug-tracks-pivot/debug-track-pivot.png.sha256
index 70b08d1..b60e10c 100644
--- a/test/data/ui-screenshots/debug_tracks.test.ts/debug-tracks-pivot/debug-track-pivot.png.sha256
+++ b/test/data/ui-screenshots/debug_tracks.test.ts/debug-tracks-pivot/debug-track-pivot.png.sha256
@@ -1 +1 @@
-041c6705748d105d70bbfd6c6c1b20e8a180b5b55f5609ccbfe6ffaa2f8c1245
\ No newline at end of file
+92a4f052e91634de4887f31c85023865bb99fd8318d9b8169de36d177c5e0729
\ No newline at end of file
diff --git a/test/data/ui-screenshots/debug_tracks.test.ts/debug-tracks/debug-track-added.png.sha256 b/test/data/ui-screenshots/debug_tracks.test.ts/debug-tracks/debug-track-added.png.sha256
index 223ef3d..c202914 100644
--- a/test/data/ui-screenshots/debug_tracks.test.ts/debug-tracks/debug-track-added.png.sha256
+++ b/test/data/ui-screenshots/debug_tracks.test.ts/debug-tracks/debug-track-added.png.sha256
@@ -1 +1 @@
-637ab36131e5be8bdecd5d663f51b10d25e5609ea92967740d16d6174262fff5
\ No newline at end of file
+7e0147d0b1c2838da380d8fe3f1b2092b3c4fa5436e382047248fe1047b3f528
\ No newline at end of file
diff --git a/test/data/ui-screenshots/queries.test.ts/omnibox-query/query-mode.png.sha256 b/test/data/ui-screenshots/queries.test.ts/omnibox-query/query-mode.png.sha256
index 0007159..bbc53a0 100644
--- a/test/data/ui-screenshots/queries.test.ts/omnibox-query/query-mode.png.sha256
+++ b/test/data/ui-screenshots/queries.test.ts/omnibox-query/query-mode.png.sha256
@@ -1 +1 @@
-d98e4788e021aef8c614a701ea42c4c2ff34b922c9777b8b2c1f73707ac7d37d
\ No newline at end of file
+0fb679d56cd6ecfe11694c62ab6d99e898e961898ef7fa9d5f18744c1fc12634
\ No newline at end of file
diff --git a/test/data/ui-screenshots/queries.test.ts/omnibox-query/row-1-clicked.png.sha256 b/test/data/ui-screenshots/queries.test.ts/omnibox-query/row-1-clicked.png.sha256
index 54cac92..533b797 100644
--- a/test/data/ui-screenshots/queries.test.ts/omnibox-query/row-1-clicked.png.sha256
+++ b/test/data/ui-screenshots/queries.test.ts/omnibox-query/row-1-clicked.png.sha256
@@ -1 +1 @@
-85787265be730744bc172fd15abc982de32d2a3cdcfa8c6e34c4f10c299a95c9
\ No newline at end of file
+34ad41c4b2e79ed16086dba5bc6a496d940bcb4ccd4c3b84ec77be58e8b118e0
\ No newline at end of file
diff --git a/test/data/ui-screenshots/queries.test.ts/omnibox-query/row-2-clicked.png.sha256 b/test/data/ui-screenshots/queries.test.ts/omnibox-query/row-2-clicked.png.sha256
index 56c557a..cca4697 100644
--- a/test/data/ui-screenshots/queries.test.ts/omnibox-query/row-2-clicked.png.sha256
+++ b/test/data/ui-screenshots/queries.test.ts/omnibox-query/row-2-clicked.png.sha256
@@ -1 +1 @@
-85bd3d580a798fd2a220d7251c2ef3ed84297c69d74b6543d859c56ba5171453
\ No newline at end of file
+1d2d5b9cea02db2df03143908b19af2e025e7a9f44e9941cce7f91d163147a17
\ No newline at end of file
diff --git a/test/data/ui-screenshots/queries.test.ts/query-page/query-limit-1.png.sha256 b/test/data/ui-screenshots/queries.test.ts/query-page/query-limit-1.png.sha256
index 41f1808..add5a99 100644
--- a/test/data/ui-screenshots/queries.test.ts/query-page/query-limit-1.png.sha256
+++ b/test/data/ui-screenshots/queries.test.ts/query-page/query-limit-1.png.sha256
@@ -1 +1 @@
-b1124b56fbd6e34c9b7c079d6b03f3cf135f59b1c99ba583c5aca86dcbd9d0c9
\ No newline at end of file
+8e13ae6a0d09a702ef1715bfeeced64b93e4e22bfd67cf730ce2ed6caa31196f
\ No newline at end of file
diff --git a/test/data/ui-screenshots/queries.test.ts/query-page/query-limit-2.png.sha256 b/test/data/ui-screenshots/queries.test.ts/query-page/query-limit-2.png.sha256
index d063d7b..fd12947 100644
--- a/test/data/ui-screenshots/queries.test.ts/query-page/query-limit-2.png.sha256
+++ b/test/data/ui-screenshots/queries.test.ts/query-page/query-limit-2.png.sha256
@@ -1 +1 @@
-1c5f315ea37dcfb1a641d13cad0eabd7443ef0079a8f83a9850d55fa8fa8fd37
\ No newline at end of file
+fd4afd0a622c3bba0b8ada3c69f92eb2a8861b947990c20aaad640940ba946cc
\ No newline at end of file
diff --git a/test/data/ui-screenshots/queries.test.ts/query-page/query-limit-3.png.sha256 b/test/data/ui-screenshots/queries.test.ts/query-page/query-limit-3.png.sha256
index fe9dc9d..d30ad29 100644
--- a/test/data/ui-screenshots/queries.test.ts/query-page/query-limit-3.png.sha256
+++ b/test/data/ui-screenshots/queries.test.ts/query-page/query-limit-3.png.sha256
@@ -1 +1 @@
-f4beb70b3ef0a654f83ad59ec9e22b60d5394a8b52acbf682a0aea4c835adf0b
\ No newline at end of file
+59764856f020a29e8becc97c02920efe3d2ef750348022d8d87dbe12235f9562
\ No newline at end of file
diff --git a/ui/src/assets/common.scss b/ui/src/assets/common.scss
index cf03557..aee786e 100644
--- a/ui/src/assets/common.scss
+++ b/ui/src/assets/common.scss
@@ -245,49 +245,6 @@
}
}
-.pf-query-table {
- min-width: 100%;
- font-size: 14px;
- border: 0;
- thead td {
- position: sticky;
- top: 0;
- background-color: hsl(214, 22%, 90%);
- color: #262f3c;
- text-align: center;
- padding: 1px 3px;
- border-style: solid;
- border-color: #fff;
- border-right-width: 1px;
- border-left-width: 1px;
- }
- tbody tr {
- @include transition();
- background-color: hsl(214, 22%, 100%);
- font-family: var(--monospace-font);
- &:nth-child(even) {
- background-color: hsl(214, 22%, 95%);
- }
- td:first-child {
- padding-left: 5px;
- }
- td:last-child {
- padding-right: 5px;
- }
- &:hover {
- background-color: hsl(214, 22%, 90%);
- }
- &[clickable] {
- cursor: pointer;
- &:active {
- background-color: hsl(206, 19%, 75%);
- box-shadow: inset 1px 1px 4px #00000040;
- transition: none;
- }
- }
- }
-}
-
.query-error {
padding: 20px 10px;
color: hsl(-10, 50%, 50%);
diff --git a/ui/src/assets/components/data_grid.scss b/ui/src/assets/components/data_grid.scss
new file mode 100644
index 0000000..2074b12
--- /dev/null
+++ b/ui/src/assets/components/data_grid.scss
@@ -0,0 +1,116 @@
+// Copyright (C) 2025 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.
+
+$border: 1px solid rgb(225, 225, 225);
+
+.pf-data-grid {
+ font-weight: 300;
+
+ table {
+ min-width: 100%;
+ border-spacing: 0;
+
+ td,
+ th {
+ padding: 0.2em 0.4em;
+ border-right: $border;
+ border-bottom: $border;
+ white-space: nowrap;
+
+ &:first-child {
+ border-left: $border;
+ }
+ }
+
+ th {
+ font-weight: 400;
+ border-top: $border;
+ }
+
+ tr {
+ &:hover {
+ background-color: rgb(239, 241, 244);
+ }
+ }
+
+ thead {
+ position: sticky;
+ top: 0;
+ background-color: white;
+
+ tr {
+ &:hover {
+ background-color: unset;
+ }
+ }
+ }
+ }
+
+ &__toolbar {
+ display: flex;
+ padding: 2px;
+ align-items: baseline;
+ }
+
+ &__toolbar-filters {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ gap: 4px;
+ padding: 2px;
+ flex-grow: 1;
+ }
+
+ &__filter-chip {
+ cursor: pointer;
+ }
+
+ &__toolbar-pagination {
+ display: flex;
+ gap: 4px;
+ align-items: baseline;
+ }
+
+ &__cell {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 0.3em;
+ align-items: center;
+ text-align: left;
+
+ &-button {
+ visibility: hidden;
+ &.pf-active {
+ visibility: visible;
+ }
+ }
+
+ // When hovering over a cell, show the button
+ &:hover {
+ .pf-data-grid__cell-button {
+ visibility: visible;
+ }
+ }
+
+ &--number {
+ text-align: right;
+ }
+
+ &--null {
+ text-align: center;
+ color: gray;
+ font-style: italic;
+ }
+ }
+}
diff --git a/ui/src/assets/perfetto.scss b/ui/src/assets/perfetto.scss
index df86993..59d1b13 100644
--- a/ui/src/assets/perfetto.scss
+++ b/ui/src/assets/perfetto.scss
@@ -28,7 +28,8 @@
@import "hiring_banner";
@import "statusbar";
-// Widgets - keep these sorted (they should NOT have any inter-dependencies)
+// Widgets/components - keep these sorted alphabetically
+@import "components/data_grid";
@import "widgets/anchor";
@import "widgets/button";
@import "widgets/callout";
@@ -47,8 +48,8 @@
@import "widgets/icon";
@import "widgets/menu";
@import "widgets/middle_ellipsis";
-@import "widgets/multiselect";
@import "widgets/multiselect_input";
+@import "widgets/multiselect";
@import "widgets/popup";
@import "widgets/section";
@import "widgets/segmented_buttons";
diff --git a/ui/src/base/semantic_icons.ts b/ui/src/base/semantic_icons.ts
index 20ae348..051581a 100644
--- a/ui/src/base/semantic_icons.ts
+++ b/ui/src/base/semantic_icons.ts
@@ -43,6 +43,18 @@
static readonly Chart = 'bar_chart';
static readonly Change = 'change_circle';
static readonly GoTo = 'arrow_forward';
- static readonly MoreVert = 'more_vert';
+ static readonly ContextMenuAlt = 'more_vert';
static readonly Warning = 'warning';
+
+ // Page control
+ static readonly NextPage = 'chevron_right';
+ static readonly PrevPage = 'chevron_left';
+ static readonly LastPage = 'last_page';
+ static readonly FirstPage = 'first_page';
+
+ // Sorting
+ static readonly SortAsc = 'arrow_upward';
+ static readonly SortDesc = 'arrow_downward';
+ static readonly ResetState = 'restart_alt';
+ static readonly Remove = 'clear';
}
diff --git a/ui/src/components/query_table/queries.ts b/ui/src/components/query_table/queries.ts
index c077593..ff1bf7e 100644
--- a/ui/src/components/query_table/queries.ts
+++ b/ui/src/components/query_table/queries.ts
@@ -65,7 +65,7 @@
const row: Row = {};
for (const colName of columns) {
const value = iter.get(colName);
- row[colName] = value === null ? 'NULL' : value;
+ row[colName] = value;
}
rows.push(row);
}
diff --git a/ui/src/components/query_table/query_table.ts b/ui/src/components/query_table/query_table.ts
index 5b03022..1e77dbe 100644
--- a/ui/src/components/query_table/query_table.ts
+++ b/ui/src/components/query_table/query_table.ts
@@ -16,25 +16,18 @@
import {copyToClipboard} from '../../base/clipboard';
import {QueryResponse} from './queries';
import {Row} from '../../trace_processor/query_result';
-import {Anchor} from '../../widgets/anchor';
import {Button} from '../../widgets/button';
import {Callout} from '../../widgets/callout';
import {DetailsShell} from '../../widgets/details_shell';
-import {downloadData} from '../../base/download_utils';
import {Router} from '../../core/router';
import {AppImpl} from '../../core/app_impl';
import {Trace} from '../../public/trace';
import {MenuItem, PopupMenu} from '../../widgets/menu';
import {Icons} from '../../base/semantic_icons';
-
-// Controls how many rows we see per page when showing paginated results.
-const ROWS_PER_PAGE = 50;
-
-interface QueryTableRowAttrs {
- readonly trace: Trace;
- readonly row: Row;
- readonly columns: ReadonlyArray<string>;
-}
+import {DataGrid, renderCell} from '../widgets/data_grid/data_grid';
+import {DataGridDataSource} from '../widgets/data_grid/common';
+import {InMemoryDataSource} from '../widgets/data_grid/in_memory_data_source';
+import {Anchor} from '../../widgets/anchor';
type Numeric = bigint | number;
@@ -81,128 +74,41 @@
return undefined;
}
-class QueryTableRow implements m.ClassComponent<QueryTableRowAttrs> {
- private readonly trace: Trace;
-
- constructor({attrs}: m.Vnode<QueryTableRowAttrs>) {
- this.trace = attrs.trace;
- }
-
- view(vnode: m.Vnode<QueryTableRowAttrs>) {
- const {row, columns} = vnode.attrs;
- const cells = columns.map((col) => this.renderCell(col, row[col]));
-
- // TODO(dproy): Make click handler work from analyze page.
- if (
- Router.parseUrl(window.location.href).page === '/viewer' &&
- isSliceish(row)
- ) {
- return m(
- 'tr',
- {
- onclick: () => this.selectAndRevealSlice(row, false),
- // TODO(altimin): Consider improving the logic here (e.g. delay?) to
- // account for cases when dblclick fires late.
- ondblclick: () => this.selectAndRevealSlice(row, true),
- clickable: true,
- title: 'Go to slice',
- },
- cells,
- );
- } else {
- return m('tr', cells);
- }
- }
-
- private renderCell(name: string, value: Row[string]) {
- if (value instanceof Uint8Array) {
- return m('td', this.renderBlob(name, value));
- } else {
- return m('td', `${value}`);
- }
- }
-
- private renderBlob(name: string, value: Uint8Array) {
- return m(
- Anchor,
- {
- onclick: () => downloadData(`${name}.blob`, value),
- },
- `Blob (${value.length} bytes)`,
- );
- }
-
- private selectAndRevealSlice(
- row: Row & Sliceish,
- switchToCurrentSelectionTab: boolean,
- ) {
- const sliceId = getSliceId(row);
- if (sliceId === undefined) {
- return;
- }
- this.trace.selection.selectSqlEvent('slice', sliceId, {
- switchToCurrentSelectionTab,
- scrollToSelection: true,
- });
- }
-}
-
-interface QueryTableContentAttrs {
- readonly trace: Trace;
- readonly columns: ReadonlyArray<string>;
- readonly rows: ReadonlyArray<Row>;
-}
-
-class QueryTableContent implements m.ClassComponent<QueryTableContentAttrs> {
- view({attrs}: m.CVnode<QueryTableContentAttrs>) {
- const cols = [];
- for (const col of attrs.columns) {
- cols.push(m('td', col));
- }
- const tableHeader = m('tr', cols);
-
- const rows = attrs.rows.map((row) => {
- return m(QueryTableRow, {
- trace: attrs.trace,
- row,
- columns: attrs.columns,
- });
- });
-
- return m('table.pf-query-table', m('thead', tableHeader), m('tbody', rows));
- }
-}
-
interface QueryTableAttrs {
- trace: Trace;
- query: string;
- resp?: QueryResponse;
- contextButtons?: m.Child[];
- fillParent: boolean;
+ readonly trace: Trace;
+ readonly query: string;
+ readonly resp?: QueryResponse;
+ readonly contextButtons?: m.Child[];
+ readonly fillParent: boolean;
}
export class QueryTable implements m.ClassComponent<QueryTableAttrs> {
private readonly trace: Trace;
- private pageNumber = 0;
+ private dataSource?: DataGridDataSource;
constructor({attrs}: m.CVnode<QueryTableAttrs>) {
this.trace = attrs.trace;
+ if (attrs.resp) {
+ this.dataSource = new InMemoryDataSource(attrs.resp.rows);
+ }
+ }
+
+ onbeforeupdate(
+ vnode: m.Vnode<QueryTableAttrs, this>,
+ old: m.VnodeDOM<QueryTableAttrs, this>,
+ ): boolean | void {
+ if (vnode.attrs.resp !== old.attrs.resp) {
+ if (vnode.attrs.resp) {
+ this.dataSource = new InMemoryDataSource(vnode.attrs.resp.rows);
+ } else {
+ this.dataSource = undefined;
+ }
+ }
}
view({attrs}: m.CVnode<QueryTableAttrs>) {
const {resp, query, contextButtons = [], fillParent} = attrs;
- // Clamp the page number to ensure the page count doesn't exceed the number
- // of rows in the results.
- if (resp) {
- const pageCount = this.getPageCount(resp.rows.length);
- if (this.pageNumber >= pageCount) {
- this.pageNumber = Math.max(0, pageCount - 1);
- }
- } else {
- this.pageNumber = 0;
- }
-
return m(
DetailsShell,
{
@@ -211,24 +117,10 @@
buttons: this.renderButtons(query, contextButtons, resp),
fillParent,
},
- resp && this.renderTableContent(resp),
+ resp && this.dataSource && this.renderTableContent(resp, this.dataSource),
);
}
- private getPageCount(rowCount: number) {
- return Math.floor((rowCount - 1) / ROWS_PER_PAGE) + 1;
- }
-
- private getFirstRowInPage() {
- return this.pageNumber * ROWS_PER_PAGE;
- }
-
- private getCountOfRowsInPage(totalRows: number) {
- const firstRow = this.getFirstRowInPage();
- const endStop = Math.min(firstRow + ROWS_PER_PAGE, totalRows);
- return endStop - firstRow;
- }
-
private renderTitle(resp?: QueryResponse) {
if (!resp) {
return 'Query - running';
@@ -247,7 +139,6 @@
resp?: QueryResponse,
) {
return [
- resp && this.renderPrevNextButtons(resp),
contextButtons,
m(
PopupMenu,
@@ -276,35 +167,10 @@
];
}
- private renderPrevNextButtons(resp: QueryResponse) {
- const from = this.getFirstRowInPage();
- const to = Math.min(from + this.getCountOfRowsInPage(resp.rows.length)) - 1;
- const pageCount = this.getPageCount(resp.rows.length);
-
- return [
- `Showing rows ${from + 1} to ${to + 1} of ${resp.rows.length}`,
- m(Button, {
- label: 'Prev',
- icon: 'skip_previous',
- title: 'Go to previous page of results',
- disabled: this.pageNumber === 0,
- onclick: () => {
- this.pageNumber = Math.max(0, this.pageNumber - 1);
- },
- }),
- m(Button, {
- label: 'Next',
- icon: 'skip_next',
- title: 'Go to next page of results',
- disabled: this.pageNumber >= pageCount - 1,
- onclick: () => {
- this.pageNumber = Math.min(pageCount - 1, this.pageNumber + 1);
- },
- }),
- ];
- }
-
- private renderTableContent(resp: QueryResponse) {
+ private renderTableContent(
+ resp: QueryResponse,
+ dataSource: DataGridDataSource,
+ ) {
return m(
'.pf-query-panel',
resp.statementWithOutputCount > 1 &&
@@ -318,31 +184,57 @@
'Only the results for the last statement are displayed.',
),
),
- this.renderContent(resp),
+ this.renderContent(resp, dataSource),
);
}
- private renderContent(resp: QueryResponse) {
+ private renderContent(resp: QueryResponse, dataSource: DataGridDataSource) {
if (resp.error) {
return m('.query-error', `SQL error: ${resp.error}`);
}
- // Pick out only the rows in this page.
- const rowOffset = this.getFirstRowInPage();
- const totalRows = this.getCountOfRowsInPage(resp.rows.length);
- const rowsInPage: Row[] = [];
- for (
- let rowIndex = rowOffset;
- rowIndex < rowOffset + totalRows;
- ++rowIndex
- ) {
- rowsInPage.push(resp.rows[rowIndex]);
- }
+ const onViewerPage =
+ Router.parseUrl(window.location.href).page === '/viewer';
- return m(QueryTableContent, {
- trace: this.trace,
- columns: resp.columns,
- rows: rowsInPage,
+ return m(DataGrid, {
+ // If filters are defined by no onFilterChanged handler, the grid operates
+ // in filter read only mode.
+ filters: [],
+ columns: resp.columns.map((c) => ({name: c})),
+ dataSource,
+ cellRenderer: (value, name, row) => {
+ const sliceId = getSliceId(row);
+ const cell = renderCell(value, name);
+ if (
+ name === 'id' &&
+ sliceId !== undefined &&
+ onViewerPage &&
+ isSliceish(row)
+ ) {
+ return m(
+ Anchor,
+ {
+ title: 'Go to slice',
+ icon: Icons.UpdateSelection,
+ onclick: () => this.goToSlice(sliceId, false),
+ ondblclick: () => this.goToSlice(sliceId, true),
+ },
+ cell,
+ );
+ } else {
+ return cell;
+ }
+ },
+ });
+ }
+
+ private goToSlice(
+ sliceId: number,
+ switchToCurrentSelectionTab: boolean,
+ ): void {
+ this.trace.selection.selectSqlEvent('slice', sliceId, {
+ switchToCurrentSelectionTab,
+ scrollToSelection: true,
});
}
}
diff --git a/ui/src/components/widgets/data_grid/common.ts b/ui/src/components/widgets/data_grid/common.ts
new file mode 100644
index 0000000..82932b4
--- /dev/null
+++ b/ui/src/components/widgets/data_grid/common.ts
@@ -0,0 +1,61 @@
+// Copyright (C) 2025 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 {SqlValue} from '../../../trace_processor/query_result';
+
+export interface ColumnDefinition {
+ readonly name: string;
+}
+
+export interface FilterValue {
+ readonly column: string;
+ readonly op: '=' | '!=' | '<' | '<=' | '>' | '>=' | 'glob';
+ readonly value: SqlValue;
+}
+
+export interface FilterNull {
+ readonly column: string;
+ readonly op: 'is null' | 'is not null';
+}
+
+export type FilterDefinition = FilterValue | FilterNull;
+
+export interface SortByColumn {
+ readonly column: string;
+ readonly direction: 'asc' | 'desc';
+}
+
+export interface Unsorted {
+ readonly direction: 'unsorted';
+}
+
+export type SortBy = SortByColumn | Unsorted;
+
+export interface DataSourceResult {
+ readonly totalRows: number;
+ readonly rowOffset: number;
+ readonly rows: ReadonlyArray<RowDef>;
+}
+
+export type RowDef = {[key: string]: SqlValue};
+
+export interface DataGridDataSource {
+ readonly rows: DataSourceResult;
+ notifyUpdate(
+ sortBy: SortBy,
+ filters: ReadonlyArray<FilterDefinition>,
+ rowOffset: number,
+ rowLimit: number,
+ ): void;
+}
diff --git a/ui/src/components/widgets/data_grid/data_grid.ts b/ui/src/components/widgets/data_grid/data_grid.ts
new file mode 100644
index 0000000..0a8bbc2
--- /dev/null
+++ b/ui/src/components/widgets/data_grid/data_grid.ts
@@ -0,0 +1,680 @@
+// Copyright (C) 2025 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 {SqlValue} from '../../../trace_processor/query_result';
+import {Button} from '../../../widgets/button';
+import {downloadData} from '../../../base/download_utils';
+import {Anchor} from '../../../widgets/anchor';
+import {
+ ColumnDefinition,
+ DataGridDataSource,
+ DataSourceResult,
+ FilterDefinition,
+ RowDef,
+ SortBy,
+ SortByColumn,
+} from './common';
+import {MenuDivider, MenuItem, PopupMenu} from '../../../widgets/menu';
+import {Chip} from '../../../widgets/chip';
+import {Icon} from '../../../widgets/icon';
+import {Icons} from '../../../base/semantic_icons';
+
+const DEFAULT_ROWS_PER_PAGE = 50;
+
+export interface DataGridAttrs {
+ /**
+ * Defines the columns to be displayed in the data grid and how they are
+ * displayed.
+ */
+ readonly columns: ReadonlyArray<ColumnDefinition>;
+
+ /**
+ * The data source that provides rows to the grid. Responsible for fetching,
+ * filtering, and sorting data based on the current state.
+ *
+ * The data source is responsible for applying the filters, sorting, and
+ * paging and providing the rows that are displayed in the grid.
+ */
+ readonly dataSource: DataGridDataSource;
+
+ /**
+ * Current sort configuration - can operate in controlled or uncontrolled
+ * mode.
+ *
+ * In controlled mode: Provide this prop along with onSortByChange callback.
+ * In uncontrolled mode: Omit this prop to let the grid manage sorting state
+ * internally.
+ *
+ * Specifies which column to sort by and the direction (asc/desc/unsorted). If
+ * not provided, defaults to internal state with direction 'unsorted'.
+ */
+ readonly sortBy?: SortBy;
+
+ /**
+ * Array of filters to apply to the data - can operate in controlled or
+ * uncontrolled mode.
+ *
+ * In controlled mode: Provide this prop along with onFilterChange callback.
+ * In uncontrolled mode: Omit this prop to let the grid manage filter state
+ * internally.
+ *
+ * Each filter contains a column name, operator, and comparison value. If not
+ * provided, defaults to an empty array (no filters initially applied).
+ */
+ readonly filters?: ReadonlyArray<FilterDefinition>;
+
+ /**
+ * Controls how many rows are displayed per page.
+ */
+ readonly maxRowsPerPage?: number;
+
+ /**
+ * Callback triggered when the sort configuration changes.
+ * Allows parent components to react to sorting changes.
+ * Required for controlled mode sorting - when provided with sortBy,
+ * the parent component becomes responsible for updating the sortBy prop.
+ * @param sortBy The new sort configuration
+ */
+ onSortByChange?(sortBy: SortBy): void;
+
+ /**
+ * Callback triggered when filters are added or removed.
+ * Allows parent components to react to filtering changes.
+ * Required for controlled mode filtering - when provided with filters,
+ * the parent component becomes responsible for updating the filters prop.
+ * @param filters The new array of filter definitions
+ */
+ onFilterChange?(filters: ReadonlyArray<FilterDefinition>): void;
+
+ /**
+ * Optional custom cell renderer function.
+ * Allows customization of how cell values are displayed.
+ * @param value The raw value from the data source
+ * @param columnName The name of the column being rendered
+ * @param row The complete row data
+ * @returns Renderable Mithril content for the cell
+ */
+ cellRenderer?: (
+ value: SqlValue,
+ columnName: string,
+ row: RowDef,
+ ) => m.Children;
+
+ /**
+ * Display applied filters in the toolbar. Set to false to hide them, for
+ * example, if filters are displayed elsewhere in the UI. This does not
+ * disable filtering functionality.
+ *
+ * Defaults to true.
+ */
+ readonly showFiltersInToolbar?: boolean;
+}
+
+export class DataGrid implements m.ClassComponent<DataGridAttrs> {
+ // Internal state
+ private currentPage = 0;
+ private internalSortBy: SortBy = {direction: 'unsorted'};
+ private internalFilters: ReadonlyArray<FilterDefinition> = [];
+
+ view({attrs}: m.Vnode<DataGridAttrs>) {
+ const {
+ columns,
+ dataSource,
+ sortBy: externalSorting,
+ filters: externalFilters,
+ onSortByChange,
+ onFilterChange,
+ cellRenderer,
+ maxRowsPerPage = DEFAULT_ROWS_PER_PAGE,
+ showFiltersInToolbar = true,
+ } = attrs;
+
+ // If filters are passed in from outside but no onFilterChange handler
+ // specified, then there is no way to edit the filters so we hide the
+ // options to specify filters.
+ const areFiltersControlled = externalFilters !== undefined;
+ const filters = areFiltersControlled
+ ? externalFilters
+ : this.internalFilters;
+
+ // If filters are not controlled, they are always editable because the
+ // filter state is stored internally so we don't need a callback to modify
+ // the filters. If the filters are controlled and we have a callback then
+ // filters are similarly editable, however if we don't have a callback then
+ // filters cannot be changed so we consider them readonly.
+ const filtersAreEditable =
+ !areFiltersControlled || onFilterChange !== undefined;
+
+ const isSortingControlled = externalSorting !== undefined;
+ const sortBy = isSortingControlled ? externalSorting : this.internalSortBy;
+ const sortingIsEditable =
+ !isSortingControlled || onSortByChange !== undefined;
+
+ const currentPage = this.currentPage;
+ this.updateDataSource(
+ dataSource,
+ sortBy,
+ filters,
+ currentPage,
+ maxRowsPerPage,
+ );
+
+ const rowData = dataSource.rows;
+ const totalRows = rowData.totalRows;
+
+ // Calculate total pages based on totalRows and rowsPerPage
+ const totalPages = Math.max(1, Math.ceil(totalRows / maxRowsPerPage));
+
+ // Ensure current page doesn't exceed total pages
+ if (this.currentPage >= totalPages && totalPages > 0) {
+ this.currentPage = Math.max(0, totalPages - 1);
+ }
+
+ const addFilter = filtersAreEditable
+ ? (filter: FilterDefinition) =>
+ this.addFilter(filters, filter, onFilterChange)
+ : undefined;
+
+ const updateSorting = sortingIsEditable
+ ? (sortBy: SortBy) => {
+ this.internalSortBy = sortBy;
+ onSortByChange?.(sortBy);
+ }
+ : undefined;
+
+ return m(
+ '.pf-data-grid',
+ this.renderTableToolbar(
+ totalPages,
+ totalRows,
+ filters,
+ sortBy,
+ onSortByChange,
+ onFilterChange,
+ maxRowsPerPage,
+ showFiltersInToolbar,
+ ),
+ m(
+ 'table',
+ this.renderTableHeader(columns, sortBy, updateSorting, addFilter),
+ this.renderTableBody(
+ columns,
+ rowData,
+ filtersAreEditable,
+ filters,
+ onFilterChange,
+ cellRenderer,
+ maxRowsPerPage,
+ ),
+ ),
+ );
+ }
+
+ private updateDataSource(
+ dataSource: DataGridDataSource,
+ sortBy: SortBy,
+ filters: ReadonlyArray<FilterDefinition>,
+ currentPage: number,
+ maxRowsPerPage: number,
+ ) {
+ const offset = currentPage * maxRowsPerPage;
+ const limit = maxRowsPerPage;
+ dataSource.notifyUpdate(sortBy, filters, offset, limit);
+ }
+
+ private renderTableToolbar(
+ totalPages: number,
+ totalRows: number,
+ filters: ReadonlyArray<FilterDefinition>,
+ sortBy: SortBy,
+ onSortByChange: ((sortBy: SortBy) => void) | undefined,
+ onFiltersChange:
+ | ((filters: ReadonlyArray<FilterDefinition>) => void)
+ | undefined,
+ maxRowsPerPage: number,
+ showFilters: boolean,
+ ) {
+ return m('.pf-data-grid__toolbar', [
+ m(Button, {
+ icon: Icons.ResetState,
+ label: 'Reset',
+ disabled: filters.length === 0 && sortBy.direction === 'unsorted',
+ title: 'Reset filters and sorting',
+ onclick: () => {
+ const newSortBy: SortBy = {direction: 'unsorted'};
+ this.internalSortBy = newSortBy;
+ onSortByChange?.(newSortBy);
+
+ const newFilters: ReadonlyArray<FilterDefinition> = [];
+ this.internalFilters = newFilters;
+ onFiltersChange?.(newFilters);
+ },
+ }),
+ m(
+ '.pf-data-grid__toolbar-filters',
+ showFilters &&
+ filters.map((filter) =>
+ m(Chip, {
+ className: 'pf-data-grid__filter-chip',
+ title: 'Remove filter',
+ label: this.formatFilter(filter),
+ onclick: () => {
+ const newFilters = filters.filter((f) => f !== filter);
+ this.internalFilters = newFilters;
+ onFiltersChange?.(newFilters);
+ },
+ }),
+ ),
+ ),
+ m('.pf-data-grid__toolbar-pagination', [
+ m(Button, {
+ icon: Icons.FirstPage,
+ disabled: this.currentPage === 0,
+ onclick: () => {
+ if (this.currentPage !== 0) {
+ this.currentPage = 0;
+ }
+ },
+ }),
+ m(Button, {
+ icon: Icons.PrevPage,
+ disabled: this.currentPage === 0,
+ onclick: () => {
+ if (this.currentPage > 0) {
+ this.currentPage -= 1;
+ }
+ },
+ }),
+ m(
+ 'span.pf-data-grid__toolbar-page',
+ this.renderPageInfo(this.currentPage, maxRowsPerPage, totalRows),
+ ),
+ m(Button, {
+ icon: Icons.NextPage,
+ disabled: this.currentPage >= totalPages - 1,
+ onclick: () => {
+ if (this.currentPage < totalPages - 1) {
+ this.currentPage += 1;
+ }
+ },
+ }),
+ m(Button, {
+ icon: Icons.LastPage,
+ disabled: this.currentPage >= totalPages - 1,
+ onclick: () => {
+ if (this.currentPage < totalPages - 1) {
+ this.currentPage = Math.max(0, totalPages - 1);
+ }
+ },
+ }),
+ ]),
+ ]);
+ }
+
+ private formatFilter(filter: FilterDefinition) {
+ if ('value' in filter) {
+ return `${filter.column} ${filter.op} ${filter.value}`;
+ } else {
+ return `${filter.column} ${filter.op}`;
+ }
+ }
+
+ private renderPageInfo(
+ currentPage: number,
+ maxRowsPerPage: number,
+ totalRows: number,
+ ): string {
+ const startRow = Math.min(currentPage * maxRowsPerPage + 1, totalRows);
+ const endRow = Math.min((currentPage + 1) * maxRowsPerPage, totalRows);
+
+ const startRowStr = startRow.toLocaleString();
+ const endRowStr = endRow.toLocaleString();
+ const totalRowsStr = totalRows.toLocaleString();
+
+ return `${startRowStr}-${endRowStr} of ${totalRowsStr}`;
+ }
+
+ private renderTableHeader(
+ columns: ReadonlyArray<ColumnDefinition>,
+ currentSortBy: SortBy,
+ updateSorting: ((sortBy: SortBy) => void) | undefined,
+ addFilter: ((filter: FilterDefinition) => void) | undefined,
+ ) {
+ return m(
+ 'thead',
+ m(
+ 'tr',
+ columns.map((column) => {
+ // Determine if this column is currently sorted
+ const isCurrentSortColumn =
+ currentSortBy.direction !== 'unsorted' &&
+ (currentSortBy as SortByColumn).column === column.name;
+
+ const currentDirection = isCurrentSortColumn
+ ? (currentSortBy as SortByColumn).direction
+ : undefined;
+
+ return m(
+ 'th',
+ m(
+ '.pf-data-grid__cell',
+ m(
+ 'span',
+ column.name,
+ isCurrentSortColumn
+ ? currentDirection === 'asc'
+ ? m(Icon, {icon: Icons.SortAsc})
+ : m(Icon, {icon: Icons.SortDesc})
+ : undefined,
+ ),
+ (updateSorting || addFilter) &&
+ m(
+ PopupMenu,
+ {
+ trigger: m(Button, {
+ className: 'pf-data-grid__cell-button',
+ icon: Icons.ContextMenuAlt,
+ compact: true,
+ }),
+ },
+ updateSorting && [
+ (!isCurrentSortColumn || currentDirection === 'desc') &&
+ m(MenuItem, {
+ label: 'Sort Ascending',
+ icon: Icons.SortAsc,
+ onclick: () => {
+ updateSorting?.({
+ column: column.name,
+ direction: 'asc',
+ });
+ },
+ }),
+ (!isCurrentSortColumn || currentDirection === 'asc') &&
+ m(MenuItem, {
+ label: 'Sort Descending',
+ icon: Icons.SortDesc,
+ onclick: () => {
+ updateSorting?.({
+ column: column.name,
+ direction: 'desc',
+ });
+ },
+ }),
+ isCurrentSortColumn &&
+ m(MenuItem, {
+ label: 'Clear Sort',
+ icon: Icons.Remove,
+ onclick: () => {
+ updateSorting?.({
+ direction: 'unsorted',
+ });
+ },
+ }),
+ ],
+
+ addFilter && updateSorting && m(MenuDivider),
+
+ addFilter && [
+ m(MenuItem, {
+ label: 'Filter out nulls',
+ onclick: () => {
+ addFilter({column: column.name, op: 'is not null'});
+ },
+ }),
+ m(MenuItem, {
+ label: 'Only show nulls',
+ onclick: () => {
+ addFilter({column: column.name, op: 'is null'});
+ },
+ }),
+ ],
+ ),
+ ),
+ );
+ }),
+ ),
+ );
+ }
+
+ private renderTableBody(
+ columns: ReadonlyArray<ColumnDefinition>,
+ rowData: DataSourceResult,
+ enableFilters: boolean,
+ filters: ReadonlyArray<FilterDefinition>,
+ onFilterChange:
+ | ((filters: ReadonlyArray<FilterDefinition>) => void)
+ | undefined,
+ cellRenderer:
+ | ((value: SqlValue, columnName: string, row: RowDef) => m.Children)
+ | undefined,
+ maxRowsPerPage: number,
+ ) {
+ const {rows, totalRows, rowOffset} = rowData;
+
+ // Create array for all potential rows on the current page
+ const startIndex = this.currentPage * maxRowsPerPage;
+ const endIndex = Math.min(startIndex + maxRowsPerPage, totalRows);
+ const displayRowCount = Math.max(0, endIndex - startIndex);
+
+ // Generate array of indices for rows that should be displayed
+ const indices = Array.from(
+ {length: displayRowCount},
+ (_, i) => startIndex + i,
+ );
+
+ return m(
+ 'tbody',
+ indices.map((rowIndex) => {
+ // Calculate the relative index within the available rows array
+ const relativeIndex = rowIndex - rowOffset;
+ // Check if this index is valid for the available rows
+ const row =
+ relativeIndex >= 0 && relativeIndex < rows.length
+ ? rows[relativeIndex]
+ : undefined;
+
+ if (row) {
+ // Return a populated row if data is available
+ return m(
+ 'tr',
+ columns.map((column) => {
+ const value = row[column.name];
+ return m(
+ 'td',
+ m(
+ '.pf-data-grid__cell',
+ cellRenderer
+ ? cellRenderer(value, column.name, row)
+ : renderCell(value, column.name),
+ enableFilters &&
+ m(
+ PopupMenu,
+ {
+ trigger: m(Button, {
+ className: 'pf-data-grid__cell-button',
+ icon: Icons.ContextMenuAlt,
+ compact: true,
+ }),
+ },
+ value !== null && [
+ m(MenuItem, {
+ label: 'Filter equal to this',
+ onclick: () => {
+ this.addFilter(
+ filters,
+ {
+ column: column.name,
+ op: '=',
+ value: value,
+ },
+ onFilterChange,
+ );
+ },
+ }),
+ m(MenuItem, {
+ label: 'Filter not equal to this',
+ onclick: () => {
+ this.addFilter(
+ filters,
+ {
+ column: column.name,
+ op: '!=',
+ value: value,
+ },
+ onFilterChange,
+ );
+ },
+ }),
+ ],
+
+ isNumeric(value) && [
+ m(MenuItem, {
+ label: 'Filter greater than this',
+ onclick: () => {
+ this.addFilter(
+ filters,
+ {
+ column: column.name,
+ op: '>',
+ value: value,
+ },
+ onFilterChange,
+ );
+ },
+ }),
+ m(MenuItem, {
+ label: 'Filter greater than or equal to this',
+ onclick: () => {
+ this.addFilter(
+ filters,
+ {
+ column: column.name,
+ op: '>=',
+ value: value,
+ },
+ onFilterChange,
+ );
+ },
+ }),
+ m(MenuItem, {
+ label: 'Filter less than this',
+ onclick: () => {
+ this.addFilter(
+ filters,
+ {
+ column: column.name,
+ op: '<',
+ value: value,
+ },
+ onFilterChange,
+ );
+ },
+ }),
+ m(MenuItem, {
+ label: 'Filter less than or equal to this',
+ onclick: () => {
+ this.addFilter(
+ filters,
+ {
+ column: column.name,
+ op: '<=',
+ value: value,
+ },
+ onFilterChange,
+ );
+ },
+ }),
+ ],
+
+ value === null && [
+ m(MenuItem, {
+ label: 'Filter out nulls',
+ onclick: () => {
+ this.addFilter(
+ filters,
+ {
+ column: column.name,
+ op: 'is not null',
+ },
+ onFilterChange,
+ );
+ },
+ }),
+ m(MenuItem, {
+ label: 'Only show nulls',
+ onclick: () => {
+ this.addFilter(
+ filters,
+ {
+ column: column.name,
+ op: 'is null',
+ },
+ onFilterChange,
+ );
+ },
+ }),
+ ],
+ ),
+ ),
+ );
+ }),
+ );
+ } else {
+ // Return an empty placeholder row if data is not available
+ return m(
+ 'tr',
+ columns.map(() => m('td', m('.pf-data-grid__cell--loading', ''))),
+ );
+ }
+ }),
+ );
+ }
+
+ private addFilter(
+ filters: ReadonlyArray<FilterDefinition>,
+ newFilter: FilterDefinition,
+ onFilterChange:
+ | ((filters: ReadonlyArray<FilterDefinition>) => void)
+ | undefined,
+ ) {
+ const newFilters = [...filters, newFilter];
+ this.internalFilters = newFilters;
+ this.currentPage = 0;
+ onFilterChange?.(newFilters);
+ }
+}
+
+export function renderCell(value: SqlValue, columnName: string) {
+ if (value instanceof Uint8Array) {
+ return m(
+ Anchor,
+ {
+ onclick: () => downloadData(`${columnName}.blob`, value),
+ },
+ `Blob (${value.length} bytes)`,
+ );
+ } else if (typeof value === 'number' || typeof value === 'bigint') {
+ return m('span.pf-data-grid__cell--number', `${value}`);
+ } else if (value === null) {
+ return m('span.pf-data-grid__cell--null', 'null');
+ } else {
+ return m('span', `${value}`);
+ }
+}
+
+// Check if the value is numeric (number or bigint)
+export function isNumeric(value: SqlValue): value is number | bigint {
+ return typeof value === 'number' || typeof value === 'bigint';
+}
diff --git a/ui/src/components/widgets/data_grid/in_memory_data_source.ts b/ui/src/components/widgets/data_grid/in_memory_data_source.ts
new file mode 100644
index 0000000..8497065
--- /dev/null
+++ b/ui/src/components/widgets/data_grid/in_memory_data_source.ts
@@ -0,0 +1,269 @@
+// Copyright (C) 2025 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 {SqlValue} from '../../../trace_processor/query_result';
+import {
+ DataGridDataSource,
+ DataSourceResult,
+ FilterDefinition,
+ RowDef,
+ SortBy,
+ SortByColumn,
+} from './common';
+
+export class InMemoryDataSource implements DataGridDataSource {
+ private data: ReadonlyArray<RowDef> = [];
+ private filteredSortedData: ReadonlyArray<RowDef> = [];
+
+ // Cached state for diffing
+ private oldSortBy: SortBy = {direction: 'unsorted'};
+ private oldFilters: ReadonlyArray<FilterDefinition> = [];
+
+ constructor(data: ReadonlyArray<RowDef>) {
+ this.data = data;
+ this.filteredSortedData = data;
+ }
+
+ get rows(): DataSourceResult {
+ return {
+ rowOffset: 0,
+ rows: this.filteredSortedData,
+ totalRows: this.filteredSortedData.length,
+ };
+ }
+
+ notifyUpdate(sortBy: SortBy, filters: ReadonlyArray<FilterDefinition>): void {
+ if (
+ !this.isSortByEqual(sortBy, this.oldSortBy) ||
+ !this.areFiltersEqual(filters, this.oldFilters)
+ ) {
+ // Apply filters
+ let result = this.applyFilters(this.data, filters);
+
+ // Apply sorting
+ result = this.applySorting(result, sortBy);
+
+ // Store the filtered and sorted data
+ this.filteredSortedData = result;
+
+ this.oldSortBy = sortBy;
+ this.oldFilters = filters;
+ }
+ }
+
+ private isSortByEqual(a: SortBy, b: SortBy): boolean {
+ if (a === b) return true;
+
+ if (a.direction === 'unsorted' && b.direction === 'unsorted') {
+ return true;
+ }
+
+ if (a.direction !== 'unsorted' && b.direction !== 'unsorted') {
+ const aColumn = a as SortByColumn;
+ const bColumn = b as SortByColumn;
+ return (
+ aColumn.column === bColumn.column &&
+ aColumn.direction === bColumn.direction
+ );
+ }
+
+ return false;
+ }
+
+ // Helper functions for comparing objects
+ private areFiltersEqual(
+ filtersA: ReadonlyArray<FilterDefinition>,
+ filtersB: ReadonlyArray<FilterDefinition>,
+ ): boolean {
+ if (filtersA === filtersB) return true;
+ if (filtersA.length !== filtersB.length) return false;
+
+ // Compare each filter
+ return filtersA.every((filterA, index) => {
+ const filterB = filtersB[index];
+ return (
+ filterA.column === filterB.column &&
+ filterA.op === filterB.op &&
+ (!('value' in filterA) ||
+ !('value' in filterB) ||
+ this.isValueEqual(filterA.value, filterB.value))
+ );
+ });
+ }
+
+ private isValueEqual(valueA: SqlValue, valueB: SqlValue): boolean {
+ if (valueA === valueB) return true;
+
+ if (valueA instanceof Uint8Array && valueB instanceof Uint8Array) {
+ if (valueA.length !== valueB.length) return false;
+ return valueA.every((byte, i) => byte === valueB[i]);
+ }
+
+ return false;
+ }
+
+ private applyFilters(
+ data: ReadonlyArray<RowDef>,
+ filters: ReadonlyArray<FilterDefinition>,
+ ): ReadonlyArray<RowDef> {
+ if (filters.length === 0) {
+ return data;
+ }
+
+ return data.filter((row) => {
+ // Check if row passes all filters
+ return filters.every((filter) => {
+ const value = row[filter.column];
+
+ switch (filter.op) {
+ case '=':
+ return valuesEqual(value, filter.value);
+ case '!=':
+ return !valuesEqual(value, filter.value);
+ case '<':
+ return compareNumeric(value, filter.value) < 0;
+ case '<=':
+ return compareNumeric(value, filter.value) <= 0;
+ case '>':
+ return compareNumeric(value, filter.value) > 0;
+ case '>=':
+ return compareNumeric(value, filter.value) >= 0;
+ case 'is null':
+ return value === null;
+ case 'is not null':
+ return value !== null;
+ case 'glob':
+ if (typeof value === 'string' && typeof filter.value === 'string') {
+ // Simple glob matching - convert glob to regex
+ const regexPattern = filter.value
+ .replace(/\*/g, '.*')
+ .replace(/\?/g, '.')
+ .replace(/\[!([^\]]+)\]/g, '[^$1]');
+ const regex = new RegExp(`^${regexPattern}$`);
+ return regex.test(value);
+ }
+ return false;
+ default:
+ return false;
+ }
+ });
+ });
+ }
+
+ private applySorting(
+ data: ReadonlyArray<RowDef>,
+ sortBy: SortBy,
+ ): ReadonlyArray<RowDef> {
+ if (sortBy.direction === 'unsorted') {
+ return data;
+ }
+
+ const sortColumn = (sortBy as SortByColumn).column;
+ const sortDirection = (sortBy as SortByColumn).direction;
+
+ return [...data].sort((a, b) => {
+ const valueA = a[sortColumn];
+ const valueB = b[sortColumn];
+
+ // Handle null values - they come first in ascending, last in descending
+ if (valueA === null && valueB === null) return 0;
+ if (valueA === null) return sortDirection === 'asc' ? -1 : 1;
+ if (valueB === null) return sortDirection === 'asc' ? 1 : -1;
+
+ if (typeof valueA === 'number' && typeof valueB === 'number') {
+ return sortDirection === 'asc' ? valueA - valueB : valueB - valueA;
+ }
+
+ if (typeof valueA === 'bigint' && typeof valueB === 'bigint') {
+ return sortDirection === 'asc'
+ ? Number(valueA - valueB)
+ : Number(valueB - valueA);
+ }
+
+ if (typeof valueA === 'string' && typeof valueB === 'string') {
+ return sortDirection === 'asc'
+ ? valueA.localeCompare(valueB)
+ : valueB.localeCompare(valueA);
+ }
+
+ if (valueA instanceof Uint8Array && valueB instanceof Uint8Array) {
+ // Compare by length for Uint8Arrays
+ return sortDirection === 'asc'
+ ? valueA.length - valueB.length
+ : valueB.length - valueA.length;
+ }
+
+ // Default comparison using string conversion
+ const strA = String(valueA);
+ const strB = String(valueB);
+ return sortDirection === 'asc'
+ ? strA.localeCompare(strB)
+ : strB.localeCompare(strA);
+ });
+ }
+}
+
+// Compare values, using a special deep comparison for Uint8Arrays.
+function valuesEqual(a: SqlValue, b: SqlValue): boolean {
+ if (a === b) {
+ return true;
+ }
+
+ if (a instanceof Uint8Array && b instanceof Uint8Array) {
+ if (a.length !== b.length) {
+ return false;
+ }
+
+ for (let i = 0; i < a.length; i++) {
+ if (a[i] !== b[i]) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ return false;
+}
+
+function isNumeric(value: SqlValue): value is number | bigint {
+ return typeof value === 'number' || typeof value === 'bigint';
+}
+
+/**
+ * Compare two numeric values (number or bigint).
+ *
+ * @returns Returns > 0 if a > b, < 0 if a < b, 0 if a == b.
+ */
+function compareNumeric(a: SqlValue, b: SqlValue): number {
+ // Handle the null cases - null is always considered smaller than a numerical
+ // value to match sqlite.
+ if (a === null && b === null) return 0;
+ if (a === null) return -1;
+ if (b === null) return 1;
+
+ if (!isNumeric(a) || !isNumeric(b)) {
+ throw new Error('Cannot compare non-numeric values');
+ }
+
+ if (typeof a === 'number' && typeof b === 'number') {
+ return a - b;
+ } else if (typeof a === 'bigint' && typeof b === 'bigint') {
+ return Number(a - b);
+ } else {
+ // One is a number and the other is a bigint. We've lost precision anyway,
+ // so just convert both to numbers.
+ return Number(a) - Number(b);
+ }
+}
diff --git a/ui/src/components/widgets/data_grid/in_memory_data_source_unittest.ts b/ui/src/components/widgets/data_grid/in_memory_data_source_unittest.ts
new file mode 100644
index 0000000..648f552
--- /dev/null
+++ b/ui/src/components/widgets/data_grid/in_memory_data_source_unittest.ts
@@ -0,0 +1,405 @@
+// Copyright (C) 2025 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 {InMemoryDataSource} from './in_memory_data_source';
+import {FilterDefinition, RowDef, SortBy} from './common';
+
+describe('InMemoryDataSource', () => {
+ const sampleData: ReadonlyArray<RowDef> = [
+ {
+ id: 1,
+ name: 'Alice',
+ value: 100,
+ active: 1,
+ tag: 'A',
+ blob: new Uint8Array([1, 2]),
+ },
+ {
+ id: 2,
+ name: 'Bob',
+ value: 200,
+ active: 0,
+ tag: 'B',
+ blob: new Uint8Array([3, 4, 5]),
+ },
+ {id: 3, name: 'Charlie', value: 150, active: 1, tag: 'A', blob: null},
+ {
+ id: 4,
+ name: 'David',
+ value: null,
+ active: 0,
+ tag: 'C',
+ blob: new Uint8Array([6]),
+ },
+ {
+ id: 5,
+ name: 'Eve',
+ value: 100,
+ active: 1,
+ tag: 'B',
+ blob: new Uint8Array([7, 8, 9, 0]),
+ },
+ {
+ id: 6,
+ name: 'Mallory',
+ value: 300n,
+ active: 0,
+ tag: 'C',
+ blob: new Uint8Array([0]),
+ },
+ {
+ id: 7,
+ name: 'Trent',
+ value: 250n,
+ active: 1,
+ tag: 'A',
+ blob: new Uint8Array([1, 1]),
+ },
+ ];
+
+ let dataSource: InMemoryDataSource;
+
+ beforeEach(() => {
+ dataSource = new InMemoryDataSource([...sampleData]); // Use a copy for each test
+ });
+
+ test('initialization', () => {
+ const result = dataSource.rows;
+ expect(result.rowOffset).toBe(0);
+ expect(result.totalRows).toBe(sampleData.length);
+ expect(result.rows).toEqual(sampleData);
+ });
+
+ describe('filtering', () => {
+ test('equality filter', () => {
+ const filters: FilterDefinition[] = [
+ {column: 'name', op: '=', value: 'Alice'},
+ ];
+ dataSource.notifyUpdate({direction: 'unsorted'}, filters);
+ const result = dataSource.rows;
+ expect(result.totalRows).toBe(1);
+ expect(result.rows[0].name).toBe('Alice');
+ });
+
+ test('inequality filter', () => {
+ const filters: FilterDefinition[] = [
+ {column: 'active', op: '!=', value: 1},
+ ];
+ dataSource.notifyUpdate({direction: 'unsorted'}, filters);
+ const result = dataSource.rows;
+ expect(result.totalRows).toBe(3); // Bob, David, Mallory
+ result.rows.forEach((row) => expect(row.active).toBe(0));
+ });
+
+ test('less than filter', () => {
+ const filters: FilterDefinition[] = [
+ {column: 'value', op: '<', value: 150},
+ ];
+ dataSource.notifyUpdate({direction: 'unsorted'}, filters);
+ const result = dataSource.rows;
+ // David (null), Alice (100), Eve (100)
+ expect(result.totalRows).toBe(3);
+ expect(result.rows.map((r) => r.id).sort()).toEqual([1, 4, 5]);
+ });
+
+ test('less than or equal filter', () => {
+ const filters: FilterDefinition[] = [
+ {column: 'value', op: '<=', value: 150},
+ ];
+ dataSource.notifyUpdate({direction: 'unsorted'}, filters);
+ const result = dataSource.rows;
+ // David (null), Alice (100), Charlie (150), Eve (100)
+ expect(result.totalRows).toBe(4);
+ expect(result.rows.map((r) => r.id).sort()).toEqual([1, 3, 4, 5]);
+ });
+
+ test('greater than filter', () => {
+ const filters: FilterDefinition[] = [
+ {column: 'value', op: '>', value: 200},
+ ];
+ dataSource.notifyUpdate({direction: 'unsorted'}, filters);
+ const result = dataSource.rows;
+ expect(result.totalRows).toBe(2); // Mallory (300n), Trent (250n)
+ expect(result.rows.map((r) => r.id).sort()).toEqual([6, 7]);
+ });
+
+ test('greater than or equal filter with bigint', () => {
+ const filters: FilterDefinition[] = [
+ {column: 'value', op: '>=', value: 250n},
+ ];
+ dataSource.notifyUpdate({direction: 'unsorted'}, filters);
+ const result = dataSource.rows;
+ expect(result.totalRows).toBe(2); // Mallory, Trent
+ expect(result.rows.map((r) => r.id).sort()).toEqual([6, 7]);
+ });
+
+ test('is null filter', () => {
+ const filters: FilterDefinition[] = [{column: 'value', op: 'is null'}];
+ dataSource.notifyUpdate({direction: 'unsorted'}, filters);
+ const result = dataSource.rows;
+ expect(result.totalRows).toBe(1);
+ expect(result.rows[0].id).toBe(4); // David
+ });
+
+ test('is not null filter', () => {
+ const filters: FilterDefinition[] = [{column: 'blob', op: 'is not null'}];
+ dataSource.notifyUpdate({direction: 'unsorted'}, filters);
+ const result = dataSource.rows;
+ expect(result.totalRows).toBe(6); // All except Charlie
+ expect(result.rows.find((r) => r.id === 3)).toBeUndefined();
+ });
+
+ test('glob filter', () => {
+ const filters: FilterDefinition[] = [
+ {column: 'name', op: 'glob', value: 'A*e'},
+ ];
+ dataSource.notifyUpdate({direction: 'unsorted'}, filters);
+ const result = dataSource.rows;
+ expect(result.totalRows).toBe(1);
+ expect(result.rows[0].name).toBe('Alice');
+ });
+
+ test('glob filter with ?', () => {
+ const filters: FilterDefinition[] = [
+ {column: 'name', op: 'glob', value: 'B?b'},
+ ];
+ dataSource.notifyUpdate({direction: 'unsorted'}, filters);
+ const result = dataSource.rows;
+ expect(result.totalRows).toBe(1);
+ expect(result.rows[0].name).toBe('Bob');
+ });
+
+ test('multiple filters', () => {
+ const filters: FilterDefinition[] = [
+ {column: 'active', op: '=', value: 1},
+ {column: 'tag', op: '=', value: 'A'},
+ ];
+ dataSource.notifyUpdate({direction: 'unsorted'}, filters);
+ const result = dataSource.rows;
+ expect(result.totalRows).toBe(3); // Alice, Charlie, Trent
+ result.rows.forEach((row) => {
+ expect(row.active).toBe(1);
+ expect(row.tag).toBe('A');
+ });
+ });
+
+ test('no matching rows filter', () => {
+ const filters: FilterDefinition[] = [
+ {column: 'name', op: '=', value: 'NonExistent'},
+ ];
+ dataSource.notifyUpdate({direction: 'unsorted'}, filters);
+ const result = dataSource.rows;
+ expect(result.totalRows).toBe(0);
+ expect(result.rows.length).toBe(0);
+ });
+ });
+
+ describe('sorting', () => {
+ test('sort by string ascending', () => {
+ const sortBy: SortBy = {column: 'name', direction: 'asc'};
+ dataSource.notifyUpdate(sortBy, []);
+ const result = dataSource.rows;
+ expect(result.rows.map((r) => r.name)).toEqual([
+ 'Alice',
+ 'Bob',
+ 'Charlie',
+ 'David',
+ 'Eve',
+ 'Mallory',
+ 'Trent',
+ ]);
+ });
+
+ test('sort by string descending', () => {
+ const sortBy: SortBy = {column: 'name', direction: 'desc'};
+ dataSource.notifyUpdate(sortBy, []);
+ const result = dataSource.rows;
+ expect(result.rows.map((r) => r.name)).toEqual([
+ 'Trent',
+ 'Mallory',
+ 'Eve',
+ 'David',
+ 'Charlie',
+ 'Bob',
+ 'Alice',
+ ]);
+ });
+
+ test('sort by number ascending (includes nulls)', () => {
+ const sortBy: SortBy = {column: 'value', direction: 'asc'};
+ dataSource.notifyUpdate(sortBy, []);
+ const result = dataSource.rows;
+ // Nulls first, then 100, 100, 150, 200, 250n, 300n
+ expect(result.rows.map((r) => r.id)).toEqual([4, 1, 5, 3, 2, 7, 6]);
+ });
+
+ test('sort by number descending (includes nulls and bigint)', () => {
+ const sortBy: SortBy = {column: 'value', direction: 'desc'};
+ dataSource.notifyUpdate(sortBy, []);
+ const result = dataSource.rows;
+ // 300n, 250n, 200, 150, 100, 100, Nulls last
+ expect(result.rows.map((r) => r.id)).toEqual([6, 7, 2, 3, 1, 5, 4]);
+ });
+
+ test('sort by boolean ascending', () => {
+ const sortBy: SortBy = {column: 'active', direction: 'asc'}; // 0 then 1
+ dataSource.notifyUpdate(sortBy, []);
+ const result = dataSource.rows;
+ expect(result.rows.map((r) => r.active)).toEqual([
+ 0,
+ 0,
+ 0,
+ 1,
+ 1,
+ 1,
+ 1,
+ ]);
+ });
+
+ test('sort by Uint8Array ascending (by length)', () => {
+ const sortBy: SortBy = {column: 'blob', direction: 'asc'};
+ dataSource.notifyUpdate(sortBy, []);
+ const result = dataSource.rows;
+ // null (Charlie, id:3), len 1 (David id:4, Mallory id:6), len 2 (Alice id:1, Trent id:7), len 3 (Bob id:2), len 4 (Eve id:5)
+ // Original order for same length: David before Mallory, Alice before Trent.
+ expect(result.rows.map((r) => r.id)).toEqual([3, 4, 6, 1, 7, 2, 5]);
+ });
+
+ test('sort by Uint8Array descending (by length)', () => {
+ const sortBy: SortBy = {column: 'blob', direction: 'desc'};
+ dataSource.notifyUpdate(sortBy, []);
+ const result = dataSource.rows;
+ // len 4, len 3, len 2, len 2, len 1, len 0, null
+ expect(result.rows.map((r) => r.id)).toEqual([5, 2, 1, 7, 4, 6, 3]);
+ });
+
+ test('unsorted', () => {
+ const sortBy: SortBy = {direction: 'unsorted'};
+ // Apply some sort first
+ dataSource.notifyUpdate({column: 'name', direction: 'asc'}, []);
+ // Then unsort
+ dataSource.notifyUpdate(sortBy, []);
+ const result = dataSource.rows;
+ // Should revert to original order if no filters applied
+ expect(result.rows.map((r) => r.id)).toEqual(sampleData.map((r) => r.id));
+ });
+ });
+
+ describe('combined filtering and sorting', () => {
+ test('filter then sort', () => {
+ const filters: FilterDefinition[] = [
+ {column: 'active', op: '=', value: 1},
+ ];
+ const sortBy: SortBy = {column: 'value', direction: 'desc'};
+ dataSource.notifyUpdate(sortBy, filters);
+ const result = dataSource.rows;
+ // Active: Alice (100), Charlie (150), Eve (100), Trent (250n)
+ // Sorted by value desc: Trent, Charlie, Alice, Eve (Alice/Eve order by original due to stable sort on value)
+ expect(result.rows.map((r) => r.id)).toEqual([7, 3, 1, 5]);
+ result.rows.forEach((row) => expect(row.active).toBe(1));
+ });
+ });
+
+ describe('caching behavior', () => {
+ test('data is not reprocessed if sortBy and filters are identical', () => {
+ const filters: FilterDefinition[] = [
+ {column: 'tag', op: '=', value: 'A'},
+ ];
+ const sortBy: SortBy = {column: 'name', direction: 'asc'};
+
+ dataSource.notifyUpdate(sortBy, filters);
+ const result1 = dataSource.rows.rows; // Access internal array
+
+ // Spy on internal methods if possible, or check object identity
+ // For this test, we'll check if the returned array reference is the same
+ dataSource.notifyUpdate(sortBy, filters); // Identical call
+ const result2 = dataSource.rows.rows;
+
+ expect(result1).toBe(result2); // Should be the same array instance due to caching
+ });
+
+ test('data is reprocessed if sortBy changes', () => {
+ const filters: FilterDefinition[] = [
+ {column: 'tag', op: '=', value: 'A'},
+ ];
+ const sortBy1: SortBy = {column: 'name', direction: 'asc'};
+ const sortBy2: SortBy = {column: 'name', direction: 'desc'};
+
+ dataSource.notifyUpdate(sortBy1, filters);
+ const result1 = dataSource.rows.rows;
+
+ dataSource.notifyUpdate(sortBy2, filters); // Different sort
+ const result2 = dataSource.rows.rows;
+
+ expect(result1).not.toBe(result2);
+ expect(result1.map((r) => r.id)).not.toEqual(result2.map((r) => r.id));
+ });
+
+ test('data is reprocessed if filters change', () => {
+ const filters1: FilterDefinition[] = [
+ {column: 'tag', op: '=', value: 'A'},
+ ];
+ const filters2: FilterDefinition[] = [
+ {column: 'tag', op: '=', value: 'B'},
+ ];
+ const sortBy: SortBy = {column: 'name', direction: 'asc'};
+
+ dataSource.notifyUpdate(sortBy, filters1);
+ const result1 = dataSource.rows.rows;
+
+ dataSource.notifyUpdate(sortBy, filters2); // Different filters
+ const result2 = dataSource.rows.rows;
+
+ expect(result1).not.toBe(result2);
+ expect(result1.map((r) => r.id)).not.toEqual(result2.map((r) => r.id));
+ });
+
+ test('data is reprocessed if filter value changes (Uint8Array)', () => {
+ const filters1: FilterDefinition[] = [
+ {column: 'blob', op: '=', value: new Uint8Array([1, 2])},
+ ];
+ const filters2: FilterDefinition[] = [
+ {column: 'blob', op: '=', value: new Uint8Array([3, 4, 5])},
+ ];
+ const sortBy: SortBy = {direction: 'unsorted'};
+
+ dataSource.notifyUpdate(sortBy, filters1);
+ const result1 = dataSource.rows.rows;
+ expect(result1.length).toBe(1);
+ expect(result1[0].id).toBe(1);
+
+ dataSource.notifyUpdate(sortBy, filters2);
+ const result2 = dataSource.rows.rows;
+ expect(result2.length).toBe(1);
+ expect(result2[0].id).toBe(2);
+
+ expect(result1).not.toBe(result2);
+ });
+ });
+
+ test('empty data source', () => {
+ const emptyDataSource = new InMemoryDataSource([]);
+ const result = emptyDataSource.rows;
+ expect(result.rowOffset).toBe(0);
+ expect(result.totalRows).toBe(0);
+ expect(result.rows).toEqual([]);
+
+ emptyDataSource.notifyUpdate({column: 'id', direction: 'asc'}, [
+ {column: 'name', op: '=', value: 'test'},
+ ]);
+ const resultAfterUpdate = emptyDataSource.rows;
+ expect(resultAfterUpdate.totalRows).toBe(0);
+ expect(resultAfterUpdate.rows).toEqual([]);
+ });
+});
diff --git a/ui/src/components/widgets/data_grid/sql_data_source.ts b/ui/src/components/widgets/data_grid/sql_data_source.ts
new file mode 100644
index 0000000..5fa1e41
--- /dev/null
+++ b/ui/src/components/widgets/data_grid/sql_data_source.ts
@@ -0,0 +1,217 @@
+import {AsyncLimiter} from '../../../base/async_limiter';
+import {Engine} from '../../../trace_processor/engine';
+import {NUM, SqlValue} from '../../../trace_processor/query_result';
+import {runQueryForQueryTable} from '../../query_table/queries';
+import {
+ DataGridDataSource,
+ DataSourceResult,
+ FilterDefinition,
+ SortBy,
+ SortByColumn,
+} from './common';
+
+export class SQLDataSource implements DataGridDataSource {
+ private readonly engine: Engine;
+ private readonly baseQuery: string;
+ private readonly limiter = new AsyncLimiter();
+
+ // Previous query (for diffing)
+ private oldQuery = '';
+
+ // Query state
+ private cachedResult: DataSourceResult = {
+ totalRows: 0,
+ rows: [],
+ rowOffset: 0,
+ };
+
+ constructor(engine: Engine, query: string) {
+ this.engine = engine;
+ this.baseQuery = query;
+ }
+
+ /**
+ * Getter for the current rows result
+ */
+ get rows(): DataSourceResult {
+ return this.cachedResult;
+ }
+
+ /**
+ * Notify of parameter changes and trigger data update
+ */
+ notifyUpdate(
+ sortBy: SortBy,
+ filters: ReadonlyArray<FilterDefinition>,
+ offset: number,
+ limit: number,
+ ): void {
+ const query = this.buildQuery(filters, sortBy, limit, offset);
+ if (query !== this.oldQuery) {
+ this.oldQuery = query;
+ this.limiter.schedule(async () => {
+ try {
+ const result = await this.executeQueries(
+ filters,
+ sortBy,
+ limit,
+ offset,
+ );
+
+ if (result) {
+ this.cachedResult = result;
+ }
+ } catch (error) {
+ console.error('Error executing query:', error);
+ }
+ });
+ }
+ }
+
+ /**
+ * Builds a complete SQL query with filtering, sorting, and pagination
+ */
+ private buildQuery(
+ filters: ReadonlyArray<FilterDefinition>,
+ sortBy: SortBy,
+ limit: number,
+ offset: number,
+ ): string {
+ // Wrap the base query as a subquery
+ let query = `WITH base_data AS (${this.baseQuery})`;
+
+ // Start the main query
+ query += `\nSELECT * FROM base_data`;
+
+ // Add WHERE clause if there are filters
+ if (filters.length > 0) {
+ const whereConditions = filters
+ .map((filter) => {
+ switch (filter.op) {
+ case '=':
+ return `${filter.column} = ${this.sqlValue(filter.value)}`;
+ case '!=':
+ return `${filter.column} != ${this.sqlValue(filter.value)}`;
+ case '<':
+ return `${filter.column} < ${this.sqlValue(filter.value)}`;
+ case '<=':
+ return `${filter.column} <= ${this.sqlValue(filter.value)}`;
+ case '>':
+ return `${filter.column} > ${this.sqlValue(filter.value)}`;
+ case '>=':
+ return `${filter.column} >= ${this.sqlValue(filter.value)}`;
+ case 'glob':
+ return `${filter.column} GLOB ${this.sqlValue(filter.value)}`;
+ case 'is null':
+ return `${filter.column} IS NULL`;
+ case 'is not null':
+ return `${filter.column} IS NOT NULL`;
+ default:
+ return '1=1'; // Default to true if unknown operator
+ }
+ })
+ .join(' AND ');
+
+ query += `\nWHERE ${whereConditions}`;
+ }
+
+ // Add ORDER BY clause for sorting
+ if (sortBy.direction !== 'unsorted') {
+ const {column, direction} = sortBy as SortByColumn;
+ query += `\nORDER BY ${column} ${direction.toUpperCase()}`;
+ }
+
+ // Add pagination with LIMIT and OFFSET
+ query += `\nLIMIT ${limit} OFFSET ${offset}`;
+
+ return query;
+ }
+
+ /**
+ * Builds a count query to get the total number of rows (for pagination)
+ */
+ private buildCountQuery(filters: ReadonlyArray<FilterDefinition>): string {
+ // Wrap the base query as a subquery
+ let query = `WITH base_data AS (${this.baseQuery})`;
+
+ // Start the count query
+ query += `\nSELECT COUNT(*) as total_count FROM base_data`;
+
+ // Add WHERE clause if there are filters
+ if (filters.length > 0) {
+ const whereConditions = filters
+ .map((filter) => {
+ switch (filter.op) {
+ case '=':
+ case '!=':
+ case '<':
+ case '<=':
+ case '>':
+ case '>=':
+ return `${filter.column} ${filter.op} ${this.sqlValue(filter.value)}`;
+ case 'glob':
+ return `${filter.column} GLOB ${this.sqlValue(filter.value)}`;
+ case 'is null':
+ return `${filter.column} IS NULL`;
+ case 'is not null':
+ return `${filter.column} IS NOT NULL`;
+ default:
+ return '1=1'; // Default to true if unknown operator
+ }
+ })
+ .join(' AND ');
+
+ query += `\nWHERE ${whereConditions}`;
+ }
+
+ return query;
+ }
+
+ /**
+ * Converts a JavaScript value to a SQL string representation
+ */
+ private sqlValue(value: SqlValue): string {
+ if (typeof value === 'string') {
+ // Escape single quotes in strings
+ return `'${value.replace(/'/g, "''")}'`;
+ } else if (typeof value === 'number' || typeof value === 'bigint') {
+ return value.toString();
+ } else if (typeof value === 'boolean') {
+ return value ? '1' : '0';
+ } else {
+ // For other types, convert to string
+ return `'${String(value)}'`;
+ }
+ }
+
+ private async executeQueries(
+ filters: ReadonlyArray<FilterDefinition>,
+ sortBy: SortBy,
+ limit: number,
+ offset: number,
+ ): Promise<DataSourceResult | undefined> {
+ const countQuery = this.buildCountQuery(filters);
+ const countResult = await this.engine.query(countQuery);
+ const firstRow = countResult.maybeFirstRow({total_count: NUM});
+ if (!firstRow) {
+ return undefined;
+ }
+
+ const totalRows = firstRow.total_count;
+
+ // Build the data query
+ const dataQuery = this.buildQuery(filters, sortBy, limit, offset);
+ const dataResult = await runQueryForQueryTable(dataQuery, this.engine);
+
+ if (dataResult.error) {
+ console.error('Error executing data query:', dataResult.error);
+ return undefined;
+ }
+
+ return {
+ totalRows,
+ rows: dataResult.rows,
+ rowOffset: offset,
+ };
+ }
+}
diff --git a/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/query_canvas.ts b/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/query_canvas.ts
index 82edb3f..f8d87e2 100644
--- a/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/query_canvas.ts
+++ b/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/query_canvas.ts
@@ -64,7 +64,7 @@
{
trigger: m(Button, {
iconFilled: true,
- icon: Icons.MoreVert,
+ icon: Icons.ContextMenuAlt,
}),
},
attrs.renderNodeActionsMenuItems(node),
diff --git a/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/query_node_explorer.ts b/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/query_node_explorer.ts
index baa04c6..2e6221b 100644
--- a/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/query_node_explorer.ts
+++ b/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/query_node_explorer.ts
@@ -106,7 +106,7 @@
PopupMenu,
{
trigger: m(Button, {
- icon: Icons.MoreVert,
+ icon: Icons.ContextMenuAlt,
}),
},
[
diff --git a/ui/src/plugins/dev.perfetto.QueryPage/index.ts b/ui/src/plugins/dev.perfetto.QueryPage/index.ts
index ede0881..281b84b 100644
--- a/ui/src/plugins/dev.perfetto.QueryPage/index.ts
+++ b/ui/src/plugins/dev.perfetto.QueryPage/index.ts
@@ -18,12 +18,12 @@
runQueryForQueryTable,
} from '../../components/query_table/queries';
import {QueryTable} from '../../components/query_table/query_table';
+import {App} from '../../public/app';
+import {Flag} from '../../public/feature_flag';
import {PerfettoPlugin} from '../../public/plugin';
import {Trace} from '../../public/trace';
import {Editor} from '../../widgets/editor';
import {QueryPage} from './query_page';
-import {App} from '../../public/app';
-import {Flag} from '../../public/feature_flag';
export default class QueryPagePlugin implements PerfettoPlugin {
static readonly id = 'dev.perfetto.QueryPage';
diff --git a/ui/src/plugins/dev.perfetto.WidgetsPage/widgets_page.ts b/ui/src/plugins/dev.perfetto.WidgetsPage/widgets_page.ts
index 5f78fb8..29e04eb 100644
--- a/ui/src/plugins/dev.perfetto.WidgetsPage/widgets_page.ts
+++ b/ui/src/plugins/dev.perfetto.WidgetsPage/widgets_page.ts
@@ -62,6 +62,14 @@
import {parseAndPrintTree} from '../../base/perfetto_sql_lang/language';
import {CursorTooltip} from '../../widgets/cursor_tooltip';
import {MultiselectInput} from '../../widgets/multiselect_input';
+import {
+ DataGrid,
+ DataGridAttrs,
+} from '../../components/widgets/data_grid/data_grid';
+import {InMemoryDataSource} from '../../components/widgets/data_grid/in_memory_data_source';
+import {SQLDataSource} from '../../components/widgets/data_grid/sql_data_source';
+import {App} from '../../public/app';
+import {Engine} from '../../trace_processor/engine';
const DATA_ENGLISH_LETTER_FREQUENCY = {
table: [
@@ -667,8 +675,8 @@
};
}
-export class WidgetsPage implements m.ClassComponent {
- view() {
+export class WidgetsPage implements m.ClassComponent<{app: App}> {
+ view({attrs}: m.Vnode<{app: App}>) {
return m(
'.widgets-page',
m('h1', 'Widgets'),
@@ -1572,10 +1580,59 @@
showCloseButtons: true,
},
}),
+
+ renderWidgetShowcase({
+ label: 'DataGrid (memory backed)',
+ description: `An interactive data explorer and viewer.`,
+ renderWidget: ({readonlyFilters, readonlySorting, ...rest}) =>
+ m(DataGridShowcase, {
+ ...rest,
+ filters: readonlyFilters ? [] : undefined,
+ sortBy: readonlySorting ? {direction: 'unsorted'} : undefined,
+ }),
+ initialOpts: {
+ showFiltersInToolbar: true,
+ readonlyFilters: false,
+ readonlySorting: false,
+ },
+ }),
+
+ renderWidgetShowcase({
+ label: 'DataGrid (query backed)',
+ description: `An interactive data explorer and viewer - fetched from SQL.`,
+ renderWidget: ({readonlyFilters, readonlySorting, ...rest}) => {
+ const trace = attrs.app.trace;
+ if (trace) {
+ return m(DataGridSqlShowcase, {
+ ...rest,
+ engine: trace.engine,
+ filters: readonlyFilters ? [] : undefined,
+ sortBy: readonlySorting ? {direction: 'unsorted'} : undefined,
+ });
+ } else {
+ return 'Load a trace to start';
+ }
+ },
+ initialOpts: {
+ showFiltersInToolbar: true,
+ readonlyFilters: false,
+ readonlySorting: false,
+ },
+ }),
);
}
}
+function renderWidgetShowcase<T extends Options = {}>(attrs: {
+ label: string;
+ description?: string;
+ renderWidget(opts: T): m.Children;
+ initialOpts?: T;
+ wide?: boolean;
+}) {
+ return m(WidgetShowcase, attrs);
+}
+
function CursorTooltipShowcase() {
let show = false;
return {
@@ -1630,6 +1687,72 @@
};
}
+function DataGridShowcase() {
+ const dataSource = new InMemoryDataSource([
+ {
+ id: 1,
+ name: 'foo',
+ ts: 123n,
+ dur: 16n,
+ data: new Uint8Array(),
+ maybe_null: null,
+ },
+ {
+ id: 2,
+ name: 'bar',
+ ts: 185n,
+ dur: 4n,
+ data: new Uint8Array([1, 2, 3]),
+ maybe_null: 'Non null',
+ },
+ {
+ id: 3,
+ name: 'baz',
+ ts: 575n,
+ dur: 12n,
+ data: new Uint8Array([1, 2, 3]),
+ maybe_null: null,
+ },
+ ]);
+
+ return {
+ view({attrs}: m.Vnode<Partial<DataGridAttrs>>) {
+ return m(DataGrid, {
+ ...attrs,
+ columns: [
+ {name: 'id'},
+ {name: 'ts'},
+ {name: 'dur'},
+ {name: 'name'},
+ {name: 'data'},
+ {name: 'maybe_null'},
+ ],
+ dataSource,
+ });
+ },
+ };
+}
+
+function DataGridSqlShowcase(
+ vnode: m.Vnode<Partial<DataGridAttrs> & {engine: Engine}>,
+) {
+ const dataSource = new SQLDataSource(
+ vnode.attrs.engine,
+ 'SELECT * FROM slice',
+ );
+
+ return {
+ view({attrs}: m.Vnode<Partial<DataGridAttrs> & {engine: Engine}>) {
+ return m(DataGrid, {
+ ...attrs,
+ columns: [{name: 'id'}, {name: 'ts'}, {name: 'dur'}],
+ dataSource,
+ maxRowsPerPage: 10,
+ });
+ },
+ };
+}
+
class ModalShowcase implements m.ClassComponent {
private static counter = 0;
diff --git a/ui/src/test/queries.test.ts b/ui/src/test/queries.test.ts
index 9cb513a..174e428 100644
--- a/ui/src/test/queries.test.ts
+++ b/ui/src/test/queries.test.ts
@@ -41,10 +41,10 @@
await pth.waitForIdleAndScreenshot('query mode.png');
- page.locator('.pf-query-table').getByText('17806091326279').click();
+ page.locator('.pf-data-grid').getByText('17806091326279').click();
await pth.waitForIdleAndScreenshot('row 1 clicked.png');
- page.locator('.pf-query-table').getByText('17806092405136').click();
+ page.locator('.pf-data-grid').getByText('17806092405136').click();
await pth.waitForIdleAndScreenshot('row 2 clicked.png');
// Clear the omnibox
@@ -88,6 +88,6 @@
page.locator('.query-page .query-history .history-item').nth(1).dblclick();
await pth.waitForPerfettoIdle();
expect(
- await page.locator('.query-page .pf-query-table tbody tr').count(),
+ await page.locator('.query-page .pf-data-grid tbody tr').count(),
).toEqual(2);
});
diff --git a/ui/src/widgets/common.ts b/ui/src/widgets/common.ts
index cc20add..6134629 100644
--- a/ui/src/widgets/common.ts
+++ b/ui/src/widgets/common.ts
@@ -28,6 +28,7 @@
readonly title?: string;
readonly className?: string;
readonly onclick?: (e: PointerEvent) => void;
+ readonly ondblclick?: (e: PointerEvent) => void;
readonly onmouseover?: (e: MouseEvent) => void;
readonly onmouseout?: (e: MouseEvent) => void;
readonly onmousedown?: (e: MouseEvent) => void;