Thread/process details tab

- Add implementations of "details" tab for threads and processes, which
  will allow the user to inspect the relevant details via the UI.
- Switch the slice details panel to use "thread / process ref" widget
  instead of just displaying the name, which would allow the user to
  inspect the relevant data using the UI.

R=stevegolton@google.com

Change-Id: I493c317244bb1a85191e63c32b9ba4ea91d5a065
diff --git a/test/data/ui-screenshots/ui-chrome_rendering_desktop_select_slice_with_flows.png.sha256 b/test/data/ui-screenshots/ui-chrome_rendering_desktop_select_slice_with_flows.png.sha256
index 5e02d8e..999c318 100644
--- a/test/data/ui-screenshots/ui-chrome_rendering_desktop_select_slice_with_flows.png.sha256
+++ b/test/data/ui-screenshots/ui-chrome_rendering_desktop_select_slice_with_flows.png.sha256
@@ -1 +1 @@
-9e7e068fe412696696c601d610dd15ca31d23d213223595b87951ee566479416
\ No newline at end of file
+82b026182d09d325c15160185b636917a54ab2431f0f7cb493cd5341bd7798c5
\ No newline at end of file
diff --git a/ui/src/common/addEphemeralTab.ts b/ui/src/common/addEphemeralTab.ts
index d04148c..066b5e7 100644
--- a/ui/src/common/addEphemeralTab.ts
+++ b/ui/src/common/addEphemeralTab.ts
@@ -14,14 +14,28 @@
 import {uuidv4} from '../base/uuid';
 import {BottomTab} from '../frontend/bottom_tab';
 import {globals} from '../frontend/globals';
+import {Tab} from '../public';
 import {BottomTabToTabAdapter} from '../public/utils';
+
 import {Actions} from './actions';
 
