Merge "Correctly handle partial writes."
diff --git a/include/perfetto/base/scoped_file.h b/include/perfetto/base/scoped_file.h
index 7e380f0..59ca75f 100644
--- a/include/perfetto/base/scoped_file.h
+++ b/include/perfetto/base/scoped_file.h
@@ -24,6 +24,7 @@
#if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN)
#include <corecrt_io.h>
+typedef int mode_t;
#else
#include <dirent.h>
#include <unistd.h>
@@ -36,6 +37,8 @@
namespace perfetto {
namespace base {
+constexpr mode_t kInvalidMode = static_cast<mode_t>(-1);
+
// RAII classes for auto-releasing fds and dirs.
template <typename T,
int (*CloseFunction)(T),
@@ -81,8 +84,8 @@
using ScopedFile = ScopedResource<int, close, -1>;
inline static ScopedFile OpenFile(const std::string& path,
int flags,
- mode_t mode = 0) {
- PERFETTO_DCHECK((flags & O_CREAT) == 0 || mode != 0);
+ mode_t mode = kInvalidMode) {
+ PERFETTO_DCHECK((flags & O_CREAT) == 0 || mode != kInvalidMode);
#if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN)
ScopedFile fd(open(path.c_str(), flags, mode));
#else
diff --git a/ui/package-lock.json b/ui/package-lock.json
index d5a0e39..67ee3c9 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -3256,6 +3256,11 @@
"integrity": "sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ==",
"dev": true
},
+ "immer": {
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/immer/-/immer-1.7.2.tgz",
+ "integrity": "sha512-4Urocwu9+XLDJw4Tc6ZCg7APVjjLInCFvO4TwGsAYV5zT6YYSor14dsZR0+0tHlDIN92cFUOq+i7fC00G5vTxA=="
+ },
"immutable": {
"version": "3.8.2",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz",
@@ -5974,20 +5979,12 @@
"requires": {
"@types/estree": "0.0.39",
"@types/node": "*"
- },
- "dependencies": {
- "@types/node": {
- "version": "8.10.15",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-8.10.15.tgz",
- "integrity": "sha512-qNb+m5Cuj6YUMK7YFcvuSgcHCKfVg1uXAUOP91SWvAakZlZTzbGmJaBi99CgDWEAyfZo51NlUhXkuP5WtXsgjg==",
- "dev": true
- }
}
},
"rollup-plugin-commonjs": {
- "version": "9.1.3",
- "resolved": "https://registry.npmjs.org/rollup-plugin-commonjs/-/rollup-plugin-commonjs-9.1.3.tgz",
- "integrity": "sha512-g91ZZKZwTW7F7vL6jMee38I8coj/Q9GBdTmXXeFL7ldgC1Ky5WJvHgbKlAiXXTh762qvohhExwUgeQGFh9suGg==",
+ "version": "9.1.8",
+ "resolved": "https://registry.npmjs.org/rollup-plugin-commonjs/-/rollup-plugin-commonjs-9.1.8.tgz",
+ "integrity": "sha512-c3nAfVVyEwbq9OohIeQudfQQdGV9Cl1RE8MUc90fH9UdtCiWAYpI+au3HxGwNf1DdV51HfBjCDbT4fwjsZEUUg==",
"dev": true,
"requires": {
"estree-walker": "^0.5.1",
@@ -5997,9 +5994,9 @@
}
},
"rollup-plugin-node-resolve": {
- "version": "3.3.0",
- "resolved": "https://registry.npmjs.org/rollup-plugin-node-resolve/-/rollup-plugin-node-resolve-3.3.0.tgz",
- "integrity": "sha512-9zHGr3oUJq6G+X0oRMYlzid9fXicBdiydhwGChdyeNRGPcN/majtegApRKHLR5drboUvEWU+QeUmGTyEZQs3WA==",
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/rollup-plugin-node-resolve/-/rollup-plugin-node-resolve-3.4.0.tgz",
+ "integrity": "sha512-PJcd85dxfSBWih84ozRtBkB731OjXk0KnzN0oGp7WOWcarAFkVa71cV5hTJg2qpVsV2U8EUwrzHP3tvy9vS3qg==",
"dev": true,
"requires": {
"builtin-modules": "^2.0.0",
@@ -6015,6 +6012,17 @@
}
}
},
+ "rollup-plugin-replace": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/rollup-plugin-replace/-/rollup-plugin-replace-2.0.0.tgz",
+ "integrity": "sha512-pK9mTd/FNrhtBxcTBXoh0YOwRIShV0gGhv9qvUtNcXHxIMRZMXqfiZKVBmCRGp8/2DJRy62z2JUE7/5tP6WxOQ==",
+ "dev": true,
+ "requires": {
+ "magic-string": "^0.22.4",
+ "minimatch": "^3.0.2",
+ "rollup-pluginutils": "^2.0.1"
+ }
+ },
"rollup-pluginutils": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.3.0.tgz",
diff --git a/ui/package.json b/ui/package.json
index b28879f..148426d 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -13,6 +13,7 @@
"dependencies": {
"@types/mithril": "^1.1.12",
"@types/uuid": "^3.4.3",
+ "immer": "^1.7.2",
"mithril": "^1.1.6",
"protobufjs": "^6.8.6",
"uuid": "^3.3.2"
@@ -26,8 +27,9 @@
"node-sass": "^4.9.2",
"puppeteer": "^1.5.0",
"rollup": "^0.59.4",
- "rollup-plugin-commonjs": "^9.1.3",
- "rollup-plugin-node-resolve": "^3.3.0",
+ "rollup-plugin-commonjs": "^9.1.8",
+ "rollup-plugin-node-resolve": "^3.4.0",
+ "rollup-plugin-replace": "^2.0.0",
"sorcery": "^0.10.0",
"tslib": "^1.9.3",
"tslint": "^5.10.0",
diff --git a/ui/rollup.config.js b/ui/rollup.config.js
index 168f320..8c884f1 100644
--- a/ui/rollup.config.js
+++ b/ui/rollup.config.js
@@ -1,5 +1,6 @@
import commonjs from 'rollup-plugin-commonjs';
import nodeResolve from 'rollup-plugin-node-resolve';
+import replace from 'rollup-plugin-replace';
export default {
output: {name: 'perfetto'},
@@ -15,6 +16,11 @@
'fs',
'path',
]
+ }),
+
+ replace({
+ 'immer_1.produce': 'immer_1',
})
+
]
}
diff --git a/ui/src/common/actions.ts b/ui/src/common/actions.ts
index bd7cada..fb108c0 100644
--- a/ui/src/common/actions.ts
+++ b/ui/src/common/actions.ts
@@ -12,125 +12,320 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-import {State} from './state';
+import {DraftObject} from 'immer';
+
+import {defaultTraceTime, State, Status, TraceTime} from './state';
import {TimeSpan} from './time';
export interface Action { type: string; }
+// TODO(hjd): Temporary until the reducer/action refactoring is done.
export function openTraceFromUrl(url: string) {
- return {
- type: 'OPEN_TRACE_FROM_URL',
+ return Actions.openTraceFromUrl({
url,
- };
+ });
}
+// TODO(hjd): Temporary until the reducer/action refactoring is done.
export function openTraceFromFile(file: File) {
- return {
- type: 'OPEN_TRACE_FROM_FILE',
+ return Actions.openTraceFromFile({
file,
- };
+ });
}
-// TODO(hjd): Remove CPU and add a generic way to handle track specific state.
+// TODO(hjd): Temporary until the reducer/action refactoring is done.
export function addTrack(
engineId: string, trackKind: string, name: string, config: {}) {
- return {
- type: 'ADD_TRACK',
+ return Actions.addTrack({
engineId,
- trackKind,
+ kind: trackKind,
name,
config,
- };
+ });
}
+// TODO(hjd): Temporary until the reducer/action refactoring is done.
export function requestTrackData(
trackId: string, start: number, end: number, resolution: number) {
- return {type: 'REQ_TRACK_DATA', trackId, start, end, resolution};
+ return Actions.reqTrackData({trackId, start, end, resolution});
}
+// TODO(hjd): Temporary until the reducer/action refactoring is done.
export function clearTrackDataRequest(trackId: string) {
- return {type: 'CLEAR_TRACK_DATA_REQ', trackId};
+ return Actions.clearTrackDataReq({trackId});
}
-export function executeQuery(engineId: string, queryId: string, query: string) {
- return {
- type: 'EXECUTE_QUERY',
- engineId,
- queryId,
- query,
- };
-}
-
+// TODO(hjd): Temporary until the reducer/action refactoring is done.
export function deleteQuery(queryId: string) {
- return {
- type: 'DELETE_QUERY',
+ return Actions.deleteQuery({
queryId,
- };
+ });
}
+// TODO(hjd): Temporary until the reducer/action refactoring is done.
export function navigate(route: string) {
- return {
- type: 'NAVIGATE',
+ return Actions.navigate({
route,
- };
+ });
}
+// TODO(hjd): Temporary until the reducer/action refactoring is done.
export function moveTrack(trackId: string, direction: 'up'|'down') {
- return {
- type: 'MOVE_TRACK',
+ return Actions.moveTrack({
trackId,
direction,
- };
+ });
}
+// TODO(hjd): Temporary until the reducer/action refactoring is done.
export function toggleTrackPinned(trackId: string) {
- return {
- type: 'TOGGLE_TRACK_PINNED',
+ return Actions.toggleTrackPinned({
trackId,
- };
+ });
}
+// TODO(hjd): Temporary until the reducer/action refactoring is done.
export function setEngineReady(engineId: string, ready = true) {
- return {type: 'SET_ENGINE_READY', engineId, ready};
+ return Actions.setEngineReady({engineId, ready});
}
+// TODO(hjd): Temporary until the reducer/action refactoring is done.
export function createPermalink() {
- return {type: 'CREATE_PERMALINK', requestId: new Date().toISOString()};
+ return Actions.createPermalink({requestId: new Date().toISOString()});
}
+// TODO(hjd): Temporary until the reducer/action refactoring is done.
export function setPermalink(requestId: string, hash: string) {
- return {type: 'SET_PERMALINK', requestId, hash};
+ return Actions.setPermalink({requestId, hash});
}
+// TODO(hjd): Temporary until the reducer/action refactoring is done.
export function loadPermalink(hash: string) {
- return {type: 'LOAD_PERMALINK', requestId: new Date().toISOString(), hash};
+ return Actions.loadPermalink({requestId: new Date().toISOString(), hash});
}
+// TODO(hjd): Temporary until the reducer/action refactoring is done.
export function setState(newState: State) {
- return {
- type: 'SET_STATE',
- newState,
- };
+ return Actions.setState({newState});
}
export function setTraceTime(ts: TimeSpan) {
- return {
- type: 'SET_TRACE_TIME',
+ return Actions.setTraceTime({
startSec: ts.start,
endSec: ts.end,
lastUpdate: Date.now() / 1000,
- };
+ });
}
export function setVisibleTraceTime(ts: TimeSpan) {
- return {
- type: 'SET_VISIBLE_TRACE_TIME',
+ return Actions.setVisibleTraceTime({
startSec: ts.start,
endSec: ts.end,
lastUpdate: Date.now() / 1000,
- };
+ });
}
export function updateStatus(msg: string) {
- return {type: 'UPDATE_STATUS', msg, timestamp: Date.now() / 1000};
+ return Actions.updateStatus({msg, timestamp: Date.now() / 1000});
}
+
+type StateDraft = DraftObject<State>;
+
+export const StateActions = {
+
+ navigate(state: StateDraft, args: {route: string}): void {
+ state.route = args.route;
+ },
+
+ openTraceFromFile(state: StateDraft, args: {file: File}): void {
+ state.traceTime = defaultTraceTime;
+ state.visibleTraceTime = defaultTraceTime;
+ const id = `${state.nextId++}`;
+ // Reset displayed tracks.
+ state.pinnedTracks = [];
+ state.scrollingTracks = [];
+ state.engines[id] = {
+ id,
+ ready: false,
+ source: args.file,
+ };
+ state.route = `/viewer`;
+ },
+
+ openTraceFromUrl(state: StateDraft, args: {url: string}): void {
+ state.traceTime = defaultTraceTime;
+ state.visibleTraceTime = defaultTraceTime;
+ const id = `${state.nextId++}`;
+ // Reset displayed tracks.
+ state.pinnedTracks = [];
+ state.scrollingTracks = [];
+ state.engines[id] = {
+ id,
+ ready: false,
+ source: args.url,
+ };
+ state.route = `/viewer`;
+ },
+
+ addTrack(
+ state: StateDraft,
+ args: {engineId: string; kind: string; name: string; config: {};}): void {
+ const id = `${state.nextId++}`;
+ state.tracks[id] = {
+ id,
+ engineId: args.engineId,
+ kind: args.kind,
+ name: args.name,
+ config: args.config,
+ };
+ state.scrollingTracks.push(id);
+ },
+
+ reqTrackData(state: StateDraft, args: {
+ trackId: string; start: number; end: number; resolution: number;
+ }): void {
+ const id = args.trackId;
+ state.tracks[id].dataReq = {
+ start: args.start,
+ end: args.end,
+ resolution: args.resolution
+ };
+ },
+
+ clearTrackDataReq(state: StateDraft, args: {trackId: string}): void {
+ const id = args.trackId;
+ state.tracks[id].dataReq = undefined;
+ },
+
+ executeQuery(
+ state: StateDraft,
+ args: {queryId: string; engineId: string; query: string}): void {
+ state.queries[args.queryId] = {
+ id: args.queryId,
+ engineId: args.engineId,
+ query: args.query,
+ };
+ },
+
+ deleteQuery(state: StateDraft, args: {queryId: string}): void {
+ delete state.queries[args.queryId];
+ },
+
+ moveTrack(
+ state: StateDraft, args: {trackId: string; direction: 'up' | 'down';}):
+ void {
+ const id = args.trackId;
+ const isPinned = state.pinnedTracks.includes(id);
+ const isScrolling = state.scrollingTracks.includes(id);
+ if (!isScrolling && !isPinned) {
+ throw new Error(`No track with id ${id}`);
+ }
+ const tracks = isPinned ? state.pinnedTracks : state.scrollingTracks;
+
+ const oldIndex: number = tracks.indexOf(id);
+ const newIndex = args.direction === 'up' ? oldIndex - 1 : oldIndex + 1;
+ const swappedTrackId = tracks[newIndex];
+ if (isPinned && newIndex === state.pinnedTracks.length) {
+ // Move from last element of pinned to first element of scrolling.
+ state.scrollingTracks.unshift(state.pinnedTracks.pop()!);
+ } else if (isScrolling && newIndex === -1) {
+ // Move first element of scrolling to last element of pinned.
+ state.pinnedTracks.push(state.scrollingTracks.shift()!);
+ } else if (swappedTrackId) {
+ tracks[newIndex] = id;
+ tracks[oldIndex] = swappedTrackId;
+ }
+ },
+
+ toggleTrackPinned(state: StateDraft, args: {trackId: string}): void {
+ const id = args.trackId;
+ const isPinned = state.pinnedTracks.includes(id);
+
+ if (isPinned) {
+ state.pinnedTracks.splice(state.pinnedTracks.indexOf(id), 1);
+ state.scrollingTracks.unshift(id);
+ } else {
+ state.scrollingTracks.splice(state.scrollingTracks.indexOf(id), 1);
+ state.pinnedTracks.push(id);
+ }
+ },
+
+ setEngineReady(state: StateDraft, args: {engineId: string; ready: boolean}):
+ void {
+ state.engines[args.engineId].ready = args.ready;
+ },
+
+ createPermalink(state: StateDraft, args: {requestId: string}): void {
+ state.permalink = {requestId: args.requestId, hash: undefined};
+ },
+
+ setPermalink(state: StateDraft, args: {requestId: string; hash: string}):
+ void {
+ // Drop any links for old requests.
+ if (state.permalink.requestId !== args.requestId) return;
+ state.permalink = args;
+ },
+
+ loadPermalink(state: StateDraft, args: {requestId: string; hash: string}):
+ void {
+ state.permalink = args;
+ },
+
+ setTraceTime(state: StateDraft, args: TraceTime): void {
+ state.traceTime = args;
+ },
+
+ setVisibleTraceTime(state: StateDraft, args: TraceTime): void {
+ state.visibleTraceTime = args;
+ },
+
+ updateStatus(state: StateDraft, args: Status): void {
+ state.status = args;
+ },
+
+ // TODO(hjd): Remove setState - it causes problems due to reuse of ids.
+ setState(_state: StateDraft, _args: {newState: State}): void {
+ // This has to be handled at a higher level since we can't
+ // replace the whole tree here however we still need a method here
+ // so it appears on the proxy Actions class.
+ throw new Error('Called setState on StateActions.');
+ },
+};
+
+// A DeferredAction is a bundle of Args and a method name.
+export interface DeferredAction<Args = {}> {
+ type: string;
+ args: Args;
+}
+
+// This type magic creates a type function DeferredActions<T> which takes a type
+// T and 'maps' its attributes. For each attribute on T matching the signature:
+// (state: StateDraft, args: Args) => void
+// DeferredActions<T> has an attribute:
+// (args: Args) => DeferredAction<Args>
+type ActionFunction<Args> = (state: StateDraft, args: Args) => void;
+type DeferredActionFunc<T> = T extends ActionFunction<infer Args>?
+ (args: Args) => DeferredAction<Args>:
+ never;
+type DeferredActions<C> = {
+ [P in keyof C]: DeferredActionFunc<C[P]>;
+};
+
+// Actions is an implementation of DeferredActions<typeof StateActions>.
+// (since StateActions is a variable not a type we have to do
+// 'typeof StateActions' to access the (unnamed) type of StateActions).
+// It's a Proxy such that any attribute access returns a function:
+// (args) => {return {type: ATTRIBUTE_NAME, args};}
+export const Actions =
+ // tslint:disable-next-line no-any
+ new Proxy<DeferredActions<typeof StateActions>>({} as any, {
+ // tslint:disable-next-line no-any
+ get(_: any, prop: string, _2: any) {
+ return (args: {}): DeferredAction<{}> => {
+ return {
+ type: prop,
+ args,
+ };
+ };
+ },
+ });
diff --git a/ui/src/common/actions_unittest.ts b/ui/src/common/actions_unittest.ts
new file mode 100644
index 0000000..7a3dc94
--- /dev/null
+++ b/ui/src/common/actions_unittest.ts
@@ -0,0 +1,238 @@
+// 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 {produce} from 'immer';
+import {StateActions} from './actions';
+import {createEmptyState, State, TrackState} from './state';
+
+function fakeTrack(state: State, id: string): TrackState {
+ const track: TrackState = {
+ id,
+ engineId: '1',
+ kind: 'SOME_TRACK_KIND',
+ name: 'A track',
+ config: {},
+ };
+ state.tracks[id] = track;
+ return track;
+}
+
+test('navigate', () => {
+ const after = produce(createEmptyState(), draft => {
+ StateActions.navigate(draft, {route: '/foo'});
+ });
+ expect(after.route).toBe('/foo');
+});
+
+test('add tracks', () => {
+ const once = produce(createEmptyState(), draft => {
+ StateActions.addTrack(draft, {
+ engineId: '1',
+ kind: 'cpu',
+ name: 'Cpu 1',
+ config: {},
+ });
+ });
+ const twice = produce(once, draft => {
+ StateActions.addTrack(draft, {
+ engineId: '2',
+ kind: 'cpu',
+ name: 'Cpu 2',
+ config: {},
+ });
+ });
+
+ expect(Object.values(twice.tracks).length).toBe(2);
+ expect(twice.scrollingTracks.length).toBe(2);
+});
+
+test('reorder tracks', () => {
+ const once = produce(createEmptyState(), draft => {
+ StateActions.addTrack(draft, {
+ engineId: '1',
+ kind: 'cpu',
+ name: 'Cpu 1',
+ config: {},
+ });
+ StateActions.addTrack(draft, {
+ engineId: '2',
+ kind: 'cpu',
+ name: 'Cpu 2',
+ config: {},
+ });
+ });
+
+ const firstTrackId = once.scrollingTracks[0];
+ const secondTrackId = once.scrollingTracks[1];
+
+ const twice = produce(once, draft => {
+ StateActions.moveTrack(draft, {
+ trackId: `${firstTrackId}`,
+ direction: 'down',
+ });
+ });
+
+ expect(twice.scrollingTracks[0]).toBe(secondTrackId);
+ expect(twice.scrollingTracks[1]).toBe(firstTrackId);
+});
+
+test('reorder pinned to scrolling', () => {
+ const state = createEmptyState();
+ fakeTrack(state, 'a');
+ fakeTrack(state, 'b');
+ fakeTrack(state, 'c');
+ state.pinnedTracks = ['a', 'b'];
+ state.scrollingTracks = ['c'];
+
+ const after = produce(state, draft => {
+ StateActions.moveTrack(draft, {
+ trackId: 'b',
+ direction: 'down',
+ });
+ });
+
+ expect(after.pinnedTracks).toEqual(['a']);
+ expect(after.scrollingTracks).toEqual(['b', 'c']);
+});
+
+test('reorder scrolling to pinned', () => {
+ const state = createEmptyState();
+ fakeTrack(state, 'a');
+ fakeTrack(state, 'b');
+ fakeTrack(state, 'c');
+ state.pinnedTracks = ['a'];
+ state.scrollingTracks = ['b', 'c'];
+
+ const after = produce(state, draft => {
+ StateActions.moveTrack(draft, {
+ trackId: 'b',
+ direction: 'up',
+ });
+ });
+
+ expect(after.pinnedTracks).toEqual(['a', 'b']);
+ expect(after.scrollingTracks).toEqual(['c']);
+});
+
+test('reorder clamp bottom', () => {
+ const state = createEmptyState();
+ fakeTrack(state, 'a');
+ fakeTrack(state, 'b');
+ fakeTrack(state, 'c');
+ state.pinnedTracks = ['a', 'b'];
+ state.scrollingTracks = ['c'];
+
+ const after = produce(state, draft => {
+ StateActions.moveTrack(draft, {
+ trackId: 'a',
+ direction: 'up',
+ });
+ });
+ expect(after).toEqual(state);
+});
+
+test('reorder clamp top', () => {
+ const state = createEmptyState();
+ fakeTrack(state, 'a');
+ fakeTrack(state, 'b');
+ fakeTrack(state, 'c');
+ state.pinnedTracks = ['a'];
+ state.scrollingTracks = ['b', 'c'];
+
+ const after = produce(state, draft => {
+ StateActions.moveTrack(draft, {
+ trackId: 'c',
+ direction: 'down',
+ });
+ });
+ expect(after).toEqual(state);
+});
+
+test('pin', () => {
+ const state = createEmptyState();
+ fakeTrack(state, 'a');
+ fakeTrack(state, 'b');
+ fakeTrack(state, 'c');
+ state.pinnedTracks = ['a'];
+ state.scrollingTracks = ['b', 'c'];
+
+ const after = produce(state, draft => {
+ StateActions.toggleTrackPinned(draft, {
+ trackId: 'c',
+ });
+ });
+ expect(after.pinnedTracks).toEqual(['a', 'c']);
+ expect(after.scrollingTracks).toEqual(['b']);
+});
+
+test('unpin', () => {
+ const state = createEmptyState();
+ fakeTrack(state, 'a');
+ fakeTrack(state, 'b');
+ fakeTrack(state, 'c');
+ state.pinnedTracks = ['a', 'b'];
+ state.scrollingTracks = ['c'];
+
+ const after = produce(state, draft => {
+ StateActions.toggleTrackPinned(draft, {
+ trackId: 'a',
+ });
+ });
+ expect(after.pinnedTracks).toEqual(['b']);
+ expect(after.scrollingTracks).toEqual(['a', 'c']);
+});
+
+test('open trace', () => {
+ const after = produce(createEmptyState(), draft => {
+ StateActions.openTraceFromUrl(draft, {
+ url: 'https://example.com/bar',
+ });
+ });
+
+ const engineKeys = Object.keys(after.engines);
+ expect(engineKeys.length).toBe(1);
+ expect(after.engines[engineKeys[0]].source).toBe('https://example.com/bar');
+ expect(after.route).toBe('/viewer');
+});
+
+test('open second trace from file', () => {
+ const once = produce(createEmptyState(), draft => {
+ StateActions.openTraceFromUrl(draft, {
+ url: 'https://example.com/bar',
+ });
+ });
+
+ const twice = produce(once, draft => {
+ StateActions.addTrack(draft, {
+ engineId: '1',
+ kind: 'cpu',
+ name: 'Cpu 1',
+ config: {},
+ });
+ });
+
+ const thrice = produce(twice, draft => {
+ StateActions.openTraceFromUrl(draft, {
+ url: 'https://example.com/foo',
+ });
+ });
+
+ const engineKeys = Object.keys(thrice.engines);
+ expect(engineKeys.length).toBe(2);
+ expect(thrice.engines[engineKeys[0]].source).toBe('https://example.com/bar');
+ expect(thrice.engines[engineKeys[1]].source).toBe('https://example.com/foo');
+ expect(thrice.pinnedTracks.length).toBe(0);
+ expect(thrice.scrollingTracks.length).toBe(0);
+ expect(thrice.route).toBe('/viewer');
+});
diff --git a/ui/src/controller/globals.ts b/ui/src/controller/globals.ts
index 8a336fa..c46ffbf 100644
--- a/ui/src/controller/globals.ts
+++ b/ui/src/controller/globals.ts
@@ -12,13 +12,15 @@
// See the License for the specific language governing permissions and
// limitations under the License.
+import {produce} from 'immer';
+
import {assertExists} from '../base/logging';
import {Remote} from '../base/remote';
-import {Action} from '../common/actions';
+import {DeferredAction, StateActions} from '../common/actions';
import {createEmptyState, State} from '../common/state';
+
import {ControllerAny} from './controller';
import {Engine} from './engine';
-import {rootReducer} from './reducer';
import {WasmEngineProxy} from './wasm_engine_proxy';
import {
createWasmEngine,
@@ -33,19 +35,19 @@
private _rootController?: ControllerAny;
private _frontend?: Remote;
private _runningControllers = false;
- private _queuedActions = new Array<Action>();
+ private _queuedActions = new Array<DeferredAction>();
initialize(rootController: ControllerAny, frontendProxy: Remote) {
- this._state = createEmptyState();
this._rootController = rootController;
this._frontend = frontendProxy;
+ this._state = createEmptyState();
}
- dispatch(action: Action): void {
+ dispatch(action: DeferredAction): void {
this.dispatchMultiple([action]);
}
- dispatchMultiple(actions: Action[]): void {
+ dispatchMultiple(actions: DeferredAction[]): void {
this._queuedActions = this._queuedActions.concat(actions);
// If we are in the middle of running the controllers, queue the actions
@@ -67,10 +69,10 @@
for (let iter = 0; runAgain || this._queuedActions.length > 0; iter++) {
if (iter > 100) throw new Error('Controllers are stuck in a livelock');
const actions = this._queuedActions;
- this._queuedActions = new Array<Action>();
+ this._queuedActions = new Array<DeferredAction>();
for (const action of actions) {
console.debug('Applying action', action);
- this._state = rootReducer(this.state, action);
+ this.applyAction(action);
}
this._runningControllers = true;
try {
@@ -102,8 +104,21 @@
return assertExists(this._state);
}
- set state(state: State) {
- this._state = state;
+ applyAction(action: DeferredAction): void {
+ assertExists(this._state);
+ // We need a special case for when we want to replace the whole tree.
+ if (action.type === 'setState') {
+ const args = (action as DeferredAction<{newState: State}>).args;
+ this._state = args.newState;
+ return;
+ }
+ // 'produce' creates a immer proxy which wraps the current state turning
+ // all imperative mutations of the state done in the callback into
+ // immutable changes to the returned state.
+ this._state = produce(this.state, draft => {
+ // tslint:disable-next-line no-any
+ (StateActions as any)[action.type](draft, action.args);
+ });
}
resetForTesting() {
diff --git a/ui/src/controller/reducer.ts b/ui/src/controller/reducer.ts
deleted file mode 100644
index 8e6cc9b..0000000
--- a/ui/src/controller/reducer.ts
+++ /dev/null
@@ -1,229 +0,0 @@
-// 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 {defaultTraceTime, State} from '../common/state';
-
-// TODO(hjd): Type check this better.
-// tslint:disable-next-line no-any
-export function rootReducer(state: State, action: any): State {
- switch (action.type) {
- case 'NAVIGATE': {
- const nextState = {...state};
- nextState.route = action.route;
- return nextState;
- }
-
- case 'OPEN_TRACE_FROM_FILE': {
- const nextState = {...state};
- nextState.traceTime = {...defaultTraceTime};
- nextState.visibleTraceTime = {...defaultTraceTime};
- const id = `${nextState.nextId++}`;
- // Reset displayed tracks.
- nextState.pinnedTracks = [];
- nextState.scrollingTracks = [];
- nextState.engines[id] = {
- id,
- ready: false,
- source: action.file,
- };
- nextState.route = `/viewer`;
-
- return nextState;
- }
-
- case 'OPEN_TRACE_FROM_URL': {
- const nextState = {...state};
- nextState.traceTime = {...defaultTraceTime};
- nextState.visibleTraceTime = {...defaultTraceTime};
- const id = `${nextState.nextId++}`;
- // Reset displayed tracks.
- nextState.pinnedTracks = [];
- nextState.scrollingTracks = [];
- nextState.engines[id] = {
- id,
- ready: false,
- source: action.url,
- };
- nextState.route = `/viewer`;
- return nextState;
- }
-
- case 'ADD_TRACK': {
- const nextState = {...state};
- nextState.tracks = {...state.tracks};
- nextState.scrollingTracks = [...state.scrollingTracks];
- const id = `${nextState.nextId++}`;
- nextState.tracks[id] = {
- id,
- engineId: action.engineId,
- kind: action.trackKind,
- name: action.name,
- config: action.config,
- };
- nextState.scrollingTracks.push(id);
- return nextState;
- }
-
- case 'REQ_TRACK_DATA': {
- const id = action.trackId;
- const nextState = {...state};
- const nextTracks = nextState.tracks = {...state.tracks};
- const nextTrack = nextTracks[id] = {...nextTracks[id]};
- nextTrack.dataReq = {
- start: action.start,
- end: action.end,
- resolution: action.resolution
- };
- return nextState;
- }
-
- case 'CLEAR_TRACK_DATA_REQ': {
- const id = action.trackId;
- const nextState = {...state};
- const nextTracks = nextState.tracks = {...state.tracks};
- const nextTrack = nextTracks[id] = {...nextTracks[id]};
- nextTrack.dataReq = undefined;
- return nextState;
- }
-
- case 'EXECUTE_QUERY': {
- const nextState = {...state};
- nextState.queries = {...state.queries};
- nextState.queries[action.queryId] = {
- id: action.queryId,
- engineId: action.engineId,
- query: action.query,
- };
- return nextState;
- }
-
- case 'DELETE_QUERY': {
- const nextState = {...state};
- nextState.queries = {...state.queries};
- delete nextState.queries[action.queryId];
- return nextState;
- }
-
- case 'MOVE_TRACK': {
- if (!action.direction) {
- throw new Error('No direction given');
- }
- const id = action.trackId;
- const isPinned = state.pinnedTracks.includes(id);
- const isScrolling = state.scrollingTracks.includes(id);
- if (!isScrolling && !isPinned) {
- throw new Error(`No track with id ${id}`);
- }
- const nextState = {...state};
- const scrollingTracks = nextState.scrollingTracks =
- state.scrollingTracks.slice();
- const pinnedTracks = nextState.pinnedTracks = state.pinnedTracks.slice();
-
- const tracks = isPinned ? pinnedTracks : scrollingTracks;
-
- const oldIndex = tracks.indexOf(id);
- const newIndex = action.direction === 'up' ? oldIndex - 1 : oldIndex + 1;
- const swappedTrackId = tracks[newIndex];
- if (isPinned && newIndex === pinnedTracks.length) {
- // Move from last element of pinned to first element of scrolling.
- scrollingTracks.unshift(pinnedTracks.pop()!);
- } else if (isScrolling && newIndex === -1) {
- // Move first element of scrolling to last element of pinned.
- pinnedTracks.push(scrollingTracks.shift()!);
- } else if (swappedTrackId) {
- tracks[newIndex] = id;
- tracks[oldIndex] = swappedTrackId;
- } else {
- return state;
- }
- return nextState;
- }
-
- case 'TOGGLE_TRACK_PINNED': {
- const id = action.trackId;
- const isPinned = state.pinnedTracks.includes(id);
-
- const nextState = {...state};
- const pinnedTracks = nextState.pinnedTracks = [...state.pinnedTracks];
- const scrollingTracks = nextState.scrollingTracks =
- [...state.scrollingTracks];
- if (isPinned) {
- pinnedTracks.splice(pinnedTracks.indexOf(id), 1);
- scrollingTracks.unshift(id);
- } else {
- scrollingTracks.splice(scrollingTracks.indexOf(id), 1);
- pinnedTracks.push(id);
- }
- return nextState;
- }
-
- case 'SET_ENGINE_READY': {
- const nextState = {...state}; // Creates a shallow copy.
- nextState.engines = {...state.engines};
- nextState.engines[action.engineId].ready = action.ready;
- return nextState;
- }
-
- case 'CREATE_PERMALINK': {
- const nextState = {...state};
- nextState.permalink = {requestId: action.requestId, hash: undefined};
- return nextState;
- }
-
- case 'SET_PERMALINK': {
- // Drop any links for old requests.
- if (state.permalink.requestId !== action.requestId) return state;
-
- const nextState = {...state};
- nextState.permalink = {requestId: action.requestId, hash: action.hash};
- return nextState;
- }
-
- case 'LOAD_PERMALINK': {
- const nextState = {...state};
- nextState.permalink = {requestId: action.requestId, hash: action.hash};
- return nextState;
- }
-
- case 'SET_STATE': {
- return action.newState;
- }
-
- case 'SET_TRACE_TIME': {
- const nextState = {...state};
- nextState.traceTime.startSec = action.startSec;
- nextState.traceTime.endSec = action.endSec;
- nextState.traceTime.lastUpdate = action.lastUpdate;
- return nextState;
- }
-
- case 'SET_VISIBLE_TRACE_TIME': {
- const nextState = {...state};
- nextState.visibleTraceTime.startSec = action.startSec;
- nextState.visibleTraceTime.endSec = action.endSec;
- nextState.visibleTraceTime.lastUpdate = action.lastUpdate;
- return nextState;
- }
-
- case 'UPDATE_STATUS': {
- const nextState = {...state};
- nextState.status = {msg: action.msg, timestamp: action.timestamp};
- return nextState;
- }
-
- default:
- break;
- }
- return state;
-}
diff --git a/ui/src/controller/reducer_unittest.ts b/ui/src/controller/reducer_unittest.ts
deleted file mode 100644
index fc0f246..0000000
--- a/ui/src/controller/reducer_unittest.ts
+++ /dev/null
@@ -1,217 +0,0 @@
-// 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 {moveTrack, toggleTrackPinned} from '../common/actions';
-import {createEmptyState, State, TrackState} from '../common/state';
-
-import {rootReducer} from './reducer';
-
-function fakeTrack(state: State, id: string): TrackState {
- const track: TrackState = {
- id,
- engineId: '1',
- kind: 'SOME_TRACK_KIND',
- name: 'A track',
- config: {},
- };
- state.tracks[id] = track;
- return track;
-}
-
-test('navigate', async () => {
- const before = createEmptyState();
- const after = rootReducer(before, {type: 'NAVIGATE', route: '/foo'});
- expect(after.route).toBe('/foo');
-});
-
-test('add tracks', () => {
- const empty = createEmptyState();
- const step1 = rootReducer(empty, {
- type: 'ADD_TRACK',
- engineId: '1',
- trackKind: 'cpu',
- cpu: '1',
- });
- const state = rootReducer(step1, {
- type: 'ADD_TRACK',
- engineId: '2',
- trackKind: 'cpu',
- cpu: '2',
- });
- expect(Object.values(state.tracks).length).toBe(2);
- expect(state.scrollingTracks.length).toBe(2);
-});
-
-test('reorder tracks', () => {
- const empty = createEmptyState();
- const step1 = rootReducer(empty, {
- type: 'ADD_TRACK',
- engineId: '1',
- trackKind: 'cpu',
- config: {},
- });
- const before = rootReducer(step1, {
- type: 'ADD_TRACK',
- engineId: '2',
- trackKind: 'cpu',
- config: {},
- });
-
- const firstTrackId = before.scrollingTracks[0];
- const secondTrackId = before.scrollingTracks[1];
-
- const after = rootReducer(before, {
- type: 'MOVE_TRACK',
- trackId: `${firstTrackId}`,
- direction: 'down',
- });
-
- // Ensure the order is swapped. This test would fail to detect side effects
- // if the before state was modified, so other tests are needed as well.
- expect(after.scrollingTracks[0]).toBe(secondTrackId);
- expect(after.scrollingTracks[1]).toBe(firstTrackId);
-
- // Ensure the track state contents have actually swapped places in the new
- // state, but not in the old one.
- expect(before.tracks[before.scrollingTracks[0]].engineId).toBe('1');
- expect(before.tracks[before.scrollingTracks[1]].engineId).toBe('2');
- expect(after.tracks[after.scrollingTracks[0]].engineId).toBe('2');
- expect(after.tracks[after.scrollingTracks[1]].engineId).toBe('1');
-});
-
-test('reorder pinned to scrolling', () => {
- const before = createEmptyState();
-
- fakeTrack(before, 'a');
- fakeTrack(before, 'b');
- fakeTrack(before, 'c');
-
- before.pinnedTracks = ['a', 'b'];
- before.scrollingTracks = ['c'];
-
- const after = rootReducer(before, moveTrack('b', 'down'));
- expect(after.pinnedTracks).toEqual(['a']);
- expect(after.scrollingTracks).toEqual(['b', 'c']);
-});
-
-test('reorder scrolling to pinned', () => {
- const before = createEmptyState();
- fakeTrack(before, 'a');
- fakeTrack(before, 'b');
- fakeTrack(before, 'c');
-
- before.pinnedTracks = ['a'];
- before.scrollingTracks = ['b', 'c'];
-
- const after = rootReducer(before, moveTrack('b', 'up'));
- expect(after.pinnedTracks).toEqual(['a', 'b']);
- expect(after.scrollingTracks).toEqual(['c']);
-});
-
-test('reorder clamp bottom', () => {
- const before = createEmptyState();
- fakeTrack(before, 'a');
- fakeTrack(before, 'b');
- fakeTrack(before, 'c');
-
- before.pinnedTracks = ['a', 'b'];
- before.scrollingTracks = ['c'];
-
- const after = rootReducer(before, moveTrack('a', 'up'));
- expect(after).toEqual(before);
-});
-
-test('reorder clamp top', () => {
- const before = createEmptyState();
- fakeTrack(before, 'a');
- fakeTrack(before, 'b');
- fakeTrack(before, 'c');
-
- before.pinnedTracks = ['a'];
- before.scrollingTracks = ['b', 'c'];
-
- const after = rootReducer(before, moveTrack('c', 'down'));
- expect(after).toEqual(before);
-});
-
-test('pin', () => {
- const before = createEmptyState();
- fakeTrack(before, 'a');
- fakeTrack(before, 'b');
- fakeTrack(before, 'c');
-
- before.pinnedTracks = ['a'];
- before.scrollingTracks = ['b', 'c'];
-
- const after = rootReducer(before, toggleTrackPinned('c'));
- expect(after.pinnedTracks).toEqual(['a', 'c']);
- expect(after.scrollingTracks).toEqual(['b']);
-});
-
-test('unpin', () => {
- const before = createEmptyState();
- fakeTrack(before, 'a');
- fakeTrack(before, 'b');
- fakeTrack(before, 'c');
-
- before.pinnedTracks = ['a', 'b'];
- before.scrollingTracks = ['c'];
-
- const after = rootReducer(before, toggleTrackPinned('a'));
- expect(after.pinnedTracks).toEqual(['b']);
- expect(after.scrollingTracks).toEqual(['a', 'c']);
-});
-
-test('open trace', async () => {
- const before = createEmptyState();
- const after = rootReducer(before, {
- type: 'OPEN_TRACE_FROM_URL',
- url: 'https://example.com/bar',
- });
- const engineKeys = Object.keys(after.engines);
- expect(engineKeys.length).toBe(1);
- expect(after.engines[engineKeys[0]].source).toBe('https://example.com/bar');
- expect(after.route).toBe('/viewer');
-});
-
-test('set state', async () => {
- const newState = createEmptyState();
- const before = createEmptyState();
- const after = rootReducer(before, {
- type: 'SET_STATE',
- newState,
- });
- expect(after).toBe(newState);
-});
-
-test('open second trace from file', () => {
- const before = createEmptyState();
- const afterFirst = rootReducer(before, {
- type: 'OPEN_TRACE_FROM_URL',
- url: 'https://example.com/bar',
- });
- afterFirst.scrollingTracks = ['track1', 'track2'];
- afterFirst.pinnedTracks = ['track3', 'track4'];
- const after = rootReducer(afterFirst, {
- type: 'OPEN_TRACE_FROM_URL',
- url: 'https://example.com/foo',
- });
- const engineKeys = Object.keys(after.engines);
- expect(engineKeys.length).toBe(2);
- expect(after.engines[engineKeys[0]].source).toBe('https://example.com/bar');
- expect(after.engines[engineKeys[1]].source).toBe('https://example.com/foo');
- expect(after.pinnedTracks.length).toBe(0);
- expect(after.scrollingTracks.length).toBe(0);
- expect(after.route).toBe('/viewer');
-});
diff --git a/ui/src/controller/trace_controller.ts b/ui/src/controller/trace_controller.ts
index 06f570f..08b205f 100644
--- a/ui/src/controller/trace_controller.ts
+++ b/ui/src/controller/trace_controller.ts
@@ -16,8 +16,8 @@
import {assertExists, assertTrue} from '../base/logging';
import {
- Action,
addTrack,
+ DeferredAction,
navigate,
setEngineReady,
setTraceTime,
@@ -180,7 +180,7 @@
private async listTracks() {
globals.dispatch(updateStatus('Loading tracks'));
const engine = assertExists<Engine>(this.engine);
- const addToTrackActions: Action[] = [];
+ const addToTrackActions: DeferredAction[] = [];
const numCpus = await engine.getNumberOfCpus();
for (let cpu = 0; cpu < numCpus; cpu++) {
addToTrackActions.push(
diff --git a/ui/src/frontend/panel_container.ts b/ui/src/frontend/panel_container.ts
index 4fb18f5..15be904 100644
--- a/ui/src/frontend/panel_container.ts
+++ b/ui/src/frontend/panel_container.ts
@@ -32,10 +32,12 @@
*/
const SCROLLING_CANVAS_OVERDRAW_FACTOR = 2;
+// We need any here so we can accept vnodes with arbitrary attrs.
+// tslint:disable-next-line:no-any
+export type AnyAttrsVnode = m.Vnode<any, {}>;
+
interface Attrs {
- // Panels with non-empty attrs does not work without any.
- // tslint:disable-next-line:no-any
- panels: Array<m.Vnode<any, {}>>;
+ panels: AnyAttrsVnode[];
doesScroll: boolean;
}
diff --git a/ui/src/frontend/sidebar.ts b/ui/src/frontend/sidebar.ts
index 6f8e5d2..073814c 100644
--- a/ui/src/frontend/sidebar.ts
+++ b/ui/src/frontend/sidebar.ts
@@ -15,8 +15,8 @@
import * as m from 'mithril';
import {
+ Actions,
createPermalink,
- executeQuery,
navigate,
openTraceFromFile,
openTraceFromUrl
@@ -60,7 +60,11 @@
function createCannedQuery(query: string): (_: Event) => void {
return (e: Event) => {
e.preventDefault();
- globals.dispatch(executeQuery('0', 'command', query));
+ globals.dispatch(Actions.executeQuery({
+ engineId: '0',
+ queryId: 'command',
+ query,
+ }));
};
}
diff --git a/ui/src/frontend/topbar.ts b/ui/src/frontend/topbar.ts
index 03b142a..6e1acdb 100644
--- a/ui/src/frontend/topbar.ts
+++ b/ui/src/frontend/topbar.ts
@@ -14,7 +14,7 @@
import * as m from 'mithril';
-import {deleteQuery, executeQuery} from '../common/actions';
+import {Actions, deleteQuery} from '../common/actions';
import {QueryResponse} from '../common/queries';
import {EngineConfig} from '../common/state';
@@ -81,10 +81,12 @@
if (mode === 'search') {
const name = txt.value.replace(/'/g, '\\\'').replace(/[*]/g, '%');
const query = `select str from strings where str like '%${name}%' limit 10`;
- globals.dispatch(executeQuery('0', QUERY_ID, query));
+ globals.dispatch(
+ Actions.executeQuery({engineId: '0', queryId: QUERY_ID, query}));
}
if (mode === 'command' && key === 'Enter') {
- globals.dispatch(executeQuery('0', 'command', txt.value));
+ globals.dispatch(Actions.executeQuery(
+ {engineId: '0', queryId: 'command', query: txt.value}));
}
}
diff --git a/ui/src/frontend/viewer_page.ts b/ui/src/frontend/viewer_page.ts
index e801992..adca962 100644
--- a/ui/src/frontend/viewer_page.ts
+++ b/ui/src/frontend/viewer_page.ts
@@ -23,14 +23,15 @@
import {OverviewTimelinePanel} from './overview_timeline_panel';
import {createPage} from './pages';
import {PanAndZoomHandler} from './pan_and_zoom_handler';
-import {PanelContainer} from './panel_container';
+import {Panel} from './panel';
+import {AnyAttrsVnode, PanelContainer} from './panel_container';
import {TimeAxisPanel} from './time_axis_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 implements m.ClassComponent {
+class QueryTable extends Panel {
view() {
const resp = globals.queryResults.get('command') as QueryResponse;
if (resp === undefined) {
@@ -59,6 +60,8 @@
m('.query-error', `SQL error: ${resp.error}`) :
m('table.query-table', m('thead', header), m('tbody', rows)));
}
+
+ renderCanvas() {}
}
/**
@@ -130,7 +133,8 @@
}
view() {
- const scrollingPanels = globals.state.scrollingTracks.length > 0 ?
+ const scrollingPanels: AnyAttrsVnode[] =
+ globals.state.scrollingTracks.length > 0 ?
[
m(HeaderPanel, {title: 'Tracks', key: 'tracksheader'}),
...globals.state.scrollingTracks.map(
@@ -138,10 +142,10 @@
m(FlameGraphPanel, {key: 'flamegraph'}),
] :
[];
+ scrollingPanels.unshift(m(QueryTable));
+
return m(
'.page',
- m(QueryTable),
- // TODO: Pan and zoom logic should be in its own mithril component.
m('.pan-and-zoom-content',
m('.pinned-panel-container', m(PanelContainer, {
doesScroll: false,