UI: add error handler

Show a civilized error dialog when an error is raised.
Handles both errors in the frontend and in the controller.

Example: https://screenshot.googleplex.com/CktD3ag8PEh
Change-Id: I8956e3c06961420f3ef4fd4d5881e617b665327c
diff --git a/ui/src/assets/modal.scss b/ui/src/assets/modal.scss
index 99f2e6a..24a8bfd 100644
--- a/ui/src/assets/modal.scss
+++ b/ui/src/assets/modal.scss
@@ -29,7 +29,7 @@
   display: flex;
   justify-content: center;
   align-items: center;
-  z-index: 4;
+  z-index: 999;
 }
 
 .modal-container {
@@ -186,4 +186,17 @@
 
 .modal-pre {
   white-space: pre-line;
+}
+
+.modal-logs {
+  white-space: pre-wrap;
+  border: 1px solid #999;
+  background: #eee;
+  font-size: 10px;
+  font-family: monospace;
+  -webkit-user-select: text;
+  margin-top: 10px;
+  min-height: 50px;
+  max-height: 40vh;
+  overflow: scroll;
 }
\ No newline at end of file
diff --git a/ui/src/base/logging.ts b/ui/src/base/logging.ts
index c3279c1..caefe56 100644
--- a/ui/src/base/logging.ts
+++ b/ui/src/base/logging.ts
@@ -12,6 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+export type ErrorHandler = (err: string) => void;
+
+let errorHandler: ErrorHandler = (_: string) => {};
+
 export function assertExists<A>(value: A | null | undefined): A {
   if (value === null || value === undefined) {
     throw new Error('Value doesn\'t exist');
@@ -28,3 +32,31 @@
 export function assertFalse(value: boolean, optMsg?: string) {
   assertTrue(!value, optMsg);
 }
+
+export function setErrorHandler(handler: ErrorHandler) {
+  errorHandler = handler;
+}
+
+export function reportError(err: ErrorEvent|PromiseRejectionEvent|{}) {
+  let errLog = '';
+  let errorObj = undefined;
+
+  if (err instanceof ErrorEvent) {
+    errLog = err.message;
+    errorObj = err.error;
+  } else if (err instanceof PromiseRejectionEvent) {
+    errLog = `${err.reason}`;
+    errorObj = err.reason;
+  } else {
+    errLog = `${err}`;
+  }
+  if (errorObj !== undefined) {
+    const errStack = (errorObj as {stack?: string}).stack;
+    errLog += '\n';
+    errLog += errStack !== undefined ? errStack : JSON.stringify(errorObj);
+  }
+  errLog += `\n\nUA: ${navigator.userAgent}\n`;
+
+  console.error(errLog, err);
+  errorHandler(errLog);
+}
\ No newline at end of file
diff --git a/ui/src/controller/index.ts b/ui/src/controller/index.ts
index e8e7d71..24e4800 100644
--- a/ui/src/controller/index.ts
+++ b/ui/src/controller/index.ts
@@ -14,6 +14,7 @@
 
 import '../tracks/all_controller';
 
+import {reportError, setErrorHandler} from '../base/logging';
 import {Remote} from '../base/remote';
 import {warmupWasmEngine} from '../common/wasm_engine_proxy';
 
@@ -21,6 +22,8 @@
 import {globals} from './globals';
 
 function main() {
+  self.addEventListener('error', e => reportError(e));
+  self.addEventListener('unhandledrejection', e => reportError(e));
   warmupWasmEngine();
   let initialized = false;
   self.onmessage = ({data}) => {
@@ -32,7 +35,8 @@
     const frontendPort = data.frontendPort as MessagePort;
     const controllerPort = data.controllerPort as MessagePort;
     const extensionPort = data.extensionPort as MessagePort;
-
+    const errorReportingPort = data.errorReportingPort as MessagePort;
+    setErrorHandler((err: string) => errorReportingPort.postMessage(err));
     const frontend = new Remote(frontendPort);
     controllerPort.onmessage = ({data}) => globals.dispatch(data);
 
diff --git a/ui/src/controller/trace_controller.ts b/ui/src/controller/trace_controller.ts
index e9632ad..8c93273 100644
--- a/ui/src/controller/trace_controller.ts
+++ b/ui/src/controller/trace_controller.ts
@@ -110,8 +110,7 @@
             })
             .catch(err => {
               this.updateStatus(`${err}`);
-              console.error(err);
-              return;
+              throw err;
             });
         this.updateStatus('Opening trace');
         this.setState('loading_trace');
@@ -185,7 +184,7 @@
       engine.errorHandler = (err) => {
         globals.dispatch(
             Actions.setEngineFailed({mode: 'HTTP_RPC', failure: `${err}`}));
-        console.error(err);
+        throw err;
       };
       this.engine = engine;
     } else {
diff --git a/ui/src/frontend/error_dialog.ts b/ui/src/frontend/error_dialog.ts
new file mode 100644
index 0000000..565cafc
--- /dev/null
+++ b/ui/src/frontend/error_dialog.ts
@@ -0,0 +1,66 @@
+// Copyright (C) 2019 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 {showModal} from './modal';
+
+// Never show more than one dialog per minute.
+const MIN_REPORT_PERIOD_MS = 60000;
+let timeLastReport = 0;
+
+// Keeps the last ERR_QUEUE_MAX_LEN errors while the dialog is throttled.
+const queuedErrors = new Array<string>();
+const ERR_QUEUE_MAX_LEN = 10;
+
+export function maybeShowErrorDialog(errLog: string) {
+  const now = performance.now();
+  if (timeLastReport > 0 && now - timeLastReport <= MIN_REPORT_PERIOD_MS) {
+    queuedErrors.unshift(errLog);
+    if (queuedErrors.length > ERR_QUEUE_MAX_LEN) queuedErrors.pop();
+    console.log('Suppressing crash dialog, last error notified too soon.');
+    return;
+  }
+  timeLastReport = now;
+
+  // Append queued errors.
+  while (queuedErrors.length > 0) {
+    const queuedErr = queuedErrors.shift();
+    errLog += `\n\n---------------------------------------\n${queuedErr}`;
+  }
+
+  const errTitle = errLog.split('\n', 1)[0].substr(0, 80);
+  let link = 'https://goto.google.com/perfetto-ui-bug';
+  link += '?title=' + encodeURIComponent(`UI Error: ${errTitle}`);
+  link += '&description=' + encodeURIComponent(errLog.substr(0, 32768));
+
+  showModal({
+    title: 'Oops, something went wrong. Please file a bug',
+    content: m(
+        'div',
+        m('span', 'An unexpected crash occurred:'),
+        m('.modal-logs', errLog),
+        ),
+    buttons: [
+      {
+        text: 'File a bug (Googlers only)',
+        primary: true,
+        id: 'file_bug',
+        action: () => {
+          window.open(link, '_blank');
+        }
+      },
+    ]
+  });
+}
diff --git a/ui/src/frontend/index.ts b/ui/src/frontend/index.ts
index dceb528..22515ee 100644
--- a/ui/src/frontend/index.ts
+++ b/ui/src/frontend/index.ts
@@ -18,7 +18,7 @@
 import * as MicroModal from 'micromodal';
 import * as m from 'mithril';
 
-import {assertExists} from '../base/logging';
+import {assertExists, reportError, setErrorHandler} from '../base/logging';
 import {forwardRemoteCalls} from '../base/remote';
 import {Actions} from '../common/actions';
 import {
@@ -32,6 +32,7 @@
   HeapProfileFlamegraphKey
 } from '../tracks/heap_profile_flamegraph/common';
 
+import {maybeShowErrorDialog} from './error_dialog';
 import {
   CounterDetails,
   globals,
@@ -210,25 +211,32 @@
 }
 
 function main() {
+  // Add Error handlers for JS error and for uncaught exceptions in promises.
+  setErrorHandler((err: string) => maybeShowErrorDialog(err));
+  window.addEventListener('error', e => reportError(e));
+  window.addEventListener('unhandledrejection', e => reportError(e));
+
   const controller = new Worker('controller_bundle.js');
-  controller.onerror = e => {
-    console.error(e);
-  };
   const frontendChannel = new MessageChannel();
   const controllerChannel = new MessageChannel();
   const extensionLocalChannel = new MessageChannel();
+  const errorReportingChannel = new MessageChannel();
 
+  errorReportingChannel.port2.onmessage = (e) =>
+      maybeShowErrorDialog(`${e.data}`);
 
   controller.postMessage(
       {
         frontendPort: frontendChannel.port1,
         controllerPort: controllerChannel.port1,
-        extensionPort: extensionLocalChannel.port1
+        extensionPort: extensionLocalChannel.port1,
+        errorReportingPort: errorReportingChannel.port1,
       },
       [
         frontendChannel.port1,
         controllerChannel.port1,
-        extensionLocalChannel.port1
+        extensionLocalChannel.port1,
+        errorReportingChannel.port1,
       ]);
 
   const dispatch =