chromium/chrome/browser/resources/discards/database_tab.ts

// Copyright 2018 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_input/cr_input.js';
import 'chrome://resources/cr_elements/icons.html.js';
import 'chrome://resources/polymer/v3_0/iron-icon/iron-icon.js';

import type {CrInputElement} from 'chrome://resources/cr_elements/cr_input/cr_input.js';
import {assertNotReached} from 'chrome://resources/js/assert.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {getTemplate} from './database_tab.html.js';
import {boolToString, durationToString, getOrCreateSiteDataProvider, secondsToString} from './discards.js';
import type {SiteDataDatabaseSize, SiteDataEntry, SiteDataFeature, SiteDataProviderRemote} from './site_data.mojom-webui.js';
import {SortedTableMixin} from './sorted_table_mixin.js';

/**
 * Compares two db rows by their origin.
 * @param a The first value being compared.
 * @param b The second value being compared.
 * @return A negative number if a < b, 0 if a === b, and a positive
 *     number if a > b.
 */
function compareRowsByOrigin(a: SiteDataEntry, b: SiteDataEntry): number {
  return a.origin.localeCompare(b.origin);
}

/**
 * Compares two db rows by their dirty bit.
 * @param a The first value being compared.
 * @param b The second value being compared.
 * @return A negative number if a < b, 0 if a === b, and a positive
 *     number if a > b.
 */
function compareRowsByIsDirty(a: SiteDataEntry, b: SiteDataEntry): number {
  return (a.isDirty ? 1 : 0) - (b.isDirty ? 1 : 0);
}

/**
 * Compares two db rows by their last load time.
 * @param a The first value being compared.
 * @param b The second value being compared.
 * @return A negative number if a < b, 0 if a === b, and a positive
 *     number if a > b.
 */
function compareRowsByLastLoaded(a: SiteDataEntry, b: SiteDataEntry): number {
  return a.value!.lastLoaded - b.value!.lastLoaded;
}

/**
 * Compares two db rows by their CPU usage.
 * @param a The first value being compared.
 * @param b The second value being compared.
 * @return A negative number if a < b, 0 if a === b, and a positive
 *     number if a > b.
 */
function compareRowsByCpuUsage(a: SiteDataEntry, b: SiteDataEntry): number {
  const keyA =
      a.value!.loadTimeEstimates ? a.value!.loadTimeEstimates.avgCpuUsageUs : 0;
  const keyB =
      b.value!.loadTimeEstimates ? b.value!.loadTimeEstimates.avgCpuUsageUs : 0;
  return keyA - keyB;
}

/**
 * Compares two db rows by their memory usage.
 * @param a The first value being compared.
 * @param b The second value being compared.
 * @return A negative number if a < b, 0 if a === b, and a positive
 *     number if a > b.
 */
function compareRowsByMemoryUsage(a: SiteDataEntry, b: SiteDataEntry): number {
  const keyA = a.value!.loadTimeEstimates ?
      a.value!.loadTimeEstimates.avgFootprintKb :
      0;
  const keyB = b.value!.loadTimeEstimates ?
      b.value!.loadTimeEstimates.avgFootprintKb :
      0;
  return keyA - keyB;
}

/**
 * Compares two db rows by their load duration.
 * @param a The first value being compared.
 * @param b The second value being compared.
 * @return A negative number if a < b, 0 if a === b, and a positive
 *     number if a > b.
 */
function compareRowsByLoadDuration(a: SiteDataEntry, b: SiteDataEntry): number {
  const keyA = a.value!.loadTimeEstimates ?
      a.value!.loadTimeEstimates.avgLoadDurationUs :
      0;
  const keyB = b.value!.loadTimeEstimates ?
      b.value!.loadTimeEstimates.avgLoadDurationUs :
      0;
  return keyA - keyB;
}

/**
 * @param sortKey The sort key to get a function for.
 * @return {function(SiteDataEntry, SiteDataEntry): number}
 *     A comparison function that compares two tab infos, returns
 *     negative number if a < b, 0 if a === b, and a positive
 *     number if a > b.
 */
function getSortFunctionForKey(sortKey: string): (
    a: SiteDataEntry, b: SiteDataEntry) => number {
  switch (sortKey) {
    case 'origin':
      return compareRowsByOrigin;
    case 'dirty':
      return compareRowsByIsDirty;
    case 'lastLoaded':
      return compareRowsByLastLoaded;
    case 'cpuUsage':
      return compareRowsByCpuUsage;
    case 'memoryUsage':
      return compareRowsByMemoryUsage;
    case 'loadDuration':
      return compareRowsByLoadDuration;
    default:
      assertNotReached('Unknown sortKey: ' + sortKey);
  }
}

