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