chromium/content/browser/resources/traces_internals/trace_report.ts

// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/cr_elements/icons_lit.html.js';
import './icons.html.js';

import {assert} from 'chrome://resources/js/assert.js';
import {CrLitElement} from 'chrome://resources/lit/v3_0/lit.rollup.js';
import type {BigBuffer} from 'chrome://resources/mojo/mojo/public/mojom/base/big_buffer.mojom-webui.js';
import type {Time} from 'chrome://resources/mojo/mojo/public/mojom/base/time.mojom-webui.js';

import {getCss} from './trace_report.css.js';
import {getHtml} from './trace_report.html.js';
import type {ClientTraceReport} from './trace_report.mojom-webui.js';
import {ReportUploadState, SkipUploadReason} from './trace_report.mojom-webui.js';
import {TraceReportBrowserProxy} from './trace_report_browser_proxy.js';
import {Notification, NotificationType} from './trace_report_list.js';

// Create the temporary element here to hold the data to download the trace
// since it is only obtained after downloadData_ is called. This way we can
// perform a download directly in JS without touching the element that
// triggers the action. Initiate download a resource identified by |url| into
// |filename|.
function downloadUrl(fileName: string, url: string): void {
  const a = document.createElement('a');
  a.href = url;
  a.download = fileName;
  a.click();
}

export class TraceReportElement extends CrLitElement {
  static get is() {
    return 'trace-report';
  }

  static override get styles() {
    return getCss();
  }

  override render() {
    return getHtml.bind(this)();
  }

  static override get properties() {
    return {
      trace: {type: Object},
      isLoading: {type: Boolean},
    };
  }

  private traceReportProxy_: TraceReportBrowserProxy =
      TraceReportBrowserProxy.getInstance();

  protected trace: ClientTraceReport = {
    // Dummy ClientTraceReport
    uuid: {
      high: 0n,
      low: 0n,
    },
    creationTime: {internalValue: 0n},
    scenarioName: '',
    uploadRuleName: '',
    totalSize: 0n,
    uploadState: ReportUploadState.kNotUploaded,
    uploadTime: {internalValue: 0n},
    skipReason: SkipUploadReason.kNoSkip,
    hasTraceContent: false,
  };
  protected isLoading_: boolean = false;

  protected onCopyUuidClick_(): void {
    // Get the text field
    navigator.clipboard.writeText(this.getTokenAsString_());
  }

  protected getTraceSize_(): string {
    if (this.trace.totalSize < 1) {
      return '0 Bytes';
    }

    let displayedSize = Number(this.trace.totalSize);
    const k = 1024;

    const sizes = ['Bytes', 'KB', 'MB', 'GB'];

    let i = 0;

    for (i; displayedSize >= k && i < 3; i++) {
      displayedSize /= k;
    }

    return `${displayedSize.toFixed(2)} ${sizes[i]}`;
  }

  protected getSkipReason_(): string {
    // Keep this in sync with the values of SkipUploadReason in
    // tracereport.mojom
    const skipReasonMap: string[] = [
      'None',
      'Size limit exceeded',
      'Not anonymized',
      'Scenario quota exceeded',
      'Upload timed out',
    ];

    return skipReasonMap[this.trace.skipReason] ??
        'Could not get the skip reason';
  }

  protected onCopyScenarioClick_(): void {
    // Get the text field
    navigator.clipboard.writeText(this.trace.scenarioName);
  }

  protected onCopyUploadRuleClick_(): void {
    // Get the text field
    navigator.clipboard.writeText(this.trace.uploadRuleName);
  }

  protected isManualUploadPermitted_(): boolean {
    return this.trace.skipReason !== SkipUploadReason.kNotAnonymized;
  }

  protected dateToString_(mojoTime: Time): string {
    // The JS Date() is based off of the number of milliseconds since
    // the UNIX epoch (1970-01-01 00::00:00 UTC), while |internalValue|
    // of the base::Time (represented in mojom.Time) represents the
    // number of microseconds since the Windows FILETIME epoch
    // (1601-01-01 00:00:00 UTC). This computes the final JS time by
    // computing the epoch delta and the conversion from microseconds to
    // milliseconds.
    const windowsEpoch = Date.UTC(1601, 0, 1, 0, 0, 0, 0);
    const unixEpoch = Date.UTC(1970, 0, 1, 0, 0, 0, 0);
    // |epochDeltaInMs| equals to
    // base::Time::kTimeTToMicrosecondsOffset.
    const epochDeltaInMs = unixEpoch - windowsEpoch;
    const timeInMs = Number(mojoTime.internalValue) / 1000;

    // Define the format in which the date string is going to be displayed.
    return new Date(timeInMs - epochDeltaInMs)
        .toLocaleString(
            /*locales=*/ undefined, {
              hour: 'numeric',
              minute: 'numeric',
              month: 'short',
              day: 'numeric',
              year: 'numeric',
              hour12: true,
            });
  }

