Add process grouping for chrome traces

Currently the summary view is extremely slow (especially on the example
chrome trace), so it is disabled if there are more than 10 processes.
To try it out, you can use a smaller trace (you can find one at
https://goo.gl/KuFdtb).

Preview: https://deepanjan.me/previews/aosp/790720/#!/

Change-Id: I5e1c7c2874aaa52126985cfa6cdab777b1c70563
diff --git a/ui/src/assets/common.scss b/ui/src/assets/common.scss
index 886fc03..548dc88 100644
--- a/ui/src/assets/common.scss
+++ b/ui/src/assets/common.scss
@@ -385,3 +385,56 @@
   }
 }
 
+.track-group-panel {
+  --collapsed-background: hsla(190, 49%, 97%, 1);
+  --collapsed-transparent: hsla(190, 49%, 97%, 0);
+  --expanded-background: hsl(215, 22%, 19%);
+  display: grid;
+  grid-template-columns: auto 1fr;
+  grid-template-rows: 1fr;
+  border-top: 1px solid #c7d0db;
+  transition: background-color .4s, color .4s;
+  &[collapsed=true] {
+    height: 40px;
+    background-color: var(--collapsed-background-transparent);
+    .shell {
+      border-right: 1px solid #c7d0db;
+      background-color: var(--collapsed-background);
+    }
+  }
+  &[collapsed=false] {
+    height: 25px;
+    background-color: var(--expanded-background);
+    color: white;
+    font-weight: bold;
+    .shell {
+      background-color: var(--expanded-background);
+      h1 {
+        font-size: 15px;
+      }
+    }
+  }
+  .shell {
+    padding: 0 20px;
+    display: grid;
+    grid-template-areas: "title fold-button";
+    grid-template-columns: 1fr 24px;
+    align-items: center;
+    line-height: 1;
+    width: 300px;
+    transition: background-color .4s;
+    h1 {
+      grid-area: title;
+      font-size: 1em;
+      text-overflow: ellipsis;
+      font-family: 'Google Sans';
+    }
+    .fold-button {
+      grid-area: fold-button;
+      cursor: pointer;
+      &:hover {
+        color: hsl(45, 100%, 48%);
+      }
+    }
+  }
+}
diff --git a/ui/src/common/actions.ts b/ui/src/common/actions.ts
index bf030b0..6fb408b 100644
--- a/ui/src/common/actions.ts
+++ b/ui/src/common/actions.ts
@@ -14,7 +14,15 @@
 
 import {DraftObject} from 'immer';
 
-import {defaultTraceTime, State, Status, TraceTime} from './state';
+import {assertExists} from '../base/logging';
+
+import {
+  defaultTraceTime,
+  SCROLLING_TRACK_GROUP,
+  State,
+  Status,
+  TraceTime
+} from './state';
 
 type StateDraft = DraftObject<State>;
 
@@ -54,18 +62,39 @@
     state.route = `/viewer`;
   },
 