-export function addEphemeralTab(tab: BottomTab, uriPrefix: string): void {
+export function addEphemeralTab(uriPrefix: string, tab: Tab): void {
   const uri = `${uriPrefix}#${uuidv4()}`;
 
   globals.tabManager.registerTab({
     uri,
+    content: tab,
+    isEphemeral: true,
+  });
+
+  globals.dispatch(Actions.showTab({uri}));
+}
+
+export function addBottomTab(tab: BottomTab, uriPrefix: string): void {
+  const uri = `${uriPrefix}#${tab.uuid}`;
+
+  globals.tabManager.registerTab({
+    uri,
     content: new BottomTabToTabAdapter(tab),
     isEphemeral: true,
   });
diff --git a/ui/src/frontend/charts/histogram/tab.ts b/ui/src/frontend/charts/histogram/tab.ts
index 78a58b5..e5618eb 100644
--- a/ui/src/frontend/charts/histogram/tab.ts
+++ b/ui/src/frontend/charts/histogram/tab.ts
@@ -14,16 +14,17 @@
 
 import m from 'mithril';
 
-import {DetailsShell} from '../../../widgets/details_shell';
-import {uuidv4} from '../../../base/uuid';
-import {BottomTab, NewBottomTabArgs} from '../../bottom_tab';
-import {VegaView} from '../../../widgets/vega_view';
-import {addEphemeralTab} from '../../../common/addEphemeralTab';
-import {HistogramState} from './state';
 import {stringifyJsonWithBigints} from '../../../base/json_utils';
+import {uuidv4} from '../../../base/uuid';
+import {addBottomTab} from '../../../common/addEphemeralTab';
 import {Engine} from '../../../public';
+import {DetailsShell} from '../../../widgets/details_shell';
+import {VegaView} from '../../../widgets/vega_view';
+import {BottomTab, NewBottomTabArgs} from '../../bottom_tab';
 import {Filter, filterTitle} from '../../widgets/sql/table2/column';
 
+import {HistogramState} from './state';
+
 interface HistogramTabConfig {
   columnTitle: string; // Human readable column name (ex: Duration)
   sqlColumn: string; // SQL column name (ex: dur)
@@ -42,7 +43,7 @@
     uuid: uuidv4(),
   });
 
-  addEphemeralTab(histogramTab, 'histogramTab');
+  addBottomTab(histogramTab, 'histogramTab');
 }
 
 export class HistogramTab extends BottomTab<HistogramTabConfig> {
diff --git a/ui/src/frontend/get_engine.ts b/ui/src/frontend/get_engine.ts
new file mode 100644
index 0000000..4ea287c
--- /dev/null
+++ b/ui/src/frontend/get_engine.ts
@@ -0,0 +1,25 @@
+// Copyright (C) 2024 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 {assertExists} from '../base/logging';
+import {Engine} from '../public';
+
+import {globals} from './globals';
+
+// TODO(stevegolton): Find a way to make this more elegant.
+export function getEngine(tag: string): Engine {
+  const engConfig = globals.getCurrentEngine();
+  const engineId = assertExists(engConfig).id;
+  return assertExists(globals.engines.get(engineId)).getProxy(tag);
+}
diff --git a/ui/src/frontend/process_details_tab.ts b/ui/src/frontend/process_details_tab.ts
new file mode 100644
index 0000000..3a1274c
--- /dev/null
+++ b/ui/src/frontend/process_details_tab.ts
@@ -0,0 +1,79 @@
+// Copyright (C) 2024 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 {Engine, Tab} from '../public';
+import {Upid} from '../trace_processor/sql_utils/core_types';
+import {DetailsShell} from '../widgets/details_shell';
+import {GridLayout, GridLayoutColumn} from '../widgets/grid_layout';
+import {Section} from '../widgets/section';
+
+import {Details, DetailsSchema} from './widgets/sql/details/details';
+import {wellKnownTypes} from './widgets/sql/details/well_known_types';
+
+import d = DetailsSchema;
+
+export class ProcessDetailsTab implements Tab {
+  private data: Details;
+
+  // TODO(altimin): Ideally, we would not require the pid to be passed in, but
+  // fetch it from the underlying data instead.
+  //
+  // However, the only place which creates `ProcessDetailsTab` currently is `renderProcessRef`,
+  // which already has `pid` available (note that Details is already fetching the data, including
+  // the `pid` from the trace processor, but it doesn't expose it for now).
+  constructor(private args: {engine: Engine; upid: Upid; pid?: number}) {
+    this.data = new Details(
+      args.engine,
+      'process',
+      args.upid,
+      {
+        'pid': d.Value('pid'),
+        'Name': d.Value('name'),
+        'Start time': d.Timestamp('start_ts', {skipIfNull: true}),
+        'End time': d.Timestamp('end_ts', {skipIfNull: true}),
+        'Parent process': d.SqlIdRef('process', 'parent_upid', {
+          skipIfNull: true,
+        }),
+        'User ID': d.Value('uid', {skipIfNull: true}),
+        'Android app ID': d.Value('android_appid', {skipIfNull: true}),
+        'Command line': d.Value('cmdline', {skipIfNull: true}),
+        'Machine id': d.Value('machine_id', {skipIfNull: true}),
+        'Args': d.ArgSetId('arg_set_id'),
+      },
+      wellKnownTypes,
+    );
+  }
+
+  render() {
+    return m(
+      DetailsShell,
+      {
+        title: this.getTitle(),
+      },
+      m(
+        GridLayout,
+        m(GridLayoutColumn, m(Section, {title: 'Details'}, this.data.render())),
+      ),
+    );
+  }
+
+  getTitle(): string {
+    if (this.args.pid !== undefined) {
+      return `Process ${this.args.pid}`;
+    }
+    return `Process upid:${this.args.upid}`;
+  }
+}
diff --git a/ui/src/frontend/slice_details.ts b/ui/src/frontend/slice_details.ts
index c284bfb..37e770f 100644
--- a/ui/src/frontend/slice_details.ts
+++ b/ui/src/frontend/slice_details.ts
@@ -17,23 +17,23 @@
 import {BigintMath} from '../base/bigint_math';
 import {sqliteString} from '../base/string_utils';
 import {exists} from '../base/utils';
+import {SliceDetails} from '../trace_processor/sql_utils/slice';
 import {Anchor} from '../widgets/anchor';
 import {MenuItem, PopupMenu2} from '../widgets/menu';
 import {Section} from '../widgets/section';
 import {SqlRef} from '../widgets/sql_ref';
 import {Tree, TreeNode} from '../widgets/tree';
 
-import {SliceDetails} from '../trace_processor/sql_utils/slice';
 import {
   BreakdownByThreadState,
   BreakdownByThreadStateTreeNode,
 } from './sql/thread_state';
-import {DurationWidget} from './widgets/duration';
-import {Timestamp} from './widgets/timestamp';
 import {addSqlTableTab} from './sql_table_tab';
-import {getThreadName} from '../trace_processor/sql_utils/thread';
-import {getProcessName} from '../trace_processor/sql_utils/process';
+import {DurationWidget} from './widgets/duration';
+import {renderProcessRef} from './widgets/process';
 import {SqlTables} from './widgets/sql/table2/well_known_sql_tables';
+import {renderThreadRef} from './widgets/thread';
+import {Timestamp} from './widgets/timestamp';
 
 // Renders a widget storing all of the generic details for a slice from the
 // slice table.
@@ -99,12 +99,12 @@
       slice.thread &&
         m(TreeNode, {
           left: 'Thread',
-          right: getThreadName(slice.thread),
+          right: renderThreadRef(slice.thread),
         }),
       slice.process &&
         m(TreeNode, {
           left: 'Process',
-          right: getProcessName(slice.process),
+          right: renderProcessRef(slice.process),
         }),
       slice.process &&
         exists(slice.process.uid) &&
diff --git a/ui/src/frontend/sql_table_tab.ts b/ui/src/frontend/sql_table_tab.ts
index 95c6b1e..7df282d 100644
--- a/ui/src/frontend/sql_table_tab.ts
+++ b/ui/src/frontend/sql_table_tab.ts
@@ -17,18 +17,17 @@
 import {copyToClipboard} from '../base/clipboard';
 import {Icons} from '../base/semantic_icons';
 import {exists} from '../base/utils';
+import {uuidv4} from '../base/uuid';
+import {addBottomTab} from '../common/addEphemeralTab';
 import {Button} from '../widgets/button';
 import {DetailsShell} from '../widgets/details_shell';
 import {Popup, PopupPosition} from '../widgets/popup';
-import {Engine} from '../public';
-import {assertExists} from '../base/logging';
-import {uuidv4} from '../base/uuid';
-import {addEphemeralTab} from '../common/addEphemeralTab';
+
 import {BottomTab, NewBottomTabArgs} from './bottom_tab';
-import {globals} from './globals';
 import {AddDebugTrackMenu} from './debug_tracks/add_debug_track_menu';
-import {SqlTableDescription, SqlTableState} from './widgets/sql/table2/state';
+import {getEngine} from './get_engine';
 import {Filter} from './widgets/sql/table2/column';
+import {SqlTableDescription, SqlTableState} from './widgets/sql/table2/state';
 import {SqlTable} from './widgets/sql/table2/table';
 
 interface SqlTableTabConfig {
@@ -40,18 +39,11 @@
 export function addSqlTableTab(config: SqlTableTabConfig): void {
   const queryResultsTab = new SqlTableTab({
     config,
-    engine: getEngine(),
+    engine: getEngine('QueryResult'),
     uuid: uuidv4(),
   });
 
-  addEphemeralTab(queryResultsTab, 'sqlTable');
-}
-
-// TODO(stevegolton): Find a way to make this more elegant.
-function getEngine(): Engine {
-  const engConfig = globals.getCurrentEngine();
-  const engineId = assertExists(engConfig).id;
-  return assertExists(globals.engines.get(engineId)).getProxy('QueryResult');
+  addBottomTab(queryResultsTab, 'sqlTable');
 }
 
 export class SqlTableTab extends BottomTab<SqlTableTabConfig> {
diff --git a/ui/src/frontend/thread_details_tab.ts b/ui/src/frontend/thread_details_tab.ts
new file mode 100644
index 0000000..be638a8
--- /dev/null
+++ b/ui/src/frontend/thread_details_tab.ts
@@ -0,0 +1,71 @@
+// Copyright (C) 2024 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 {Engine, Tab} from '../public';
+import {Utid} from '../trace_processor/sql_utils/core_types';
+import {DetailsShell} from '../widgets/details_shell';
+import {GridLayout, GridLayoutColumn} from '../widgets/grid_layout';
+import {Section} from '../widgets/section';
+
+import {Details, DetailsSchema} from './widgets/sql/details/details';
+import {wellKnownTypes} from './widgets/sql/details/well_known_types';
+
+import d = DetailsSchema;
+
+export class ThreadDetailsTab implements Tab {
+  private data: Details;
+
+  // TODO(altimin): Ideally, we would not require the tid to be passed in, but
+  // fetch it from the underlying data instead. See comment in ProcessDetailsTab
+  // for more details.
+  constructor(private args: {engine: Engine; utid: Utid; tid?: number}) {
+    this.data = new Details(
+      args.engine,
+      'thread',
+      args.utid,
+      {
+        'tid': d.Value('tid'),
+        'Name': d.Value('name'),
+        'Process': d.SqlIdRef('process', 'upid'),
+        'Is main thread': d.Boolean('is_main_thread'),
+        'Start time': d.Timestamp('start_ts', {skipIfNull: true}),
+        'End time': d.Timestamp('end_ts', {skipIfNull: true}),
+        'Machine id': d.Value('machine_id', {skipIfNull: true}),
+      },
+      wellKnownTypes,
+    );
+  }
+
+  render() {
+    return m(
+      DetailsShell,
+      {
+        title: this.getTitle(),
+      },
+      m(
+        GridLayout,
+        m(GridLayoutColumn, m(Section, {title: 'Details'}, this.data.render())),
+      ),
+    );
+  }
+
+  getTitle(): string {
+    if (this.args.tid !== undefined) {
+      return `Thread ${this.args.tid}`;
+    }
+    return `Thread utid:${this.args.utid}`;
+  }
+}
diff --git a/ui/src/frontend/widgets/process.ts b/ui/src/frontend/widgets/process.ts
index f7bcb93..defc1f1 100644
--- a/ui/src/frontend/widgets/process.ts
+++ b/ui/src/frontend/widgets/process.ts
@@ -13,15 +13,19 @@
 // limitations under the License.
 
 import m from 'mithril';
-import {
-  ProcessInfo,
-  getProcessName,
-} from '../../trace_processor/sql_utils/process';
-import {MenuItem, PopupMenu2} from '../../widgets/menu';
-import {Anchor} from '../../widgets/anchor';
-import {exists} from '../../base/utils';
-import {Icons} from '../../base/semantic_icons';
+
 import {copyToClipboard} from '../../base/clipboard';
+import {Icons} from '../../base/semantic_icons';
+import {exists} from '../../base/utils';
+import {addEphemeralTab} from '../../common/addEphemeralTab';
+import {
+  getProcessName,
+  ProcessInfo,
+} from '../../trace_processor/sql_utils/process';
+import {Anchor} from '../../widgets/anchor';
+import {MenuItem, PopupMenu2} from '../../widgets/menu';
+import {getEngine} from '../get_engine';
+import {ProcessDetailsTab} from '../process_details_tab';
 
 export function renderProcessRef(info: ProcessInfo): m.Children {
   const name = info.name;
@@ -47,5 +51,18 @@
       label: 'Copy upid',
       onclick: () => copyToClipboard(`${info.upid}`),
     }),
+    m(MenuItem, {
+      icon: Icons.ExternalLink,
+      label: 'Show process details',
+      onclick: () =>
+        addEphemeralTab(
+          'processDetails',
+          new ProcessDetailsTab({
+            engine: getEngine('processDetails'),
+            upid: info.upid,
+            pid: info.pid,
+          }),
+        ),
+    }),
   );
 }
