xref: /aosp_15_r20/external/perfetto/ui/src/frontend/permalink.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
1*6dbdd20aSAndroid Build Coastguard Worker// Copyright (C) 2024 The Android Open Source Project
2*6dbdd20aSAndroid Build Coastguard Worker//
3*6dbdd20aSAndroid Build Coastguard Worker// Licensed under the Apache License, Version 2.0 (the "License");
4*6dbdd20aSAndroid Build Coastguard Worker// you may not use this file except in compliance with the License.
5*6dbdd20aSAndroid Build Coastguard Worker// You may obtain a copy of the License at
6*6dbdd20aSAndroid Build Coastguard Worker//
7*6dbdd20aSAndroid Build Coastguard Worker//      http://www.apache.org/licenses/LICENSE-2.0
8*6dbdd20aSAndroid Build Coastguard Worker//
9*6dbdd20aSAndroid Build Coastguard Worker// Unless required by applicable law or agreed to in writing, software
10*6dbdd20aSAndroid Build Coastguard Worker// distributed under the License is distributed on an "AS IS" BASIS,
11*6dbdd20aSAndroid Build Coastguard Worker// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12*6dbdd20aSAndroid Build Coastguard Worker// See the License for the specific language governing permissions and
13*6dbdd20aSAndroid Build Coastguard Worker// limitations under the License.
14*6dbdd20aSAndroid Build Coastguard Worker
15*6dbdd20aSAndroid Build Coastguard Workerimport m from 'mithril';
16*6dbdd20aSAndroid Build Coastguard Workerimport {assertExists} from '../base/logging';
17*6dbdd20aSAndroid Build Coastguard Workerimport {
18*6dbdd20aSAndroid Build Coastguard Worker  JsonSerialize,
19*6dbdd20aSAndroid Build Coastguard Worker  parseAppState,
20*6dbdd20aSAndroid Build Coastguard Worker  serializeAppState,
21*6dbdd20aSAndroid Build Coastguard Worker} from '../core/state_serialization';
22*6dbdd20aSAndroid Build Coastguard Workerimport {
23*6dbdd20aSAndroid Build Coastguard Worker  BUCKET_NAME,
24*6dbdd20aSAndroid Build Coastguard Worker  MIME_BINARY,
25*6dbdd20aSAndroid Build Coastguard Worker  MIME_JSON,
26*6dbdd20aSAndroid Build Coastguard Worker  GcsUploader,
27*6dbdd20aSAndroid Build Coastguard Worker} from '../base/gcs_uploader';
28*6dbdd20aSAndroid Build Coastguard Workerimport {
29*6dbdd20aSAndroid Build Coastguard Worker  SERIALIZED_STATE_VERSION,
30*6dbdd20aSAndroid Build Coastguard Worker  SerializedAppState,
31*6dbdd20aSAndroid Build Coastguard Worker} from '../core/state_serialization_schema';
32*6dbdd20aSAndroid Build Coastguard Workerimport {z} from 'zod';
33*6dbdd20aSAndroid Build Coastguard Workerimport {showModal} from '../widgets/modal';
34*6dbdd20aSAndroid Build Coastguard Workerimport {AppImpl} from '../core/app_impl';
35*6dbdd20aSAndroid Build Coastguard Workerimport {CopyableLink} from '../widgets/copyable_link';
36*6dbdd20aSAndroid Build Coastguard Worker
37*6dbdd20aSAndroid Build Coastguard Worker// Permalink serialization has two layers:
38*6dbdd20aSAndroid Build Coastguard Worker// 1. Serialization of the app state (state_serialization.ts):
39*6dbdd20aSAndroid Build Coastguard Worker//    This is a JSON object that represents the visual app state (pinned tracks,
40*6dbdd20aSAndroid Build Coastguard Worker//    visible viewport bounds, etc) BUT not the trace source.
41*6dbdd20aSAndroid Build Coastguard Worker// 2. An outer layer that contains the app state AND a link to the trace file.
42*6dbdd20aSAndroid Build Coastguard Worker//    (This file)
43*6dbdd20aSAndroid Build Coastguard Worker//
44*6dbdd20aSAndroid Build Coastguard Worker// In a nutshell:
45*6dbdd20aSAndroid Build Coastguard Worker//   AppState:  {viewport: {...}, pinnedTracks: {...}, notes: {...}}
46*6dbdd20aSAndroid Build Coastguard Worker//   Permalink: {appState: {see above}, traceUrl: 'https://gcs/trace/file'}
47*6dbdd20aSAndroid Build Coastguard Worker//
48*6dbdd20aSAndroid Build Coastguard Worker// This file deals with the outer layer, state_serialization.ts with the inner.
49*6dbdd20aSAndroid Build Coastguard Worker
50*6dbdd20aSAndroid Build Coastguard Workerconst PERMALINK_SCHEMA = z.object({
51*6dbdd20aSAndroid Build Coastguard Worker  traceUrl: z.string().optional(),
52*6dbdd20aSAndroid Build Coastguard Worker
53*6dbdd20aSAndroid Build Coastguard Worker  // We don't want to enforce validation at this level but want to delegate it
54*6dbdd20aSAndroid Build Coastguard Worker  // to parseAppState(), for two reasons:
55*6dbdd20aSAndroid Build Coastguard Worker  // 1. parseAppState() does further semantic checks (e.g. version checking).
56*6dbdd20aSAndroid Build Coastguard Worker  // 2. We want to still load the traceUrl even if the app state is invalid.
57*6dbdd20aSAndroid Build Coastguard Worker  appState: z.any().optional(),
58*6dbdd20aSAndroid Build Coastguard Worker});
59*6dbdd20aSAndroid Build Coastguard Worker
60*6dbdd20aSAndroid Build Coastguard Workertype PermalinkState = z.infer<typeof PERMALINK_SCHEMA>;
61*6dbdd20aSAndroid Build Coastguard Worker
62*6dbdd20aSAndroid Build Coastguard Workerexport async function createPermalink(): Promise<void> {
63*6dbdd20aSAndroid Build Coastguard Worker  const hash = await createPermalinkInternal();
64*6dbdd20aSAndroid Build Coastguard Worker  showPermalinkDialog(hash);
65*6dbdd20aSAndroid Build Coastguard Worker}
66*6dbdd20aSAndroid Build Coastguard Worker
67*6dbdd20aSAndroid Build Coastguard Worker// Returns the file name, not the full url (i.e. the name of the GCS object).
68*6dbdd20aSAndroid Build Coastguard Workerasync function createPermalinkInternal(): Promise<string> {
69*6dbdd20aSAndroid Build Coastguard Worker  const permalinkData: PermalinkState = {};
70*6dbdd20aSAndroid Build Coastguard Worker
71*6dbdd20aSAndroid Build Coastguard Worker  // Check if we need to upload the trace file, before serializing the app
72*6dbdd20aSAndroid Build Coastguard Worker  // state.
73*6dbdd20aSAndroid Build Coastguard Worker  let alreadyUploadedUrl = '';
74*6dbdd20aSAndroid Build Coastguard Worker  const trace = assertExists(AppImpl.instance.trace);
75*6dbdd20aSAndroid Build Coastguard Worker  const traceSource = trace.traceInfo.source;
76*6dbdd20aSAndroid Build Coastguard Worker  let dataToUpload: File | ArrayBuffer | undefined = undefined;
77*6dbdd20aSAndroid Build Coastguard Worker  let traceName = trace.traceInfo.traceTitle || 'trace';
78*6dbdd20aSAndroid Build Coastguard Worker  if (traceSource.type === 'FILE') {
79*6dbdd20aSAndroid Build Coastguard Worker    dataToUpload = traceSource.file;
80*6dbdd20aSAndroid Build Coastguard Worker    traceName = dataToUpload.name;
81*6dbdd20aSAndroid Build Coastguard Worker  } else if (traceSource.type === 'ARRAY_BUFFER') {
82*6dbdd20aSAndroid Build Coastguard Worker    dataToUpload = traceSource.buffer;
83*6dbdd20aSAndroid Build Coastguard Worker  } else if (traceSource.type === 'URL') {
84*6dbdd20aSAndroid Build Coastguard Worker    alreadyUploadedUrl = traceSource.url;
85*6dbdd20aSAndroid Build Coastguard Worker  } else {
86*6dbdd20aSAndroid Build Coastguard Worker    throw new Error(`Cannot share trace ${JSON.stringify(traceSource)}`);
87*6dbdd20aSAndroid Build Coastguard Worker  }
88*6dbdd20aSAndroid Build Coastguard Worker
89*6dbdd20aSAndroid Build Coastguard Worker  // Upload the trace file, unless it's already uploaded (type == 'URL').
90*6dbdd20aSAndroid Build Coastguard Worker  // Internally TraceGcsUploader will skip the upload if an object with the
91*6dbdd20aSAndroid Build Coastguard Worker  // same hash exists already.
92*6dbdd20aSAndroid Build Coastguard Worker  if (alreadyUploadedUrl) {
93*6dbdd20aSAndroid Build Coastguard Worker    permalinkData.traceUrl = alreadyUploadedUrl;
94*6dbdd20aSAndroid Build Coastguard Worker  } else if (dataToUpload !== undefined) {
95*6dbdd20aSAndroid Build Coastguard Worker    updateStatus(`Uploading ${traceName}`);
96*6dbdd20aSAndroid Build Coastguard Worker    const uploader: GcsUploader = new GcsUploader(dataToUpload, {
97*6dbdd20aSAndroid Build Coastguard Worker      mimeType: MIME_BINARY,
98*6dbdd20aSAndroid Build Coastguard Worker      onProgress: () => reportUpdateProgress(uploader),
99*6dbdd20aSAndroid Build Coastguard Worker    });
100*6dbdd20aSAndroid Build Coastguard Worker    await uploader.waitForCompletion();
101*6dbdd20aSAndroid Build Coastguard Worker    permalinkData.traceUrl = uploader.uploadedUrl;
102*6dbdd20aSAndroid Build Coastguard Worker  }
103*6dbdd20aSAndroid Build Coastguard Worker
104*6dbdd20aSAndroid Build Coastguard Worker  permalinkData.appState = serializeAppState(trace);
105*6dbdd20aSAndroid Build Coastguard Worker
106*6dbdd20aSAndroid Build Coastguard Worker  // Serialize the permalink with the app state (or recording state) and upload.
107*6dbdd20aSAndroid Build Coastguard Worker  updateStatus(`Creating permalink...`);
108*6dbdd20aSAndroid Build Coastguard Worker  const permalinkJson = JsonSerialize(permalinkData);
109*6dbdd20aSAndroid Build Coastguard Worker  const uploader: GcsUploader = new GcsUploader(permalinkJson, {
110*6dbdd20aSAndroid Build Coastguard Worker    mimeType: MIME_JSON,
111*6dbdd20aSAndroid Build Coastguard Worker    onProgress: () => reportUpdateProgress(uploader),
112*6dbdd20aSAndroid Build Coastguard Worker  });
113*6dbdd20aSAndroid Build Coastguard Worker  await uploader.waitForCompletion();
114*6dbdd20aSAndroid Build Coastguard Worker
115*6dbdd20aSAndroid Build Coastguard Worker  return uploader.uploadedFileName;
116*6dbdd20aSAndroid Build Coastguard Worker}
117*6dbdd20aSAndroid Build Coastguard Worker
118*6dbdd20aSAndroid Build Coastguard Worker/**
119*6dbdd20aSAndroid Build Coastguard Worker * Loads a permalink from Google Cloud Storage.
120*6dbdd20aSAndroid Build Coastguard Worker * This is invoked when passing !#?s=fileName to URL.
121*6dbdd20aSAndroid Build Coastguard Worker * @param gcsFileName the file name of the cloud storage object. This is
122*6dbdd20aSAndroid Build Coastguard Worker * expected to be a JSON file that respects the schema defined by
123*6dbdd20aSAndroid Build Coastguard Worker * PERMALINK_SCHEMA.
124*6dbdd20aSAndroid Build Coastguard Worker */
125*6dbdd20aSAndroid Build Coastguard Workerexport async function loadPermalink(gcsFileName: string): Promise<void> {
126*6dbdd20aSAndroid Build Coastguard Worker  // Otherwise, this is a request to load the permalink.
127*6dbdd20aSAndroid Build Coastguard Worker  const url = `https://storage.googleapis.com/${BUCKET_NAME}/${gcsFileName}`;
128*6dbdd20aSAndroid Build Coastguard Worker  const response = await fetch(url);
129*6dbdd20aSAndroid Build Coastguard Worker  if (!response.ok) {
130*6dbdd20aSAndroid Build Coastguard Worker    throw new Error(`Could not fetch permalink.\n URL: ${url}`);
131*6dbdd20aSAndroid Build Coastguard Worker  }
132*6dbdd20aSAndroid Build Coastguard Worker  const text = await response.text();
133*6dbdd20aSAndroid Build Coastguard Worker  const permalinkJson = JSON.parse(text);
134*6dbdd20aSAndroid Build Coastguard Worker  let permalink: PermalinkState;
135*6dbdd20aSAndroid Build Coastguard Worker  let error = '';
136*6dbdd20aSAndroid Build Coastguard Worker
137*6dbdd20aSAndroid Build Coastguard Worker  // Try to recover permalinks generated by older versions of the UI before
138*6dbdd20aSAndroid Build Coastguard Worker  // r.android.com/3119920 .
139*6dbdd20aSAndroid Build Coastguard Worker  const convertedLegacyPermalink = tryLoadLegacyPermalink(permalinkJson);
140*6dbdd20aSAndroid Build Coastguard Worker  if (convertedLegacyPermalink !== undefined) {
141*6dbdd20aSAndroid Build Coastguard Worker    permalink = convertedLegacyPermalink;
142*6dbdd20aSAndroid Build Coastguard Worker  } else {
143*6dbdd20aSAndroid Build Coastguard Worker    const res = PERMALINK_SCHEMA.safeParse(permalinkJson);
144*6dbdd20aSAndroid Build Coastguard Worker    if (res.success) {
145*6dbdd20aSAndroid Build Coastguard Worker      permalink = res.data;
146*6dbdd20aSAndroid Build Coastguard Worker    } else {
147*6dbdd20aSAndroid Build Coastguard Worker      error = res.error.toString();
148*6dbdd20aSAndroid Build Coastguard Worker      permalink = {};
149*6dbdd20aSAndroid Build Coastguard Worker    }
150*6dbdd20aSAndroid Build Coastguard Worker  }
151*6dbdd20aSAndroid Build Coastguard Worker
152*6dbdd20aSAndroid Build Coastguard Worker  let serializedAppState: SerializedAppState | undefined = undefined;
153*6dbdd20aSAndroid Build Coastguard Worker  if (permalink.appState !== undefined) {
154*6dbdd20aSAndroid Build Coastguard Worker    // This is the most common case where the permalink contains the app state
155*6dbdd20aSAndroid Build Coastguard Worker    // (and optionally a traceUrl, below).
156*6dbdd20aSAndroid Build Coastguard Worker    const parseRes = parseAppState(permalink.appState);
157*6dbdd20aSAndroid Build Coastguard Worker    if (parseRes.success) {
158*6dbdd20aSAndroid Build Coastguard Worker      serializedAppState = parseRes.data;
159*6dbdd20aSAndroid Build Coastguard Worker    } else {
160*6dbdd20aSAndroid Build Coastguard Worker      error = parseRes.error;
161*6dbdd20aSAndroid Build Coastguard Worker    }
162*6dbdd20aSAndroid Build Coastguard Worker  }
163*6dbdd20aSAndroid Build Coastguard Worker  if (permalink.traceUrl) {
164*6dbdd20aSAndroid Build Coastguard Worker    AppImpl.instance.openTraceFromUrl(permalink.traceUrl, serializedAppState);
165*6dbdd20aSAndroid Build Coastguard Worker  }
166*6dbdd20aSAndroid Build Coastguard Worker
167*6dbdd20aSAndroid Build Coastguard Worker  if (error) {
168*6dbdd20aSAndroid Build Coastguard Worker    showModal({
169*6dbdd20aSAndroid Build Coastguard Worker      title: 'Failed to restore the serialized app state',
170*6dbdd20aSAndroid Build Coastguard Worker      content: m(
171*6dbdd20aSAndroid Build Coastguard Worker        'div',
172*6dbdd20aSAndroid Build Coastguard Worker        m(
173*6dbdd20aSAndroid Build Coastguard Worker          'p',
174*6dbdd20aSAndroid Build Coastguard Worker          'Something went wrong when restoring the app state.' +
175*6dbdd20aSAndroid Build Coastguard Worker            'This is due to some backwards-incompatible change ' +
176*6dbdd20aSAndroid Build Coastguard Worker            'when the permalink is generated and then opened using ' +
177*6dbdd20aSAndroid Build Coastguard Worker            'two different UI versions.',
178*6dbdd20aSAndroid Build Coastguard Worker        ),
179*6dbdd20aSAndroid Build Coastguard Worker        m(
180*6dbdd20aSAndroid Build Coastguard Worker          'p',
181*6dbdd20aSAndroid Build Coastguard Worker          "I'm going to try to open the trace file anyways, but " +
182*6dbdd20aSAndroid Build Coastguard Worker            'the zoom level, pinned tracks and other UI ' +
183*6dbdd20aSAndroid Build Coastguard Worker            "state wont't be recovered",
184*6dbdd20aSAndroid Build Coastguard Worker        ),
185*6dbdd20aSAndroid Build Coastguard Worker        m('p', 'Error details:'),
186*6dbdd20aSAndroid Build Coastguard Worker        m('.modal-logs', error),
187*6dbdd20aSAndroid Build Coastguard Worker      ),
188*6dbdd20aSAndroid Build Coastguard Worker      buttons: [
189*6dbdd20aSAndroid Build Coastguard Worker        {
190*6dbdd20aSAndroid Build Coastguard Worker          text: 'Open only the trace file',
191*6dbdd20aSAndroid Build Coastguard Worker          primary: true,
192*6dbdd20aSAndroid Build Coastguard Worker        },
193*6dbdd20aSAndroid Build Coastguard Worker      ],
194*6dbdd20aSAndroid Build Coastguard Worker    });
195*6dbdd20aSAndroid Build Coastguard Worker  }
196*6dbdd20aSAndroid Build Coastguard Worker}
197*6dbdd20aSAndroid Build Coastguard Worker
198*6dbdd20aSAndroid Build Coastguard Worker// Tries to recover a previous permalink, before the split in two layers,
199*6dbdd20aSAndroid Build Coastguard Worker// where the permalink JSON contains the app state, which contains inside it
200*6dbdd20aSAndroid Build Coastguard Worker// the trace URL.
201*6dbdd20aSAndroid Build Coastguard Worker// If we suceed, convert it to a new-style JSON object preserving some minimal
202*6dbdd20aSAndroid Build Coastguard Worker// information (really just vieport and pinned tracks).
203*6dbdd20aSAndroid Build Coastguard Workerfunction tryLoadLegacyPermalink(data: unknown): PermalinkState | undefined {
204*6dbdd20aSAndroid Build Coastguard Worker  const legacyData = data as {
205*6dbdd20aSAndroid Build Coastguard Worker    version?: number;
206*6dbdd20aSAndroid Build Coastguard Worker    engine?: {source?: {url?: string}};
207*6dbdd20aSAndroid Build Coastguard Worker    pinnedTracks?: string[];
208*6dbdd20aSAndroid Build Coastguard Worker    frontendLocalState?: {
209*6dbdd20aSAndroid Build Coastguard Worker      visibleState?: {start?: {value?: string}; end?: {value?: string}};
210*6dbdd20aSAndroid Build Coastguard Worker    };
211*6dbdd20aSAndroid Build Coastguard Worker  };
212*6dbdd20aSAndroid Build Coastguard Worker  if (legacyData.version === undefined) return undefined;
213*6dbdd20aSAndroid Build Coastguard Worker  const vizState = legacyData.frontendLocalState?.visibleState;
214*6dbdd20aSAndroid Build Coastguard Worker  return {
215*6dbdd20aSAndroid Build Coastguard Worker    traceUrl: legacyData.engine?.source?.url,
216*6dbdd20aSAndroid Build Coastguard Worker    appState: {
217*6dbdd20aSAndroid Build Coastguard Worker      version: SERIALIZED_STATE_VERSION,
218*6dbdd20aSAndroid Build Coastguard Worker      pinnedTracks: legacyData.pinnedTracks ?? [],
219*6dbdd20aSAndroid Build Coastguard Worker      viewport: vizState
220*6dbdd20aSAndroid Build Coastguard Worker        ? {start: vizState.start?.value, end: vizState.end?.value}
221*6dbdd20aSAndroid Build Coastguard Worker        : undefined,
222*6dbdd20aSAndroid Build Coastguard Worker    } as SerializedAppState,
223*6dbdd20aSAndroid Build Coastguard Worker  } as PermalinkState;
224*6dbdd20aSAndroid Build Coastguard Worker}
225*6dbdd20aSAndroid Build Coastguard Worker
226*6dbdd20aSAndroid Build Coastguard Workerfunction reportUpdateProgress(uploader: GcsUploader) {
227*6dbdd20aSAndroid Build Coastguard Worker  switch (uploader.state) {
228*6dbdd20aSAndroid Build Coastguard Worker    case 'UPLOADING':
229*6dbdd20aSAndroid Build Coastguard Worker      const statusTxt = `Uploading ${uploader.getEtaString()}`;
230*6dbdd20aSAndroid Build Coastguard Worker      updateStatus(statusTxt);
231*6dbdd20aSAndroid Build Coastguard Worker      break;
232*6dbdd20aSAndroid Build Coastguard Worker    case 'ERROR':
233*6dbdd20aSAndroid Build Coastguard Worker      updateStatus(`Upload failed ${uploader.error}`);
234*6dbdd20aSAndroid Build Coastguard Worker      break;
235*6dbdd20aSAndroid Build Coastguard Worker    default:
236*6dbdd20aSAndroid Build Coastguard Worker      break;
237*6dbdd20aSAndroid Build Coastguard Worker  } // switch (state)
238*6dbdd20aSAndroid Build Coastguard Worker}
239*6dbdd20aSAndroid Build Coastguard Worker
240*6dbdd20aSAndroid Build Coastguard Workerfunction updateStatus(msg: string): void {
241*6dbdd20aSAndroid Build Coastguard Worker  AppImpl.instance.omnibox.showStatusMessage(msg);
242*6dbdd20aSAndroid Build Coastguard Worker}
243*6dbdd20aSAndroid Build Coastguard Worker
244*6dbdd20aSAndroid Build Coastguard Workerfunction showPermalinkDialog(hash: string) {
245*6dbdd20aSAndroid Build Coastguard Worker  showModal({
246*6dbdd20aSAndroid Build Coastguard Worker    title: 'Permalink',
247*6dbdd20aSAndroid Build Coastguard Worker    content: m(CopyableLink, {url: `${self.location.origin}/#!/?s=${hash}`}),
248*6dbdd20aSAndroid Build Coastguard Worker  });
249*6dbdd20aSAndroid Build Coastguard Worker}
250