/**
 * @param time A time in microseconds.
 * @return A friendly, human readable string representing the input
 *    time with units.
 */
function microsecondsToString(time: number): string {
  if (time < 1000) {
    return time.toString() + ' µs';
  }
  time /= 1000;
  if (time < 1000) {
    return time.toFixed(2) + ' ms';
  }
  time /= 1000;
  return time.toFixed(2) + ' s';
}

/**
 * @param value A memory amount in kilobytes.
 * @return A friendly, human readable string representing the input
 *    time with units.
 */
function kilobytesToString(value: number): string {
  if (value < 1000) {
    return value.toString() + ' KB';
  }
  value /= 1000;
  if (value < 1000) {
    return value.toFixed(1) + ' MB';
  }
  value /= 1000;
  return value.toFixed(1) + ' GB';
}

/**
 * @param item The item to retrieve a load time estimate for.
 * @param propertyName Name of the load time estimate to retrieve.
 * @return The requested load time estimate or 'N/A' if unavailable.
 */
function formatLoadTimeEstimate(
    item: SiteDataEntry, propertyName: string): string {
  if (!item.value || !item.value.loadTimeEstimates) {
    return 'N/A';
  }

  const value =
      (item.value.loadTimeEstimates as unknown as
       {[key: string]: number})[propertyName];
  if (propertyName.endsWith('Us')) {
    return microsecondsToString(value);
  } else if (propertyName.endsWith('Kb')) {
    return kilobytesToString(value);
  }
  return value.toString();
}

interface DatabaseTabElement {
  $: {
    addOriginInput: CrInputElement,
  };
}

const DatabaseTabElementBase = SortedTableMixin(PolymerElement);
class DatabaseTabElement extends DatabaseTabElementBase {
  static get is() {
    return 'database-tab';
  }

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

  static get properties() {
    return {
      /**
       * List of database rows.
       */
      rows_: Array,

      /**
       * The database size response.
       */
      size_: {
        type: Object,
        value: {numRows: -1, onDiskSizeKb: -1},
      },

      /**
       * An origin that can be added to requestedOrigins_ by onAddOriginClick_.
       */
      newOrigin_: String,
    };
  }

  private rows_: SiteDataEntry[]|null;
  private size_: SiteDataDatabaseSize;
  private newOrigin_: string;

  private updateTableTimer_: number = 0;
  private updateSizesTimer_: number = 0;
  private requestedOrigins_: {[key: string]: boolean} = {};
  private siteDataProvider_: SiteDataProviderRemote|null = null;

  override connectedCallback() {
    this.setSortKey('origin');
    this.requestedOrigins_ = {};
    this.siteDataProvider_ = getOrCreateSiteDataProvider();

    // Specifies the update interval of the table, in ms.
    const UPDATE_INTERVAL_MS = 1000;

    // Update immediately.
    this.updateDbRows_();
    this.updateDbSizes_();

    // Set an interval timer to update the database periodically.
    this.updateTableTimer_ =
        setInterval(this.updateDbRows_.bind(this), UPDATE_INTERVAL_MS);

    // Set another interval timer to update the database sizes, but much less
    // frequently, as this requires iterating the entire database.
    this.updateSizesTimer_ =
        setInterval(this.updateDbSizes_.bind(this), UPDATE_INTERVAL_MS * 30);
  }

  override disconnectedCallback() {
    // Clear the update timers to avoid memory leaks.
    clearInterval(this.updateTableTimer_);
    this.updateTableTimer_ = 0;
    clearInterval(this.updateSizesTimer_);
    this.updateSizesTimer_ = 0;
  }

  /**
   * Issues a request for the data and renders on response.
   */
  private updateDbRows_() {
    this.siteDataProvider_!
        .getSiteDataArray(Object.keys(this.requestedOrigins_))
        .then(response => {
          // Bail if the SiteData database is turned off.
          if (!response.result) {
            return;
          }

          // Add any new origins to the (monotonically increasing)
          // set of requested origins.
          const dbRows = response.result.dbRows;
          for (const dbRow of dbRows) {
            this.requestedOrigins_[dbRow.origin] = true;
          }
          this.rows_ = dbRows;
        });
  }

