• 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 m from 'mithril';
16
17import {assertExists, assertTrue} from '../base/logging';
18import {
19  oneOf,
20  optBool,
21  optStr,
22  record,
23  runValidator,
24  ValidatedType,
25} from '../base/validators';
26
27import {PageAttrs} from './pages';
28
29export const ROUTE_PREFIX = '#!';
30const DEFAULT_ROUTE = '/';
31
32const modes = ['embedded', undefined] as const;
33type Mode = (typeof modes)[number];
34
35// The set of args that can be set on the route via #!/page?a=1&b2.
36// Route args are orthogonal to pages (i.e. should NOT make sense only in a
37// only within a specific page, use /page/subpages for that).
38// Args are !== the querystring (location.search) which is sent to the
39// server. The route args are NOT sent to the HTTP server.
40// Given this URL:
41// http://host/?foo=1&bar=2#!/page/subpage?local_cache_key=a0b1&baz=3.
42//
43// location.search = 'foo=1&bar=2'.
44//   This is seen by the HTTP server. We really don't use querystrings as the
45//   perfetto UI is client only.
46//
47// location.hash = '#!/page/subpage?local_cache_key=a0b1'.
48//   This is client-only. All the routing logic in the Perfetto UI uses only
49//   this.
50
51const routeArgs = record({
52  // The local_cache_key is special and is persisted across navigations.
53  local_cache_key: optStr,
54
55  // These are transient and are really set only on startup.
56
57  // Are we loading a trace via ABT.
58  openFromAndroidBugTool: optBool,
59
60  // For permalink hash.
61  s: optStr,
62
63  // DEPRECATED: for #!/record?p=cpu subpages (b/191255021).
64  p: optStr,
65
66  // For fetching traces from Cloud Storage or local servers
67  // as with record_android_trace.
68  url: optStr,
69
70  // For connecting to a trace_processor_shell --httpd instance running on a
71  // non-standard port. This requires the CSP_WS_PERMISSIVE_PORT flag to relax
72  // the Content Security Policy.
73  rpc_port: optStr,
74
75  // Override the referrer. Useful for scripts such as
76  // record_android_trace to record where the trace is coming from.
77  referrer: optStr,
78
79  // For the 'mode' of the UI. For example when the mode is 'embedded'
80  // some features are disabled.
81  mode: oneOf<Mode>(modes, undefined),
82
83  // Should we hide the sidebar?
84  hideSidebar: optBool,
85
86  // Deep link support
87  ts: optStr,
88  dur: optStr,
89  tid: optStr,
90  pid: optStr,
91  query: optStr,
92  visStart: optStr,
93  visEnd: optStr,
94});
95type RouteArgs = ValidatedType<typeof routeArgs>;
96
97// A broken down representation of a route.
98// For instance: #!/record/gpu?local_cache_key=a0b1
99// becomes: {page: '/record', subpage: '/gpu', args: {local_cache_key: 'a0b1'}}
100export interface Route {
101  page: string;
102  subpage: string;
103  fragment: string;
104  args: RouteArgs;
105}
106
107export interface RoutesMap {
108  [key: string]: m.Component<PageAttrs>;
109}
110
111// This router does two things:
112// 1) Maps fragment paths (#!/page/subpage) to Mithril components.
113// The route map is passed to the ctor and is later used when calling the
114// resolve() method.
115//
116// 2) Handles the (optional) args, e.g. #!/page?arg=1&arg2=2.
117// Route args are carry information that is orthogonal to the page (e.g. the
118// trace id).
119// local_cache_key has some special treatment: once a URL has a local_cache_key,
120// it gets automatically appended to further navigations that don't have one.
121// For instance if the current url is #!/viewer?local_cache_key=1234 and a later
122// action (either user-initiated or code-initited) navigates to #!/info, the
123// rotuer will automatically replace the history entry with
124// #!/info?local_cache_key=1234.
125// This is to keep propagating the trace id across page changes, for handling
126// tab discards (b/175041881).
127//
128// This class does NOT deal with the "load a trace when the url contains ?url=
129// or ?local_cache_key=". That logic lives in trace_url_handler.ts, which is
130// triggered by Router.onRouteChanged().
131export class Router {
132  private readonly recentChanges: number[] = [];
133  private routes: RoutesMap;
134
135  // frontend/index.ts calls maybeOpenTraceFromRoute() + redraw here.
136  // This event is decoupled for testing and to avoid circular deps.
137  onRouteChanged: (route: Route) => void = () => {};
138
139  constructor(routes: RoutesMap) {
140    assertExists(routes[DEFAULT_ROUTE]);
141    this.routes = routes;
142    window.onhashchange = (e: HashChangeEvent) => this.onHashChange(e);
143    const route = Router.parseUrl(window.location.href);
144    this.onRouteChanged(route);
145  }
146
147  private onHashChange(e: HashChangeEvent) {
148    this.crashIfLivelock();
149
150    const oldRoute = Router.parseUrl(e.oldURL);
151    const newRoute = Router.parseUrl(e.newURL);
152
153    if (
154      newRoute.args.local_cache_key === undefined &&
155      oldRoute.args.local_cache_key
156    ) {
157      // Propagate `local_cache_key across` navigations. When a trace is loaded,
158      // the URL becomes #!/viewer?local_cache_key=123. `local_cache_key` allows
159      // reopening the trace from cache in the case of a reload or discard.
160      // When using the UI we can hit "bare" links (e.g. just '#!/info') which
161      // don't have the trace_uuid:
162      // - When clicking on an <a> element from the sidebar.
163      // - When the code calls Router.navigate().
164      // - When the user pastes a URL from docs page.
165      // In all these cases we want to keep propagating the `local_cache_key`.
166      // We do so by re-setting the `local_cache_key` and doing a
167      // location.replace which overwrites the history entry (note
168      // location.replace is NOT just a String.replace operation).
169      newRoute.args.local_cache_key = oldRoute.args.local_cache_key;
170    }
171
172    const args = m.buildQueryString(newRoute.args);
173    let normalizedFragment = `#!${newRoute.page}${newRoute.subpage}`;
174    if (args.length) {
175      normalizedFragment += `?${args}`;
176    }
177    if (newRoute.fragment) {
178      normalizedFragment += `#${newRoute.fragment}`;
179    }
180
181    if (!e.newURL.endsWith(normalizedFragment)) {
182      location.replace(normalizedFragment);
183      return;
184    }
185
186    this.onRouteChanged(newRoute);
187  }
188
189  // Returns the component for the current route in the URL.
190  // If no route matches the URL, returns a component corresponding to
191  // |this.defaultRoute|.
192  resolve(): m.Vnode<PageAttrs> {
193    const route = Router.parseFragment(location.hash);
194    let component = this.routes[route.page];
195    if (component === undefined) {
196      component = assertExists(this.routes[DEFAULT_ROUTE]);
197    }
198    return m(component, {subpage: route.subpage} as PageAttrs);
199  }
200
201  static navigate(newHash: string) {
202    assertTrue(newHash.startsWith(ROUTE_PREFIX));
203    window.location.hash = newHash;
204  }
205
206  // Breaks down a fragment into a Route object.
207  // Sample input:
208  // '#!/record/gpu?local_cache_key=abcd-1234#myfragment'
209  // Sample output:
210  // {
211  //  page: '/record',
212  //  subpage: '/gpu',
213  //  fragment: 'myfragment',
214  //  args: {local_cache_key: 'abcd-1234'}
215  // }
216  static parseFragment(hash: string): Route {
217    if (hash.startsWith(ROUTE_PREFIX)) {
218      hash = hash.substring(ROUTE_PREFIX.length);
219    } else {
220      hash = '';
221    }
222
223    const url = new URL(`https://example.com${hash}`);
224
225    const path = url.pathname;
226    let page = path;
227    let subpage = '';
228    const splittingPoint = path.indexOf('/', 1);
229    if (splittingPoint > 0) {
230      page = path.substring(0, splittingPoint);
231      subpage = path.substring(splittingPoint);
232    }
233    if (page === '/') {
234      page = '';
235    }
236
237    let rawArgs = {};
238    if (url.search) {
239      rawArgs = Router.parseQueryString(url.search);
240    }
241
242    const args = runValidator(routeArgs, rawArgs).result;
243
244    // Javascript sadly distinguishes between foo[bar] === undefined
245    // and foo[bar] is not set at all. Here we need the second case to
246    // avoid making the URL ugly.
247    for (const key of Object.keys(args)) {
248      // eslint-disable-next-line @typescript-eslint/no-explicit-any
249      if ((args as any)[key] === undefined) {
250        // eslint-disable-next-line @typescript-eslint/no-explicit-any
251        delete (args as any)[key];
252      }
253    }
254
255    let fragment = url.hash;
256    if (fragment.startsWith('#')) {
257      fragment = fragment.substring(1);
258    }
259
260    return {page, subpage, args, fragment};
261  }
262
263  private static parseQueryString(query: string) {
264    query = query.replaceAll('+', ' ');
265    return m.parseQueryString(query);
266  }
267
268  private static parseSearchParams(url: string): RouteArgs {
269    const query = new URL(url).search;
270    const rawArgs = Router.parseQueryString(query);
271    const args = runValidator(routeArgs, rawArgs).result;
272
273    // Javascript sadly distinguishes between foo[bar] === undefined
274    // and foo[bar] is not set at all. Here we need the second case to
275    // avoid making the URL ugly.
276    for (const key of Object.keys(args)) {
277      // eslint-disable-next-line @typescript-eslint/no-explicit-any
278      if ((args as any)[key] === undefined) {
279        // eslint-disable-next-line @typescript-eslint/no-explicit-any
280        delete (args as any)[key];
281      }
282    }
283
284    return args;
285  }
286
287  // Like parseFragment() but takes a full URL.
288  static parseUrl(url: string): Route {
289    const searchArgs = Router.parseSearchParams(url);
290
291    const hashPos = url.indexOf('#');
292    const fragment = hashPos < 0 ? '' : url.substring(hashPos);
293    const route = Router.parseFragment(fragment);
294    route.args = Object.assign({}, searchArgs, route.args);
295
296    return route;
297  }
298
299  // Throws if EVENT_LIMIT onhashchange events occur within WINDOW_MS.
300  private crashIfLivelock() {
301    const WINDOW_MS = 1000;
302    const EVENT_LIMIT = 20;
303    const now = Date.now();
304    while (
305      this.recentChanges.length > 0 &&
306      now - this.recentChanges[0] > WINDOW_MS
307    ) {
308      this.recentChanges.shift();
309    }
310    this.recentChanges.push(now);
311    if (this.recentChanges.length > EVENT_LIMIT) {
312      throw new Error('History rewriting livelock');
313    }
314  }
315
316  static getUrlForVersion(versionCode: string): string {
317    const url = `${window.location.origin}/${versionCode}/`;
318    return url;
319  }
320
321  static async isVersionAvailable(
322    versionCode: string,
323  ): Promise<string | undefined> {
324    if (versionCode === '') {
325      return undefined;
326    }
327    const controller = new AbortController();
328    const timeoutId = setTimeout(() => controller.abort(), 1000);
329    const url = Router.getUrlForVersion(versionCode);
330    let r;
331    try {
332      r = await fetch(url, {signal: controller.signal});
333    } catch (e) {
334      console.error(
335        `No UI version for ${versionCode} at ${url}. This is an error if ${versionCode} is a released Perfetto version`,
336      );
337      return undefined;
338    } finally {
339      clearTimeout(timeoutId);
340    }
341    if (!r.ok) {
342      return undefined;
343    }
344    return url;
345  }
346
347  static navigateToVersion(versionCode: string): void {
348    const url = Router.getUrlForVersion(versionCode);
349    if (url === undefined) {
350      throw new Error(`No URL known for UI version ${versionCode}.`);
351    }
352    window.location.replace(url);
353  }
354}
355