chromium/components/metrics/debug/structured/app.ts

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

import {assert} from 'chrome://resources/js/assert.js';
import {CustomElement} from 'chrome://resources/js/custom_element.js';

import {getTemplate} from './app.html.js';
import {StructuredMetricsBrowserProxyImpl} from './structured_metrics_browser_proxy.js';
import type {SearchParams, StructuredMetricEvent, StructuredMetricsSummary} from './structured_utils.js';
import {updateStructuredMetricsEvents, updateStructuredMetricsSummary} from './structured_utils.js';

/**
 * Gets search params from search url.
 */
function getSearchParams(): string {
  return window.location.search.substring(1);
}

export class StructuredMetricsInternalsAppElement extends CustomElement {
  static get is(): string {
    return 'structured-metrics-internals-app';
  }

  static override get template() {
    return getTemplate();
  }

  private browserProxy_: StructuredMetricsBrowserProxyImpl =
      StructuredMetricsBrowserProxyImpl.getInstance();
  private summaryIntervalId_: ReturnType<typeof setInterval>;

  initPromise: Promise<void>;

  textSearch: HTMLInputElement;

  searchQuery: SearchParams|null = null;

  searchError: boolean = false;

  constructor() {
    super();

    // This must be set before |initSearchParams_| is called.
    this.textSearch = this.getRequiredElement<HTMLInputElement>('#search-bar');
    this.initSearchParams_();

    this.initPromise = this.init_();

    // Create periodic callbacks.
    this.summaryIntervalId_ =
        setInterval(() => this.updateStructuredMetricsSummary_(), 5000);
  }

  disconnectedCallback() {
    clearInterval(this.summaryIntervalId_);
  }

  private async init_(): Promise<void> {
    // Fetch Structured Metrics summary and events.
    // TODO: Implement a push model as new events are recorded.
    await this.updateStructuredMetricsSummary_();
    await this.updateStructuredMetricsEvents_();

    const eventRefreshButton = this.getRequiredElement('#sm-refresh-events');
    eventRefreshButton.addEventListener(
        'click', () => this.updateStructuredMetricsEvents_());
  }

  /**
   * Fetches summary information of the Structured Metrics service and renders
   * it.
   */
  private async updateStructuredMetricsSummary_(): Promise<void> {
    const summary: StructuredMetricsSummary =
        await this.browserProxy_.fetchStructuredMetricsSummary();
    const template =
        this.getRequiredElement<HTMLTemplateElement>('#summary-row-template');
    const smSummaryBody = this.getRequiredElement('#sm-summary-body');
    updateStructuredMetricsSummary(smSummaryBody, summary, template);
  }

  /**
   * Fetches all events currently recorded by the Structured Metrics Service and
   * renders them. It an event has been uploaded then it will not be shown
   * again. This only shows Events recorded in Chromium. Platform2 events are
   * not supported yet.
   */
  private async updateStructuredMetricsEvents_(): Promise<void> {
    const events: StructuredMetricEvent[] =
        await this.browserProxy_.fetchStructuredMetricsEvents();
    const eventTemplate = this.getRequiredElement<HTMLTemplateElement>(
        '#structured-metrics-event-row-template');
    const eventDetailsTemplate = this.getRequiredElement<HTMLTemplateElement>(
        '#structured-metrics-event-details-template');

    const kvTemplate =
        this.getRequiredElement<HTMLTemplateElement>('#summary-row-template');
    const eventTableBody = this.getRequiredElement('#sm-events-body');

    updateStructuredMetricsEvents(
        eventTableBody, events, this.searchQuery, eventTemplate,
        eventDetailsTemplate, kvTemplate);
  }

  /**
   * Initializes search params from the URL.
   */
  private initSearchParams_() {
    const searchString = this.sanitizeUrlToSearch_();
    this.searchQuery = this.parseSearchString_(searchString);

    this.textSearch.value = searchString;
    this.textSearch.addEventListener('search', () => {
      this.updateSearchCriteria_();
    });
  }


  /**
   * Updates the windows search url.
   */
  private updateSearchCriteria_() {
    // Update the url to reflect the search string. This will redirect the new
    // url page, updating the searchQuery.
    window.location.search = '?' + this.sanitizeSearchToUrl_();
  }

  /**
   * Sanitize the search format into a valid format for the URL.
   */
  private sanitizeSearchToUrl_(): string {
    return this.textSearch.value.replace(/\s+/gi, '&').replace(/:/gi, '=');
  }

  /**
   * Sanitize the URL search parameters into the search format.
   */
  private sanitizeUrlToSearch_(): string {
    return getSearchParams().replace(/&/gi, ' ').replace(/=/gi, ':');
  }

  /**
   * Parse search format into an object.
   *
   * The format is a space separated lists of "key:value" pairs. Currently, a
   * single search term is not supported.
   */
  private parseSearchString_(text: string): SearchParams|null {
    // Page should be rebuilt on a new search query, but leaving it because it
    // is better to be safe then have an error message that doesn't disappear
    // when the page is refreshed..
    if (this.searchError) {
      this.hideSearchErrorMessage_();
    }

    if (text.length == 0) {
      return null;
    }

    // If an ':' is not found then we are doing a full text search. The string
    // is the query as is.
    if (text.indexOf(':') == -1) {
      return null;
    }

    // If it is found, then we are doing a categorical search, this parses the
    // string into a map of category and search value.
    const categories = new Map<string, string>();
    const validSearchKeys = ['project', 'event', 'metric'];

    text.split(' ').forEach((cat) => {
      const [key, value] = cat.split(':', 2);
      if (key !== undefined && value !== undefined) {
        if (validSearchKeys.find((value) => value === key) === undefined) {
          this.setSearchErrorMessage_(`invalid search key: ${
              key}. valid keys are project, event, metric`);
          return;
        }
        categories.set(key, value);
      }
    });

    return categories;
  }

  /**
   * Hides the search error message.
   */
  private hideSearchErrorMessage_(): void {
    this.searchError = false;

    const errorMsg =
        this.getRequiredElement<HTMLParagraphElement>('#search-error-msg');
    assert(errorMsg);
    errorMsg.style.display = 'none';
  }

  /**
   * Sets and shows the error message.
   */
  private setSearchErrorMessage_(msg: string): void {
    this.searchError = true;

    // Set the content of the error message.
    const errorMsg =
        this.getRequiredElement<HTMLParagraphElement>('#search-error-msg');
    assert(errorMsg);
    errorMsg.innerText = msg;

    // Shows the error message
    errorMsg.style.display = 'block';
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'structured-metrics-internals-app': StructuredMetricsInternalsAppElement;
  }
}

customElements.define(
    StructuredMetricsInternalsAppElement.is,
    StructuredMetricsInternalsAppElement);