| // 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 {QuerySlot} from '../base/query_slot'; |
| import {Icons} from '../base/semantic_icons'; |
| import type {Trace} from '../public/trace'; |
| import type {Dataset, DatasetSchema} from '../trace_processor/dataset'; |
| import {NUM, type Row, type SqlValue} from '../trace_processor/query_result'; |
| import {sqlValueToSqliteString} from '../trace_processor/sql_utils'; |
| import { |
| createPerfettoTable, |
| type DisposableSqlEntity, |
| } from '../trace_processor/sql_utils'; |
| import {Anchor} from '../widgets/anchor'; |
| import {Button} from '../widgets/button'; |
| import {DetailsShell} from '../widgets/details_shell'; |
| import {Icon} from '../widgets/icon'; |
| import {Section} from '../widgets/section'; |
| import {Spinner} from '../widgets/spinner'; |
| import {Tooltip} from '../widgets/tooltip'; |
| import {Tree, TreeNode} from '../widgets/tree'; |
| import {extensions} from './extensions'; |
| import {DurationWidget} from './widgets/duration'; |
| import {HistogramSvg} from './widgets/charts_svg/histogram_svg'; |
| import { |
| type HistogramData, |
| SQLHistogramLoader, |
| } from './widgets/charts/histogram_loader'; |
| import {DataGrid, renderCell} from './widgets/datagrid/datagrid'; |
| import {SQLDataSource} from './widgets/datagrid/sql_data_source'; |
| import type { |
| ColumnSchema, |
| SchemaRegistry, |
| } from './widgets/datagrid/datagrid_schema'; |
| import type {Column, Filter} from './widgets/datagrid/model'; |
| import type { |
| SQLSchemaRegistry, |
| SQLTableSchema, |
| } from './widgets/datagrid/sql_schema'; |
| import {formatDuration} from './time_utils'; |
| |
| export function helpIcon(help: m.Children): m.Children { |
| return m( |
| Tooltip, |
| { |
| trigger: m(Icon, { |
| className: 'pf-section-title-with-help__icon', |
| icon: 'help_outline', |
| }), |
| }, |
| help, |
| ); |
| } |
| |
| // Same layout as DistributionSummary but with placeholder values, so the |
| // surrounding panel doesn't reflow when the real summary loads. |
| export function renderDistributionPlaceholder(): m.Children { |
| return m( |
| '.pf-distribution-summary.pf-distribution-summary--placeholder', |
| m('.pf-distribution-summary__placeholder-chart'), |
| m( |
| '.pf-distribution-summary__stats', |
| m( |
| Tree, |
| {bordered: true}, |
| m(TreeNode, {left: 'Count', right: '-'}), |
| m(TreeNode, {left: 'Total', right: '-'}), |
| m(TreeNode, {left: 'Min', right: '-'}), |
| m(TreeNode, {left: 'Mean', right: '-'}), |
| m(TreeNode, {left: 'Max', right: '-'}), |
| ), |
| m( |
| Tree, |
| {bordered: true}, |
| m(TreeNode, {left: 'p50', right: '-'}), |
| m(TreeNode, {left: 'p75', right: '-'}), |
| m(TreeNode, {left: 'p95', right: '-'}), |
| m(TreeNode, {left: 'p99', right: '-'}), |
| m(TreeNode, {left: 'p99.9', right: '-'}), |
| ), |
| ), |
| ); |
| } |
| |
| export function titleWithHelp(label: string, help: m.Children): m.Children { |
| return m('span.pf-section-title-with-help', label, helpIcon(help)); |
| } |
| |
| export const SCOPE_HELP = |
| 'Which slices the histogram and instances list are computed over. ' + |
| '"This track" stays inside the same kind of slices the selected one ' + |
| 'belongs to (e.g. main-thread slices only), useful when you care about ' + |
| 'a specific code path. "Across whole trace" pools matching slices from ' + |
| 'every track in the workspace, useful when the same name appears on ' + |
| 'many threads or processes and you want the full picture.'; |
| |
| export const HISTOGRAM_HELP = |
| 'Each bar groups matching slices by their duration: taller bars mean ' + |
| 'more slices ran for that long. Use this to spot the typical case ' + |
| 'versus outliers. A tight peak means consistent timing; a long right ' + |
| 'tail points to occasional slow runs that are usually worth ' + |
| 'investigating. The stats below summarize the same values; brushing a ' + |
| 'range on the chart focuses both the stats and the instances list on ' + |
| 'that subset.'; |
| |
| export const INSTANCES_HELP = |
| 'Every individual slice that matches the filter, one row per ' + |
| 'occurrence. Sorted by duration so the slowest sit at the top, which ' + |
| 'is usually where you want to start looking. Click an id to jump to ' + |
| 'that exact slice on the timeline and see the surrounding context ' + |
| '(what came before, what thread it ran on, what it overlapped with).'; |
| |
| interface DistributionStats { |
| readonly count: number; |
| readonly min: number; |
| readonly max: number; |
| readonly mean: number; |
| readonly sum: number; |
| readonly p50: number; |
| readonly p75: number; |
| readonly p95: number; |
| readonly p99: number; |
| readonly p999: number; |
| } |
| |
| // Computed once per source query so brushing doesn't shift the X-axis ticks. |
| interface NiceBuckets { |
| readonly minValue: number; |
| readonly bucketSize: number; |
| readonly bucketCount: number; |
| } |
| |
| const TARGET_BUCKETS = 50; |
| |
| // Round v UP to the nearest 1/2/5 * 10^n, producing tick-aligned bucket sizes. |
| function niceCeil(v: number): number { |
| if (v <= 0) return 1; |
| const exp = Math.floor(Math.log10(v)); |
| const base = Math.pow(10, exp); |
| const mantissa = v / base; |
| if (mantissa <= 1) return base; |
| if (mantissa <= 2) return 2 * base; |
| if (mantissa <= 5) return 5 * base; |
| return 10 * base; |
| } |
| |
| function computeNiceBuckets(min: number, max: number): NiceBuckets { |
| const range = Math.max(max - min, 1); |
| const bucketSize = niceCeil(range / TARGET_BUCKETS); |
| const minValue = Math.floor(min / bucketSize) * bucketSize; |
| const upper = Math.ceil(max / bucketSize) * bucketSize; |
| const bucketCount = Math.max(1, Math.round((upper - minValue) / bucketSize)); |
| return {minValue, bucketSize, bucketCount}; |
| } |
| |
| export interface DistributionInputs { |
| readonly trace: Trace; |
| readonly dataset: Dataset; |
| |
| // Optional extra equality filter applied on top of the dataset. |
| // The dataset must include this column in its schema. |
| readonly filter?: {readonly col: string; readonly eq: SqlValue}; |
| |
| readonly valueColumn: string; |
| } |
| |
| export interface DistributionSummaryAttrs extends DistributionInputs { |
| // When provided, the summary reads from this pre-materialized table instead |
| // of materializing one itself — used by DistributionPanel so the embedded |
| // summary and the instances grid share a single materialized table. |
| readonly sourceTable?: DisposableSqlEntity; |
| |
| readonly brush?: {readonly start: number; readonly end: number}; |
| readonly onBrushChange?: ( |
| brush: {readonly start: number; readonly end: number} | undefined, |
| ) => void; |
| |
| // When set, the histogram bucket containing this value is drawn in a |
| // distinct color — used to show "where does this slice's duration fall |
| // in the distribution?". |
| readonly highlightValue?: number; |
| } |
| |
| // Reusable left-half: histogram (with brush) + percentile stats. Materializes |
| // the filtered dataset as a Perfetto table so the histogram and stats share |
| // a single aggregation source. |
| export class DistributionSummary |
| implements m.ClassComponent<DistributionSummaryAttrs> |
| { |
| private readonly tableSlot = new QuerySlot<DisposableSqlEntity>(); |
| private readonly boundsSlot = new QuerySlot<NiceBuckets>(); |
| private readonly statsSlot = new QuerySlot<DistributionStats>(); |
| |
| private histogramLoader?: SQLHistogramLoader; |
| private histogramLoaderTableName?: string; |
| |
| view({attrs}: m.CVnode<DistributionSummaryAttrs>): m.Children { |
| const tableEntity = attrs.sourceTable ?? this.materializeSourceTable(attrs); |
| if (tableEntity === undefined) { |
| return m('.pf-distribution-summary__loading', m(Spinner, {easing: true})); |
| } |
| const tableName = tableEntity.name; |
| const bounds = this.boundsSlot.use({ |
| key: {tableName, valueColumn: attrs.valueColumn}, |
| queryFn: () => fetchBounds(attrs, tableName), |
| }); |
| const stats = this.statsSlot.use({ |
| key: {tableName, brush: attrs.brush}, |
| queryFn: () => fetchStats(attrs, tableName), |
| retainOn: ['brush'], |
| }); |
| const histogram = this.useHistogramLoader(attrs, tableName, bounds.data); |
| |
| return m( |
| '.pf-distribution-summary', |
| this.renderToolbar(attrs), |
| this.renderHistogram(attrs, histogram.data), |
| this.renderStats(attrs, stats.data, stats.isPending), |
| ); |
| } |
| |
| onremove(): void { |
| this.histogramLoader?.dispose(); |
| this.tableSlot.dispose(); |
| this.boundsSlot.dispose(); |
| this.statsSlot.dispose(); |
| } |
| |
| private materializeSourceTable( |
| attrs: DistributionSummaryAttrs, |
| ): DisposableSqlEntity | undefined { |
| const sourceQuery = buildSourceQuery(attrs, [attrs.valueColumn]); |
| return this.tableSlot.use({ |
| key: {sourceQuery}, |
| queryFn: () => |
| createPerfettoTable({engine: attrs.trace.engine, as: sourceQuery}), |
| }).data; |
| } |
| |
| private useHistogramLoader( |
| attrs: DistributionSummaryAttrs, |
| tableName: string, |
| bounds: NiceBuckets | undefined, |
| ) { |
| let loader = this.histogramLoader; |
| if (loader === undefined || this.histogramLoaderTableName !== tableName) { |
| loader?.dispose(); |
| loader = new SQLHistogramLoader({ |
| engine: attrs.trace.engine, |
| query: `SELECT ${attrs.valueColumn} FROM ${tableName}`, |
| valueColumn: attrs.valueColumn, |
| }); |
| this.histogramLoader = loader; |
| this.histogramLoaderTableName = tableName; |
| } |
| return loader.use({ |
| bucketCount: bounds?.bucketCount ?? TARGET_BUCKETS, |
| bucketSize: bounds?.bucketSize, |
| minValue: bounds?.minValue, |
| filter: |
| attrs.brush !== undefined |
| ? {min: attrs.brush.start, max: attrs.brush.end} |
| : undefined, |
| }); |
| } |
| |
| private renderToolbar(attrs: DistributionSummaryAttrs): m.Children { |
| const canReset = |
| attrs.brush !== undefined && attrs.onBrushChange !== undefined; |
| return m( |
| '.pf-distribution-summary__histogram-toolbar', |
| canReset && |
| m(Button, { |
| label: 'Reset selection', |
| icon: Icons.Close, |
| compact: true, |
| onclick: () => attrs.onBrushChange?.(undefined), |
| }), |
| ); |
| } |
| |
| private renderHistogram( |
| attrs: DistributionSummaryAttrs, |
| data: HistogramData | undefined, |
| ): m.Children { |
| const onBrushChange = attrs.onBrushChange; |
| return m(HistogramSvg, { |
| data, |
| height: 220, |
| xAxisLabel: attrs.valueColumn, |
| yAxisLabel: 'Count', |
| formatXValue: (v) => formatDuration(attrs.trace, BigInt(Math.round(v))), |
| onBrush: |
| onBrushChange === undefined ? undefined : (r) => onBrushChange(r), |
| selection: |
| attrs.brush ?? |
| (attrs.highlightValue !== undefined |
| ? {start: attrs.highlightValue, end: attrs.highlightValue} |
| : undefined), |
| }); |
| } |
| |
| private renderStats( |
| attrs: DistributionSummaryAttrs, |
| stats: DistributionStats | undefined, |
| isPending: boolean, |
| ): m.Children { |
| if (stats === undefined) { |
| return m( |
| '.pf-distribution-summary__stats', |
| isPending ? m(Spinner) : 'No data', |
| ); |
| } |
| const dur = (v: number) => |
| m(DurationWidget, {trace: attrs.trace, dur: BigInt(Math.round(v))}); |
| return m( |
| '.pf-distribution-summary__stats', |
| m( |
| Tree, |
| {bordered: true}, |
| m(TreeNode, {left: 'Count', right: stats.count.toLocaleString()}), |
| m(TreeNode, {left: 'Total', right: dur(stats.sum)}), |
| m(TreeNode, {left: 'Min', right: dur(stats.min)}), |
| m(TreeNode, {left: 'Mean', right: dur(stats.mean)}), |
| m(TreeNode, {left: 'Max', right: dur(stats.max)}), |
| ), |
| m( |
| Tree, |
| {bordered: true}, |
| m(TreeNode, {left: 'p50', right: dur(stats.p50)}), |
| m(TreeNode, {left: 'p75', right: dur(stats.p75)}), |
| m(TreeNode, {left: 'p95', right: dur(stats.p95)}), |
| m(TreeNode, {left: 'p99', right: dur(stats.p99)}), |
| m(TreeNode, {left: 'p99.9', right: dur(stats.p999)}), |
| ), |
| ); |
| } |
| } |
| |
| export interface DistributionPanelAttrs extends DistributionInputs { |
| // Id column used to navigate to a specific instance. |
| readonly idColumn: string; |
| |
| // SQL table name passed to selectSqlEvent on id-cell click. |
| readonly sqlTable: string; |
| |
| // Columns to display in the grid, in order. Must all be in dataset.schema. |
| readonly displayColumns: ReadonlyArray<string>; |
| |
| // Per-column overrides; defaults to the DataGrid's plain-value renderer. |
| readonly cellRenderers?: Readonly< |
| Record<string, (value: Row[string]) => m.Children> |
| >; |
| |
| readonly title?: string; |
| } |
| |
| // Two-pane "value distribution" tab: instances grid + histogram summary, |
| // both reading from a single materialized Perfetto table. |
| export class DistributionPanel |
| implements m.ClassComponent<DistributionPanelAttrs> |
| { |
| private readonly tableSlot = new QuerySlot<DisposableSqlEntity>(); |
| |
| private dataSource?: SQLDataSource; |
| private dataSourceTableName?: string; |
| private brush?: {start: number; end: number}; |
| |
| view({attrs}: m.CVnode<DistributionPanelAttrs>): m.Children { |
| const tableEntity = this.materializeSourceTable(attrs); |
| return m( |
| DetailsShell, |
| { |
| title: panelTitle(attrs), |
| description: attrs.sqlTable, |
| fillHeight: true, |
| buttons: this.renderAddDebugTrackButton(attrs), |
| }, |
| m( |
| '.pf-distribution-panel', |
| this.renderInstancesPane(attrs, tableEntity), |
| this.renderHistogramPane(attrs, tableEntity), |
| ), |
| ); |
| } |
| |
| private renderAddDebugTrackButton(attrs: DistributionPanelAttrs): m.Children { |
| return m(Button, { |
| label: 'Add debug track', |
| onclick: () => { |
| const baseQuery = buildSourceQuery(attrs, [ |
| attrs.idColumn, |
| attrs.valueColumn, |
| ...attrs.displayColumns, |
| ]); |
| const brush = this.brush; |
| const sqlSource = |
| brush === undefined |
| ? baseQuery |
| : `SELECT * FROM (${baseQuery}) WHERE ${attrs.valueColumn} ` + |
| `BETWEEN ${brush.start} AND ${brush.end}`; |
| extensions.addDebugSliceTrack({ |
| trace: attrs.trace, |
| data: {sqlSource}, |
| title: panelTitle(attrs), |
| }); |
| }, |
| }); |
| } |
| |
| onremove(): void { |
| this.dataSource?.dispose(); |
| this.tableSlot.dispose(); |
| } |
| |
| private materializeSourceTable( |
| attrs: DistributionPanelAttrs, |
| ): DisposableSqlEntity | undefined { |
| const sourceQuery = buildSourceQuery(attrs, [ |
| attrs.idColumn, |
| attrs.valueColumn, |
| ...attrs.displayColumns, |
| ]); |
| return this.tableSlot.use({ |
| key: {sourceQuery}, |
| queryFn: () => |
| createPerfettoTable({engine: attrs.trace.engine, as: sourceQuery}), |
| }).data; |
| } |
| |
| private renderInstancesPane( |
| attrs: DistributionPanelAttrs, |
| tableEntity: DisposableSqlEntity | undefined, |
| ): m.Children { |
| return m( |
| '.pf-distribution-panel__instances', |
| m( |
| Section, |
| {title: titleWithHelp('Instances', INSTANCES_HELP)}, |
| tableEntity === undefined |
| ? m(Spinner, {easing: true}) |
| : this.renderGrid(attrs, tableEntity), |
| ), |
| ); |
| } |
| |
| private renderHistogramPane( |
| attrs: DistributionPanelAttrs, |
| tableEntity: DisposableSqlEntity | undefined, |
| ): m.Children { |
| return m( |
| '.pf-distribution-panel__histogram', |
| m( |
| Section, |
| {title: titleWithHelp('Histogram', HISTOGRAM_HELP)}, |
| tableEntity === undefined |
| ? m(Spinner, {easing: true}) |
| : m(DistributionSummary, { |
| trace: attrs.trace, |
| dataset: attrs.dataset, |
| filter: attrs.filter, |
| valueColumn: attrs.valueColumn, |
| sourceTable: tableEntity, |
| brush: this.brush, |
| onBrushChange: (b) => { |
| this.brush = b; |
| }, |
| }), |
| ), |
| ); |
| } |
| |
| private renderGrid( |
| attrs: DistributionPanelAttrs, |
| tableEntity: DisposableSqlEntity, |
| ): m.Children { |
| const dataSource = this.useDataSource(attrs, tableEntity.name); |
| return m(DataGrid, { |
| schema: buildGridSchema(attrs, this.renderIdCell.bind(this)), |
| rootSchema: 'root', |
| data: dataSource, |
| filters: brushFilters(attrs.valueColumn, this.brush), |
| initialColumns: gridColumns(attrs), |
| fillHeight: true, |
| emptyStateMessage: 'No matching instances', |
| }); |
| } |
| |
| private useDataSource( |
| attrs: DistributionPanelAttrs, |
| tableName: string, |
| ): SQLDataSource { |
| let ds = this.dataSource; |
| if (ds === undefined || this.dataSourceTableName !== tableName) { |
| ds?.dispose(); |
| ds = new SQLDataSource({ |
| engine: attrs.trace.engine, |
| sqlSchema: buildSqlSchema(attrs, tableName), |
| rootSchemaName: 'root', |
| }); |
| this.dataSource = ds; |
| this.dataSourceTableName = tableName; |
| this.brush = undefined; |
| } |
| return ds; |
| } |
| |
| private renderIdCell( |
| attrs: DistributionPanelAttrs, |
| value: Row[string], |
| ): m.Children { |
| const cell = renderCell(value, attrs.idColumn); |
| const id = toNumericId(value); |
| if (id === undefined) return cell; |
| return m( |
| Anchor, |
| { |
| title: `Go to ${attrs.sqlTable} on the timeline`, |
| icon: Icons.UpdateSelection, |
| onclick: () => |
| attrs.trace.selection.selectSqlEvent(attrs.sqlTable, id, { |
| scrollToSelection: true, |
| }), |
| }, |
| cell, |
| ); |
| } |
| } |
| |
| function panelTitle(attrs: DistributionPanelAttrs): string { |
| if (attrs.title !== undefined) return attrs.title; |
| if (attrs.filter !== undefined) return String(attrs.filter.eq); |
| return attrs.valueColumn; |
| } |
| |
| function buildGridSchema( |
| attrs: DistributionPanelAttrs, |
| renderIdCell: ( |
| attrs: DistributionPanelAttrs, |
| value: Row[string], |
| ) => m.Children, |
| ): SchemaRegistry { |
| const rootSchema: ColumnSchema = {}; |
| for (const col of [attrs.idColumn, ...attrs.displayColumns]) { |
| const cellRenderer = |
| col === attrs.idColumn |
| ? (value: Row[string]) => renderIdCell(attrs, value) |
| : attrs.cellRenderers?.[col]; |
| rootSchema[col] = {title: col, cellRenderer}; |
| } |
| return {root: rootSchema}; |
| } |
| |
| function gridColumns(attrs: DistributionPanelAttrs): Column[] { |
| return [ |
| {id: attrs.idColumn, field: attrs.idColumn}, |
| ...attrs.displayColumns.map( |
| (field): Column => ({ |
| id: field, |
| field, |
| sort: field === attrs.valueColumn ? 'DESC' : undefined, |
| }), |
| ), |
| ]; |
| } |
| |
| function brushFilters( |
| valueColumn: string, |
| brush: {start: number; end: number} | undefined, |
| ): Filter[] { |
| if (brush === undefined) return []; |
| return [ |
| {field: valueColumn, op: '>=', value: brush.start}, |
| {field: valueColumn, op: '<=', value: brush.end}, |
| ]; |
| } |
| |
| function toNumericId(value: Row[string]): number | undefined { |
| if (typeof value === 'bigint') return Number(value); |
| if (typeof value === 'number') return value; |
| return undefined; |
| } |
| |
| function buildSourceQuery( |
| inputs: { |
| readonly dataset: Dataset; |
| readonly filter?: {readonly col: string; readonly eq: SqlValue}; |
| }, |
| columns: ReadonlyArray<string>, |
| ): string { |
| const baseQuery = inputs.dataset.query(requiredSchema(inputs, columns)); |
| if (inputs.filter === undefined) { |
| return baseQuery; |
| } |
| const literal = sqlValueToSqliteString(inputs.filter.eq); |
| return `SELECT * FROM (${baseQuery}) WHERE ${inputs.filter.col} = ${literal}`; |
| } |
| |
| function requiredSchema( |
| inputs: { |
| readonly dataset: Dataset; |
| readonly filter?: {readonly col: string; readonly eq: SqlValue}; |
| }, |
| columns: ReadonlyArray<string>, |
| ): DatasetSchema { |
| const schema: Record<string, SqlValue> = {}; |
| for (const col of new Set([ |
| ...columns, |
| ...(inputs.filter === undefined ? [] : [inputs.filter.col]), |
| ])) { |
| schema[col] = inputs.dataset.schema[col]; |
| } |
| return schema; |
| } |
| |
| function buildSqlSchema( |
| attrs: DistributionPanelAttrs, |
| tableName: string, |
| ): SQLSchemaRegistry { |
| const columns: SQLTableSchema['columns'] = {}; |
| columns[attrs.idColumn] = {}; |
| columns[attrs.valueColumn] = {}; |
| for (const col of attrs.displayColumns) { |
| columns[col] = {}; |
| } |
| return { |
| root: { |
| table: tableName, |
| primaryKey: attrs.idColumn, |
| columns, |
| }, |
| }; |
| } |
| |
| async function fetchBounds( |
| inputs: DistributionInputs, |
| tableName: string, |
| ): Promise<NiceBuckets> { |
| const v = inputs.valueColumn; |
| const result = await inputs.trace.engine.query(` |
| SELECT IFNULL(MIN(${v}), 0) AS minv, IFNULL(MAX(${v}), 0) AS maxv |
| FROM ${tableName} |
| `); |
| const it = result.iter({minv: NUM, maxv: NUM}); |
| if (!it.valid()) { |
| return {minValue: 0, bucketSize: 1, bucketCount: 1}; |
| } |
| return computeNiceBuckets(it.minv, it.maxv); |
| } |
| |
| async function fetchStats( |
| inputs: DistributionInputs, |
| tableName: string, |
| ): Promise<DistributionStats> { |
| const v = inputs.valueColumn; |
| const result = await inputs.trace.engine.query(` |
| SELECT |
| COUNT(*) AS cnt, |
| IFNULL(MIN(${v}), 0) AS minv, |
| IFNULL(MAX(${v}), 0) AS maxv, |
| IFNULL(AVG(${v}), 0) AS meanv, |
| IFNULL(SUM(${v}), 0) AS sumv, |
| IFNULL(PERCENTILE(${v}, 50), 0) AS p50, |
| IFNULL(PERCENTILE(${v}, 75), 0) AS p75, |
| IFNULL(PERCENTILE(${v}, 95), 0) AS p95, |
| IFNULL(PERCENTILE(${v}, 99), 0) AS p99, |
| IFNULL(PERCENTILE(${v}, 99.9), 0) AS p999 |
| FROM ${tableName} |
| `); |
| const it = result.iter({ |
| cnt: NUM, |
| minv: NUM, |
| maxv: NUM, |
| meanv: NUM, |
| sumv: NUM, |
| p50: NUM, |
| p75: NUM, |
| p95: NUM, |
| p99: NUM, |
| p999: NUM, |
| }); |
| if (!it.valid()) { |
| return { |
| count: 0, |
| min: 0, |
| max: 0, |
| mean: 0, |
| sum: 0, |
| p50: 0, |
| p75: 0, |
| p95: 0, |
| p99: 0, |
| p999: 0, |
| }; |
| } |
| return { |
| count: it.cnt, |
| min: it.minv, |
| max: it.maxv, |
| mean: it.meanv, |
| sum: it.sumv, |
| p50: it.p50, |
| p75: it.p75, |
| p95: it.p95, |
| p99: it.p99, |
| p999: it.p999, |
| }; |
| } |
| |
| // Opens DistributionPanel in an ephemeral tab keyed by sqlTable/value/filter |
| // so re-invoking with the same arguments reuses the existing tab. |
| export function openDistributionTab( |
| trace: Trace, |
| config: Omit<DistributionPanelAttrs, 'trace'>, |
| ): void { |
| const filterKey = |
| config.filter !== undefined |
| ? `${config.filter.col}=${sqlValueToSqliteString(config.filter.eq)}` |
| : '*'; |
| const title = panelTitle({trace, ...config}); |
| // Title is part of the URI so two tabs of distinct scope (e.g. across-track |
| // vs in-selection) can coexist for the same (table, column, filter). |
| const uri = `distribution#${config.sqlTable}/${config.valueColumn}/${filterKey}/${title}`; |
| |
| trace.tabs.registerTab({ |
| uri, |
| isEphemeral: true, |
| content: { |
| getTitle: () => title, |
| render: () => m(DistributionPanel, {trace, ...config}), |
| }, |
| }); |
| trace.tabs.showTab(uri); |
| } |