// 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 './private_aggregation_internals_table.js';
import {assert} from 'chrome://resources/js/assert.js';
import type {AggregatableReportRequestID, ObserverInterface, WebUIAggregatableReport} from './private_aggregation_internals.mojom-webui.js';
import {Factory as PrivateAggregationInternalsFactory, HandlerRemote as PrivateAggregationInternalsHandlerRemote, ObserverReceiver, ReportStatus} from './private_aggregation_internals.mojom-webui.js';
import type {PrivateAggregationInternalsTableElement} from './private_aggregation_internals_table.js';
import type {Column} from './table_model.js';
import {TableModel} from './table_model.js';
function compareDefault<T>(a: T, b: T): number {
return (a < b) ? -1 : ((a > b) ? 1 : 0);
}
// Converts the mojo_base.mojom.Uint128 to a string
function bucketReplacer(_key: string, value: any): any {
if (_key === 'bucket') {
return (value['high'] * 2n ** 64n + value['low']).toString();
} else {
return value;
}
}
class ValueColumn<T, V> implements Column<T> {
compare: (a: T, b: T) => number;
header: string;
private minWidth?: string;
protected getValue: (param: T) => V;
constructor(
header: string, getValue: (param: T) => V, minWidth?: string,
compare?: ((a: T, b: T) => number)) {
this.header = header;
this.getValue = getValue;
this.minWidth = minWidth;
this.compare =
compare ?? ((a: T, b: T) => compareDefault(getValue(a), getValue(b)));
}
render(td: HTMLElement, row: T) {
if (this.minWidth) {
td.style.minWidth = this.minWidth;
}
td.textContent = `${this.getValue(row)}`;
}
renderHeader(th: HTMLElement) {
th.textContent = `${this.header}`;
}
}
/**
* Column that holds date.
*/
class DateColumn<T> extends ValueColumn<T, Date> {
constructor(header: string, getValue: (p: T) => Date) {
super(header, getValue);
}
override render(td: HTMLElement, row: T) {
td.innerText = this.getValue(row).toLocaleString();
}
}
class CodeColumn<T> extends ValueColumn<T, string> {
constructor(header: string, getValue: (p: T) => string) {
super(header, getValue);
}
override render(td: HTMLElement, row: T) {
const code = td.ownerDocument.createElement('code');
code.innerText = this.getValue(row);
const pre = td.ownerDocument.createElement('pre');
pre.appendChild(code);
td.appendChild(pre);
}
}
/**
* Wraps a checkbox.
*/
class Selectable {
selectCheckbox: HTMLInputElement;
constructor() {
this.selectCheckbox = document.createElement('input');
this.selectCheckbox.type = 'checkbox';
}
}
/**
* Checkbox column for selection.
*/
class SelectionColumn<T extends Selectable> implements Column<T> {
compare: ((a: T, b: T) => number)|null;
private model: TableModel<T>;
private selectAllCheckbox: HTMLInputElement;
selectionChangedListeners: Set<(param: boolean) => void>;
header: string|null;
private rowChangedListener: () => void;
constructor(model: TableModel<T>) {
// Selection column is not sortable.
this.compare = null;
this.model = model;
this.header = null;
this.selectAllCheckbox = document.createElement('input');
this.selectAllCheckbox.type = 'checkbox';
this.selectAllCheckbox.addEventListener('input', () => {
const checked = this.selectAllCheckbox.checked;
this.model.getRows().forEach((row) => {
if (!row.selectCheckbox.disabled) {
row.selectCheckbox.checked = checked;
}
});
this.notifySelectionChanged(checked);
});
this.rowChangedListener = () => this.onChange();
this.model.rowsChangedListeners.add(this.rowChangedListener);
this.selectionChangedListeners = new Set();
}
render(td: HTMLElement, row: T) {
td.appendChild(row.selectCheckbox);
}
renderHeader(th: HTMLElement) {
th.appendChild(this.selectAllCheckbox);
}
onChange() {
let anySelectable = false;
let anySelected = false;
let anyUnselected = false;
this.model.getRows().forEach((row) => {
// addEventListener deduplicates, so only one event will be fired per
// input.
row.selectCheckbox.addEventListener('input', this.rowChangedListener);
if (row.selectCheckbox.disabled) {
return;
}
anySelectable = true;
if (row.selectCheckbox.checked) {
anySelected = true;
} else {
anyUnselected = true;
}
});
this.selectAllCheckbox.disabled = !anySelectable;
this.selectAllCheckbox.checked = anySelected && !anyUnselected;
this.selectAllCheckbox.indeterminate = anySelected && anyUnselected;
this.notifySelectionChanged(anySelected);
}
notifySelectionChanged(anySelected: boolean) {
this.selectionChangedListeners.forEach((f) => f(anySelected));
}
}
function reportStatusToText(status: ReportStatus) {
switch (status) {
case ReportStatus.kPending:
return 'Pending';
case ReportStatus.kSent:
return 'Sent';
case ReportStatus.kFailedToAssemble:
return 'Failed to assemble';
case ReportStatus.kFailedToSend:
return 'Failed to send';
}
}
class Report extends Selectable {
// `null` indicates a report that wasn't stored/scheduled.
id: AggregatableReportRequestID|null;
reportBody: string;
reportUrl: string;
reportTime: Date;
status: string;
apiIdentifier: string;
apiVersion: string;
contributions: string;
constructor(mojo: WebUIAggregatableReport) {
super();
this.id = mojo.id;
this.reportBody = mojo.reportBody;
this.reportUrl = mojo.reportUrl.url;
this.reportTime = new Date(mojo.reportTime);
this.apiIdentifier = mojo.apiIdentifier;
this.apiVersion = mojo.apiVersion;
// Only pending stored/scheduled reports are selectable.
if (mojo.status !== ReportStatus.kPending || mojo.id === undefined) {
this.selectCheckbox.disabled = true;
}
this.status = reportStatusToText(mojo.status);
this.contributions =
JSON.stringify(mojo.contributions, bucketReplacer, ' ');
}
}
class ReportTableModel extends TableModel<Report> {
private sendReportsButton: HTMLButtonElement;
private selectionColumn: SelectionColumn<Report>;
private handledReports: Report[] = [];
private storedReports: Report[] = [];
constructor(sendReportsButton: HTMLButtonElement) {
super();
this.sendReportsButton = sendReportsButton;
this.selectionColumn = new SelectionColumn(this);
this.cols = [
this.selectionColumn,
new ValueColumn<Report, string>('Status', (e) => e.status),
new ValueColumn<Report, string>(
'Report URL', (e) => e.reportUrl, '250px'),
new DateColumn<Report>('Report Time', (e) => e.reportTime),
new ValueColumn<Report, string>(
'API identifier', (e) => e.apiIdentifier, '90px'),
new ValueColumn<Report, string>('API version', (e) => e.apiVersion),
new CodeColumn<Report>(
'Contributions', (e) => (e as Report).contributions),
new CodeColumn<Report>('Report Body', (e) => e.reportBody),
];
// Sort by report time by default.
this.sortIdx = 3;
assert(this.cols[this.sortIdx]!.header === 'Report Time');
this.emptyRowText = 'No sent or pending reports.';
this.sendReportsButton.addEventListener('click', () => this.sendReports_());
this.selectionColumn.selectionChangedListeners.add(
(anySelected: boolean) => {
this.sendReportsButton.disabled = !anySelected;
});
}
override getRows() {
return this.handledReports.concat(this.storedReports);
}
setStoredReports(storedReports: Report[]) {
this.storedReports = storedReports;
this.notifyRowsChanged();
}
addHandledReport(report: Report) {
// Prevent the page from consuming ever more memory if the user leaves the
// page open for a long time.
if (this.handledReports.length >= 1000) {
this.handledReports = [];
}
this.handledReports.push(report);
this.notifyRowsChanged();
}
clear() {
this.storedReports = [];
this.handledReports = [];
this.notifyRowsChanged();
}
/**
* Sends all selected reports.
* Disables the button while the reports are still being sent.
* Observer.onRequestStorageModified will be called automatically as reports
* are deleted, so there's no need to manually refresh the data on completion.
*/
private sendReports_() {
const ids: AggregatableReportRequestID[] = [];
this.storedReports.forEach((report) => {
if (!report.selectCheckbox.disabled && report.selectCheckbox.checked) {
ids.push(report.id as AggregatableReportRequestID);
}
});
if (ids.length === 0) {
return;
}
const previousText = this.sendReportsButton.innerText;
this.sendReportsButton.disabled = true;
this.sendReportsButton.innerText = 'Sending...';
pageHandler!.sendReports(ids).then(() => {
this.sendReportsButton.innerText = previousText;
});
}
}
/**
* Reference to the backend providing all the data.
*/
let pageHandler: PrivateAggregationInternalsHandlerRemote|null = null;
let reportTableModel: ReportTableModel|null = null;
/**
* Fetches all pending reports from the backend and populate the tables.
*/
function updateReports() {
pageHandler!.getReports().then((response) => {
reportTableModel!.setStoredReports(
response.reports.map((mojo) => new Report(mojo)));
});
}
/**
* Deletes all data stored by the aggregation service backend.
* Observer.onRequestStorageModified will be called automatically as reports are
* deleted, so there's no need to manually refresh the data on completion.
*/
function clearStorage() {
reportTableModel!.clear();
pageHandler!.clearStorage();
}
class Observer implements ObserverInterface {
onRequestStorageModified() {
updateReports();
}
onReportHandled(mojo: WebUIAggregatableReport) {
reportTableModel!.addHandledReport(new Report(mojo));
}
}
document.addEventListener('DOMContentLoaded', () => {
// Setup the mojo interface.
pageHandler = new PrivateAggregationInternalsHandlerRemote();
const sendReports =
document.querySelector<HTMLButtonElement>('#send-reports');
reportTableModel = new ReportTableModel(sendReports!);
const refresh = document.querySelector('#refresh');
refresh!.addEventListener('click', updateReports);
const clearData = document.querySelector('#clear-data');
clearData!.addEventListener('click', clearStorage);
const reportTable =
document.querySelector<PrivateAggregationInternalsTableElement<Report>>(
'#reportTable');
reportTable!.setModel(reportTableModel!);
PrivateAggregationInternalsFactory.getRemote().create(
new ObserverReceiver(new Observer()).$.bindNewPipeAndPassRemote(),
pageHandler.$.bindNewPipeAndPassReceiver());
updateReports();
});