-  addTrack(
-      state: StateDraft,
-      args: {engineId: string; kind: string; name: string; config: {};}): void {
-    const id = `${state.nextId++}`;
+  addTrack(state: StateDraft, args: {
+    id?: string; engineId: string; kind: string; name: string;
+    trackGroup?: string;
+    config: {};
+  }): void {
+    const id = args.id !== undefined ? args.id : `${state.nextId++}`;
     state.tracks[id] = {
       id,
       engineId: args.engineId,
       kind: args.kind,
       name: args.name,
+      trackGroup: args.trackGroup,
       config: args.config,
     };
-    state.scrollingTracks.push(id);
+    if (args.trackGroup === SCROLLING_TRACK_GROUP) {
+      state.scrollingTracks.push(id);
+    } else if (args.trackGroup !== undefined) {
+      assertExists(state.trackGroups[args.trackGroup]).tracks.push(id);
+    }
+  },
+
+  addTrackGroup(
+      state: StateDraft,
+      // Define ID in action so a track group can be referred to without running
+      // the reducer.
+      args: {
+        engineId: string; name: string; id: string; summaryTrackId: string;
+        collapsed: boolean;
+      }): void {
+    state.trackGroups[args.id] = {
+      ...args,
+      tracks: [],
+    };
   },
 
   reqTrackData(state: StateDraft, args: {
@@ -105,7 +134,8 @@
         const isPinned = state.pinnedTracks.includes(id);
         const isScrolling = state.scrollingTracks.includes(id);
         if (!isScrolling && !isPinned) {
-          throw new Error(`No track with id ${id}`);
+          // TODO(dproy): Handle track moving within track groups.
+          return;
         }
         const tracks = isPinned ? state.pinnedTracks : state.scrollingTracks;
 
@@ -127,16 +157,28 @@
   toggleTrackPinned(state: StateDraft, args: {trackId: string}): void {
     const id = args.trackId;
     const isPinned = state.pinnedTracks.includes(id);
+    const trackGroup = assertExists(state.tracks[id]).trackGroup;
 
     if (isPinned) {
       state.pinnedTracks.splice(state.pinnedTracks.indexOf(id), 1);
-      state.scrollingTracks.unshift(id);
+      if (trackGroup === undefined) {
+        state.scrollingTracks.unshift(id);
+      }
     } else {
-      state.scrollingTracks.splice(state.scrollingTracks.indexOf(id), 1);
+      if (trackGroup === undefined) {
+        state.scrollingTracks.splice(state.scrollingTracks.indexOf(id), 1);
+      }
       state.pinnedTracks.push(id);
     }
   },
 
+  toggleTrackGroupCollapsed(state: StateDraft, args: {trackGroupId: string}):
+      void {
+        const id = args.trackGroupId;
+        const trackGroup = assertExists(state.trackGroups[id]);
+        trackGroup.collapsed = !trackGroup.collapsed;
+      },
+
   setEngineReady(state: StateDraft, args: {engineId: string; ready: boolean}):
       void {
         state.engines[args.engineId].ready = args.ready;
diff --git a/ui/src/common/actions_unittest.ts b/ui/src/common/actions_unittest.ts
index 7a3dc94..5d2c9c0 100644
--- a/ui/src/common/actions_unittest.ts
+++ b/ui/src/common/actions_unittest.ts
@@ -13,8 +13,14 @@
 // limitations under the License.
 
 import {produce} from 'immer';
+
 import {StateActions} from './actions';
-import {createEmptyState, State, TrackState} from './state';
+import {
+  createEmptyState,
+  SCROLLING_TRACK_GROUP,
+  State,
+  TrackState
+} from './state';
 
 function fakeTrack(state: State, id: string): TrackState {
   const track: TrackState = {
@@ -35,12 +41,13 @@
   expect(after.route).toBe('/foo');
 });
 
-test('add tracks', () => {
+test('add scrolling tracks', () => {
   const once = produce(createEmptyState(), draft => {
     StateActions.addTrack(draft, {
       engineId: '1',
       kind: 'cpu',
       name: 'Cpu 1',
+      trackGroup: SCROLLING_TRACK_GROUP,
       config: {},
     });
   });
@@ -49,6 +56,7 @@
       engineId: '2',
       kind: 'cpu',
       name: 'Cpu 2',
+      trackGroup: SCROLLING_TRACK_GROUP,
       config: {},
     });
   });
@@ -57,6 +65,34 @@
   expect(twice.scrollingTracks.length).toBe(2);
 });
 
+test('add track to track group', () => {
+  const state = createEmptyState();
+  fakeTrack(state, 's');
+
+  const afterGroup = produce(state, draft => {
+    StateActions.addTrackGroup(draft, {
+      engineId: '1',
+      name: 'A track group',
+      id: '123-123-123',
+      summaryTrackId: 's',
+      collapsed: false,
+    });
+  });
+
+  const afterTrackAdd = produce(afterGroup, draft => {
+    StateActions.addTrack(draft, {
+      id: '1',
+      engineId: '1',
+      kind: 'slices',
+      name: 'renderer 1',
+      trackGroup: '123-123-123',
+      config: {},
+    });
+  });
+
+  expect(afterTrackAdd.trackGroups['123-123-123'].tracks[0]).toBe('1');
+});
+
 test('reorder tracks', () => {
   const once = produce(createEmptyState(), draft => {
     StateActions.addTrack(draft, {
diff --git a/ui/src/common/state.ts b/ui/src/common/state.ts
index 31ea6e6..3a7a52e 100644
--- a/ui/src/common/state.ts
+++ b/ui/src/common/state.ts
@@ -19,15 +19,27 @@
  */
 export interface ObjectById<Class extends{id: string}> { [id: string]: Class; }
 
+export const SCROLLING_TRACK_GROUP = 'ScrollingTracks';
+
 export interface TrackState {
   id: string;
   engineId: string;
   kind: string;
   name: string;
+  trackGroup?: string;
   dataReq?: TrackDataRequest;
   config: {};
 }
 
+export interface TrackGroupState {
+  id: string;
+  engineId: string;
+  name: string;
+  collapsed: boolean;
+  tracks: string[];  // Child track ids.
+  summaryTrackId: string;
+}
+
 export interface TrackDataRequest {
   start: number;
   end: number;
@@ -113,6 +125,7 @@
   engines: ObjectById<EngineConfig>;
   traceTime: TraceTime;
   visibleTraceTime: TraceTime;
+  trackGroups: ObjectById<TrackGroupState>;
   tracks: ObjectById<TrackState>;
   scrollingTracks: string[];
   pinnedTracks: string[];
@@ -135,6 +148,7 @@
     traceTime: {...defaultTraceTime},
     visibleTraceTime: {...defaultTraceTime},
     tracks: {},
+    trackGroups: {},
     pinnedTracks: [],
     scrollingTracks: [],
     queries: {},
diff --git a/ui/src/controller/engine.ts b/ui/src/controller/engine.ts
index 47f06e4..33ab884 100644
--- a/ui/src/controller/engine.ts
+++ b/ui/src/controller/engine.ts
@@ -71,8 +71,7 @@
   // TODO: This should live in code that's more specific to chrome, instead of
   // in engine.
   async getNumberOfProcesses(): Promise<number> {
-    const result =
-        await this.query('select count(distinct(upid)) from thread;');
+    const result = await this.query('select count(*) from process;');
     return +result.columns[0].longValues![0];
   }
 
diff --git a/ui/src/controller/trace_controller.ts b/ui/src/controller/trace_controller.ts
index cbefcf5..fd12b15 100644
--- a/ui/src/controller/trace_controller.ts
+++ b/ui/src/controller/trace_controller.ts
@@ -14,15 +14,19 @@
 
 import '../tracks/all_controller';
 
+import * as uuidv4 from 'uuid/v4';
+
 import {assertExists, assertTrue} from '../base/logging';
 import {
   Actions,
   DeferredAction,
 } from '../common/actions';
+import {SCROLLING_TRACK_GROUP} from '../common/state';
 import {TimeSpan} from '../common/time';
 import {QuantizedLoad, ThreadDesc} from '../frontend/globals';
 import {SLICE_TRACK_KIND} from '../tracks/chrome_slices/common';
 import {CPU_SLICE_TRACK_KIND} from '../tracks/cpu_slices/common';
+import {PROCESS_SUMMARY_TRACK} from '../tracks/process_summary/common';
 
 import {Child, Children, Controller} from './controller';
 import {Engine} from './engine';
@@ -221,38 +225,79 @@
         engineId: this.engineId,
         kind: CPU_SLICE_TRACK_KIND,
         name: `Cpu ${cpu}`,
+        trackGroup: SCROLLING_TRACK_GROUP,
         config: {
           cpu,
         }
       }));
     }
 
-    const threadQuery = await engine.query(`
-      select upid, utid, tid, thread.name, depth
-      from thread inner join (
-        select utid, max(slices.depth) as depth
-        from slices
-        group by utid
-      ) using(utid)`);
+    // Local experiments shows getting maxDepth separately is ~2x faster than
+    // joining with threads and processes.
+    const maxDepthQuery =
+        await engine.query('select utid, max(depth) from slices group by utid');
+
+    const utidToMaxDepth = new Map<number, number>();
+    for (let i = 0; i < maxDepthQuery.numRecords; i++) {
+      const utid = maxDepthQuery.columns[0].longValues![i] as number;
+      const maxDepth = maxDepthQuery.columns[1].longValues![i] as number;
+      utidToMaxDepth.set(utid, maxDepth);
+    }
+
+    const threadQuery = await engine.query(
+        'select utid, tid, upid, pid, thread.name, process.name ' +
+        'from thread inner join process using(upid)');
+
+    const upidToUuid = new Map<number, string>();
+    const addSummaryTrackActions: DeferredAction[] = [];
+    const addTrackGroupActions: DeferredAction[] = [];
     for (let i = 0; i < threadQuery.numRecords; i++) {
-      const upid = threadQuery.columns[0].longValues![i];
-      const utid = threadQuery.columns[1].longValues![i];
-      const threadId = threadQuery.columns[2].longValues![i];
-      let threadName = threadQuery.columns[3].stringValues![i];
-      threadName += `[${threadId}]`;
-      const maxDepth = threadQuery.columns[4].longValues![i];
+      const utid = threadQuery.columns[0].longValues![i] as number;
+
+      const maxDepth = utidToMaxDepth.get(utid);
+      if (maxDepth === undefined) {
+        // This thread does not have stackable slices.
+        continue;
+      }
+
+      const tid = threadQuery.columns[1].longValues![i] as number;
+      const upid = threadQuery.columns[2].longValues![i] as number;
+      const pid = threadQuery.columns[3].longValues![i] as number;
+      const threadName = threadQuery.columns[4].stringValues![i];
+      const processName = threadQuery.columns[5].stringValues![i];
+
+      let pUuid = upidToUuid.get(upid);
+      if (pUuid === undefined) {
+        pUuid = uuidv4();
+        const summaryTrackId = uuidv4();
+        upidToUuid.set(upid, pUuid);
+        addSummaryTrackActions.push(Actions.addTrack({
+          id: summaryTrackId,
+          engineId: this.engineId,
+          kind: PROCESS_SUMMARY_TRACK,
+          name: `${pid} summary`,
+          config: {upid, pid, maxDepth, utid},
+        }));
+        addTrackGroupActions.push(Actions.addTrackGroup({
+          engineId: this.engineId,
+          summaryTrackId,
+          name: `${processName} ${pid}`,
+          id: pUuid,
+          collapsed: true,
+        }));
+      }
+
       addToTrackActions.push(Actions.addTrack({
         engineId: this.engineId,
         kind: SLICE_TRACK_KIND,
-        name: threadName,
-        config: {
-          upid: upid as number,
-          utid: utid as number,
-          maxDepth: maxDepth as number,
-        }
+        name: threadName + `[${tid}]`,
+        trackGroup: pUuid,
+        config: {upid, utid, maxDepth},
       }));
     }
-    globals.dispatchMultiple(addToTrackActions);
+    const allActions =
+        addSummaryTrackActions.concat(addTrackGroupActions, addToTrackActions);
+    globals.dispatchMultiple(allActions);
   }
 
   private async listThreads() {
diff --git a/ui/src/controller/track_controller.ts b/ui/src/controller/track_controller.ts
index 4340847..ed16c67 100644
--- a/ui/src/controller/track_controller.ts
+++ b/ui/src/controller/track_controller.ts
@@ -52,6 +52,17 @@
     globals.publish('TrackData', {id: this.trackId, data});
   }
 
+  /**
+   * Returns a valid SQL table name with the given prefix that should be unique
+   * for each track.
+   */
+  tableName(prefix: string) {
+    // Derive table name from, since that is unique for each track.
+    // Track ID can be UUID but '-' is not valid for sql table name.
+    const idSuffix = this.trackId.split('-').join('_');
+    return `${prefix}_${idSuffix}`;
+  }
+
   run() {
     const dataReq = this.trackState.dataReq;
     if (dataReq === undefined) return;
diff --git a/ui/src/frontend/track.ts b/ui/src/frontend/track.ts
index c543ad8..bd21f39 100644
--- a/ui/src/frontend/track.ts
+++ b/ui/src/frontend/track.ts
@@ -34,9 +34,6 @@
  * The abstract class that needs to be implemented by all tracks.
  */
 export abstract class Track<Config = {}, Data = {}> {
-  /**
-   * Receive data published by the TrackController of this track.
-   */
   constructor(protected trackState: TrackState) {}
   abstract renderCanvas(ctx: CanvasRenderingContext2D): void;
 
@@ -44,7 +41,7 @@
     return this.trackState.config as Config;
   }
 
-  data(): Data {
+  data(): Data|undefined {
     return globals.trackDataStore.get(this.trackState.id) as Data;
   }
 
diff --git a/ui/src/frontend/track_group_panel.ts b/ui/src/frontend/track_group_panel.ts
new file mode 100644
index 0000000..a71873e
--- /dev/null
+++ b/ui/src/frontend/track_group_panel.ts
@@ -0,0 +1,107 @@
+// Copyright (C) 2018 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 * as m from 'mithril';
+
+import {assertExists} from '../base/logging';
+import {Actions} from '../common/actions';
+import {TrackGroupState, TrackState} from '../common/state';
+
+import {globals} from './globals';
+import {drawGridLines} from './gridline_helper';
+import {Panel, PanelSize} from './panel';
+import {Track} from './track';
+import {trackRegistry} from './track_registry';
+
+
+interface Attrs {
+  trackGroupId: string;
+}
+
+export class TrackGroupPanel extends Panel<Attrs> {
+  private readonly trackGroupId: string;
+  private shellWidth = 0;
+  private backgroundColor = '#ffffff';  // Updated from CSS later.
+  private summaryTrack: Track;
+
+  constructor({attrs}: m.CVnode<Attrs>) {
+    super();
+    this.trackGroupId = attrs.trackGroupId;
+    const trackCreator = trackRegistry.get(this.summaryTrackState.kind);
+    this.summaryTrack = trackCreator.create(this.summaryTrackState);
+  }
+
+  get trackGroupState(): TrackGroupState {
+    return assertExists(globals.state.trackGroups[this.trackGroupId]);
+  }
+
+  get summaryTrackState(): TrackState {
+    return assertExists(
+        globals.state.tracks[this.trackGroupState.summaryTrackId]);
+  }
+
+  view({attrs}: m.CVnode<Attrs>) {
+    const collapsed = this.trackGroupState.collapsed;
+    return m(
+        `.track-group-panel[collapsed=${collapsed}]`,
+        m('.shell',
+          m('h1', `${this.trackGroupState.name}`),
+          m('.fold-button',
+            {
+              onclick: () =>
+                  globals.dispatch(Actions.toggleTrackGroupCollapsed({
+                    trackGroupId: attrs.trackGroupId,
+                  })),
+            },
+            m('i.material-icons',
+              this.trackGroupState.collapsed ? 'expand_more' :
+                                               'expand_less'))));
+  }
+
+  oncreate(vnode: m.CVnodeDOM<Attrs>) {
+    this.onupdate(vnode);
+  }
+
+  onupdate({dom}: m.CVnodeDOM<Attrs>) {
+    const shell = assertExists(dom.querySelector('.shell'));
+    this.shellWidth = shell.getBoundingClientRect().width;
+    this.backgroundColor =
+        getComputedStyle(dom).getPropertyValue('--collapsed-background');
+  }
+
+  renderCanvas(ctx: CanvasRenderingContext2D, size: PanelSize) {
+    const collapsed = this.trackGroupState.collapsed;
+    if (!collapsed) return;
+
+    ctx.save();
+    ctx.translate(this.shellWidth, 0);
+
+    ctx.fillStyle = this.backgroundColor;
+    ctx.fillRect(0, 0, size.width, size.height);
+
+    drawGridLines(
+        ctx,
+        globals.frontendLocalState.timeScale,
+        globals.frontendLocalState.visibleWindowTime,
+        size.height);
+
+    // Do not show summary view if there are more than 10 track groups.
+    // Too slow now.
+    // TODO(dproy): Fix this.
+    if (Object.keys(globals.state.trackGroups).length < 10) {
+      this.summaryTrack.renderCanvas(ctx);
+    }
+    ctx.restore();
+  }
+}
diff --git a/ui/src/frontend/viewer_page.ts b/ui/src/frontend/viewer_page.ts
index 4aef5a0..c340797 100644
--- a/ui/src/frontend/viewer_page.ts
+++ b/ui/src/frontend/viewer_page.ts
@@ -26,9 +26,11 @@
 import {Panel} from './panel';
 import {AnyAttrsVnode, PanelContainer} from './panel_container';
 import {TimeAxisPanel} from './time_axis_panel';
+import {TrackGroupPanel} from './track_group_panel';
 import {TRACK_SHELL_WIDTH} from './track_panel';
 import {TrackPanel} from './track_panel';
 
+
 const MAX_ZOOM_SPAN_SEC = 1e-4;  // 0.1 ms.
 
 class QueryTable extends Panel {
@@ -161,6 +163,20 @@
               id => m(TrackPanel, {key: id, id})),
         ] :
         [];
+
+    for (const group of Object.values(globals.state.trackGroups)) {
+      scrollingPanels.push(m(TrackGroupPanel, {
+        trackGroupId: group.id,
+        key: `trackgroup-${group.id}`,
+      }));
+      if (group.collapsed) continue;
+      for (const trackId of group.tracks) {
+        scrollingPanels.push(m(TrackPanel, {
+          key: `track-${group.id}-${trackId}`,
+          id: trackId,
+        }));
+      }
+    }
     scrollingPanels.unshift(m(QueryTable));
 
     return m(
diff --git a/ui/src/tracks/all_controller.ts b/ui/src/tracks/all_controller.ts
index 83d4f9c..578325e 100644
--- a/ui/src/tracks/all_controller.ts
+++ b/ui/src/tracks/all_controller.ts
@@ -17,3 +17,4 @@
 import './cpu_slices/controller';
 import './chrome_slices/controller';
 import './vsync/controller';
+import './process_summary/controller';
diff --git a/ui/src/tracks/all_frontend.ts b/ui/src/tracks/all_frontend.ts
index 0a94393..14a8943 100644
--- a/ui/src/tracks/all_frontend.ts
+++ b/ui/src/tracks/all_frontend.ts
@@ -17,3 +17,4 @@
 import './cpu_slices/frontend';
 import './chrome_slices/frontend';
 import './vsync/frontend';
+import './process_summary/frontend';
diff --git a/ui/src/tracks/chrome_slices/frontend.ts b/ui/src/tracks/chrome_slices/frontend.ts
index 0072a75..9fc1508 100644
--- a/ui/src/tracks/chrome_slices/frontend.ts
+++ b/ui/src/tracks/chrome_slices/frontend.ts
@@ -77,7 +77,8 @@
     const inRange = data !== undefined &&
         (visibleWindowTime.start >= data.start &&
          visibleWindowTime.end <= data.end);
-    if (!inRange || data.resolution > getCurResolution()) {
+    if (!inRange || data === undefined ||
+        data.resolution > getCurResolution()) {
       if (!this.reqPending) {
         this.reqPending = true;
         setTimeout(() => this.reqDataDeferred(), 50);
diff --git a/ui/src/tracks/cpu_slices/frontend.ts b/ui/src/tracks/cpu_slices/frontend.ts
index e6d0e6c..642f046 100644
--- a/ui/src/tracks/cpu_slices/frontend.ts
+++ b/ui/src/tracks/cpu_slices/frontend.ts
@@ -96,7 +96,8 @@
     const inRange = data !== undefined &&
         (visibleWindowTime.start >= data.start &&
          visibleWindowTime.end <= data.end);
-    if (!inRange || data.resolution !== getCurResolution()) {
+    if (!inRange || data === undefined ||
+        data.resolution !== getCurResolution()) {
       if (!this.reqPending) {
         this.reqPending = true;
         setTimeout(() => this.reqDataDeferred(), 50);
diff --git a/ui/src/tracks/process_summary/common.ts b/ui/src/tracks/process_summary/common.ts
new file mode 100644
index 0000000..fdcbe0e
--- /dev/null
+++ b/ui/src/tracks/process_summary/common.ts
@@ -0,0 +1,26 @@
+// Copyright (C) 2018 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.
+
+export const PROCESS_SUMMARY_TRACK = 'ProcessSummaryTrack';
+
+// TODO(dproy): Consider deduping with CPU summary data.
+export interface Data {
+  start: number;
+  end: number;
+  resolution: number;
+  bucketSizeSeconds: number;
+  utilizations: Float64Array;
+}
+
+export interface Config { upid: number; }
diff --git a/ui/src/tracks/process_summary/controller.ts b/ui/src/tracks/process_summary/controller.ts
new file mode 100644
index 0000000..2908468
--- /dev/null
+++ b/ui/src/tracks/process_summary/controller.ts
@@ -0,0 +1,134 @@
+// Copyright (C) 2018 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 {
+  TrackController,
+  trackControllerRegistry
+} from '../../controller/track_controller';
+
+import {
+  Config,
+  Data,
+  PROCESS_SUMMARY_TRACK,
+} from './common';
+
+class ProcessSummaryTrackController extends TrackController<Config, Data> {
+  static readonly kind = PROCESS_SUMMARY_TRACK;
+  private busy = false;
+  private setup = false;
+
+  onBoundsChange(start: number, end: number, resolution: number): void {
+    this.update(start, end, resolution);
+  }
+
+
+  private async update(start: number, end: number, resolution: number):
+      Promise<void> {
+    // TODO: we should really call TraceProcessor.Interrupt() at this point.
+    if (this.busy) return;
+    this.busy = true;
+
+    const startNs = Math.round(start * 1e9);
+    const endNs = Math.round(end * 1e9);
+
+    if (this.setup === false) {
+      await this.query(
+          `create virtual table ${this.tableName('window')} using window;`);
+      const threadQuery = await this.query(
+          `select utid from thread where upid=${this.config.upid}`);
+      const utids = threadQuery.columns[0].longValues! as number[];
+      const processSliceView = this.tableName('process_slice_view');
+      await this.query(
+          `create view ${processSliceView} as ` +
+          // 0 as cpu is a dummy column to perform span join on.
+          `select ts, dur/${utids.length} as dur, 0 as cpu ` +
+          `from slices where depth = 0 and utid in ` +
+          // TODO(dproy): This query is faster if we write it as x < utid < y.
+          `(${utids.join(',')})`);
+      await this.query(`create virtual table ${this.tableName('span')}
+                     using span(${processSliceView}, ${
+                                                       this.tableName('window')
+                                                     }, cpu);`);
+      this.setup = true;
+    }
+
+    // |resolution| is in s/px we want # ns for 10px window:
+    const bucketSizeNs = Math.round(resolution * 10 * 1e9);
+    const windowStartNs = Math.floor(startNs / bucketSizeNs) * bucketSizeNs;
+    const windowDurNs = endNs - windowStartNs;
+
+    this.query(`update ${this.tableName('window')} set
+      window_start=${windowStartNs},
+      window_dur=${windowDurNs},
+      quantum=${bucketSizeNs}
+      where rowid = 0;`);
+
+    this.publish(await this.computeSummary(
+        fromNs(windowStartNs), end, resolution, bucketSizeNs));
+    this.busy = false;
+  }
+
+  private async computeSummary(
+      start: number, end: number, resolution: number,
+      bucketSizeNs: number): Promise<Data> {
+    const startNs = Math.round(start * 1e9);
+    const endNs = Math.round(end * 1e9);
+    const numBuckets = Math.ceil((endNs - startNs) / bucketSizeNs);
+
+    const query = `select
+      quantum_ts as bucket,
+      sum(dur)/cast(${bucketSizeNs} as float) as utilization
+      from ${this.tableName('span')}
+      where cpu = 0
+      group by quantum_ts`;
+
+    const rawResult = await this.query(query);
+    const numRows = +rawResult.numRecords;
+
+    const summary: Data = {
+      start,
+      end,
+      resolution,
+      bucketSizeSeconds: fromNs(bucketSizeNs),
+      utilizations: new Float64Array(numBuckets),
+    };
+    const cols = rawResult.columns;
+    for (let row = 0; row < numRows; row++) {
+      const bucket = +cols[0].longValues![row];
+      summary.utilizations[bucket] = +cols[1].doubleValues![row];
+    }
+    return summary;
+  }
+
+  // TODO(dproy); Dedup with other controllers.
+  private async query(query: string) {
+    const result = await this.engine.query(query);
+    if (result.error) {
+      console.error(`Query error "${query}": ${result.error}`);
+      throw new Error(`Query error "${query}": ${result.error}`);
+    }
+    return result;
+  }
+
+  onDestroy(): void {
+    if (this.setup) {
+      this.query(`drop table ${this.tableName('window')}`);
+      this.query(`drop table ${this.tableName('span')}`);
+      this.setup = false;
+    }
+  }
+}
+
+trackControllerRegistry.register(ProcessSummaryTrackController);
diff --git a/ui/src/tracks/process_summary/frontend.ts b/ui/src/tracks/process_summary/frontend.ts
new file mode 100644
index 0000000..c7038ab
--- /dev/null
+++ b/ui/src/tracks/process_summary/frontend.ts
@@ -0,0 +1,124 @@
+// Copyright (C) 2018 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 {Actions} from '../../common/actions';
+import {TrackState} from '../../common/state';
+import {checkerboardExcept} from '../../frontend/checkerboard';
+import {globals} from '../../frontend/globals';
+import {Track} from '../../frontend/track';
+import {trackRegistry} from '../../frontend/track_registry';
+
+import {
+  Config,
+  Data,
+  PROCESS_SUMMARY_TRACK,
+} from './common';
+
+// 0.5 Makes the horizontal lines sharp.
+const MARGIN_TOP = 7.5;
+const RECT_HEIGHT = 30;
+
+function getCurResolution() {
+  // Truncate the resolution to the closest power of 10.
+  const resolution = globals.frontendLocalState.timeScale.deltaPxToDuration(1);
+  return Math.pow(10, Math.floor(Math.log10(resolution)));
+}
+
+class ProcessSummaryTrack extends Track<Config, Data> {
+  static readonly kind = PROCESS_SUMMARY_TRACK;
+  static create(trackState: TrackState): ProcessSummaryTrack {
+    return new ProcessSummaryTrack(trackState);
+  }
+
+  private reqPending = false;
+  private hue: number;
+
+  constructor(trackState: TrackState) {
+    super(trackState);
+    this.hue = (128 + (32 * this.config.upid)) % 256;
+  }
+
+  // TODO(dproy): This code should be factored out.
+  reqDataDeferred() {
+    const {visibleWindowTime} = globals.frontendLocalState;
+    const reqStart = visibleWindowTime.start - visibleWindowTime.duration;
+    const reqEnd = visibleWindowTime.end + visibleWindowTime.duration;
+    const reqRes = getCurResolution();
+    this.reqPending = false;
+    globals.dispatch(Actions.reqTrackData({
+      trackId: this.trackState.id,
+      start: reqStart,
+      end: reqEnd,
+      resolution: reqRes
+    }));
+  }
+
+  renderCanvas(ctx: CanvasRenderingContext2D): void {
+    const {timeScale, visibleWindowTime} = globals.frontendLocalState;
+    const data = this.data();
+
+    // If there aren't enough cached slices data in |data| request more to
+    // the controller.
+    const inRange = data !== undefined &&
+        (visibleWindowTime.start >= data.start &&
+         visibleWindowTime.end <= data.end);
+    if (!inRange || data === undefined ||
+        data.resolution !== getCurResolution()) {
+      if (!this.reqPending) {
+        this.reqPending = true;
+        setTimeout(() => this.reqDataDeferred(), 50);
+      }
+    }
+    if (data === undefined) return;  // Can't possibly draw anything.
+
+    checkerboardExcept(
+        ctx,
+        timeScale.timeToPx(visibleWindowTime.start),
+        timeScale.timeToPx(visibleWindowTime.end),
+        timeScale.timeToPx(data.start),
+        timeScale.timeToPx(data.end));
+
+    this.renderSummary(ctx, data);
+  }
+
+  // TODO(dproy): Dedup with CPU slices.
+  renderSummary(ctx: CanvasRenderingContext2D, data: Data): void {
+    const {timeScale, visibleWindowTime} = globals.frontendLocalState;
+    const startPx = Math.floor(timeScale.timeToPx(visibleWindowTime.start));
+    const bottomY = MARGIN_TOP + RECT_HEIGHT;
+
+    let lastX = startPx;
+    let lastY = bottomY;
+
+    ctx.fillStyle = `hsl(${this.hue}, 50%, 60%)`;
+    ctx.beginPath();
+    ctx.moveTo(lastX, lastY);
+    for (let i = 0; i < data.utilizations.length; i++) {
+      // TODO(dproy): Investigate why utilization is > 1 sometimes.
+      const utilization = Math.min(data.utilizations[i], 1);
+      const startTime = i * data.bucketSizeSeconds + data.start;
+
+      lastX = Math.floor(timeScale.timeToPx(startTime));
+
+      ctx.lineTo(lastX, lastY);
+      lastY = MARGIN_TOP + Math.round(RECT_HEIGHT * (1 - utilization));
+      ctx.lineTo(lastX, lastY);
+    }
+    ctx.lineTo(lastX, bottomY);
+    ctx.closePath();
+    ctx.fill();
+  }
+}
+
+trackRegistry.register(ProcessSummaryTrack);
diff --git a/ui/src/tracks/vsync/frontend.ts b/ui/src/tracks/vsync/frontend.ts
index 7aa8782..825896e 100644
--- a/ui/src/tracks/vsync/frontend.ts
+++ b/ui/src/tracks/vsync/frontend.ts
@@ -64,7 +64,8 @@
     const inRange = data !== undefined &&
         (visibleWindowTime.start >= data.start &&
          visibleWindowTime.end <= data.end);
-    if (!inRange || data.resolution !== getCurResolution()) {
+    if (!inRange || data === undefined ||
+        data.resolution !== getCurResolution()) {
       if (!this.reqPending) {
         this.reqPending = true;
         setTimeout(() => this.reqDataDeferred(), 50);