diff --git a/ui/src/frontend/widgets/sql/details/details.ts b/ui/src/frontend/widgets/sql/details/details.ts
index 09b7f4c..31e4b79 100644
--- a/ui/src/frontend/widgets/sql/details/details.ts
+++ b/ui/src/frontend/widgets/sql/details/details.ts
@@ -168,6 +168,13 @@
     return new ScalarValueSchema('url', value, args);
   }
 
+  export function Boolean(
+    value: string,
+    args?: ScalarValueParams,
+  ): ScalarValueSchema {
+    return new ScalarValueSchema('boolean', value, args);
+  }
+
   // Create an object representing a reference to a SQL table row in the schema.
   // |table| - name of the table.
   // |id| - SQL expression (e.g. column name) for the id.
@@ -339,7 +346,13 @@
 // from SQL).
 class ScalarValueSchema {
   constructor(
-    public kind: 'timestamp' | 'duration' | 'arg_set_id' | 'value' | 'url',
+    public kind:
+      | 'timestamp'
+      | 'duration'
+      | 'arg_set_id'
+      | 'value'
+      | 'url'
+      | 'boolean',
     public sourceExpression: string,
     public params?: ScalarValueParams,
   ) {}
@@ -347,7 +360,7 @@
 
 // Resolved version of simple scalar values.
 type ResolvedScalarValue = {
-  kind: 'timestamp' | 'duration' | 'value' | 'url';
+  kind: 'timestamp' | 'duration' | 'value' | 'url' | 'boolean';
   source: ExpressionIndex;
 } & ScalarValueParams;
 
@@ -435,7 +448,13 @@
   // Source statements for the SQL references.
   sqlIdRefs: {tableName: string; idExpression: string}[];
   // Fetched data for the SQL references.
-  sqlIdRefData: ({data: {}; id: bigint} | Err)[];
+  sqlIdRefData: (
+    | {
+        data: {};
+        id: bigint | null;
+      }
+    | Err
+  )[];
 }
 
 // Class responsible for collecting the description of the data to fetch and
