chromium/components/optimization_guide/optimization_guide_internals/resources/optimization_guide_internals.ts

// Copyright 2022 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_tab_box/cr_tab_box.js';

import {assert} from 'chrome://resources/js/assert.js';
import {$, getRequiredElement} from 'chrome://resources/js/util.js';
import type {Time} from 'chrome://resources/mojo/mojo/public/mojom/base/time.mojom-webui.js';

import type {DownloadedModelInfo, LoggedClientIds} from './optimization_guide_internals.mojom-webui.js';
import {PageHandlerFactory} from './optimization_guide_internals.mojom-webui.js';
import {OptimizationGuideInternalsBrowserProxy} from './optimization_guide_internals_browser_proxy.js';

// Contains all the log events received when the internals page is open.
const logMessages: Array<{
  eventTime: string,
  logSource: string,
  sourceLocation: string,
  message: string,
}> = [];

// Type for a string filtering function.
type StringFilterPredicate = (s: string) => boolean;

/**
 * Class to apply filter functionality and updated related UI.
 */
class TableFilter {
  // Delay between input change to filter application, in ms.
  static readonly FILTER_DELAY_MS: number = 500;

  // Main <table> element for filtering. First row is assumed to be the title,
  readonly table: HTMLTableElement;

  // The text <input> element to get "include" filter text.
  readonly includeInput: HTMLInputElement;

  // The text <input> element to get "exclude" filter text.
  readonly excludeInput: HTMLInputElement;

  // The <span> element to output filter stats.
  readonly filterStatsSpan: HTMLSpanElement;

  // Assuming same number of cells in each row, list of indices of cells in each
  // row to examine for filtering. These correspond to <th> elements with
  // "filterable" in its classList.
  readonly filterCellIndices: number[];

  // Filter function for "include" filtering. Null if unspecified.
  includeFun: StringFilterPredicate|null;

  // Filter function for "exclude" filtering. Null if unspecified.
  excludeFun: StringFilterPredicate|null;

  // Id to setTimeout() for filter delay, so the timeout can be detected and
  // cancelled. Null if no timeout is live.
  filterDelayTimeoutId: number|null;

  // Total number of rows examined by the filter.
  numRows: number;

  // Total number of rows being show after filtering.
  numShown: number;

  /**
   * @param {!HTMLTableElement} table
   * @param {!HTMLInputElement} includeInput
   * @param {!HTMLInputElement!} excludeInput
   * @param {!HTMLSpanElement} filterStatsSpan
   */
  constructor(
      table: HTMLTableElement, includeInput: HTMLInputElement,
      excludeInput: HTMLInputElement, filterStatsSpan: HTMLSpanElement) {
    this.table = table;
    this.includeInput = includeInput;
    this.excludeInput = excludeInput;
    this.filterStatsSpan = filterStatsSpan;

    this.filterCellIndices = [];
    this.readFilterCellIndices();

    this.includeFun = this.readFilter(this.includeInput);
    this.excludeFun = this.readFilter(this.excludeInput);
    this.filterDelayTimeoutId = null;
    this.numRows = 0;
    this.numShown = 0;

    this.includeInput.addEventListener('input', (e) => this.triggerUpdate(e));
    this.excludeInput.addEventListener('input', (e) => this.triggerUpdate(e));
  }

  readFilterCellIndices() {
    this.filterCellIndices.length = 0;
    const headers = this.table.rows[0]!.cells;
    for (const [idx, header] of Array.from(headers).entries()) {
      if (header.classList.contains('filterable')) {
        this.filterCellIndices.push(idx);
      }
    }
  }

  /**
   * Reads filter text and returns a filter predicate, or null if no filter.
   * @param {!HTMLInputElement} input
   */
  readFilter(input: HTMLInputElement): StringFilterPredicate|null {
    const t = input.value;
    return (t === '') ? null : ((s: string) => (s.indexOf(t) >= 0));
  }

  /**
   * Returns whether the provided <tr> element should be shown.
   * @param {!HTMLTableElement} row
   */
  shouldRowBeShown(row: HTMLTableRowElement) {
    // Perform exclusion first since it has higher priority than inclusion.
    if (this.excludeFun != null) {
      for (const idx of this.filterCellIndices) {
        const text = row.cells[idx]?.textContent ?? '';
        if (this !.excludeFun(text)) {
          return false;
        }
      }
    }
    if (this.includeFun != null) {
      for (const idx of this.filterCellIndices) {
        const text = row.cells[idx]?.textContent ?? '';
        if (this !.includeFun(text)) {
          return true;
        }
      }
      return false;
    }
    return true;
  }

  /**
   * Shows or hides a <tr> element depending on its content.
   * @param {!HTMLTableRowElement} row
   * @return Whether the element is shown.
   */
  applyFilterToRow(row: HTMLTableRowElement): boolean {
    const shouldShow = this.shouldRowBeShown(row);
    row.classList.toggle('hidden', !shouldShow);
    return shouldShow;
  }

  /** Updates `filterStatsSpan` content to show filter stats. */
  writeFilterStats() {
    this.filterStatsSpan.textContent = `${this.numShown} / ${this.numRows}`;
  }