  /**
   * Adds the current new origin to requested origins and starts an update.
   */
  private addNewOrigin_() {
    this.requestedOrigins_[this.newOrigin_] = true;
    this.newOrigin_ = '';
    this.updateDbRows_();
  }

  /**
   * An on-click handler that adds the current new origin to requested
   * origins.
   */
  private onAddOriginClick_() {
    this.addNewOrigin_();

    // Set the focus back to the input field for convenience.
    this.$.addOriginInput.focus();
  }

  /**
   * A key-down handler that adds the current new origin to requested origins.
   */
  private onOriginKeydown_(e: KeyboardEvent) {
    if (e.key === 'Enter' && this.isValidOrigin_(this.newOrigin_)) {
      this.addNewOrigin_();
      e.stopPropagation();
    }
  }

  /** Issues a request for the database sizes and renders on response. */
  private updateDbSizes_() {
    this.siteDataProvider_!.getSiteDataDatabaseSize().then(response => {
      // Bail if the SiteData database is turned off.
      if (!response.dbSize) {
        return;
      }
      this.size_ = response.dbSize;
    });
  }

  /**
   * Returns a sort function to compare tab infos based on the provided sort
   * key and a boolean reverse flag.
   * @param sortKey The sort key for the  returned function.
   * @param sortReverse True if sorting is reversed.
   * @return A comparison function that compares two tab infos, returns
   *     negative number if a < b, 0 if a === b, and a positive
   *     number if a > b.
   */
  private computeSortFunction_(sortKey: string, sortReverse: boolean):
      (a: SiteDataEntry, b: SiteDataEntry) => number {
    // Polymer 2 may invoke multi-property observers before all properties
    // are defined.
    if (!sortKey) {
      return (_a, _b) => 0;
    }

    const sortFunction = getSortFunctionForKey(sortKey);
    return (a, b) => {
      const comp = sortFunction(a, b);
      return sortReverse ? -comp : comp;
    };
  }

  /**
   * @param origin A potentially valid origin string.
   * @return Whether the origin is valid.
   */
  private isValidOrigin_(origin: string): boolean {
    const re = /(https?|ftp):\/\/[a-z+.]/;

    return re.test(origin);
  }

  /**
   * @param origin A potentially valid origin string.
   * @return Whether the origin is valid or empty.
   */
  private isEmptyOrValidOrigin_(origin: string): boolean {
    return !origin || this.isValidOrigin_(origin);
  }

  /**
   * @param value The value to convert.
   * @return A display string representing value.
   */
  private boolToString_(value: boolean): string {
    return boolToString(value);
  }

  /**
   * @param time Time in seconds since epoch.
   * @return A user-friendly string explaining how long ago time
   *     occurred.
   */
  private lastUseToString_(time: number): string {
    const nowSecondsFromEpoch = Math.round(Date.now() / 1000);
    return durationToString(nowSecondsFromEpoch - time);
  }

  /**
   * @param feature The feature in question.
   * @return A human-readable string representing the feature.
   */
  private featureToString_(feature: SiteDataFeature|null): string {
    if (!feature) {
      return 'N/A';
    }

    if (feature.useTimestamp) {
      const nowSecondsFromEpoch = Math.round(Date.now() / 1000);
      return 'Used ' +
          durationToString(
                 Number(BigInt(nowSecondsFromEpoch) - feature.useTimestamp));
    }

    if (feature.observationDuration) {
      return secondsToString(Number(feature.observationDuration));
    }

    return 'N/A';
  }

  /**
   * @param item The item to retrieve a load time estimate for.
   * @param propertyName Name of the load time estimate to retrieve.
   * @return The requested load time estimate or 'N/A' if
   *     unavailable.
   */
  private getLoadTimeEstimate_(item: SiteDataEntry, propertyName: string):
      string {
    return formatLoadTimeEstimate(item, propertyName);
  }

  /**
   * @param value A value in units of kilobytes, or -1 indicating not
   *     available.
   * @return A human readable string representing value.
   */
  private kilobytesToString_(value: number): string {
    return value === -1 ? 'N/A' : kilobytesToString(value);
  }

  /**
   * @param value A numeric value or -1, indicating not available.
   * @return A human readable string representing value.
   */
  private optionalIntegerToString_(value: number): string {
    return value === -1 ? 'N/A' : value.toString();
  }
}

customElements.define(DatabaseTabElement.is, DatabaseTabElement);