• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright (C) 2018 The Android Open Source Project
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15import * as m from 'mithril';
16import {assertExists, assertTrue} from '../base/logging';
17import {PageAttrs} from './pages';
18
19export const ROUTE_PREFIX = '#!';
20const DEFAULT_ROUTE = '/';
21
22/*
23 * A broken down representation of a route.
24 * For instance: #!/record/gpu?local_cache_key=a0b1
25 * becomes: {page: '/record', subpage: '/gpu', args: {local_cache_key: 'a0b1'}}
26 */
27export interface Route {
28  page: string;
29  subpage: string;
30  args: RouteArgs;
31}
32
33/*
34 * The set of args that can be set on the route via #!/page?a=1&b2.
35 * Route args are orthogonal to pages (i.e. should NOT make sense only in a
36 * only within a specific page, use /page/subpages for that).
37 * Args are !== the querystring (location.search) which is sent to the
38 * server. The route args are NOT sent to the HTTP server.
39 * Given this URL:
40 * http://host/?foo=1&bar=2#!/page/subpage?local_cache_key=a0b1&baz=3.
41 *
42 * location.search = 'foo=1&bar=2'.
43 *   This is seen by the HTTP server. We really don't use querystrings as the
44 *   perfetto UI is client only.
45 *
46 * location.hash = '#!/page/subpage?local_cache_key=a0b1'.
47 *   This is client-only. All the routing logic in the Perfetto UI uses only
48 *   this.
49 */
50export interface RouteArgs {
51  // The local_cache_key is special and is persisted across navigations.
52  local_cache_key?: string;
53
54  // These are transient and are really set only on startup.
55  openFromAndroidBugTool?: string;
56  s?: string;    // For permalinks.
57  p?: string;    // DEPRECATED: for #!/record?p=cpu subpages (b/191255021).
58  url?: string;  // For fetching traces from Cloud Storage.
59}
60
61export interface RoutesMap {
62  [key: string]: m.Component<PageAttrs>;
63}
64
65/*
66 * This router does two things:
67 * 1) Maps fragment paths (#!/page/subpage) to Mithril components.
68 * The route map is passed to the ctor and is later used when calling the
69 * resolve() method.
70 *
71 * 2) Handles the (optional) args, e.g. #!/page?arg=1&arg2=2.
72 * Route args are carry information that is orthogonal to the page (e.g. the
73 * trace id).
74 * local_cache_key has some special treatment: once a URL has a local_cache_key,
75 * it gets automatically appended to further navigations that don't have one.
76 * For instance if the current url is #!/viewer?local_cache_key=1234 and a later
77 * action (either user-initiated or code-initited) navigates to #!/info, the
78 * rotuer will automatically replace the history entry with
79 * #!/info?local_cache_key=1234.
80 * This is to keep propagating the trace id across page changes, for handling
81 * tab discards (b/175041881).
82 *
83 * This class does NOT deal with the "load a trace when the url contains ?url=
84 * or ?local_cache_key=". That logic lives in trace_url_handler.ts, which is
85 * triggered by Router.onRouteChanged().
86 */
87export class Router {
88  private readonly recentChanges: number[] = [];
89  private routes: RoutesMap;
90
91  // frontend/index.ts calls maybeOpenTraceFromRoute() + redraw here.
92  // This event is decoupled for testing and to avoid circular deps.
93  onRouteChanged: (route: Route) => (void) = () => {};
94
95  constructor(routes: RoutesMap) {
96    assertExists(routes[DEFAULT_ROUTE]);
97    this.routes = routes;
98    window.onhashchange = (e: HashChangeEvent) => this.onHashChange(e);
99  }
100
101  private onHashChange(e: HashChangeEvent) {
102    this.crashIfLivelock();
103
104    const oldRoute = Router.parseUrl(e.oldURL);
105    const newRoute = Router.parseUrl(e.newURL);
106
107    if (newRoute.args.local_cache_key === undefined &&
108        oldRoute.args.local_cache_key) {
109      // Propagate `local_cache_key across` navigations. When a trace is loaded,
110      // the URL becomes #!/viewer?local_cache_key=123. `local_cache_key` allows
111      // reopening the trace from cache in the case of a reload or discard.
112      // When using the UI we can hit "bare" links (e.g. just '#!/info') which
113      // don't have the trace_uuid:
114      // - When clicking on an <a> element from the sidebar.
115      // - When the code calls Router.navigate().
116      // - When the user pastes a URL from docs page.
117      // In all these cases we want to keep propagating the `local_cache_key`.
118      // We do so by re-setting the `local_cache_key` and doing a
119      // location.replace which overwrites the history entry (note
120      // location.replace is NOT just a String.replace operation).
121      newRoute.args.local_cache_key = oldRoute.args.local_cache_key;
122    }
123
124    const args = m.buildQueryString(newRoute.args);
125    let normalizedFragment = `#!${newRoute.page}${newRoute.subpage}`;
126    normalizedFragment += args.length > 0 ? '?' + args : '';
127    if (!e.newURL.endsWith(normalizedFragment)) {
128      location.replace(normalizedFragment);
129      return;
130    }
131
132    this.onRouteChanged(newRoute);
133  }
134
135  /**
136   * Returns the component for the current route in the URL.
137   * If no route matches the URL, returns a component corresponding to
138   * |this.defaultRoute|.
139   */
140  resolve(): m.Vnode<PageAttrs> {
141    const route = Router.parseFragment(location.hash);
142    let component = this.routes[route.page];
143    if (component === undefined) {
144      component = assertExists(this.routes[DEFAULT_ROUTE]);
145    }
146    return m(component, {subpage: route.subpage} as PageAttrs);
147  }
148
149  static navigate(newHash: string) {
150    assertTrue(newHash.startsWith(ROUTE_PREFIX));
151    window.location.hash = newHash;
152  }
153
154  /*
155   * Breaks down a fragment into a Route object.
156   * Sample input:
157   * '#!/record/gpu?local_cache_key=abcd-1234'
158   * Sample output:
159   * {page: '/record', subpage: '/gpu', args: {local_cache_key: 'abcd-1234'}}
160   */
161  static parseFragment(hash: string): Route {
162    const prefixLength = ROUTE_PREFIX.length;
163    let route = '';
164    if (hash.startsWith(ROUTE_PREFIX)) {
165      route = hash.substr(prefixLength).split('?')[0];
166    }
167
168    let page = route;
169    let subpage = '';
170    const splittingPoint = route.indexOf('/', 1);
171    if (splittingPoint > 0) {
172      page = route.substr(0, splittingPoint);
173      subpage = route.substr(splittingPoint);
174    }
175
176    const argsStart = hash.indexOf('?');
177    const argsStr = argsStart < 0 ? '' : hash.substr(argsStart + 1);
178    const args = argsStr ? m.parseQueryString(hash.substr(argsStart)) : {};
179
180    // TODO(primiano): remove this in mid-2022. trace_id is the same concept of
181    // local_cache_key. Just at some point we renamed it to make it more obvious
182    // to people that those URLs cannot be copy-pasted in bugs. For now this
183    // handles cases of reloading pages from old version.
184    if ('trace_id' in args) {
185      if (!('local_cache_key' in args)) {
186        args['local_cache_key'] = args['trace_id'];
187      }
188      delete args['trace_id'];
189    }
190
191    return {page, subpage, args};
192  }
193
194  /*
195   * Like parseFragment() but takes a full URL.
196   */
197  static parseUrl(url: string): Route {
198    const hashPos = url.indexOf('#');
199    const fragment = hashPos < 0 ? '' : url.substr(hashPos);
200    return Router.parseFragment(fragment);
201  }
202
203  /*
204   * Throws if EVENT_LIMIT onhashchange events occur within WINDOW_MS.
205   */
206  private crashIfLivelock() {
207    const WINDOW_MS = 1000;
208    const EVENT_LIMIT = 20;
209    const now = Date.now();
210    while (this.recentChanges.length > 0 &&
211           now - this.recentChanges[0] > WINDOW_MS) {
212      this.recentChanges.shift();
213    }
214    this.recentChanges.push(now);
215    if (this.recentChanges.length > EVENT_LIMIT) {
216      throw new Error('History rewriting livelock');
217    }
218  }
219}
220