Merge "TP: Ignore FrameTimeline events with zero timestamp"
diff --git a/CHANGELOG b/CHANGELOG
index cc3e886..36fca95 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -5,6 +5,10 @@
*
UI:
*
+ SDK:
+ * Use process start time hashed with the process id as a unique process
+ identifier, allowing multiple independent users of the SDK in the same
+ process to interleave their events on shared tracks.
v12.1 - 2021-02-01:
diff --git a/gn/standalone/write_ui_dist_file_map.py b/gn/standalone/write_ui_dist_file_map.py
deleted file mode 100644
index 20ba344..0000000
--- a/gn/standalone/write_ui_dist_file_map.py
+++ /dev/null
@@ -1,89 +0,0 @@
-#!/usr/bin/env python
-# Copyright (C) 2020 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.
-""" Writes a TypeScript dict that contains SHA256s of the passed files.
-
-The output looks like this:
-{
- hex_digest: '6c761701c5840483833ffb0bd70ae155b2b3c70e8f667c7bd1f6abc98095930',
- files: {
- 'frontend_bundle.js': 'sha256-2IVKK/3mEMlDdXNADyK03L1cANKbBpU+xue+vnLOcyo=',
- 'index.html': 'sha256-ZRS1+Xh/dFZeWZi/dz8QMWg/8PYQHNdazsNX2oX8s70=',
- ...
- }
-}
-"""
-
-from __future__ import print_function
-
-import argparse
-import base64
-import hashlib
-import os
-import sys
-
-from base64 import b64encode
-
-
-def hash_file(file_path):
- hasher = hashlib.sha256()
- with open(file_path, 'rb') as f:
- for chunk in iter(lambda: f.read(32768), b''):
- hasher.update(chunk)
- return file_path, hasher.digest()
-
-
-def hash_list_hex(args):
- hasher = hashlib.sha256()
- for arg in args:
- hasher.update(arg)
- return hasher.hexdigest()
-
-
-def main():
- parser = argparse.ArgumentParser()
- parser.add_argument('--out', help='Path of the output file')
- parser.add_argument(
- '--strip', help='Strips the leading path in the generated file list')
- parser.add_argument('file_list', nargs=argparse.REMAINDER)
- args = parser.parse_args()
-
- # Compute the hash of each file.
- digests = dict(map(hash_file, args.file_list))
-
- contents = '// __generated_by %s\n' % __file__
- contents += 'export const UI_DIST_MAP = {\n'
- contents += ' files: {\n'
- strip = args.strip + ('' if args.strip[-1] == os.path.sep else os.path.sep)
- for fname, digest in digests.items():
- if not fname.startswith(strip):
- raise Exception('%s must start with %s (--strip arg)' % (fname, strip))
- fname = fname[len(strip):]
- # We use b64 instead of hexdigest() because it's handy for handling fetch()
- # subresource integrity.
- contents += ' \'%s\': \'sha256-%s\',\n' % (
- fname, b64encode(digest).decode("ascii"))
- contents += ' },\n'
-
- # Compute the hash of the all resources' hashes.
- contents += ' hex_digest: \'%s\',\n' % hash_list_hex(digests.values())
- contents += '};\n'
-
- with open(args.out + '.tmp', 'w') as fout:
- fout.write(contents)
- os.rename(args.out + '.tmp', args.out)
-
-
-if __name__ == '__main__':
- sys.exit(main())
diff --git a/infra/ci/config.py b/infra/ci/config.py
index a512fff..35a141a 100755
--- a/infra/ci/config.py
+++ b/infra/ci/config.py
@@ -110,10 +110,6 @@
'PERFETTO_TEST_GN_ARGS': '',
'PERFETTO_TEST_SCRIPT': 'test/ci/bazel_tests.sh',
},
- 'ui-clang-x86_64-debug': {
- 'PERFETTO_TEST_GN_ARGS': 'is_debug=true',
- 'PERFETTO_TEST_SCRIPT': 'test/ci/ui_tests.sh',
- },
'ui-clang-x86_64-release': {
'PERFETTO_TEST_GN_ARGS': 'is_debug=false',
'PERFETTO_TEST_SCRIPT': 'test/ci/ui_tests.sh',
diff --git a/infra/ci/frontend/static/script.js b/infra/ci/frontend/static/script.js
index 9f56e3d..f5e83c3 100644
--- a/infra/ci/frontend/static/script.js
+++ b/infra/ci/frontend/static/script.js
@@ -26,7 +26,6 @@
{ id: 'linux-clang-x86-asan_lsan', label: 'x86 {a,l}san' },
{ id: 'linux-clang-x86_64-libfuzzer', label: 'fuzzer' },
{ id: 'linux-clang-x86_64-bazel', label: 'bazel' },
- { id: 'ui-clang-x86_64-debug', label: 'dbg' },
{ id: 'ui-clang-x86_64-release', label: 'rel' },
{ id: 'android-clang-arm-release', label: 'rel' },
{ id: 'android-clang-arm-asan', label: 'asan' },
@@ -194,16 +193,16 @@
m('td[rowspan=4]', 'Status'),
m('td[rowspan=4]', 'Owner'),
m('td[rowspan=4]', 'Updated'),
- m('td[colspan=12]', 'Bots'),
+ m('td[colspan=11]', 'Bots'),
),
m('tr',
- m('td[colspan=10]', 'linux'),
+ m('td[colspan=9]', 'linux'),
m('td[colspan=2]', 'android'),
),
m('tr',
m('td', 'gcc7'),
m('td[colspan=7]', 'clang'),
- m('td[colspan=2]', 'ui'),
+ m('td[colspan=1]', 'ui'),
m('td[colspan=2]', 'clang-arm'),
),
m('tr#cls_header',
diff --git a/infra/perfetto.dev/cloudbuild.yaml b/infra/perfetto.dev/cloudbuild.yaml
index 1acfb31..f8e0889 100644
--- a/infra/perfetto.dev/cloudbuild.yaml
+++ b/infra/perfetto.dev/cloudbuild.yaml
@@ -4,4 +4,5 @@
- name: gcr.io/perfetto-ui/perfetto-ui-builder
args:
- 'infra/perfetto.dev/cloud_build_entrypoint.sh'
- timeout: 1200s
+# Timeout: 15 min (last measured time in Feb 2021: 2 min)
+timeout: 900s
diff --git a/infra/ui.perfetto.dev/README.md b/infra/ui.perfetto.dev/README.md
new file mode 100644
index 0000000..6006a7f
--- /dev/null
+++ b/infra/ui.perfetto.dev/README.md
@@ -0,0 +1,38 @@
+# ui.perfetto.dev Cloud scripts
+
+See [go/perfetto-ui-autopush](http://go/perfetto-ui-autopush) for docs on how
+this works end-to-end.
+
+## /appengine : GAE <> GCS proxy
+
+The Google AppEngine instance that responds to ui.perfetto.dev.
+It simply passes through the requests to the bucket gs://ui.perfetto.dev .
+This should NOT be re-deployed when uploading a new version of the ui,
+as the actual UI artifacts live in GCS.
+
+We are using AppEngine for historical reasons, at some point this should
+be migrated to a Type 7 Google Cloud Load Balancer, which supports
+direct backing by a GCS bucket. The only blocker for that is figuring out
+a seamless migration strategy for the SSL certificate.
+
+## /builder : Docker container for Google Cloud Build
+
+Contains the Dockerfile to generate the container image which is used by
+Google Cloud Build when auto-triggering new ui builds.
+Cloud Build invokes the equivalent of:
+
+```bash
+docker run gcr.io/perfetto-ui/perfetto-ui-builder \
+ ui/release/builder_entrypoint.sh
+```
+
+NOTE: the `builder_entrypoint.sh` script is not bundled in the docker container
+and is taken from the HEAD if the checked out repo.
+
+To update the container:
+
+```bash
+cd infra/ui.perfetto.dev/builder
+docker build -t gcr.io/perfetto-ui/perfetto-ui-builder .
+docker push gcr.io/perfetto-ui/perfetto-ui-builder .
+```
diff --git a/infra/ui.perfetto.dev/appengine/.gcloudignore b/infra/ui.perfetto.dev/appengine/.gcloudignore
new file mode 100644
index 0000000..cc91f41
--- /dev/null
+++ b/infra/ui.perfetto.dev/appengine/.gcloudignore
@@ -0,0 +1,6 @@
+.gcloudignore
+.git
+.gitignore
+__pycache__/
+/setup.cfg
+deploy
diff --git a/infra/ui.perfetto.dev/appengine/app.yaml b/infra/ui.perfetto.dev/appengine/app.yaml
new file mode 100644
index 0000000..1f4e65c
--- /dev/null
+++ b/infra/ui.perfetto.dev/appengine/app.yaml
@@ -0,0 +1,5 @@
+runtime: python38
+instance_class: B1
+basic_scaling:
+ max_instances: 4
+ idle_timeout: 10m
diff --git a/infra/ui.perfetto.dev/appengine/deploy b/infra/ui.perfetto.dev/appengine/deploy
new file mode 100755
index 0000000..37a13b7
--- /dev/null
+++ b/infra/ui.perfetto.dev/appengine/deploy
@@ -0,0 +1,20 @@
+#!/bin/bash
+# Copyright (C) 2021 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.
+
+set -exu
+
+cd -P ${BASH_SOURCE[0]%/*}
+gcloud app deploy app.yaml --project perfetto-ui \
+ -v testing --no-promote --stop-previous-version
diff --git a/infra/ui.perfetto.dev/appengine/index.yaml b/infra/ui.perfetto.dev/appengine/index.yaml
new file mode 100644
index 0000000..6536212
--- /dev/null
+++ b/infra/ui.perfetto.dev/appengine/index.yaml
@@ -0,0 +1,2 @@
+indexes:
+# AUTOGENERATED
diff --git a/infra/ui.perfetto.dev/appengine/main.py b/infra/ui.perfetto.dev/appengine/main.py
new file mode 100644
index 0000000..0fac701
--- /dev/null
+++ b/infra/ui.perfetto.dev/appengine/main.py
@@ -0,0 +1,65 @@
+# Copyright (C) 2021 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 flask
+import requests
+
+BUCKET_NAME = 'ui.perfetto.dev'
+
+REQ_HEADERS = [
+ 'Accept',
+ 'Accept-Encoding',
+ 'Cache-Control',
+]
+
+RESP_HEADERS = [
+ 'Content-Type',
+ 'Content-Encoding',
+ 'Content-Length',
+ 'Cache-Control',
+ 'Date',
+ 'Expires',
+]
+
+app = flask.Flask(__name__)
+
+
+# Redirect v1.2.3 to v.1.2.3/
+@app.route('/v<int:x>.<int:y>.<int:z>')
+def version_redirect(x, y, z):
+ return flask.redirect('/v%d.%d.%d/' % (x, y, z), code=302)
+
+
+# Serve the requests from the GCS bucket.
+@app.route('/', methods=['GET'])
+@app.route('/<path:path>', methods=['GET'])
+def main(path=''):
+ path = '/' + path
+ path += 'index.html' if path.endswith('/') else ''
+ req_headers = {}
+ for key in set(flask.request.headers.keys()).intersection(REQ_HEADERS):
+ req_headers[key] = flask.request.headers.get(key)
+ url = 'https://commondatastorage.googleapis.com/' + BUCKET_NAME + path
+ req = requests.get(url, headers=req_headers)
+ if (req.status_code != 200):
+ return flask.abort(req.status_code)
+ resp = flask.Response(req.content)
+ for key in set(req.headers.keys()).intersection(RESP_HEADERS):
+ resp.headers[key] = req.headers.get(key)
+ return resp
+
+
+if __name__ == '__main__':
+ # This is used when running locally only.
+ app.run(host='127.0.0.1', port=10000, debug=True)
diff --git a/infra/ui.perfetto.dev/appengine/requirements.txt b/infra/ui.perfetto.dev/appengine/requirements.txt
new file mode 100644
index 0000000..1faca3d
--- /dev/null
+++ b/infra/ui.perfetto.dev/appengine/requirements.txt
@@ -0,0 +1,2 @@
+Flask==1.1.2
+requests
diff --git a/infra/ui.perfetto.dev/builder/Dockerfile b/infra/ui.perfetto.dev/builder/Dockerfile
new file mode 100644
index 0000000..07944c1
--- /dev/null
+++ b/infra/ui.perfetto.dev/builder/Dockerfile
@@ -0,0 +1,40 @@
+# Copyright (C) 2021 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.
+
+# The image that builds the Perfetto UI and deploys to GCS.
+# See go/perfetto-ui-autopush for docs on how this works end-to-end.
+
+FROM debian:buster-slim
+
+ENV PATH=/builder/google-cloud-sdk/bin/:$PATH
+RUN set -ex; \
+ export DEBIAN_FRONTEND=noninteractive; \
+ apt-get update; \
+ apt-get -y install python3 python3-distutils python3-pip git curl tar tini \
+ pkg-config zip libc-dev libgcc-8-dev; \
+ update-alternatives --install /usr/bin/python python /usr/bin/python3.7 1; \
+ pip3 install --quiet protobuf crcmod; \
+ mkdir -p /builder && \
+ curl -s -o - https://dl.google.com/dl/cloudsdk/release/google-cloud-sdk.tar.gz | tar -zx -C /builder; \
+ /builder/google-cloud-sdk/install.sh \
+ --usage-reporting=false \
+ --bash-completion=false \
+ --disable-installation-options \
+ --override-components gcloud gsutil; \
+ git config --system credential.helper gcloud.sh; \
+ useradd -d /home/perfetto perfetto; \
+ apt-get -y autoremove; \
+ rm -rf /var/lib/apt/lists/* /usr/share/man/* /usr/share/doc/*;
+
+ENTRYPOINT [ "tini", "-g", "--" ]
diff --git a/infra/ui.perfetto.dev/cloudbuild.yaml b/infra/ui.perfetto.dev/cloudbuild.yaml
new file mode 100644
index 0000000..6cfca1b
--- /dev/null
+++ b/infra/ui.perfetto.dev/cloudbuild.yaml
@@ -0,0 +1,9 @@
+# See go/perfetto-ui-autopush for docs on how this works end-to-end.
+steps:
+- name: gcr.io/$PROJECT_ID/perfetto-ui-builder
+ args:
+ - 'ui/release/builder_entrypoint.sh'
+# Timeout = 30 min (last measured time in Feb 2021: 12 min)
+timeout: 1800s
+options:
+ machineType: E2_HIGHCPU_32
diff --git a/src/tracing/track.cc b/src/tracing/track.cc
index 56525f7..56b6820 100644
--- a/src/tracing/track.cc
+++ b/src/tracing/track.cc
@@ -16,6 +16,11 @@
#include "perfetto/tracing/track.h"
+#include "perfetto/ext/base/file_utils.h"
+#include "perfetto/ext/base/hash.h"
+#include "perfetto/ext/base/scoped_file.h"
+#include "perfetto/ext/base/string_splitter.h"
+#include "perfetto/ext/base/string_utils.h"
#include "perfetto/ext/base/uuid.h"
#include "perfetto/tracing/internal/track_event_data_source.h"
#include "protos/perfetto/trace/track_event/process_descriptor.gen.h"
@@ -69,6 +74,35 @@
}
namespace internal {
+namespace {
+
+uint64_t GetProcessStartTime() {
+#if !PERFETTO_BUILDFLAG(PERFETTO_OS_WIN)
+ std::string stat;
+ if (!base::ReadFile("/proc/self/stat", &stat))
+ return 0u;
+ // The stat file is a single line split into space-separated fields as "pid
+ // (comm) state ppid ...". However because the command name can contain any
+ // characters (including parentheses and spaces), we need to skip past it
+ // before parsing the rest of the fields. To do that, we look for the last
+ // instance of ") " (parentheses followed by space) and parse forward from
+ // that point.
+ size_t comm_end = stat.rfind(") ");
+ if (comm_end == std::string::npos)
+ return 0u;
+ stat = stat.substr(comm_end + strlen(") "));
+ base::StringSplitter splitter(stat, ' ');
+ for (size_t skip = 0; skip < 20; skip++) {
+ if (!splitter.Next())
+ return 0u;
+ }
+ return base::CStringToUInt64(splitter.cur_token()).value_or(0u);
+#else
+ return 0;
+#endif // !PERFETTO_BUILDFLAG(PERFETTO_OS_WIN)
+}
+
+} // namespace
// static
TrackRegistry* TrackRegistry::instance_;
@@ -83,7 +117,21 @@
if (instance_)
return;
instance_ = new TrackRegistry();
- Track::process_uuid = static_cast<uint64_t>(base::Uuidv4().lsb());
+
+ // Use the process start time + pid as the unique identifier for this process.
+ // This ensures that if there are two independent copies of the Perfetto SDK
+ // in the same process (e.g., one in the app and another in a system
+ // framework), events emitted by each will be consistently interleaved on
+ // common thread and process tracks.
+ if (uint64_t start_time = GetProcessStartTime()) {
+ base::Hash hash;
+ hash.Update(start_time);
+ hash.Update(base::GetProcessId());
+ Track::process_uuid = hash.digest();
+ } else {
+ // Fall back to a randomly generated identifier.
+ Track::process_uuid = static_cast<uint64_t>(base::Uuidv4().lsb());
+ }
}
void TrackRegistry::UpdateTrack(Track track,
diff --git a/tools/write_version_header.py b/tools/write_version_header.py
index 88dcb30..028ad2a 100755
--- a/tools/write_version_header.py
+++ b/tools/write_version_header.py
@@ -101,6 +101,7 @@
help='Skips running git rev-parse, emits only the version from CHANGELOG')
parser.add_argument('--cpp_out', help='Path of the generated .h file.')
parser.add_argument('--ts_out', help='Path of the generated .ts file.')
+ parser.add_argument('--stdout', help='Write to stdout', action='store_true')
parser.add_argument('--changelog', help='Path to CHANGELOG.')
args = parser.parse_args()
@@ -142,6 +143,9 @@
content = '\n'.join(lines)
write_if_unchanged(args.ts_out, content)
+ if args.stdout:
+ print(version)
+
if __name__ == '__main__':
sys.exit(main())
diff --git a/ui/build.js b/ui/build.js
index 3363d53..3760b71 100644
--- a/ui/build.js
+++ b/ui/build.js
@@ -66,6 +66,7 @@
const argparse = require('argparse');
const child_process = require('child_process');
+var crypto = require('crypto');
const fs = require('fs');
const http = require('http');
const path = require('path');
@@ -85,14 +86,17 @@
// The fields below will be changed by main() after cmdline parsing.
// Directory structure:
- // out/xxx/ -> outDir : Root build dir, for both ninja/wasm and UI.
- // ui/ -> outUiDir : UI dir. All outputs from this script.
- // tsc/ -> outTscDir : Transpiled .ts -> .js.
- // gen/ -> outGenDir : Auto-generated .ts/.js (e.g. protos).
- // dist/ -> outDistDir : Final artifacts (JS bundles, assets).
- // chrome_extension/ : Chrome extension.
+ // out/xxx/ -> outDir : Root build dir, for both ninja/wasm and UI.
+ // ui/ -> outUiDir : UI dir. All outputs from this script.
+ // tsc/ -> outTscDir : Transpiled .ts -> .js.
+ // gen/ -> outGenDir : Auto-generated .ts/.js (e.g. protos).
+ // dist/ -> outDistRootDir : Only index.html and service_worker.js
+ // v1.2/ -> outDistDir : JS bundles and assets
+ // chrome_extension/ : Chrome extension.
outDir: pjoin(ROOT_DIR, 'out/ui'),
+ version: '', // v1.2.3, derived from the CHANGELOG + git.
outUiDir: '',
+ outDistRootDir: '',
outTscDir: '',
outGenDir: '',
outDistRootDir: '',
@@ -101,14 +105,14 @@
};
const RULES = [
- {r: /ui\/src\/assets\/(index.html)/, f: copyIntoDistRoot},
+ {r: /ui\/src\/assets\/index.html/, f: copyIndexHtml},
{r: /ui\/src\/assets\/((.*)[.]png)/, f: copyAssets},
{r: /buildtools\/typefaces\/(.+[.]woff2)/, f: copyAssets},
{r: /buildtools\/catapult_trace_viewer\/(.+(js|html))/, f: copyAssets},
{r: /ui\/src\/assets\/.+[.]scss/, f: compileScss},
{r: /ui\/src\/assets\/.+[.]scss/, f: compileScss},
{r: /ui\/src\/chrome_extension\/.*/, f: copyExtensionAssets},
- {r: /.*\/dist\/(?!service_worker).*/, f: genServiceWorkerDistHashes},
+ {r: /.*\/dist\/.+\/(?!manifest\.json).*/, f: genServiceWorkerManifestJson},
{r: /.*\/dist\/.*/, f: notifyLiveServer},
];
@@ -136,9 +140,9 @@
cfg.outUiDir = ensureDir(pjoin(cfg.outDir, 'ui'), clean);
cfg.outExtDir = ensureDir(pjoin(cfg.outUiDir, 'chrome_extension'));
cfg.outDistRootDir = ensureDir(pjoin(cfg.outUiDir, 'dist'));
- // TODO(primiano): for now distDir == distRootDir. In next CLs distDir will
- // become dist/v1.2.3/.
- cfg.outDistDir = cfg.outDistRootDir;
+ const proc = exec('python3', [VERSION_SCRIPT, '--stdout'], {stdout: 'pipe'});
+ cfg.version = proc.stdout.toString().trim();
+ cfg.outDistDir = ensureDir(pjoin(cfg.outDistRootDir, cfg.version));
cfg.outTscDir = ensureDir(pjoin(cfg.outUiDir, 'tsc'));
cfg.outGenDir = ensureDir(pjoin(cfg.outUiDir, 'tsc/gen'));
cfg.watch = !!args.watch;
@@ -162,7 +166,7 @@
console.log('Entering', cfg.outDir);
process.chdir(cfg.outDir);
- updateSymilnks(); // Links //ui/out -> //out/xxx/ui/
+ updateSymlinks(); // Links //ui/out -> //out/xxx/ui/
// Enqueue empty task. This is needed only for --no-build --serve. The HTTP
// server is started when the task queue reaches quiescence, but it takes at
@@ -178,12 +182,9 @@
compileProtos();
genVersion();
transpileTsProject('ui');
- bundleJs('rollup.config.js');
-
- // ServiceWorker.
- genServiceWorkerDistHashes();
transpileTsProject('ui/src/service_worker');
- bundleJs('rollup-serviceworker.config.js');
+ bundleJs('rollup.config.js');
+ genServiceWorkerManifestJson();
// Watches the /dist. When changed:
// - Notifies the HTTP live reload clients.
@@ -214,8 +215,23 @@
}
}
-function copyIntoDistRoot(src, dst) {
- addTask(cp, [src, pjoin(cfg.outDistRootDir, dst)]);
+function copyIndexHtml(src) {
+ const index_html = () => {
+ let html = fs.readFileSync(src).toString();
+ // First copy the index.html as-is into the dist/v1.2.3/ directory. This is
+ // only used for archival purporses, so one can open
+ // ui.perfetto.dev/v1.2.3/ to skip the auto-update and channel logic.
+ fs.writeFileSync(pjoin(cfg.outDistDir, 'index.html'), html);
+
+ // Then copy it into the dist/ root by patching the version code.
+ // TODO(primiano): in next CLs, this script should take a
+ // --release_map=xxx.json argument, to populate this with multiple channels.
+ const versionMap = JSON.stringify({'stable': cfg.version});
+ const bodyRegex = /data-perfetto_version='[^']*'/;
+ html = html.replace(bodyRegex, `data-perfetto_version='${versionMap}'`);
+ fs.writeFileSync(pjoin(cfg.outDistRootDir, 'index.html'), html);
+ };
+ addTask(index_html);
}
function copyAssets(src, dst) {
@@ -267,8 +283,15 @@
addTask(exec, [cmd, args]);
}
-function updateSymilnks() {
+function updateSymlinks() {
mklink(cfg.outUiDir, pjoin(ROOT_DIR, 'ui/out'));
+
+ // Creates a out/dist_version -> out/dist/v1.2.3 symlink, so rollup config
+ // can point to that without having to know the current version number.
+ mklink(
+ path.relative(cfg.outUiDir, cfg.outDistDir),
+ pjoin(cfg.outUiDir, 'dist_version'));
+
mklink(
pjoin(ROOT_DIR, 'ui/node_modules'), pjoin(cfg.outTscDir, 'node_modules'))
}
@@ -304,12 +327,7 @@
// This transpiles all the sources (frontend, controller, engine, extension) in
// one go. The only project that has a dedicated invocation is service_worker.
function transpileTsProject(project) {
- const args = [
- '--project',
- pjoin(ROOT_DIR, project),
- '--outDir',
- cfg.outTscDir,
- ];
+ const args = ['--project', pjoin(ROOT_DIR, project)];
if (cfg.watch) {
args.push('--watch', '--preserveWatchOutput');
addTask(execNode, ['tsc', args, {async: true}]);
@@ -333,24 +351,23 @@
}
}
-// Generates a map of {"dist_file_name" -> "sha256-01234"} used by the SW.
-function genServiceWorkerDistHashes() {
- function write_ui_dist_file_map() {
- const distFiles = [];
- const skipRegex = /(service_worker\.js)|(\.map$)/;
- walk(cfg.outDistDir, f => distFiles.push(f), skipRegex);
- const dst = pjoin(cfg.outGenDir, 'dist_file_map.ts');
- const cmd = 'python3';
- const args = [
- pjoin(ROOT_DIR, 'gn/standalone/write_ui_dist_file_map.py'),
- '--out',
- dst,
- '--strip',
- cfg.outDistDir,
- ].concat(distFiles);
- exec(cmd, args);
+function genServiceWorkerManifestJson() {
+ function make_manifest() {
+ const manifest = {resources: {}};
+ // When building the subresource manifest skip source maps, the manifest
+ // itself and the copy of the index.html which is copied under /v1.2.3/.
+ // The root /index.html will be fetched by service_worker.js separately.
+ const skipRegex = /(\.map|manifest\.json|index.html)$/;
+ walk(cfg.outDistDir, absPath => {
+ const contents = fs.readFileSync(absPath);
+ const relPath = path.relative(cfg.outDistDir, absPath);
+ const b64 = crypto.createHash('sha256').update(contents).digest('base64');
+ manifest.resources[relPath] = 'sha256-' + b64;
+ }, skipRegex);
+ const manifestJson = JSON.stringify(manifest, null, 2);
+ fs.writeFileSync(pjoin(cfg.outDistDir, 'manifest.json'), manifestJson);
}
- addTask(write_ui_dist_file_map, []);
+ addTask(make_manifest, []);
}
function startServer() {
@@ -403,7 +420,8 @@
'Cache-Control': 'no-cache',
};
res.writeHead(200, head);
- res.end(data);
+ res.write(data);
+ res.end();
});
})
.listen(port);
diff --git a/ui/config/rollup.config.js b/ui/config/rollup.config.js
index 0055444..380aa91 100644
--- a/ui/config/rollup.config.js
+++ b/ui/config/rollup.config.js
@@ -60,9 +60,32 @@
};
}
+function defServiceWorkerBundle() {
+ return {
+ input: `${OUT_SYMLINK}/tsc/service_worker/service_worker.js`,
+ output: {
+ name: 'service_worker',
+ format: 'iife',
+ esModule: false,
+ file: `${OUT_SYMLINK}/dist/service_worker.js`,
+ sourcemap: true,
+ },
+ plugins: [
+ nodeResolve({
+ mainFields: ['browser'],
+ browser: true,
+ preferBuiltins: false,
+ }),
+ commonjs(),
+ sourcemaps(),
+ ],
+ };
+}
+
export default [
- defBundle('frontend', 'dist'),
- defBundle('controller', 'dist'),
- defBundle('engine', 'dist'),
+ defBundle('frontend', 'dist_version'),
+ defBundle('controller', 'dist_version'),
+ defBundle('engine', 'dist_version'),
defBundle('chrome_extension', 'chrome_extension'),
+ defServiceWorkerBundle(),
]
diff --git a/ui/deploy b/ui/deploy
deleted file mode 100755
index 6eb0740..0000000
--- a/ui/deploy
+++ /dev/null
@@ -1,124 +0,0 @@
-#!/bin/bash
-# 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.
-
-set -e
-
-function print_usage {
- echo "Usage: $0 [--no-clean] [--prod] [--staging]"
- echo " --no-clean Don't remove $OUT_DIR"
- echo " --prod Deploy prod version"
- echo " --staging Deploy staging version"
- echo " --debug Do a debug build"
- echo " -h|--help Display this message"
-}
-
-function echo_and_do {
- echo $@
- $@
-}
-
-PROJECT_ROOT="$(cd -P ${BASH_SOURCE[0]%/*}/..; pwd)"
-OUT_DIR="$PROJECT_ROOT/out/ui-deploy.tmp"
-UI_DIST_DIR="$OUT_DIR/ui-dist"
-
-CLEAN_OUT_DIR=true
-DEPLOY_PROD=false
-DEPLOY_STAGING=false
-DEBUG_ARG=""
-
-while [[ $# -gt 0 ]]; do
- key="$1"
- case $key in
- --no-clean)
- CLEAN_OUT_DIR=false
- shift
- ;;
- --prod)
- DEPLOY_PROD=true
- shift
- ;;
- --staging)
- DEPLOY_STAGING=true
- shift
- ;;
- --debug)
- DEBUG_ARG="--debug"
- shift
- ;;
- -h|--help)
- print_usage
- exit 0
- shift
- ;;
- *)
- print_usage
- exit 1
- shift
- ;;
- esac
-done
-
-if [ "$CLEAN_OUT_DIR" = true ]; then
- echo_and_do rm -rf "$OUT_DIR"
-fi
-echo_and_do mkdir -p "$UI_DIST_DIR"
-
-echo_and_do "$PROJECT_ROOT/ui/build" --out "$OUT_DIR" $DEBUG_ARG
-
-echo "Writing $UI_DIST_DIR/app.yaml"
-cat<<EOF > "$UI_DIST_DIR/app.yaml"
-runtime: python27
-api_version: 1
-threadsafe: yes
-instance_class: B1
-default_expiration: "1m"
-manual_scaling:
- instances: 1
-handlers:
-- url: /
- static_files: static/index.html
- upload: static/index.html
- secure: always
- redirect_http_response_code: 301
-- url: /(.*[.]wasm)
- static_files: static/\1
- upload: static/(.*[.]wasm)
- mime_type: application/wasm
-- url: /assets/(.*)
- static_files: static/assets/\1
- upload: static/assets/(.*)
-- url: /(.*)
- static_files: static/\1
- upload: static/(.*)
-EOF
-
-echo_and_do rm -f $UI_DIST_DIR/static; ln -fs ../ui/dist $UI_DIST_DIR/static
-
-(
- echo_and_do cd "$UI_DIST_DIR";
- if [ "$DEPLOY_PROD" = true ]; then
- echo_and_do gcloud app deploy app.yaml --project perfetto-ui -v prod
- elif [ "$DEPLOY_STAGING" = true ]; then
- echo_and_do gcloud app deploy app.yaml --project perfetto-ui \
- -v staging --no-promote --stop-previous-version
- else
- echo_and_do gcloud app deploy app.yaml --project perfetto-ui \
- -v $USER --no-promote --stop-previous-version
- fi
-)
-
-if [ "$CLEAN_OUT_DIR" = true ]; then
- echo_and_do rm -rf "$OUT_DIR"
-fi
diff --git a/ui/release/OWNERS b/ui/release/OWNERS
new file mode 100644
index 0000000..fe8fd04
--- /dev/null
+++ b/ui/release/OWNERS
@@ -0,0 +1,7 @@
+set noparent
+eseckler@google.com
+hjd@google.com
+oysteine@google.com
+nyquist@google.com
+primiano@google.com
+skyostil@google.com
diff --git a/ui/release/build_all_channels.py b/ui/release/build_all_channels.py
new file mode 100755
index 0000000..4a61e5e
--- /dev/null
+++ b/ui/release/build_all_channels.py
@@ -0,0 +1,147 @@
+#!/usr/bin/env python3
+# Copyright (C) 2021 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.
+""" Builds all the revisions in channels.json and deploys them if --upload.
+
+See go/perfetto-ui-autopush for docs on how this works end-to-end.
+"""
+
+import argparse
+import json
+import os
+import re
+import shutil
+import subprocess
+import sys
+
+from os.path import dirname
+pjoin = os.path.join
+
+BUCKET_NAME = 'ui.perfetto.dev'
+CUR_DIR = dirname(os.path.abspath(__file__))
+ROOT_DIR = dirname(dirname(CUR_DIR))
+
+
+def check_call_and_log(args):
+ print(' '.join(args))
+ subprocess.check_call(args)
+
+
+def check_output(args):
+ return subprocess.check_output(args).decode().strip()
+
+
+def version_exists(version):
+ url = 'https://commondatastorage.googleapis.com/%s/%s/manifest.json' % (
+ BUCKET_NAME, version)
+ return 0 == subprocess.call(['curl', '-fLs', '-o', '/dev/null', url])
+
+
+def build_git_revision(channel, git_ref, tmp_dir):
+ workdir = pjoin(tmp_dir, channel)
+ check_call_and_log(['rm', '-rf', workdir])
+ check_call_and_log(['git', 'clone', '--quiet', '--shared', ROOT_DIR, workdir])
+ old_cwd = os.getcwd()
+ os.chdir(workdir)
+ try:
+ check_call_and_log(['git', 'reset', '--hard', git_ref])
+ check_call_and_log(['git', 'clean', '-dfx'])
+ git_sha = check_output(['git', 'rev-parse', 'HEAD'])
+ print('===================================================================')
+ print('Building UI for channel %s @ %s (%s)' % (channel, git_ref, git_sha))
+ print('===================================================================')
+ version = check_output(['tools/write_version_header.py', '--stdout'])
+ check_call_and_log(['tools/install-build-deps', '--ui'])
+ check_call_and_log(['ui/build'])
+ return version, pjoin(workdir, 'ui/out/dist')
+ finally:
+ os.chdir(old_cwd)
+
+
+def build_all_channels(channels, tmp_dir, merged_dist_dir):
+ channel_map = {}
+ for chan in channels:
+ channel = chan['name']
+ git_ref = chan['rev']
+ # version here is something like "v1.2.3".
+ version, dist_dir = build_git_revision(channel, git_ref, tmp_dir)
+ channel_map[channel] = version
+ check_call_and_log(['cp', '-an', pjoin(dist_dir, version), merged_dist_dir])
+ if channel != 'stable':
+ continue
+ # Copy also the /index.html and /service_worker.*, but only for the stable
+ # channel. The /index.html and SW must be shared between all channels,
+ # because they are all reachable through ui.perfetto.dev/. Both the index
+ # and the SQ are supposed to be version-independent (go/perfetto-channels).
+ # If an accidental incompatibility bug sneaks in, we should much rather
+ # crash canary (or any other channel) rather than stable. Hence why we copy
+ # the index+sw from the stable channel.
+ for fname in os.listdir(dist_dir):
+ fpath = pjoin(dist_dir, fname)
+ if os.path.isfile(fpath):
+ check_call_and_log(['cp', '-an', fpath, merged_dist_dir])
+ return channel_map
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--upload', action='store_true')
+ parser.add_argument('--tmp', default='/tmp/perfetto_ui')
+ args = parser.parse_args()
+
+ # Read the releases.json, which maps channel names to git refs, e.g.:
+ # {name:'stable', rev:'a0b1c2...0}, {name:'canary', rev:'HEAD'}
+ channels = []
+ with open(pjoin(CUR_DIR, 'channels.json')) as f:
+ channels = json.load(f)['channels']
+
+ merged_dist_dir = pjoin(args.tmp, 'dist')
+ check_call_and_log(['rm', '-rf', merged_dist_dir])
+ shutil.os.makedirs(merged_dist_dir)
+ channel_map = build_all_channels(channels, args.tmp, merged_dist_dir)
+
+ print('Updating index in ' + merged_dist_dir)
+ with open(pjoin(merged_dist_dir, 'index.html'), 'r+') as f:
+ index_html = f.read()
+ f.seek(0, 0)
+ f.truncate()
+ index_html = re.sub(r"data-perfetto_version='[^']*'",
+ "data-perfetto_version='%s'" % json.dumps(channel_map),
+ index_html)
+ f.write(index_html)
+
+ if not args.upload:
+ return
+
+ print('===================================================================')
+ print('Uploading to gs://%s' % BUCKET_NAME)
+ print('===================================================================')
+ cp_cmd = [
+ 'gsutil', '-m', '-h', 'Cache-Control:public, max-age=3600', 'cp', '-j',
+ 'html,js,css,wasm'
+ ]
+ for name in os.listdir(merged_dist_dir):
+ path = pjoin(merged_dist_dir, name)
+ if os.path.isdir(path):
+ if version_exists(name):
+ print('Skipping upload of %s because it already exists on GCS' % name)
+ continue
+ check_call_and_log(cp_cmd + ['-r', path, 'gs://%s/' % BUCKET_NAME])
+ else:
+ # /index.html or /service_worker.js{,.map}
+ check_call_and_log(cp_cmd + [path, 'gs://%s/%s' % (BUCKET_NAME, name)])
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/ui/release/builder_entrypoint.sh b/ui/release/builder_entrypoint.sh
new file mode 100755
index 0000000..7a940b8
--- /dev/null
+++ b/ui/release/builder_entrypoint.sh
@@ -0,0 +1,40 @@
+#!/bin/bash
+# Copyright (C) 2021 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.
+
+# See go/perfetto-ui-autopush for docs on how this works end-to-end.
+
+set -exu
+
+CUR_DUR=$(dirname ${BASH_SOURCE[0]})
+
+env
+pwd
+mount
+
+# This script will be run in /workspace after the Cloud Build environment has
+# pulled the GitHub repo in shallow mode. We want to build off the official
+# AOSP repo, not the GitHub mirror though, hence why the clone below.
+# GitHub is used only as a trigger. This is because Google Cloud Build doesn't
+# support yet triggering from Gerrit.
+
+cd /workspace/
+ls -A1 | xargs rm -rf
+UPSTREAM="https://android.googlesource.com/platform/external/perfetto.git"
+git clone $UPSTREAM upstream
+
+cd upstream/
+git rev-parse HEAD
+mkdir /workspace/tmp
+python3 -u "$CUR_DUR/build_all_channels.py" --upload --tmp=/workspace/tmp
diff --git a/ui/release/channels.json b/ui/release/channels.json
new file mode 100644
index 0000000..40a7f17
--- /dev/null
+++ b/ui/release/channels.json
@@ -0,0 +1,12 @@
+{
+ "channels": [
+ {
+ "name": "stable",
+ "rev": "HEAD"
+ },
+ {
+ "name": "canary",
+ "rev": "HEAD"
+ }
+ ]
+}
diff --git a/ui/src/assets/common.scss b/ui/src/assets/common.scss
index f6f5fa5..2c2fd4e 100644
--- a/ui/src/assets/common.scss
+++ b/ui/src/assets/common.scss
@@ -212,34 +212,6 @@
}
}
-.home-page {
- text-align: center;
- padding-top: 20vh;
- align-items: center;
-}
-
-.home-page .logo {
- width: var(--track-shell-width);
-}
-
-.home-page-title {
- font-size: 60px;
- margin: 25px;
- text-align: center;
- font-family: 'Raleway', sans-serif;
- font-weight: 100;
- color: #333;
-}
-
-.privacy {
- text-decoration: none;
- font-family: 'Raleway', sans-serif;
- color: #333;
- position: absolute;
- bottom: 60px;
- font-size: 15px;
-}
-
.query-table-container {
width: 100%;
overflow-x: auto;
diff --git a/ui/src/assets/home_page.scss b/ui/src/assets/home_page.scss
new file mode 100644
index 0000000..8eed245
--- /dev/null
+++ b/ui/src/assets/home_page.scss
@@ -0,0 +1,125 @@
+// Copyright (C) 2021 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.
+
+.home-page {
+ text-align: center;
+ align-items: center;
+ display: grid;
+ grid-template-columns: 1fr;
+ grid-template-rows: 2em 1fr 60px;
+ grid-template-areas: "." "center" "footer";
+
+ .home-page-center {
+ grid-area: center;
+
+ .logo {
+ width: var(--track-shell-width);
+ }
+
+ .home-page-title {
+ font-size: 60px;
+ margin: 25px;
+ text-align: center;
+ font-family: 'Raleway', sans-serif;
+ font-weight: 100;
+ color: #333;
+ }
+
+ .channel-select {
+ font-family: 'Roboto', sans-serif;
+ font-size: 1.2rem;
+ font-weight: 200;
+ margin-top: 3em;
+ --chan-width: 100px;
+ --chan-num: 2;
+
+ input[type=radio] {
+ width: 0;
+ height: 0;
+ margin: 0;
+ padding: 0;
+ -moz-appearance: none;
+ -webkit-appearance: none;
+ &:nth-of-type(1):checked ~ .highlight { margin-left: 0; }
+ &:nth-of-type(2):checked ~ .highlight {
+ margin-left: 100px;
+ background-color: hsl(54, 100%, 40%);
+ }
+ &:nth-of-type(3):checked ~ .highlight {
+ margin-left: 200px;
+ background-color: hsl(24, 80%, 42%);
+ }
+ }
+
+ fieldset {
+ margin: 30px auto 10px auto;
+ padding: 0;
+ position: relative;
+ background-color: hsl(218, 8%, 30%);
+ border-radius: 3px;
+ box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.4);
+ border: 0;
+ width: calc(var(--chan-width) * var(--chan-num));
+ height: 40px;
+ line-height: 40px;
+ z-index: 0;
+ }
+
+ label {
+ display: inline-block;
+ cursor: pointer;
+ position: relative;
+ width: var(--chan-width);
+ height: 100%;
+ color: white;
+ z-index: 2;
+ text-transform: uppercase;
+ font-size: 16px;
+ font-weight: 200;
+ letter-spacing: 0.3px;
+ }
+
+ .highlight {
+ width: var(--chan-width);
+ height: 100%;
+ position: absolute;
+ background: hsla(122, 45%, 45%, 0.99);
+ background-image: linear-gradient(rgba(255, 255, 255, 0.2), transparent);
+ top: 0;
+ left: 0;
+ z-index: 1;
+ border-radius: inherit;
+ @include transition();
+ }
+
+ .home-page-reload {
+ font-size: 12px;
+ opacity: 0;
+ color: #da4534;
+ font-weight: 400;
+ @include transition;
+ &.show { opacity: 1; }
+ }
+ } // .channel-select
+ } // .home-page-center
+
+ .privacy {
+ grid-area: footer;
+ text-decoration: none;
+ font-family: 'Roboto', sans-serif;
+ font-weight: 200;
+ color: #333;
+ font-size: 15px;
+ }
+} // .home-page
diff --git a/ui/src/assets/index.html b/ui/src/assets/index.html
index 3237423..7ad3b21 100644
--- a/ui/src/assets/index.html
+++ b/ui/src/assets/index.html
@@ -1,20 +1,118 @@
<!doctype html>
<html lang="en-us">
<head>
+ <meta charset="utf-8">
<title>Perfetto UI</title>
- <!-- See b/149573396 for CSP rationale. -->
- <!-- TODO(b/121211019): remove script-src-elem rule once fixed. -->
- <meta http-equiv="Content-Security-Policy" content="default-src 'self' 'sha256-LirUKeorCU4uRNtNzr8tlB11uy8rzrdmqHCX38JSwHY='; script-src 'self' https://*.google.com https://*.googleusercontent.com https://www.googletagmanager.com https://www.google-analytics.com; object-src 'none'; connect-src 'self' http://127.0.0.1:9001 https://www.google-analytics.com https://*.googleapis.com blob: data:; img-src 'self' data: blob: https://www.google-analytics.com https://www.googletagmanager.com; navigate-to https://*.perfetto.dev;">
-
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" name="viewport" />
- <link href="perfetto.css" rel="stylesheet">
- <link rel="icon" type="image/png" href="assets/favicon.png">
+ <link rel="shortcut icon" id="favicon" type="image/png" href="data:image/png;base64,iVBORw0KGgo=">
</head>
-<body>
- <main>
- <div class="full-page-loading-screen"></div>
- </main>
- <div id="main-modal" aria-hidden="true" class="modal micromodal-slide"></div>
+<body data-perfetto_version='{"filled_by_build_js":"."}'>
+ <!--
+ Don't add any content here. The whole <body> is replaced by
+ frontend/index.ts when bootstrapping. This is only used for very early
+ error reporting.
+ -->
+ <style>
+ #app_load_failure {opacity:0;transition:opacity 1s ease;position:absolute;background:#080082;top:0;left:0;width:100%;height:100%;bottom:0;right:0;margin:0;opacity:0;user-select:text}
+ #app_load_failure > pre {color:#fff;position:absolute;margin:auto;white-space:pre-wrap;top:50%;transform:translate(0,-50%);max-width:90vw;width:880px;left:0;right:0;font-size:16px;line-height:30px;font-weight:700}
+ #app_load_failure > pre span {background:#fff;color:#080082;padding:2px}
+ #app_load_failure a {color:#fff}
+ #app_load { position: absolute; top: 0; left: 0; right:0; bottom: 0; background-color: #2c3e50;}
+ #app_load_spinner { margin: 30vh auto; width: 150px; height: 150px; border: 3px solid rgba(255,255,255,.3); border-radius: 50%; border-top-color: #fff; animation: app_load_spin 1s ease-in-out infinite; }
+ @keyframes app_load_spin { to { transform: rotate(360deg); } }
+ </style>
+ <div id="app_load"><div id="app_load_spinner"></div></div>
+ <div id="app_load_failure">
+<pre>
+<span>Perfetto UI - An unrecoverable problem occurred</span>
+
+If you are seeing this message, something went wrong while loading the UI.
+Please file a bug (details below) and try these remediation steps:
+
+* Force-reload the page with Ctrl+Shift+R (Mac: Meta+Shift+R) or
+ Shift + click on the refresh button.
+
+* <a href="javascript:clearAllCaches();">Click here</a> to clear all the site storage and caches and reload the page.
+
+* Clear the site data and caches from devtools, following <a target="_blank" href="https://developers.google.com/web/tools/chrome-devtools/storage/cache#deletecache">these instructions</a>.
+
+* If neither of these work, you can use an old fallback instance hosted at
+ <a target="_blank" href="https://staging-dot-perfetto-ui.appspot.com">staging-dot-perfetto-ui.appspot.com</a>
+
+In any case, **FILE A BUG** attaching logs and screenshots from devtools.
+ Googlers: <a href="http://go/perfetto-ui-bug" target="_blank">go/perfetto-ui-bug</a>
+ Non-googlers: <a href="https://github.com/google/perfetto/issues/new" target="_blank">github.com/google/perfetto/issues/new</a>
+
+<div id=app_load_failure_err></div>
+Technical Information:
+<div id=app_load_failure_dbg></div>
+</pre>
+ </div>
+ <script type="text/javascript">
+ 'use strict';
+ (function () {
+ const TIMEOUT_MS = 10000;
+ let errTimerId = undefined;
+
+ function errHandler(err) {
+ // Note: we deliberately don't clearTimeout(), which means that this
+ // handler is called also in the happy case when the UI loads. In that
+ // case, though, the onCssLoaded() in frontend/index.ts will empty the
+ // <body>, so |div| below will be null and this function becomes a
+ // no-op.
+ const div = document.getElementById('app_load_failure');
+ if (!div) return;
+ div.style.opacity ='1';
+ const errDom = document.getElementById('app_load_failure_err');
+ if (!errDom) return;
+ console.error(err);
+ errDom.innerText += `${err}\n`;
+ const storageJson = JSON.stringify(window.localStorage);
+ const dbg = document.getElementById('app_load_failure_dbg');
+ if (!dbg) return;
+ dbg.innerText = `LocalStorage: ${storageJson}\n`;
+ if (errTimerId !== undefined) clearTimeout(errTimerId);
+ }
+
+ // For the 'Click here to clear all caches'.
+ window.clearAllCaches = () => {
+ if (window.localStorage) window.localStorage.clear();
+ if (window.sessionStorage) window.sessionStorage.clear();
+ const promises = [];
+ if (window.caches) {
+ window.caches.keys().then(
+ keys => keys.forEach(k => promises.push(window.caches.delete(k))));
+ }
+ if (navigator.serviceWorker) {
+ navigator.serviceWorker.getRegistrations().then(regs => {
+ regs.forEach(reg => promises.push(reg.unregister()));
+ });
+ }
+ Promise.all(promises).then(() => window.location.reload());
+ }
+
+ // If the frontend doesn't come up, make the error page above visible.
+ errTimerId = setTimeout(() => errHandler('Timed out'), TIMEOUT_MS);
+ window.onerror = errHandler;
+ window.onunhandledrejection = errHandler;
+
+ const versionStr = document.body.dataset['perfetto_version'] || '{}';
+ const versionMap = JSON.parse(versionStr);
+ const channel = localStorage.getItem('perfettoUiChannel') || 'stable';
+
+ // The '.' below is a fallback for the case of opening a pinned version
+ // (e.g., ui.perfetto.dev/v1.2.3./). In that case, the index.html has no
+ // valid version map; we want to load the frontend from the same
+ // sub-directory directory, hence ./frontend_bundle.js.
+ const version = versionMap[channel] || versionMap['stable'] || '.';
+
+ const script = document.createElement('script');
+ script.async = true;
+ script.src = version + '/frontend_bundle.js';
+ script.onerror = () => errHandler(`Failed to load ${script.src}`);
+
+ document.head.append(script);
+ })();
+ </script>
</body>
</html>
-<script src="frontend_bundle.js"></script>
diff --git a/ui/src/assets/perfetto.scss b/ui/src/assets/perfetto.scss
index 05de317..d3cc23a 100644
--- a/ui/src/assets/perfetto.scss
+++ b/ui/src/assets/perfetto.scss
@@ -14,6 +14,7 @@
@import 'typefaces';
@import 'common';
+@import 'home_page';
@import 'analyze_page';
@import 'metrics_page';
@import 'sidebar';
diff --git a/ui/src/assets/sidebar.scss b/ui/src/assets/sidebar.scss
index b368214..cb0e1c0 100644
--- a/ui/src/assets/sidebar.scss
+++ b/ui/src/assets/sidebar.scss
@@ -43,6 +43,17 @@
height: 40px;
margin-top: 4px;
}
+ &.canary::before {
+ content: 'CANARY';
+ display: block;
+ color: gold;
+ position: absolute;
+ font-size: 10px;
+ line-height: 10px;
+ font-family: 'Roboto', sans-serif;
+ left: 155px;
+ top: 7px;
+ }
}
.sidebar-button {
position: absolute;
diff --git a/ui/src/assets/typefaces.scss b/ui/src/assets/typefaces.scss
index b5999b3..5e4ba16 100644
--- a/ui/src/assets/typefaces.scss
+++ b/ui/src/assets/typefaces.scss
@@ -52,6 +52,7 @@
}
.material-icons {
+ font-display: block;
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
diff --git a/ui/src/frontend/analytics.ts b/ui/src/frontend/analytics.ts
index 5abf7ed..d7913d7 100644
--- a/ui/src/frontend/analytics.ts
+++ b/ui/src/frontend/analytics.ts
@@ -13,6 +13,7 @@
// limitations under the License.
import {globals} from '../frontend/globals';
+import * as version from '../gen/perfetto_version';
type TraceCategories = 'Trace Actions'|'Record Trace'|'User Actions';
const ANALYTICS_ID = 'UA-137828855-1';
@@ -92,6 +93,8 @@
referrer: document.referrer.split('?')[0],
send_page_view: false,
dimension1: globals.isInternalUser ? '1' : '0',
+ dimension2: version.VERSION,
+ dimension3: globals.channel,
});
this.updatePath(route);
}
diff --git a/ui/src/frontend/css_constants.ts b/ui/src/frontend/css_constants.ts
index a443306..3b95eab 100644
--- a/ui/src/frontend/css_constants.ts
+++ b/ui/src/frontend/css_constants.ts
@@ -12,34 +12,29 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-function readCssVarSlow(prop: string, defaultValue: string): string {
- // This code can be used in unittests where we can't read CSS variables.
- if (typeof window === 'undefined') {
- return defaultValue;
- } else {
- const body = window.document.body;
- return window.getComputedStyle(body).getPropertyValue(prop);
- }
+// This code can be used in unittests where we can't read CSS variables.
+// Also we cannot have global constructors beacause when the javascript is
+// loaded, the CSS might not be ready yet.
+export let TRACK_SHELL_WIDTH = 100;
+export let TRACK_BORDER_COLOR = '#ffc0cb';
+export let TOPBAR_HEIGHT = 48;
+
+export function initCssConstants() {
+ TRACK_SHELL_WIDTH = getCssNum('--track-shell-width') || TRACK_SHELL_WIDTH;
+ TRACK_BORDER_COLOR = getCssStr('--track-border-color') || TRACK_BORDER_COLOR;
+ TOPBAR_HEIGHT = getCssNum('--topbar-height') || TOPBAR_HEIGHT;
}
-function getTrackShellWidth(): number {
- const width = readCssVarSlow('--track-shell-width', '100px');
- const match = width.match(/^\W*(\d+)px$/);
- if (!match) throw Error(`Could not parse shell width as number (${width})`);
+function getCssStr(prop: string): string|undefined {
+ if (typeof window === 'undefined') return undefined;
+ const body = window.document.body;
+ return window.getComputedStyle(body).getPropertyValue(prop);
+}
+
+function getCssNum(prop: string): number|undefined {
+ const str = getCssStr(prop);
+ if (str === undefined) return undefined;
+ const match = str.match(/^\W*(\d+)px$/);
+ if (!match) throw Error(`Could not parse CSS property "${str}" as a number`);
return Number(match[1]);
}
-
-function getTrackBorderColor(): string {
- return readCssVarSlow('--track-border-color', '#ffc0cb');
-}
-
-function getTopbarHeight(): number {
- const width = readCssVarSlow('--topbar-height', '48px');
- const match = width.match(/^\W*(\d+)px$/);
- if (!match) throw Error(`Could not parse topbar height as number (${width})`);
- return Number(match[1]);
-}
-
-export const TRACK_SHELL_WIDTH = getTrackShellWidth();
-export const TRACK_BORDER_COLOR = getTrackBorderColor();
-export const TOPBAR_HEIGHT = getTopbarHeight();
diff --git a/ui/src/frontend/globals.ts b/ui/src/frontend/globals.ts
index 38bd7a9..dff9c1b 100644
--- a/ui/src/frontend/globals.ts
+++ b/ui/src/frontend/globals.ts
@@ -125,10 +125,20 @@
}
type ThreadMap = Map<number, ThreadDesc>;
+function getRoot() {
+ // Works out the root directory where the content should be served from
+ // e.g. `http://origin/v1.2.3/`.
+ let root = (document.currentScript as HTMLScriptElement).src;
+ root = root.substr(0, root.lastIndexOf('/') + 1);
+ return root;
+}
+
/**
* Global accessors for state/dispatch in the frontend.
*/
class Globals {
+ readonly root = getRoot();
+
private _dispatch?: Dispatch = undefined;
private _controllerWorker?: Worker = undefined;
private _state?: State = undefined;
@@ -137,6 +147,7 @@
private _serviceWorkerController?: ServiceWorkerController = undefined;
private _logging?: Analytics = undefined;
private _isInternalUser: boolean|undefined = undefined;
+ private _channel: string|undefined = undefined;
// TODO(hjd): Unify trackDataStore, queryResults, overviewStore, threads.
private _trackDataStore?: TrackDataStore = undefined;
@@ -444,6 +455,13 @@
this._isInternalUser = value;
}
+ get channel() {
+ if (this._channel === undefined) {
+ this._channel = localStorage.getItem('perfettoUiChannel') || 'stable';
+ }
+ return this._channel;
+ }
+
// Used when switching to the legacy TraceViewer UI.
// Most resources are cleaned up by replacing the current |window| object,
// however pending RAFs and workers seem to outlive the |window| and need to
diff --git a/ui/src/frontend/home_page.ts b/ui/src/frontend/home_page.ts
index 5d37cbb..91a3186 100644
--- a/ui/src/frontend/home_page.ts
+++ b/ui/src/frontend/home_page.ts
@@ -13,17 +13,52 @@
// limitations under the License.
import * as m from 'mithril';
+import {globals} from './globals';
import {createPage} from './pages';
+let channelChanged = false;
+
export const HomePage = createPage({
view() {
return m(
'.page.home-page',
- m('.home-page-title', 'Perfetto'),
- m('img.logo[src=assets/logo-3d.png]'),
+ m(
+ '.home-page-center',
+ m('.home-page-title', 'Perfetto'),
+ m(`img.logo[src=${globals.root}assets/logo-3d.png]`),
+ m(
+ 'div.channel-select',
+ m('div',
+ 'Feeling adventurous? Try our bleeding edge Canary version'),
+ m(
+ 'fieldset',
+ mkChan('stable'),
+ mkChan('canary'),
+ m('.highlight'),
+ ),
+ m(`.home-page-reload${channelChanged ? '.show' : ''}`,
+ 'You need to reload the page for the changes to have effect'),
+ ),
+ ),
m('a.privacy',
{href: 'https://policies.google.com/privacy', target: '_blank'},
'Privacy policy'));
}
});
+
+function mkChan(chan: string) {
+ const checked =
+ !channelChanged && globals.channel === chan ? '[checked=true]' : '';
+ return [
+ m(`input[type=radio][name=chan][id=chan_${chan}]${checked}`,
+ {onchange: () => changeChannel(chan)}),
+ m(`label[for=chan_${chan}]`, chan),
+ ];
+}
+
+function changeChannel(chan: string) {
+ localStorage.setItem('perfettoUiChannel', chan);
+ channelChanged = true;
+ globals.rafScheduler.scheduleFullRedraw();
+}
diff --git a/ui/src/frontend/index.ts b/ui/src/frontend/index.ts
index a2ba0ac..1b6584e 100644
--- a/ui/src/frontend/index.ts
+++ b/ui/src/frontend/index.ts
@@ -15,9 +15,9 @@
import '../tracks/all_frontend';
import {applyPatches, Patch} from 'immer';
-import * as MicroModal from 'micromodal';
import * as m from 'mithril';
+import {defer} from '../base/deferred';
import {assertExists, reportError, setErrorHandler} from '../base/logging';
import {forwardRemoteCalls} from '../base/remote';
import {Actions} from '../common/actions';
@@ -33,6 +33,7 @@
import {AnalyzePage} from './analyze_page';
import {loadAndroidBugToolInfo} from './android_bug_tool';
+import {initCssConstants} from './css_constants';
import {maybeShowErrorDialog} from './error_dialog';
import {
CounterDetails,
@@ -264,13 +265,81 @@
}));
}
+function setupContentSecurityPolicy() {
+ // Note: self and sha-xxx must be quoted, urls data: and blob: must not.
+ const policy = {
+ 'default-src': [
+ `'self'`,
+ // Google Tag Manager bootstrap.
+ `'sha256-LirUKeorCU4uRNtNzr8tlB11uy8rzrdmqHCX38JSwHY='`,
+ ],
+ 'script-src': [
+ `'self'`,
+ 'https://*.google.com',
+ 'https://*.googleusercontent.com',
+ 'https://www.googletagmanager.com',
+ 'https://www.google-analytics.com',
+ ],
+ 'object-src': ['none'],
+ 'connect-src': [
+ `'self'`,
+ 'http://127.0.0.1:9001', // For trace_processor_shell --http.
+ 'https://www.google-analytics.com',
+ 'https://*.googleapis.com', // For Google Cloud Storage fetches.
+ 'blob:',
+ 'data:',
+ ],
+ 'img-src': [
+ `'self'`,
+ 'data:',
+ 'blob:',
+ 'https://www.google-analytics.com',
+ 'https://www.googletagmanager.com',
+ ],
+ 'navigate-to': ['https://*.perfetto.dev']
+ };
+ const meta = document.createElement('meta');
+ meta.httpEquiv = 'Content-Security-Policy';
+ let policyStr = '';
+ for (const [key, list] of Object.entries(policy)) {
+ policyStr += `${key} ${list.join(' ')}; `;
+ }
+ meta.content = policyStr;
+ document.head.appendChild(meta);
+}
+
function main() {
+ setupContentSecurityPolicy();
+
+ // Load the css. The load is asynchronous and the CSS is not ready by the time
+ // appenChild returns.
+ const cssLoadPromise = defer<void>();
+ const css = document.createElement('link');
+ css.rel = 'stylesheet';
+ css.href = globals.root + 'perfetto.css';
+ css.onload = () => cssLoadPromise.resolve();
+ css.onerror = (err) => cssLoadPromise.reject(err);
+ const favicon = document.head.querySelector('#favicon') as HTMLLinkElement;
+ if (favicon) favicon.href = globals.root + 'assets/favicon.png';
+
+ // Load the script to detect if this is a Googler (see comments on globals.ts)
+ // and initialize GA after that (or after a timeout if something goes wrong).
+ const script = document.createElement('script');
+ script.src =
+ 'https://storage.cloud.google.com/perfetto-ui-internal/is_internal_user.js';
+ script.async = true;
+ script.onerror = () => globals.logging.initialize();
+ script.onload = () => globals.logging.initialize();
+ setTimeout(() => globals.logging.initialize(), 5000);
+
+ document.head.append(script, css);
+
// 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');
+ const controller = new Worker(globals.root + 'controller_bundle.js');
const frontendChannel = new MessageChannel();
const controllerChannel = new MessageChannel();
const extensionLocalChannel = new MessageChannel();
@@ -333,32 +402,35 @@
});
}
- updateAvailableAdbDevices();
- try {
- navigator.usb.addEventListener(
- 'connect', () => updateAvailableAdbDevices());
- navigator.usb.addEventListener(
- 'disconnect', () => updateAvailableAdbDevices());
- } catch (e) {
- console.error('WebUSB API not supported');
- }
// This forwards the messages from the controller to the extension
extensionLocalChannel.port2.onmessage = ({data}) => {
if (extensionPort) extensionPort.postMessage(data);
};
- const main = assertExists(document.body.querySelector('main'));
-
- globals.rafScheduler.domRedraw = () =>
- m.render(main, m(router.resolve(globals.state.route)));
-
- // Add support for opening traces from postMessage().
- window.addEventListener('message', postMessageHandler, {passive: true});
// Put these variables in the global scope for better debugging.
(window as {} as {m: {}}).m = m;
(window as {} as {globals: {}}).globals = globals;
(window as {} as {Actions: {}}).Actions = Actions;
+ // Prevent pinch zoom.
+ document.body.addEventListener('wheel', (e: MouseEvent) => {
+ if (e.ctrlKey) e.preventDefault();
+ }, {passive: false});
+
+ cssLoadPromise.then(() => onCssLoaded(router));
+}
+
+function onCssLoaded(router: Router) {
+ initCssConstants();
+ // Clear all the contents of the initial page (e.g. the <pre> error message)
+ // And replace it with the root <main> element which will be used by mithril.
+ document.body.innerHTML = '<main></main>';
+ const main = assertExists(document.body.querySelector('main'));
+ globals.rafScheduler.domRedraw = () =>
+ m.render(main, m(router.resolve(globals.state.route)));
+
+ router.navigateToCurrentHash();
+
// /?s=xxxx for permalinks.
const stateHash = Router.param('s');
const urlHash = Router.param('url');
@@ -390,31 +462,24 @@
});
}
- // Prevent pinch zoom.
- document.body.addEventListener('wheel', (e: MouseEvent) => {
- if (e.ctrlKey) e.preventDefault();
- }, {passive: false});
-
- router.navigateToCurrentHash();
-
- MicroModal.init();
-
- // Load the script to detect if this is a Googler (see comments on globals.ts)
- // and initialize GA after that (or after a timeout if something goes wrong).
- const script = document.createElement('script');
- script.src =
- 'https://storage.cloud.google.com/perfetto-ui-internal/is_internal_user.js';
- script.async = true;
- script.onerror = () => globals.logging.initialize();
- script.onload = () => globals.logging.initialize();
- setTimeout(() => globals.logging.initialize(), 5000);
- document.head.appendChild(script);
+ // Add support for opening traces from postMessage().
+ window.addEventListener('message', postMessageHandler, {passive: true});
// Will update the chip on the sidebar footer that notifies that the RPC is
// connected. Has no effect on the controller (which will repeat this check
// before creating a new engine).
CheckHttpRpcConnection();
initLiveReloadIfLocalhost();
+
+ updateAvailableAdbDevices();
+ try {
+ navigator.usb.addEventListener(
+ 'connect', () => updateAvailableAdbDevices());
+ navigator.usb.addEventListener(
+ 'disconnect', () => updateAvailableAdbDevices());
+ } catch (e) {
+ console.error('WebUSB API not supported');
+ }
}
main();
diff --git a/ui/src/frontend/legacy_trace_viewer.ts b/ui/src/frontend/legacy_trace_viewer.ts
index a200ea9..122a618 100644
--- a/ui/src/frontend/legacy_trace_viewer.ts
+++ b/ui/src/frontend/legacy_trace_viewer.ts
@@ -15,6 +15,7 @@
import * as m from 'mithril';
import {inflate} from 'pako';
import {assertTrue} from '../base/logging';
+import {globals} from './globals';
import {showModal} from './modal';
const CTRACE_HEADER = 'TRACE:\n';
@@ -126,9 +127,7 @@
// The location.pathname mangling is to make this code work also when hosted
// in a non-root sub-directory, for the case of CI artifacts.
- const urlParts = location.pathname.split('/');
- urlParts[urlParts.length - 1] = 'assets/catapult_trace_viewer.html';
- const catapultUrl = urlParts.join('/');
+ const catapultUrl = globals.root + 'assets/catapult_trace_viewer.html';
const newWin = window.open(catapultUrl) as Window;
if (newWin) {
// Popup succeedeed.
diff --git a/ui/src/frontend/modal.ts b/ui/src/frontend/modal.ts
index 0778e4c..69482e3 100644
--- a/ui/src/frontend/modal.ts
+++ b/ui/src/frontend/modal.ts
@@ -32,8 +32,23 @@
action: () => void;
}
+// We need to create a div outside of the mithril's render root (<main>), that's
+// why the manual DOM manipulation.
+function getOrCreateDOM() {
+ let div = document.getElementById('main-modal') as HTMLElement;
+ if (div) return div;
+ div = document.createElement('div');
+ div.id = 'main-modal';
+ div.classList.add('modal');
+ div.classList.add('micromodal-slide');
+ (div as {} as {ariaHidden: boolean}).ariaHidden = true;
+ document.body.appendChild(div);
+ MicroModal.init();
+ return div;
+}
+
export async function showModal(attrs: ModalDefinition): Promise<void> {
- const modal = document.querySelector('#main-modal') as HTMLElement;
+ const modal = getOrCreateDOM();
m.render(
modal,
m('.modal-overlay[data-micromodal-close]',
diff --git a/ui/src/frontend/record_page.ts b/ui/src/frontend/record_page.ts
index 3f425f8..4de254a 100644
--- a/ui/src/frontend/record_page.ts
+++ b/ui/src/frontend/record_page.ts
@@ -137,7 +137,7 @@
return m(
`label${cfg.mode === mode ? '.selected' : ''}`,
m(`input[type=radio][name=rec_mode]`, checkboxArgs),
- m(`img[src=assets/${img}]`),
+ m(`img[src=${globals.root}assets/${img}]`),
m('span', title));
};
diff --git a/ui/src/frontend/record_widgets.ts b/ui/src/frontend/record_widgets.ts
index 2e6db1f..fd919a8 100644
--- a/ui/src/frontend/record_widgets.ts
+++ b/ui/src/frontend/record_widgets.ts
@@ -70,7 +70,7 @@
return m(
`.probe${enabled ? '.enabled' : ''}`,
attrs.img && m('img', {
- src: `assets/${attrs.img}`,
+ src: `${globals.root}assets/${attrs.img}`,
onclick: () => onToggle(!enabled),
}),
m('label',
diff --git a/ui/src/frontend/service_worker_controller.ts b/ui/src/frontend/service_worker_controller.ts
index 55bcfdc..bc11ee3 100644
--- a/ui/src/frontend/service_worker_controller.ts
+++ b/ui/src/frontend/service_worker_controller.ts
@@ -74,12 +74,24 @@
async install() {
if (!('serviceWorker' in navigator)) return; // Not supported.
+ if (location.pathname !== '/') {
+ // Disable the service worker when the UI is loaded from a non-root URL
+ // (e.g. from the CI artifacts GCS bucket). Supporting the case of a
+ // nested index.html is too cumbersome and has no benefits.
+ return;
+ }
+
if (await caches.has(BYPASS_ID)) {
this._bypassed = true;
console.log('Skipping service worker registration, disabled by the user');
return;
}
- navigator.serviceWorker.register('service_worker.js').then(registration => {
+ // In production cases versionDir == VERSION. We use this here for ease of
+ // testing (so we can have /v1.0.0a/ /v1.0.0b/ even if they have the same
+ // version code).
+ const versionDir = globals.root.split('/').slice(-2)[0];
+ const swUri = `/service_worker.js?v=${versionDir}`;
+ navigator.serviceWorker.register(swUri).then(registration => {
this._initialWorker = registration.active;
// At this point there are two options:
@@ -90,8 +102,7 @@
this.monitorWorker(registration.installing);
this.monitorWorker(registration.active);
- // Setup the event that shows the "A new release is available"
- // notification.
+ // Setup the event that shows the "Updated to v1.2.3" notification.
registration.addEventListener('updatefound', () => {
this.monitorWorker(registration.installing);
});
diff --git a/ui/src/frontend/sidebar.ts b/ui/src/frontend/sidebar.ts
index a2d0a8d..37394ed 100644
--- a/ui/src/frontend/sidebar.ts
+++ b/ui/src/frontend/sidebar.ts
@@ -688,6 +688,7 @@
{
href: `https://github.com/google/perfetto/tree/${
version.SCM_REVISION}/ui`,
+ title: `Channel: ${globals.channel}`,
target: '_blank',
},
`${version.VERSION}`),
@@ -813,8 +814,8 @@
ontransitionend: () => this._redrawWhileAnimating.stop(),
},
m(
- 'header',
- m('img[src=assets/brand.png].brand'),
+ `header.${globals.channel}`,
+ m(`img[src=${globals.root}assets/brand.png].brand`),
m('button.sidebar-button',
{
onclick: () => {
diff --git a/ui/src/frontend/topbar.ts b/ui/src/frontend/topbar.ts
index dfeb056..0af8781 100644
--- a/ui/src/frontend/topbar.ts
+++ b/ui/src/frontend/topbar.ts
@@ -16,6 +16,7 @@
import {Actions} from '../common/actions';
import {EngineConfig} from '../common/state';
+import * as version from '../gen/perfetto_version';
import {globals} from './globals';
import {executeSearch} from './search_handler';
@@ -198,17 +199,10 @@
}
return m(
'.new-version-toast',
- 'A new version of the UI is available!',
+ `Updated to ${version.VERSION} and ready for offline use!`,
m('button.notification-btn.preferred',
{
onclick: () => {
- location.reload();
- }
- },
- 'Reload'),
- m('button.notification-btn',
- {
- onclick: () => {
globals.frontendLocalState.newVersionAvailable = false;
globals.rafScheduler.scheduleFullRedraw();
}
diff --git a/ui/src/service_worker/service_worker.ts b/ui/src/service_worker/service_worker.ts
index 668f840..fbe5551 100644
--- a/ui/src/service_worker/service_worker.ts
+++ b/ui/src/service_worker/service_worker.ts
@@ -18,38 +18,110 @@
// When a new version of the UI is released (e.g. v1 -> v2), the following
// happens on the next visit:
-// 1. The v1 (old) service worker is activated (at this point we don't know yet
-// that v2 is released).
-// 2. /index.html is requested. The SW intercepts the request and serves
-// v1 from cache.
-// 3. The browser checks if a new version of service_worker.js is available. It
-// does that by comparing the bytes of the current and new version.
-// 5. service_worker.js v2 will not be byte identical with v1, even if v2 was a
-// css-only change. This is due to the hashes in UI_DIST_MAP below. For this
-// reason v2 is installed in the background (it takes several seconds).
-// 6. The 'install' handler is triggered, the new resources are fetched and
-// populated in the cache.
-// 7. The 'activate' handler is triggered. The old caches are deleted at this
+// 1. The v1 (old) service worker is activated. At this point we don't know yet
+// that v2 is released.
+// 2. /index.html is requested. The SW intercepts the request and serves it from
+// the network.
+// 3a If the request fails (offline / server unreachable) or times out, the old
+// v1 is served.
+// 3b If the request succeeds, the browser receives the index.html for v2. That
+// will try to fetch resources from /v2/frontend_bundle.ts.
+// 4. When the SW sees the /v2/ request, will have a cache miss and will issue
+// a network fetch(), returning the fresh /v2/ content.
+// 4. The v2 site will call serviceWorker.register('service_worker.js?v=v2').
+// This (i.e. the different querystring) will cause a re-installation of the
+// service worker (even if the service_worker.js script itself is unchanged).
+// 5. In the "install" step, the service_worker.js script will fetch the newer
+// version (v2).
+// Note: the v2 will be fetched twice, once upon the first request that
+// causes causes a cache-miss, the second time while re-installing the SW.
+// The latter though will hit a HTTP 304 (Not Changed) and will be served
+// from the browser cache after the revalidation request.
+// 6. The 'activate' handler is triggered. The old v1 cache is deleted at this
// point.
-// 8. frontend/index.ts (in setupServiceWorker()) is notified about the activate
-// and shows a notification prompting to reload the UI.
-//
-// If the user just closes the tab or hits refresh, v2 will be served anyways
-// on the next load.
-
-// UI_DIST_FILES is map of {file_name -> sha1}.
-// It is really important that this map is bundled directly in the
-// service_worker.js bundle file, as it's used to cause the browser to
-// re-install the service worker and re-fetch resources when anything changes.
-// This is why the map contains the SHA1s even if we don't directly use them in
-// the code (because it makes the final .js file content-dependent).
-
-import {UI_DIST_MAP} from '../gen/dist_file_map';
declare var self: ServiceWorkerGlobalScope;
+export {};
-const CACHE_NAME = 'dist-' + UI_DIST_MAP.hex_digest.substr(0, 16);
-const LOG_TAG = `ServiceWorker[${UI_DIST_MAP.hex_digest.substr(0, 16)}]: `;
+const LOG_TAG = `ServiceWorker: `;
+const CACHE_NAME = 'ui-perfetto-dev';
+
+// If the fetch() for the / doesn't respond within 3s, return a cached version.
+// This is to avoid that a user waits too much if on a flaky network.
+const INDEX_TIMEOUT_MS = 3000;
+
+// Use more relaxed timeouts when caching the subresources for the new version
+// in the background.
+const INSTALL_TIMEOUT_MS = 30000;
+
+// The install() event is fired:
+// 1. On the first visit, when there is no SW installed.
+// 2. Every time the user opens the site and the version has been updated (they
+// will get the newer version regardless, unless we hit INDEX_TIMEOUT_MS).
+// The latter happens because:
+// - / (index.html) is always served from the network (% timeout) and it pulls
+// /v1.2.3/frontend_bundle.js.
+// - /v1.2.3/frontend_bundle.js will register /service_worker.js?v=v1.2.3 .
+// The service_worker.js script itself never changes, but the browser
+// re-installs it because the version in the V? query-string argument changes.
+// The reinstallation will cache the new files from the v.1.2.3/manifest.json.
+self.addEventListener('install', event => {
+ const doInstall = async () => {
+ if (await caches.has('BYPASS_SERVICE_WORKER')) {
+ // Throw will prevent the installation.
+ throw new Error(LOG_TAG + 'skipping installation, bypass enabled');
+ }
+
+ // Delete old cache entries from the pre-feb-2021 service worker.
+ for (const key of await caches.keys()) {
+ if (key.startsWith('dist-')) {
+ await caches.delete(key);
+ }
+ }
+
+ // The UI should register this as service_worker.js?v=v1.2.3. Extract the
+ // version number and pre-fetch all the contents for the version.
+ const match = /\bv=([\w.]*)/.exec(location.search);
+ if (!match) {
+ throw new Error(
+ 'Failed to install. Was epecting a query string like ' +
+ `?v=v1.2.3 query string, got "${location.search}" instead`);
+ }
+ await installAppVersionIntoCache(match[1]);
+
+ // skipWaiting() still waits for the install to be complete. Without this
+ // call, the new version would be activated only when all tabs are closed.
+ // Instead, we ask to activate it immediately. This is safe because the
+ // subresources are versioned (e.g. /v1.2.3/frontend_bundle.js). Even if
+ // there is an old UI tab opened while we activate() a newer version, the
+ // activate() would just cause cache-misses, hence fetch from the network,
+ // for the old tab.
+ self.skipWaiting();
+ };
+ event.waitUntil(doInstall());
+});
+
+self.addEventListener('activate', (event) => {
+ console.info(LOG_TAG + 'activated');
+ const doActivate = async () => {
+ // This makes a difference only for the very first load, when no service
+ // worker is present. In all the other cases the skipWaiting() will hot-swap
+ // the active service worker anyways.
+ await self.clients.claim();
+ };
+ event.waitUntil(doActivate());
+});
+
+self.addEventListener('fetch', event => {
+ // The early return here will cause the browser to fall back on standard
+ // network-based fetch.
+ if (!shouldHandleHttpRequest(event.request)) {
+ console.debug(LOG_TAG + `serving ${event.request.url} from network`);
+ return;
+ }
+
+ event.respondWith(handleHttpRequest(event.request));
+});
function shouldHandleHttpRequest(req: Request): boolean {
@@ -80,88 +152,90 @@
// resources, which is undesirable.
// * Only Ctrl+R. Ctrl+Shift+R will always bypass service-worker for all the
// requests (index.html and the rest) made in that tab.
- try {
- const cacheOps = {cacheName: CACHE_NAME} as CacheQueryOptions;
- const cachedRes = await caches.match(req, cacheOps);
- if (cachedRes) {
- console.debug(LOG_TAG + `serving ${req.url} from cache`);
- return cachedRes;
+
+ const cacheOps = {cacheName: CACHE_NAME} as CacheQueryOptions;
+ const url = new URL(req.url);
+ if (url.pathname === '/') {
+ try {
+ console.debug(LOG_TAG + `Fetching live ${req.url}`);
+ // The await bleow is needed to fall through in case of an exception.
+ return await fetchWithTimeout(req, INDEX_TIMEOUT_MS);
+ } catch (err) {
+ console.warn(LOG_TAG + `Failed to fetch ${req.url}, using cache.`, err);
+ // Fall through the code below.
}
- console.warn(LOG_TAG + `cache miss on ${req.url}`);
- } catch (exc) {
- console.error(LOG_TAG + `Cache request failed for ${req.url}`, exc);
+ } else if (url.pathname === '/offline') {
+ // Escape hatch to force serving the offline version without attemping the
+ // network fetch.
+ const cachedRes = await caches.match(new Request('/'), cacheOps);
+ if (cachedRes) return cachedRes;
+ }
+
+ const cachedRes = await caches.match(req, cacheOps);
+ if (cachedRes) {
+ console.debug(LOG_TAG + `serving ${req.url} from cache`);
+ return cachedRes;
}
// In any other case, just propagate the fetch on the network, which is the
// safe behavior.
- console.debug(LOG_TAG + `falling back on network fetch() for ${req.url}`);
+ console.warn(LOG_TAG + `cache miss on ${req.url}, using live network`);
return fetch(req);
}
-// The install() event is fired:
-// - The very first time the site is visited, after frontend/index.ts has
-// executed the serviceWorker.register() method.
-// - *After* the site is loaded, if the service_worker.js code
-// has changed (because of the hashes in UI_DIST_MAP, service_worker.js will
-// change if anything in the UI has changed).
-self.addEventListener('install', event => {
- const doInstall = async () => {
- if (await caches.has('BYPASS_SERVICE_WORKER')) {
- // Throw will prevent the installation.
- throw new Error(LOG_TAG + 'skipping installation, bypass enabled');
+async function installAppVersionIntoCache(version: string) {
+ const manifestUrl = `${version}/manifest.json`;
+ try {
+ console.log(LOG_TAG + `Starting installation of ${manifestUrl}`);
+ await caches.delete(CACHE_NAME);
+ const resp = await fetchWithTimeout(manifestUrl, INSTALL_TIMEOUT_MS);
+ const manifest = await resp.json();
+ const manifestResources = manifest['resources'];
+ if (!manifestResources || !(manifestResources instanceof Object)) {
+ throw new Error(`Invalid manifest ${manifestUrl} : ${manifest}`);
}
- console.log(LOG_TAG + 'installation started');
+
const cache = await caches.open(CACHE_NAME);
const urlsToCache: RequestInfo[] = [];
- for (const [file, integrity] of Object.entries(UI_DIST_MAP.files)) {
- const reqOpts:
- RequestInit = {cache: 'reload', mode: 'same-origin', integrity};
- urlsToCache.push(new Request(file, reqOpts));
- if (file === 'index.html' && location.host !== 'storage.googleapis.com') {
- // Disable cachinig of '/' for cases where the UI is hosted on GCS.
- // GCS doesn't support auto indexes. GCS returns a 404 page on / that
- // fails the integrity check.
- urlsToCache.push(new Request('/', reqOpts));
- }
+
+ // We use cache:reload to make sure that the index is always current and we
+ // don't end up in some cycle where we keep re-caching the index coming from
+ // the service worker itself.
+ urlsToCache.push(new Request('/', {cache: 'reload', mode: 'same-origin'}));
+
+ for (const [resource, integrity] of Object.entries(manifestResources)) {
+ // We use cache: no-cache rather then reload here because the versioned
+ // sub-resources are expected to be immutable and should never be
+ // ambiguous. A revalidation request is enough.
+ const reqOpts: RequestInit = {
+ cache: 'no-cache',
+ mode: 'same-origin',
+ integrity: `${integrity}`
+ };
+ urlsToCache.push(new Request(`${version}/${resource}`, reqOpts));
}
await cache.addAll(urlsToCache);
- console.log(LOG_TAG + 'installation completed');
-
- // skipWaiting() still waits for the install to be complete. Without this
- // call, the new version would be activated only when all tabs are closed.
- // Instead, we ask to activate it immediately. This is safe because each
- // service worker version uses a different cache named after the SHA256 of
- // the contents. When the old version is activated, the activate() method
- // below will evict the cache for the old versions. If there is an old still
- // opened, any further request from that tab will be a cache-miss and go
- // through the network (which is inconsitent, but not the end of the world).
- self.skipWaiting();
- };
- event.waitUntil(doInstall());
-});
-
-self.addEventListener('activate', (event) => {
- console.warn(LOG_TAG + 'activated');
- const doActivate = async () => {
- // Clear old caches.
- for (const key of await caches.keys()) {
- if (key !== CACHE_NAME) await caches.delete(key);
- }
- // This makes a difference only for the very first load, when no service
- // worker is present. In all the other cases the skipWaiting() will hot-swap
- // the active service worker anyways.
- await self.clients.claim();
- };
- event.waitUntil(doActivate());
-});
-
-self.addEventListener('fetch', event => {
- // The early return here will cause the browser to fall back on standard
- // network-based fetch.
- if (!shouldHandleHttpRequest(event.request)) {
- console.debug(LOG_TAG + `serving ${event.request.url} from network`);
- return;
+ console.log(LOG_TAG + 'installation completed for ' + version);
+ } catch (err) {
+ await caches.delete(CACHE_NAME);
+ console.error(LOG_TAG + `Installation failed for ${manifestUrl}`, err);
+ throw err;
}
+}
- event.respondWith(handleHttpRequest(event.request));
-});
+function fetchWithTimeout(req: Request|string, timeoutMs: number) {
+ const url = (req as {url?: string}).url || `${req}`;
+ return new Promise<Response>((resolve, reject) => {
+ const timerId = setTimeout(() => {
+ reject(`Timed out while fetching ${url}`);
+ }, timeoutMs);
+ fetch(req).then(resp => {
+ clearTimeout(timerId);
+ if (resp.ok) {
+ resolve(resp);
+ } else {
+ reject(`Fetch failed for ${url}: ${resp.status} ${resp.statusText}`);
+ }
+ }, reject);
+ });
+}
diff --git a/ui/src/service_worker/tsconfig.json b/ui/src/service_worker/tsconfig.json
index 7b58182..35ff5b6 100644
--- a/ui/src/service_worker/tsconfig.json
+++ b/ui/src/service_worker/tsconfig.json
@@ -5,6 +5,7 @@
"../gen/"
],
"compilerOptions": {
+ "outDir": "../../out/tsc/service_worker",
"lib": [
"webworker",
"es2018",
diff --git a/ui/tsconfig.base.json b/ui/tsconfig.base.json
index f8cdfc4..075b1b3 100644
--- a/ui/tsconfig.base.json
+++ b/ui/tsconfig.base.json
@@ -8,7 +8,6 @@
"allowJs": true,
"declaration": false, // Generates corresponding '.d.ts' file.
"sourceMap": true, // Generates corresponding '.map' file.
- "outDir": "./out/tsc", // Redirect output structure to the directory.
"removeComments": false, // Do not emit comments to output.
"importHelpers": true, // Import emit helpers from 'tslib'.
"downlevelIteration": true, // Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'.
diff --git a/ui/tsconfig.json b/ui/tsconfig.json
index ada3c9b..5867aea 100644
--- a/ui/tsconfig.json
+++ b/ui/tsconfig.json
@@ -8,6 +8,7 @@
"./out"
],
"compilerOptions": {
+ "outDir": "./out/tsc",
"lib": [
"dom", // Need to be explicitly mentioned now since we're overriding default included libs.
"es2018", // Need this to use Object.values.