  /**
   * Visits every row (except the first, which is the titles) of `this.table`
   * and shows and hides it. Displays the number of hidden rows (as negative
   * value) in `filterStatsSpan`.
   */
  readAndApplyAllFilters() {
    this.readFilterCellIndices();
    this.includeFun = this.readFilter(this.includeInput);
    this.excludeFun = this.readFilter(this.excludeInput);
    this.numRows = 0;
    this.numShown = 0;
    let isTitle = true;
    for (const row of this.table.rows) {
      if (isTitle) {
        isTitle = false;
      } else {
        this.numShown += Number(this.applyFilterToRow(row));
        ++this.numRows;
      }
    }
    this.writeFilterStats();
  }

  /**
   * Applies the filter to a newly added row, and updates filter stats.
   * @param {!HTMLTableRowElement} row
   */
  filterNewRow(row: HTMLTableRowElement) {
    this.numShown += Number(this.applyFilterToRow(row));
    ++this.numRows;
    this.writeFilterStats();
  }

  /**
   * [Re]triggers timer to call readAndApplyAllFilters() after waiting for a
   * delay that lasts `FILTER_DELAY_MS`. Debouncing is applied.
   * @param {!Event} e
   */
  triggerUpdate(e: Event) {
    const elt = e!.target as HTMLElement;
    // Debounce: New trigger cancels an existing trigger's timeout.
    if (this.filterDelayTimeoutId != null) {
      clearTimeout(this.filterDelayTimeoutId);
      this.filterDelayTimeoutId = null;
    }
    elt.classList.add('input-dirty');
    this.filterDelayTimeoutId = setTimeout(() => {
      this.filterDelayTimeoutId = null;
      this.includeInput.classList.remove('input-dirty');
      this.excludeInput.classList.remove('input-dirty');
      this.readAndApplyAllFilters();
    }, TableFilter.FILTER_DELAY_MS);
  }
}

/**
 * Converts a mojo time to a JS time.
 * @param {!mojoBase.mojom.Time} mojoTime
 * @return {!Date}
 */
function convertMojoTimeToJS(mojoTime: Time) {
  // 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;

  return new Date(timeInMs - epochDeltaInMs);
}

/**
 * Creates the chromium source URL from sourceLocation.
 * @param sourceFile
 * @param sourceLine
 * @param targetElement The element to which source link should be created.
 */
function createChromiumSourceLink(
    sourceFile: string, sourceLine: number, targetElement: Element) {
  // Valid source file starts with ../../
  if (!sourceFile.startsWith('../../')) {
    targetElement.textContent = `${sourceFile}(${sourceLine})`;
    return;
  }
  const fileName = sourceFile.slice(sourceFile.lastIndexOf('/') + 1);
  if (fileName.length == 0) {
    targetElement.textContent = `${sourceFile}(${sourceLine})`;
    return;
  }
  const anchor = document.createElement('a');
  anchor.appendChild(document.createTextNode(`${fileName}(${sourceLine}`));
  anchor.href = `https://source.chromium.org/chromium/chromium/src/+/main:${
      sourceFile.slice(6)};l=${sourceLine}`;
  targetElement.appendChild(anchor);
}

/**
 * Maps the logSource to a human readable string representation.
 * Must be kept in sync with the |LogSource| enum in
 * //components/optimization_guide/core/optimization_guide_common.mojom.
 * @param logSource
 * @returns string
 */
function getLogSource(logSource: number) {
  if (logSource == 0) {
    return 'SERVICE_AND_SETTINGS';
  }
  if (logSource == 1) {
    return 'HINTS';
  }
  if (logSource == 2) {
    return 'MODEL_MANAGEMENT';
  }
  if (logSource == 3) {
    return 'PAGE_CONTENT_ANNOTATIONS';
  }
  if (logSource == 4) {
    return 'HINTS_NOTIFICATIONS';
  }
  if (logSource == 5) {
    return 'TEXT_CLASSIFIER';
  }
  if (logSource == 6) {
    return 'MODEL_EXECUTION';
  }
  if (logSource == 7) {
    return 'NTP_MODULE';
  }
  return logSource.toString();
}

/**
 * The callback to button#log-messages-dump to save the logs to a file.
 */
function onLogMessagesDump() {
  const data = JSON.stringify(logMessages);
  const blob = new Blob([data], {'type': 'text/json'});
  const url = URL.createObjectURL(blob);
  const filename = 'optimization_guide_internals_logs_dump.json';

  const a = document.createElement('a');
  a.setAttribute('href', url);
  a.setAttribute('download', filename);

  const event = document.createEvent('MouseEvent');
  event.initMouseEvent(
      'click', true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0,
      null);
  a.dispatchEvent(event);
}

