| // Copyright (C) 2026 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| import m from 'mithril'; |
| import {Box} from '../../widgets/box'; |
| import {Button} from '../../widgets/button'; |
| import {PopupPosition} from '../../widgets/popup'; |
| import {Tooltip} from '../../widgets/tooltip'; |
| import {Duration} from '../../base/time'; |
| import {BigtraceAsyncDataSource} from '../query/bigtrace_async_data_source'; |
| import { |
| formatCompact, |
| queryStore, |
| statusDisplayLabel, |
| TERMINAL_STATUSES, |
| } from '../query/query_store'; |
| import type {BigTraceEditorTab} from './query_tabs_state'; |
| |
| // "<1s" for sub-500ms runs so the user sees the query actually ran. |
| export function formatDurationS(ms: number): string { |
| if (ms < 500) return '<1s'; |
| return Duration.format(Duration.fromMillis(Math.round(ms / 1000) * 1000)); |
| } |
| |
| export function renderStatusBox(tab: BigTraceEditorTab): m.Children { |
| if (!tab.materialize || !tab.queryUuid) return false; |
| |
| const isTerminal = |
| tab.execution?.status !== undefined && |
| TERMINAL_STATUSES.has(tab.execution.status); |
| const processedRows = tab.execution?.processedRows ?? 0; |
| const hasNewData = !isTerminal && processedRows > tab.lastProcessedRows; |
| |
| let durationMs = 0; |
| if ( |
| isTerminal && |
| tab.execution?.endTime !== undefined && |
| tab.execution?.startTime !== undefined |
| ) { |
| durationMs = tab.execution.endTime - tab.execution.startTime; |
| } else if (!isTerminal) { |
| const start = |
| tab.execution?.startTime !== undefined |
| ? tab.execution.startTime |
| : tab.clientStartTime; |
| if (start !== undefined) { |
| durationMs = Date.now() - start; |
| } |
| } |
| |
| const status = tab.execution?.status ?? 'UNKNOWN'; |
| const processedTraces = tab.execution?.processedTraces ?? 0; |
| const totalTraces = tab.execution?.totalTraces ?? 0; |
| const durationStr = formatDurationS(durationMs); |
| |
| const leftGroup = m( |
| '.pf-bt-status-bar-group', |
| m( |
| 'div.pf-bt-status-bar-refresh', |
| m(Button, { |
| icon: 'refresh', |
| title: hasNewData |
| ? 'New data available. Click to refresh.' |
| : 'Refresh data', |
| onclick: () => refreshAsyncStatus(tab), |
| }), |
| hasNewData && |
| m('span.pf-bt-status-bar-notif', { |
| 'aria-label': 'New data available', |
| }), |
| ), |
| m( |
| 'span.pf-bt-status-bar-pill', |
| {className: `pf-bt-status-${status.toLowerCase().replace(/_/g, '-')}`}, |
| statusDisplayLabel(status), |
| ), |
| m( |
| 'span.pf-bt-status-bar-duration', |
| m('span.pf-bt-status-bar-duration-value', durationStr), |
| ), |
| ); |
| |
| const rowsStatClasses = [ |
| 'pf-bt-status-bar-stat', |
| 'pf-bt-status-bar-stat--rows', |
| processedRows === 0 && 'pf-bt-status-bar-stat--empty', |
| ] |
| .filter(Boolean) |
| .join(' '); |
| const rightGroupContent = m( |
| '.pf-bt-status-bar-group', |
| m( |
| 'span.pf-bt-status-bar-stat.pf-bt-status-bar-stat--traces', |
| m('span.pf-bt-status-bar-stat-label', 'Traces:'), |
| m( |
| 'span.pf-bt-status-bar-stat-value', |
| { |
| title: |
| `${processedTraces.toLocaleString()} of ` + |
| `${totalTraces.toLocaleString()}` + |
| (!isTerminal |
| ? ' — numerator lags the poll (≤3s); denominator is exact.' |
| : ''), |
| }, |
| formatCompact(processedTraces), |
| ), |
| renderInlineProgressBar(processedTraces, totalTraces, !isTerminal), |
| ), |
| m( |
| 'span', |
| {className: rowsStatClasses}, |
| m('span.pf-bt-status-bar-stat-label', 'Rows:'), |
| m( |
| 'span.pf-bt-status-bar-stat-value', |
| { |
| title: `${processedRows.toLocaleString()} of result limit ${tab.limit.toLocaleString()}`, |
| }, |
| formatCompact(processedRows), |
| ), |
| renderInlineProgressBar(processedRows, tab.limit, !isTerminal), |
| ), |
| ); |
| |
| const rightGroup = !isTerminal |
| ? m( |
| Tooltip, |
| { |
| trigger: rightGroupContent, |
| position: PopupPosition.Top, |
| }, |
| m( |
| '.pf-bt-status-bar-progress-tooltip', |
| m('div', `Traces: ${formatCompact(processedTraces)}`), |
| m('div', `Rows: ${formatCompact(processedRows)}`), |
| ), |
| ) |
| : rightGroupContent; |
| |
| return m( |
| Box, |
| { |
| className: isTerminal |
| ? 'pf-bt-status-bar' |
| : 'pf-bt-status-bar pf-bt-status-bar--running', |
| }, |
| leftGroup, |
| rightGroup, |
| ); |
| } |
| |
| function renderInlineProgressBar( |
| done: number, |
| total: number, |
| live: boolean, |
| ): m.Children { |
| if (!live) return null; |
| if (total <= 0) return null; |
| const pct = Math.max(0, Math.min(100, (done / total) * 100)); |
| return m( |
| 'span.pf-bt-inline-progress', |
| m('span.pf-bt-inline-progress-fill', { |
| style: {width: `${pct}%`}, |
| }), |
| ); |
| } |
| |
| async function refreshAsyncStatus(tab: BigTraceEditorTab): Promise<void> { |
| if (!tab.queryUuid) return; |
| try { |
| const status = await tab.queryClient?.getStatus( |
| tab.queryUuid, |
| tab.lifecycle.signal, |
| ); |
| if (status) { |
| queryStore.update(tab.queryUuid, { |
| processedRows: status.processedRows ?? 0, |
| processedTraces: status.processedTraces ?? 0, |
| totalTraces: status.totalTraces ?? 0, |
| status: status.status ?? 'N/A', |
| }); |
| } |
| } catch (e) { |
| console.error('Failed to fetch query status on refresh:', e); |
| } |
| if (tab.dataSource instanceof BigtraceAsyncDataSource) { |
| tab.dataSource.refresh(); |
| tab.lastProcessedRows = tab.execution?.processedRows ?? 0; |
| } |
| m.redraw(); |
| } |