@@ -501,7 +520,7 @@
       const argSetId = data.values[argSetIndex];
       if (argSetId === null) {
         data.argSets.push([]);
-      } else if (typeof argSetId !== 'number') {
+      } else if (typeof argSetId !== 'number' && typeof argSetId !== 'bigint') {
         data.argSets.push(
           new Err(
             `Incorrect type for arg set ${
@@ -510,7 +529,9 @@
           ),
         );
       } else {
-        data.argSets.push(await getArgs(this.engine, asArgSetId(argSetId)));
+        data.argSets.push(
+          await getArgs(this.engine, asArgSetId(Number(argSetId))),
+        );
       }
     }
 
@@ -522,7 +543,10 @@
         continue;
       }
       const id = data.values[ref.id];
-      if (typeof id !== 'bigint') {
+      if (id === null) {
+        data.sqlIdRefData.push({data: {}, id});
+        continue;
+      } else if (typeof id !== 'bigint') {
         data.sqlIdRefData.push(
           new Err(
             `Incorrect type for SQL reference ${
@@ -673,10 +697,10 @@
       });
     case 'url': {
       const url = data.values[value.source];
-      let rhs: m.Child;
+      let rhs: m.Children;
       if (url === null) {
         if (value.skipIfNull) return null;
-        rhs = m('i', 'NULL');
+        rhs = renderNull();
       } else if (typeof url !== 'string') {
         rhs = renderError(
           `Incorrect type for URL ${
@@ -695,6 +719,21 @@
         right: rhs,
       });
     }
+    case 'boolean': {
+      const bool = data.values[value.source];
+      if (bool === null && value.skipIfNull) return null;
+      let rhs: m.Child;
+      if (typeof bool !== 'bigint' && typeof bool !== 'number') {
+        rhs = renderError(
+          `Incorrect type for boolean ${
+            data.valueExpressions[value.source]
+          }: expected bigint or number, got ${typeof bool}`,
+        );
+      } else {
+        rhs = bool ? 'true' : 'false';
+      }
+      return m(TreeNode, {left: key, right: rhs});
+    }
     case 'timestamp': {
       const ts = data.values[value.source];
       let rhs: m.Child;
@@ -747,6 +786,8 @@
       let children: m.Children;
       if (refData instanceof Err) {
         rhs = renderError(refData.message);
+      } else if (refData.id === null && value.skipIfNull === true) {
+        rhs = renderNull();
       } else {
         const renderer = sqlIdRefRenderers[ref.tableName];
         if (renderer === undefined) {
@@ -828,3 +869,7 @@
     }
   }
 }
+
+function renderNull(): m.Children {
+  return m('i', 'NULL');
+}
diff --git a/ui/src/frontend/widgets/thread.ts b/ui/src/frontend/widgets/thread.ts
index b668d1c..1254a0b 100644
--- a/ui/src/frontend/widgets/thread.ts
+++ b/ui/src/frontend/widgets/thread.ts
@@ -14,15 +14,18 @@
 
 import m from 'mithril';
 
-import {
-  ThreadInfo,
-  getThreadName,
-} from '../../trace_processor/sql_utils/thread';
-import {MenuItem, PopupMenu2} from '../../widgets/menu';
-import {Anchor} from '../../widgets/anchor';
-import {exists} from '../../base/utils';
-import {Icons} from '../../base/semantic_icons';
 import {copyToClipboard} from '../../base/clipboard';
+import {Icons} from '../../base/semantic_icons';
+import {exists} from '../../base/utils';
+import {addEphemeralTab} from '../../common/addEphemeralTab';
+import {
+  getThreadName,
+  ThreadInfo,
+} from '../../trace_processor/sql_utils/thread';
+import {Anchor} from '../../widgets/anchor';
+import {MenuItem, PopupMenu2} from '../../widgets/menu';
+import {getEngine} from '../get_engine';
+import {ThreadDetailsTab} from '../thread_details_tab';
 
 export function renderThreadRef(info: ThreadInfo): m.Children {
   const name = info.name;
@@ -48,5 +51,18 @@
       label: 'Copy utid',
       onclick: () => copyToClipboard(`${info.utid}`),
     }),
+    m(MenuItem, {
+      icon: Icons.ExternalLink,
+      label: 'Show thread details',
+      onclick: () =>
+        addEphemeralTab(
+          'threadDetails',
+          new ThreadDetailsTab({
+            engine: getEngine('ThreadDetails'),
+            utid: info.utid,
+            tid: info.tid,
+          }),
+        ),
+    }),
   );
 }