async function onModelsPageOpen() {
  const downloadedModelsContainer =
      getRequiredElement<HTMLTableElement>('downloaded-models-container');
  try {
    const response: {downloadedModelsInfo: DownloadedModelInfo[]} =
        await PageHandlerFactory.getRemote().requestDownloadedModelsInfo();
    const downloadedModelsInfo = response.downloadedModelsInfo;
    for (const {optimizationTarget, version, filePath} of
             downloadedModelsInfo) {
      const versionStr = version.toString();
      const existingModel = $<HTMLTableRowElement>(optimizationTarget);
      if (existingModel) {
        existingModel.querySelector('.downloaded-models-version')!.textContent =
            versionStr;
        existingModel.querySelector(
                         '.downloaded-models-file-path')!.textContent =
            filePath;
      } else {
        const downloadedModel = downloadedModelsContainer.insertRow();
        downloadedModel.id = optimizationTarget;
        appendTD(
            downloadedModel, optimizationTarget,
            'downloaded-models-optimization-target');
        appendTD(downloadedModel, versionStr, 'downloaded-models-version');
        appendTD(downloadedModel, filePath, 'downloaded-models-file-path');
      }
    }
  } catch (err) {
    throw new Error(
        `Error resolving promise from requestDownloadedModelsInfo, ${err}`);
  }
}

async function onClientIDsPageOpen() {
  const loggedClientIdsContainer =
      getRequiredElement<HTMLTableElement>('logged-client-ids-container');
  try {
    const response: {loggedClientIds: LoggedClientIds[]} =
        await PageHandlerFactory.getRemote()
            .requestLoggedModelQualityClientIds();
    const loggedClientIds = response.loggedClientIds;
    for (const {clientId} of loggedClientIds) {
      const clientIdStr = clientId.toString();
      const loggedClients = loggedClientIdsContainer.insertRow();
      appendTD(loggedClients, clientIdStr, 'logged-client-ids');
    }
  } catch (err) {
    throw new Error(
        `Error resolving promise from requestLoggedClientIds, ${err}`);
  }
}

/**
 * Appends a new TD element to the specified |parent| element, and returns the
 * newly created element.
 *
 * @param {HTMLTableRowElement} parent The element to which a new TD element is
 *     appended.
 * @param {string} textContent The inner HTML of the element.
 * @param {string} className The class name of the element.
 */
function appendTD(
    parent: HTMLTableRowElement, textContent: string, className: string) {
  const td = parent.insertCell();
  td.textContent = textContent;
  td.className = className;
  parent.appendChild(td);
  return td;
}

function getProxy(): OptimizationGuideInternalsBrowserProxy {
  return OptimizationGuideInternalsBrowserProxy.getInstance();
}


function initialize() {
  const tabbox = document.querySelector('cr-tab-box');
  assert(tabbox);
  tabbox.hidden = false;

  const logMessageContainer =
      getRequiredElement<HTMLTableElement>('log-message-container');

  const tabFilter = new TableFilter(
      logMessageContainer,
      getRequiredElement<HTMLInputElement>('log-message-include'),
      getRequiredElement<HTMLInputElement>('log-message-exclude'),
      getRequiredElement<HTMLSpanElement>('log-message-filter-stats'));

  getRequiredElement('log-messages-dump')
      .addEventListener('click', onLogMessagesDump);

  getProxy().getCallbackRouter().onLogMessageAdded.addListener(
      (eventTime: Time, logSource: number, sourceFile: string,
       sourceLine: number, message: string) => {
        const eventTimeStr = convertMojoTimeToJS(eventTime).toISOString();
        const logSourceStr = getLogSource(logSource);
        logMessages.push({
          eventTime: eventTimeStr,
          logSource: logSourceStr,
          sourceLocation: `${sourceFile}:${sourceLine}`,
          message,
        });
        const logMessage = logMessageContainer.insertRow();
        logMessage.innerHTML =
            window.trustedTypes ? window.trustedTypes.emptyHTML : '';
        appendTD(logMessage, eventTimeStr, 'event-logs-time');
        appendTD(logMessage, logSourceStr, 'event-logs-log-source');
        createChromiumSourceLink(
            sourceFile, sourceLine,
            appendTD(logMessage, '', 'event-logs-source-location'));
        appendTD(logMessage, message, 'event-logs-message');
        tabFilter.filterNewRow(logMessage);
      });

  const tabpanelNodeList = document.querySelectorAll('div[slot=\'panel\']');
  const tabpanels = Array.prototype.slice.call(tabpanelNodeList, 0);
  const tabpanelIds = tabpanels.map(function(tab) {
    return tab.id;
  });

  tabbox.addEventListener('selected-index-change', e => {
    const tabpanel = tabpanels[(e as CustomEvent).detail];
    const hash = tabpanel.id.match(/(?:^tabpanel-)(.+)/)[1];
    window.location.hash = hash;
  });

  const activateTabByHash = function() {
    let hash = window.location.hash;

    // Remove the first character '#'.
    hash = hash.substring(1);

    const id = 'tabpanel-' + hash;
    const index = tabpanelIds.indexOf(id);
    if (index === -1) {
      return;
    }
    tabbox.setAttribute('selected-index', `${index}`);

    if (hash === 'models') {
      onModelsPageOpen();
    } else if (hash === 'client-ids') {
      onClientIDsPageOpen();
    }
  };

  window.onhashchange = activateTabByHash;
  activateTabByHash();
}

document.addEventListener('DOMContentLoaded', initialize);