| // 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).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(); |
| }); |