[ui] ThreadStateTab
Refactor selection details panel for thread state to use BottomTab API.
Add Value API to support rendering JSON-like values in a panel.
Change-Id: Icb5b0725b0733fd1dc608b26cf0f06cbfbacb465
diff --git a/ui/src/controller/selection_controller.ts b/ui/src/controller/selection_controller.ts
index 4b4ee82..653bad6 100644
--- a/ui/src/controller/selection_controller.ts
+++ b/ui/src/controller/selection_controller.ts
@@ -22,7 +22,6 @@
STR_NULL,
} from '../common/query_result';
import {ChromeSliceSelection} from '../common/state';
-import {translateState} from '../common/thread_state';
import {fromNs, toNs} from '../common/time';
import {SliceDetails, ThreadStateDetails} from '../frontend/globals';
import {
@@ -311,19 +310,16 @@
return trackId;
}
+ // TODO(altimin): We currently rely on the ThreadStateDetails for supporting
+ // marking the area (the rest goes is handled by ThreadStateTab
+ // directly. Refactor it to be plugin-friendly and remove this.
async threadStateDetails(id: number) {
const query = `
SELECT
ts,
- thread_state.dur as dur,
- state,
- io_wait as ioWait,
- thread_state.utid as utid,
- thread_state.cpu as cpu,
- sched.id as id,
- thread_state.blocked_function as blockedFunction
+ thread_state.dur as dur
from thread_state
- left join sched using(ts) where thread_state.id = ${id}
+ where thread_state.id = ${id}
`;
const result = await this.args.engine.query(query);
@@ -332,25 +328,11 @@
const row = result.firstRow({
ts: NUM,
dur: NUM,
- state: STR_NULL,
- ioWait: NUM_NULL,
- utid: NUM,
- cpu: NUM_NULL,
- id: NUM_NULL,
- blockedFunction: STR_NULL,
});
const ts = row.ts;
const timeFromStart = fromNs(ts) - globals.state.traceTime.startSec;
const dur = fromNs(row.dur);
- const ioWait = row.ioWait === null ? undefined : row.ioWait > 0;
- const state = translateState(row.state || undefined, ioWait);
- const utid = row.utid;
- const cpu = row.cpu === null ? undefined : row.cpu;
- const sliceId = row.id === null ? undefined : row.id;
- const blockedFunction =
- row.blockedFunction === null ? undefined : row.blockedFunction;
- const selected: ThreadStateDetails =
- {ts: timeFromStart, dur, state, utid, cpu, sliceId, blockedFunction};
+ const selected: ThreadStateDetails = {ts: timeFromStart, dur};
publishThreadStateDetails(selected);
}
}
diff --git a/ui/src/frontend/details_panel.ts b/ui/src/frontend/details_panel.ts
index 9b7e617..d195b7e 100644
--- a/ui/src/frontend/details_panel.ts
+++ b/ui/src/frontend/details_panel.ts
@@ -39,7 +39,7 @@
import {PivotTableRedux} from './pivot_table_redux';
import {QueryTable} from './query_table';
import {SliceDetailsPanel} from './slice_details_panel';
-import {ThreadStatePanel} from './thread_state_panel';
+import {ThreadStateTab} from './thread_state_tab';
const UP_ICON = 'keyboard_arrow_up';
const DOWN_ICON = 'keyboard_arrow_down';
@@ -230,6 +230,15 @@
});
}
break;
+ case 'THREAD_STATE':
+ bottomTabList.addTab({
+ kind: ThreadStateTab.kind,
+ tag: currentSelectionTag,
+ config: {
+ id: newSelection.id,
+ },
+ });
+ break;
default:
bottomTabList.closeTabByTag(currentSelectionTag);
}
@@ -315,13 +324,6 @@
vnode: m(ChromeSliceDetailsPanel, {key: 'chrome_slice'}),
});
break;
- case 'THREAD_STATE':
- detailsPanels.push({
- key: 'current_selection',
- name: 'Current Selection',
- vnode: m(ThreadStatePanel, {key: 'thread_state'}),
- });
- break;
default:
break;
}
diff --git a/ui/src/frontend/globals.ts b/ui/src/frontend/globals.ts
index 0e5de55..b49e6cd 100644
--- a/ui/src/frontend/globals.ts
+++ b/ui/src/frontend/globals.ts
@@ -114,11 +114,6 @@
export interface ThreadStateDetails {
ts?: number;
dur?: number;
- state?: string;
- utid?: number;
- cpu?: number;
- sliceId?: number;
- blockedFunction?: string;
}
export interface FlamegraphDetails {
diff --git a/ui/src/frontend/popup_menu.ts b/ui/src/frontend/popup_menu.ts
index 863ddd1..e4c9190 100644
--- a/ui/src/frontend/popup_menu.ts
+++ b/ui/src/frontend/popup_menu.ts
@@ -23,6 +23,16 @@
callback: () => void;
}
+// Helper function for simplifying defining menus.
+export function menuItem(
+ text: string, action: () => void): RegularPopupMenuItem {
+ return {
+ itemType: 'regular',
+ text,
+ callback: action,
+ };
+}
+
export interface GroupPopupMenuItem {
itemType: 'group';
text: string;
diff --git a/ui/src/frontend/sql_types.ts b/ui/src/frontend/sql_types.ts
new file mode 100644
index 0000000..a2159e3
--- /dev/null
+++ b/ui/src/frontend/sql_types.ts
@@ -0,0 +1,67 @@
+// Copyright (C) 2023 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 {fromNs} from '../common/time';
+import {globals} from './globals';
+
+// Type-safe aliases for various flavours of ints Trace Processor exposes
+// (e.g. timestamp or ids into a given SQL table) and functions to work with
+// them.
+//
+// These rely on TypeScript's type branding: extending a number with additional
+// compile-time-only type information, which prevents "implicit" conversions
+// between different ids.
+
+// Timestamp (in nanoseconds) in the same time domain as Trace Processor is
+// exposing.
+export type TPTimestamp = number&{
+ __type: 'TPTimestamp'
+}
+
+// TODO: unify this with common/time.ts.
+export function toTraceTime(ts: TPTimestamp): number {
+ return fromNs(ts) - globals.state.traceTime.startSec;
+}
+
+// Unique id for a process, id into |process| table.
+export type Upid = number&{
+ __type: 'Upid'
+}
+
+export function asUpid(v: number): Upid;
+export function asUpid(v?: number): Upid|undefined;
+export function asUpid(v?: number): Upid|undefined {
+ return v as (Upid | undefined);
+}
+
+// Unique id for a thread, id into |thread| table.
+export type Utid = number&{
+ __type: 'Utid'
+}
+
+export function asUtid(v: number): Utid;
+export function asUtid(v?: number): Utid|undefined;
+export function asUtid(v?: number): Utid|undefined {
+ return v as (Utid | undefined);
+}
+
+// Id into |sched| SQL table.
+export type SchedSqlId = number&{
+ __type: 'SchedSqlId'
+}
+
+// Id into |thread_state| SQL table.
+export type ThreadStateSqlId = number&{
+ __type: 'ThreadStateSqlId'
+}
diff --git a/ui/src/frontend/sql_utils.ts b/ui/src/frontend/sql_utils.ts
new file mode 100644
index 0000000..40b5d72
--- /dev/null
+++ b/ui/src/frontend/sql_utils.ts
@@ -0,0 +1,47 @@
+// Copyright (C) 2023 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 {SortDirection} from '../common/state';
+
+interface OrderClause {
+ fieldName: string;
+ direction?: SortDirection;
+}
+
+// Interface for defining constraints which can be passed to a SQL query.
+export interface SQLConstraints {
+ where?: string[];
+ orderBy?: OrderClause[];
+ limit?: number;
+}
+
+// Formatting given constraints into a string which can be injected into
+// SQL query.
+export function constraintsToQueryFragment(c: SQLConstraints): string {
+ const result: string[] = [];
+ if (c.where && c.where.length > 0) {
+ result.push(`WHERE ${c.where.join(' and ')}`);
+ }
+ if (c.orderBy && c.orderBy.length > 0) {
+ const orderBys = c.orderBy.map((clause) => {
+ const direction = clause.direction ? ` ${clause.direction}` : '';
+ return `${clause.fieldName}${direction}`;
+ });
+ result.push(`ORDER BY ${orderBys.join(', ')}`);
+ }
+ if (c.limit) {
+ result.push(`LIMIT ${c.limit}`);
+ }
+ return result.join('\n');
+}
diff --git a/ui/src/frontend/sql_utils_unittest.ts b/ui/src/frontend/sql_utils_unittest.ts
new file mode 100644
index 0000000..d8e35ba
--- /dev/null
+++ b/ui/src/frontend/sql_utils_unittest.ts
@@ -0,0 +1,44 @@
+// Copyright (C) 2023 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 {constraintsToQueryFragment} from './sql_utils';
+
+// Clean up repeated whitespaces to allow for easier testing.
+function normalize(s: string): string {
+ return s.replace(/\s+/g, ' ');
+}
+
+test('constraintsToQueryFragment: where', () => {
+ expect(normalize(constraintsToQueryFragment({
+ where: ['ts > 1000', 'dur != 0'],
+ }))).toEqual('WHERE ts > 1000 and dur != 0');
+});
+
+test('constraintsToQueryFragment: order by', () => {
+ expect(normalize(constraintsToQueryFragment({
+ orderBy: [{fieldName: 'name'}, {fieldName: 'count', direction: 'DESC'}],
+ }))).toEqual('ORDER BY name, count DESC');
+});
+
+test('constraintsToQueryFragment: limit', () => {
+ expect(normalize(constraintsToQueryFragment({limit: 3}))).toEqual('LIMIT 3');
+});
+
+test('constraintsToQueryFragment: all', () => {
+ expect(normalize(constraintsToQueryFragment({
+ where: ['id != 1'],
+ orderBy: [{fieldName: 'ts'}],
+ limit: 1,
+ }))).toEqual('WHERE id != 1 ORDER BY ts LIMIT 1');
+});
diff --git a/ui/src/frontend/thread_and_process_info.ts b/ui/src/frontend/thread_and_process_info.ts
new file mode 100644
index 0000000..3d531d3
--- /dev/null
+++ b/ui/src/frontend/thread_and_process_info.ts
@@ -0,0 +1,120 @@
+// Copyright (C) 2023 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 {EngineProxy} from '../common/engine';
+import {NUM, NUM_NULL, STR, STR_NULL} from '../common/query_result';
+import {Upid, Utid} from './sql_types';
+
+// Interface definitions for process and thread-related information
+// and functions to extract them from SQL.
+
+// TODO(altimin): Current implementation ends up querying process and thread
+// information separately for each thread. Given that there is a limited
+// numer of threads and processes, it might be easier to fetch this information
+// once when loading the trace and then just look it up synchronously.
+
+export interface ProcessInfo {
+ upid: Upid;
+ pid?: number;
+ name?: string;
+ uid?: number;
+ packageName?: string;
+ versionCode?: number;
+}
+
+async function getProcessInfo(
+ engine: EngineProxy, upid: Upid): Promise<ProcessInfo> {
+ const it = (await engine.query(`
+ SELECT pid, name, uid FROM process WHERE upid = ${upid};
+ `)).iter({pid: NUM, name: STR_NULL, uid: NUM_NULL});
+ if (!it.valid()) {
+ return {upid};
+ }
+ const result: ProcessInfo = {
+ upid,
+ pid: it.pid || undefined,
+ name: it.name || undefined,
+ };
+
+ if (it.pid === null) {
+ return result;
+ }
+ result.pid = it.pid || undefined;
+
+ if (it.uid === undefined) {
+ return result;
+ }
+
+ const packageResult = await engine.query(`
+ SELECT
+ package_name as packageName,
+ version_code as versionCode
+ FROM package_list WHERE uid = ${it.uid};
+ `);
+ // The package_list table is not populated in some traces so we need to
+ // check if the result has returned any rows.
+ if (packageResult.numRows() > 0) {
+ const packageDetails = packageResult.firstRow({
+ packageName: STR,
+ versionCode: NUM,
+ });
+ result.packageName = packageDetails.packageName;
+ result.versionCode = packageDetails.versionCode || undefined;
+ }
+ return result;
+}
+
+function getDisplayName(name: string|undefined, id: number|undefined): string|
+ undefined {
+ if (name === undefined) {
+ return id === undefined ? undefined : `${id}`;
+ }
+ return id === undefined ? name : `${name} [${id}]`;
+}
+
+export function getProcessName(info?: ProcessInfo): string|undefined {
+ return getDisplayName(info?.name, info?.pid);
+}
+
+export interface ThreadInfo {
+ utid: Utid;
+ tid?: number;
+ name?: string;
+ process?: ProcessInfo;
+}
+
+export async function getThreadInfo(
+ engine: EngineProxy, utid: Utid): Promise<ThreadInfo> {
+ const it = (await engine.query(`
+ SELECT tid, name, upid
+ FROM thread
+ WHERE utid = ${utid};
+ `)).iter({tid: NUM, name: STR_NULL, upid: NUM_NULL});
+ if (!it.valid()) {
+ return {
+ utid,
+ };
+ }
+ const upid = it.upid as (Upid | null);
+ return {
+ utid,
+ tid: it.tid,
+ name: it.name || undefined,
+ process: upid ? await getProcessInfo(engine, upid) : undefined,
+ };
+}
+
+export function getThreadName(info?: ThreadInfo): string|undefined {
+ return getDisplayName(info?.name, info?.tid);
+}
diff --git a/ui/src/frontend/thread_state.ts b/ui/src/frontend/thread_state.ts
new file mode 100644
index 0000000..56c9260
--- /dev/null
+++ b/ui/src/frontend/thread_state.ts
@@ -0,0 +1,200 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use size 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 {Actions} from '../common/actions';
+import {EngineProxy} from '../common/engine';
+import {NUM, NUM_NULL, STR_NULL} from '../common/query_result';
+import {translateState} from '../common/thread_state';
+import {fromNs, timeToCode} from '../common/time';
+
+import {copyToClipboard} from './clipboard';
+import {globals} from './globals';
+import {menuItem} from './popup_menu';
+import {scrollToTrackAndTs} from './scroll_helper';
+import {
+ asUtid,
+ SchedSqlId,
+ ThreadStateSqlId,
+ toTraceTime,
+ TPTimestamp,
+} from './sql_types';
+import {constraintsToQueryFragment, SQLConstraints} from './sql_utils';
+import {
+ getProcessName,
+ getThreadInfo,
+ getThreadName,
+ ThreadInfo,
+} from './thread_and_process_info';
+import {dict, Dict, maybeValue, Value, value} from './value';
+
+// Representation of a single thread state object, corresponding to
+// a row for the |thread_slice| table.
+export interface ThreadState {
+ // Id into |thread_state| table.
+ threadStateSqlId: ThreadStateSqlId;
+ // Id of the corresponding entry in the |sched| table.
+ schedSqlId?: SchedSqlId;
+ // Timestamp of the the beginning of this thread state in nanoseconds.
+ ts: TPTimestamp;
+ // Duration of this thread state in nanoseconds.
+ dur: number;
+ // CPU id if this thread state corresponds to a thread running on the CPU.
+ cpu?: number;
+ // Human-readable name of this thread state.
+ state: string;
+ blockedFunction?: string;
+
+ thread?: ThreadInfo;
+ wakerThread?: ThreadInfo;
+}
+
+// Gets a list of thread state objects from Trace Processor with given
+// constraints.
+export async function getThreadStateFromConstraints(
+ engine: EngineProxy, constraints: SQLConstraints): Promise<ThreadState[]> {
+ const query = await engine.query(`
+ SELECT
+ thread_state.id as threadStateSqlId,
+ (select sched.id
+ from sched
+ where sched.ts=thread_state.ts and sched.utid=thread_state.utid
+ limit 1
+ ) as schedSqlId,
+ ts,
+ thread_state.dur as dur,
+ thread_state.cpu as cpu,
+ state,
+ thread_state.blocked_function as blockedFunction,
+ io_wait as ioWait,
+ thread_state.utid as utid,
+ waker_utid as wakerUtid
+ FROM thread_state
+ ${constraintsToQueryFragment(constraints)}`);
+ const it = query.iter({
+ threadStateSqlId: NUM,
+ schedSqlId: NUM_NULL,
+ ts: NUM,
+ dur: NUM,
+ cpu: NUM_NULL,
+ state: STR_NULL,
+ blockedFunction: STR_NULL,
+ ioWait: NUM_NULL,
+ utid: NUM,
+ wakerUtid: NUM_NULL,
+ });
+
+ const result: ThreadState[] = [];
+
+ for (; it.valid(); it.next()) {
+ const ioWait = it.ioWait === null ? undefined : it.ioWait > 0;
+ const wakerUtid = asUtid(it.wakerUtid || undefined);
+
+ // TODO(altimin): Consider fetcing thread / process info using a single
+ // query instead of one per row.
+ result.push({
+ threadStateSqlId: it.threadStateSqlId as ThreadStateSqlId,
+ schedSqlId: (it.schedSqlId as SchedSqlId | null) || undefined,
+ ts: it.ts as TPTimestamp,
+ dur: it.dur,
+ cpu: it.cpu || undefined,
+ state: translateState(it.state || undefined, ioWait),
+ blockedFunction: it.blockedFunction || undefined,
+ thread: await getThreadInfo(engine, asUtid(it.utid)),
+ wakerThread: wakerUtid ? await getThreadInfo(engine, wakerUtid) :
+ undefined,
+ });
+ }
+ return result;
+}
+
+export async function getThreadState(
+ engine: EngineProxy, id: number): Promise<ThreadState|undefined> {
+ const result = await getThreadStateFromConstraints(engine, {
+ where: [`id=${id}`],
+ });
+ if (result.length > 1) {
+ throw new Error(`thread_state table has more than one row with id ${id}`);
+ }
+ if (result.length === 0) {
+ return undefined;
+ }
+ return result[0];
+}
+
+export function goToSchedSlice(cpu: number, id: SchedSqlId, ts: TPTimestamp) {
+ let trackId;
+ for (const track of Object.values(globals.state.tracks)) {
+ if (track.kind === 'CpuSliceTrack' &&
+ (track.config as {cpu: number}).cpu === cpu) {
+ trackId = track.id;
+ }
+ }
+ if (!trackId) return;
+ globals.makeSelection(Actions.selectSlice({id, trackId}));
+ scrollToTrackAndTs(trackId, ts);
+}
+
+function stateToValue(
+ state: string,
+ cpu: number|undefined,
+ id: SchedSqlId|undefined,
+ ts: TPTimestamp): Value|null {
+ if (!state) {
+ return null;
+ }
+ if (id === undefined || cpu === undefined) {
+ return value(state);
+ }
+ return value(`${state} on CPU ${cpu}`, {
+ rightButton: {
+ action: () => {
+ goToSchedSlice(cpu, id, ts);
+ },
+ hoverText: 'Go to CPU slice',
+ },
+ });
+}
+
+export function threadStateToDict(state: ThreadState): Dict {
+ const result: {[name: string]: Value|null} = {};
+
+ result['Start time'] = value(timeToCode(toTraceTime(state.ts)));
+ result['Duration'] = value(timeToCode(fromNs(state.dur)));
+ result['State'] =
+ stateToValue(state.state, state.cpu, state.schedSqlId, state.ts);
+ result['Blocked function'] = maybeValue(state.blockedFunction);
+ const process = state?.thread?.process;
+ result['Process'] = maybeValue(process ? getProcessName(process) : undefined);
+ const thread = state?.thread;
+ result['Thread'] = maybeValue(thread ? getThreadName(thread) : undefined);
+ if (state.wakerThread) {
+ const process = state.wakerThread.process;
+ result['Waker'] = dict({
+ 'Process': maybeValue(process ? getProcessName(process) : undefined),
+ 'Thread': maybeValue(getThreadName(state.wakerThread)),
+ });
+ }
+ result['SQL id'] = value(`thread_state[${state.threadStateSqlId}]`, {
+ contextMenu: [
+ menuItem(
+ 'Copy SQL query',
+ () => {
+ copyToClipboard(`select * from thread_state where id=${
+ state.threadStateSqlId}`);
+ }),
+ ],
+ });
+
+ return dict(result);
+}
diff --git a/ui/src/frontend/thread_state_panel.ts b/ui/src/frontend/thread_state_panel.ts
deleted file mode 100644
index 337832a..0000000
--- a/ui/src/frontend/thread_state_panel.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use size 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 * as m from 'mithril';
-
-import {Actions} from '../common/actions';
-import {timeToCode, toNs} from '../common/time';
-
-import {globals} from './globals';
-import {Panel, PanelSize} from './panel';
-import {scrollToTrackAndTs} from './scroll_helper';
-
-
-export class ThreadStatePanel extends Panel {
- view() {
- const threadState = globals.threadStateDetails;
- if (threadState === undefined || threadState.utid === undefined ||
- threadState.ts === undefined || threadState.dur === undefined ||
- threadState.state === undefined) {
- return m('.details-panel');
- }
- const threadInfo = globals.threads.get(threadState.utid);
- if (threadInfo) {
- return m(
- '.details-panel',
- m('.details-panel-heading', m('h2', 'Thread State')),
- m('.details-table', [m('table', [
- m('tr',
- m('th', `Start time`),
- m('td', `${timeToCode(threadState.ts)}`)),
- m('tr',
- m('th', `Duration`),
- m(
- 'td',
- `${timeToCode(threadState.dur)} `,
- )),
- m('tr',
- m('th', `State`),
- m('td',
- this.getStateContent(
- threadState.state,
- threadState.cpu,
- threadState.sliceId,
- threadState.ts))),
- m('tr',
- m('th', `Process`),
- m('td', `${threadInfo.procName} [${threadInfo.pid}]`)),
- this.getBlockedFunctionContent(threadState.blockedFunction),
- ])]));
- }
- return m('.details-panel');
- }
-
- renderCanvas(_ctx: CanvasRenderingContext2D, _size: PanelSize) {}
-
- // If it is the running state, we want to show which CPU and a button to
- // go to the sched slice. Otherwise, just show the state.
- getStateContent(
- state: string, cpu: number|undefined, sliceId: number|undefined,
- ts: number) {
- if (sliceId === undefined || cpu === undefined) {
- return [state];
- }
-
- return [
- `${state} on CPU ${cpu}`,
- m(
- 'i.material-icons.grey',
- {
- onclick: () => {
- // TODO(hjd): Use trackId from TP.
- let trackId;
- for (const track of Object.values(globals.state.tracks)) {
- if (track.kind === 'CpuSliceTrack' &&
- (track.config as {cpu: number}).cpu === cpu) {
- trackId = track.id;
- }
- }
- if (trackId) {
- globals.makeSelection(
- Actions.selectSlice({id: sliceId, trackId}));
- scrollToTrackAndTs(
- trackId, toNs(ts + globals.state.traceTime.startSec));
- }
- },
- title: 'Go to CPU slice',
- },
- 'call_made'),
- ];
- }
-
- getBlockedFunctionContent(blockedFunction: string|undefined) {
- if (blockedFunction === undefined) {
- return null;
- }
- return m('tr', m('th', `Blocked Function`), m('td', blockedFunction));
- }
-}
diff --git a/ui/src/frontend/thread_state_tab.ts b/ui/src/frontend/thread_state_tab.ts
new file mode 100644
index 0000000..df8a775
--- /dev/null
+++ b/ui/src/frontend/thread_state_tab.ts
@@ -0,0 +1,75 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use size 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 * as m from 'mithril';
+
+import {BottomTab, bottomTabRegistry, NewBottomTabArgs} from './bottom_tab';
+import {globals} from './globals';
+import {ThreadStateSqlId} from './sql_types';
+import {getThreadState, ThreadState, threadStateToDict} from './thread_state';
+import {renderDict} from './value';
+
+interface ThreadStateTabConfig {
+ // Id into |thread_state| sql table.
+ readonly id: ThreadStateSqlId;
+}
+
+export class ThreadStateTab extends BottomTab<ThreadStateTabConfig> {
+ static readonly kind = 'org.perfetto.ThreadStateTab';
+
+ state?: ThreadState;
+ loaded: boolean = false;
+
+ static create(args: NewBottomTabArgs): ThreadStateTab {
+ return new ThreadStateTab(args);
+ }
+
+ constructor(args: NewBottomTabArgs) {
+ super(args);
+
+ getThreadState(this.engine, this.config.id).then((state?: ThreadState) => {
+ this.loaded = true;
+ this.state = state;
+ globals.rafScheduler.scheduleFullRedraw();
+ });
+ }
+
+ getTitle() {
+ // TODO(altimin): Support dynamic titles here.
+ return 'Current Selection';
+ }
+
+ renderTabContents(): m.Child {
+ if (!this.loaded) {
+ return m('h2', 'Loading');
+ }
+ if (!this.state) {
+ return m('h2', `Thread state ${this.config.id} does not exist`);
+ }
+ return renderDict(threadStateToDict(this.state));
+ }
+
+ viewTab() {
+ // TODO(altimin): Create a reusable component for showing the header and
+ // differentiate between "Current Selection" and "Pinned" views.
+ return m(
+ 'div.details-panel',
+ m('header.overview', m('span', 'Thread State')),
+ this.renderTabContents());
+ }
+
+ renderTabCanvas(): void {}
+}
+
+bottomTabRegistry.register(ThreadStateTab);
diff --git a/ui/src/frontend/value.ts b/ui/src/frontend/value.ts
new file mode 100644
index 0000000..2f14c07
--- /dev/null
+++ b/ui/src/frontend/value.ts
@@ -0,0 +1,189 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use size 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 * as m from 'mithril';
+
+import {PopupMenuButton, PopupMenuItem} from './popup_menu';
+
+// This file implements a component for rendering JSON-like values (with
+// customisation options like context menu and action buttons).
+//
+// It defines the common Value, StringValue, DictValue, ArrayValue types,
+// to be used as an interchangeable format between different components
+// and `renderValue` function to convert DictValue into vdom nodes.
+
+// Leaf (non-dict and non-array) value which can be displayed to the user
+// together with the rendering customisation parameters.
+type StringValue = {
+ kind: 'STRING',
+ value: string,
+}&StringValueParams;
+
+// Helper function to create a StringValue from string together with optional
+// parameters.
+export function value(value: string, params?: StringValueParams): StringValue {
+ return {
+ kind: 'STRING',
+ value,
+ ...params,
+ };
+}
+
+// Helper function to convert a potentially undefined value to StringValue or
+// null.
+export function maybeValue(v?: string, params?: StringValueParams): StringValue|
+ null {
+ if (!v) {
+ return null;
+ }
+ return value(v, params);
+}
+
+// A basic type for the JSON-like value, comprising a primitive type (string)
+// and composite types (arrays and dicts).
+export type Value = StringValue|Array|Dict;
+
+// Dictionary type.
+export type Dict = {
+ kind: 'DICT',
+ items: {[name: string]: Value},
+}&ValueParams;
+
+// Helper function to simplify creation of an dictionary.
+// This function accepts and filters out nulls as values in the passed
+// dictionary (useful for simplifying the code to render optional values).
+export function dict(
+ items: {[name: string]: Value|null}, params?: ValueParams): Dict {
+ const result: {[name: string]: Value} = {};
+ for (const [name, value] of Object.entries(items)) {
+ if (value !== null) {
+ result[name] = value;
+ }
+ }
+ return {
+ kind: 'DICT',
+ items: result,
+ ...params,
+ };
+}
+
+// Array type.
+export type Array = {
+ kind: 'ARRAY', items: Value[];
+}&ValueParams;
+
+// Helper function to simplify creation of an array.
+// This function accepts and filters out nulls in the passed array (useful for
+// simplifying the code to render optional values).
+export function array(items: (Value|null)[], params?: ValueParams): Array {
+ return {
+ kind: 'ARRAY',
+ items: items.filter((item: Value|null) => item !== null) as Value[],
+ ...params,
+ };
+}
+
+// Parameters for displaying a button next to a value to perform
+// the context-dependent action (i.e. go to the corresponding slice).
+type ButtonParams = {
+ action: () => void;
+ hoverText?: string;
+ icon?: string;
+}
+
+// Customisation parameters which apply to any Value (e.g. context menu).
+interface ValueParams {
+ contextMenu?: PopupMenuItem[];
+}
+
+// Customisation parameters which apply for a primitive value (e.g. showing
+// button next to a string, or making it clickable, or adding onhover effect).
+interface StringValueParams extends ValueParams {
+ leftButton?: ButtonParams;
+ rightButton?: ButtonParams;
+}
+
+export function isArray(value: Value): value is Array {
+ return value.kind === 'ARRAY';
+};
+
+export function isDict(value: Value): value is Dict {
+ return value.kind === 'DICT';
+};
+
+export function isStringValue(value: Value): value is StringValue {
+ return !isArray(value) && !isDict(value);
+};
+
+// Recursively render the given value and its children, returning a list of
+// vnodes corresponding to the nodes of the table.
+function*
+ renderValue(name: string, value: Value, depth: number): Generator<m.Child> {
+ const row = [
+ m('th',
+ {
+ style: `padding-left: ${15 * depth}px`,
+ },
+ name,
+ value.contextMenu ? m(PopupMenuButton, {
+ icon: 'arrow_drop_down',
+ items: value.contextMenu,
+ }) :
+ null),
+ ];
+ if (isArray(value)) {
+ yield m('tr', row);
+ for (let i = 0; i < value.items.length; ++i) {
+ yield* renderValue(`[${i}]`, value.items[i], depth + 1);
+ }
+ return;
+ } else if (isDict(value)) {
+ yield m('tr', row);
+ for (const key of Object.keys(value.items)) {
+ yield* renderValue(key, value.items[key], depth + 1);
+ }
+ return;
+ }
+ const renderButton = (button?: ButtonParams) => {
+ if (!button) {
+ return null;
+ }
+ return m(
+ 'i.material-icons.grey',
+ {
+ onclick: button.action,
+ title: button.hoverText,
+ },
+ button.icon ? button.icon : 'call_made');
+ };
+ if (value.kind === 'STRING') {
+ row.push(
+ m('td',
+ renderButton(value.leftButton),
+ m('span', value.value),
+ renderButton(value.rightButton)));
+ }
+ yield m('tr', row);
+}
+
+// Render a given dictionary into a vnode.
+export function renderDict(dict: Dict): m.Child {
+ const rows: m.Child[] = [];
+ for (const key of Object.keys(dict.items)) {
+ for (const vnode of renderValue(key, dict.items[key], 0)) {
+ rows.push(vnode);
+ }
+ }
+ return m('table.auto-layout', rows);
+}