  protected async onDownloadTraceClick_(): Promise<void> {
    this.isLoading_ = true;
    const {trace} =
        await this.traceReportProxy_.handler.downloadTrace(this.trace.uuid);
    if (trace !== null) {
      this.downloadData_(`${this.getTokenAsString_()}.gz`, trace);
    } else {
      this.dispatchToast_(`Failed to download trace ${this.getTokenAsString_()}.`);
    }
    this.isLoading_ = false;
  }

  private downloadData_(fileName: string, data: BigBuffer): void {
    if (data.invalidBuffer) {
      this.dispatchToast_(
          `Invalid buffer received for ${this.getTokenAsString_()}.`);
      return;
    }
    try {
      let bytes: Uint8Array;
      if (Array.isArray(data.bytes)) {
        bytes = new Uint8Array(data.bytes);
      } else {
        assert(!!data.sharedMemory, 'sharedMemory must be defined here');
        const sharedMemory = data.sharedMemory!;
        const {buffer, result} =
            sharedMemory.bufferHandle.mapBuffer(0, sharedMemory.size);
        assert(result === Mojo.RESULT_OK, 'Could not map buffer');
        bytes = new Uint8Array(buffer);
      }
      const url = URL.createObjectURL(
          new Blob([bytes], {type: 'application/octet-stream'}));
      downloadUrl(fileName, url);
    } catch (e) {
      this.dispatchToast_(
          `Unable to create blob from trace data for ${this.getTokenAsString_()}.`);
    }
  }

  protected async onDeleteTraceClick_(): Promise<void> {
    this.isLoading_ = true;
    const {success} =
        await this.traceReportProxy_.handler.deleteSingleTrace(this.trace.uuid);
    if (!success) {
      this.dispatchToast_(`Failed to delete ${this.getTokenAsString_()}.`);
    } else {
      this.dispatchReloadRequest_();
    }
    this.isLoading_ = false;
  }

  protected async onUploadTraceClick_(): Promise<void> {
    this.isLoading_ = true;
    const {success} =
        await this.traceReportProxy_.handler.userUploadSingleTrace(
            this.trace.uuid);
    if (!success) {
      this.dispatchToast_(`Failed to upload trace ${this.getTokenAsString_()}.`);
    } else {
      this.dispatchReloadRequest_();
    }
    this.isLoading_ = false;
  }

  protected uploadStateEqual_(state: ReportUploadState): boolean {
    return this.trace.uploadState === state;
  }

  protected getTokenAsString_(): string {
    return `${this.trace.uuid.high.toString(16)}-${
        this.trace.uuid.low.toString(16)}`;
  }

  private dispatchToast_(message: string): void {
    this.dispatchEvent(new CustomEvent('show-toast', {
      bubbles: true,
      composed: true,
      detail: new Notification(NotificationType.ERROR, message),
    }));
  }

  protected isDownloadDisabled_(): boolean {
    return this.isLoading_ || !this.trace.hasTraceContent;
  }

  protected getDownloadTooltip_(): string {
    return this.trace.hasTraceContent ? 'Download Trace' : 'Trace expired';
  }

  private dispatchReloadRequest_(): void {
    this.fire('refresh-traces-request');
  }

  protected getStateCssClass_(): string {
    switch (this.trace.uploadState) {
      case ReportUploadState.kNotUploaded:
        return 'state-default';
      case ReportUploadState.kPending:
      case ReportUploadState.kPending_UserRequested:
        return 'state-pending';
      case ReportUploadState.kUploaded:
        return 'state-success';
      default:
        return '';
    }
  }

  protected getStateText_(): string {
    switch (this.trace.uploadState) {
      case ReportUploadState.kNotUploaded:
        return `Skip reason: ${this.getSkipReason_()}`;
      case ReportUploadState.kPending:
        return 'Pending upload';
      case ReportUploadState.kPending_UserRequested:
        return 'Pending upload: User requested';
      case ReportUploadState.kUploaded:
        return `Uploaded: ${this.dateToString_(this.trace.uploadTime)}`;
      default:
        return '';
    }
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'trace-report': TraceReportElement;
  }
}

customElements.define(TraceReportElement.is, TraceReportElement);