blob: 0e03c8eac0fec264484ba6ad69d6a7c18d14dc98 [file] [log] [blame]
// Copyright 2020 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 './attribution_internals_table.js';
import {assertNotReached} from 'chrome://resources/js/assert_ts.js';
import {getTrustedHTML} from 'chrome://resources/js/static_types.js';
import {Origin} from 'chrome://resources/mojo/url/mojom/origin.mojom-webui.js';
import {TriggerVerification} from './attribution.mojom-webui.js';
import {Factory, HandlerInterface, HandlerRemote, ObserverInterface, ObserverReceiver, ReportID, SourceStatus, WebUIDebugReport, WebUIOsRegistration, WebUIRegistration, WebUIReport, WebUISource, WebUISource_Attributability, WebUISourceRegistration, WebUITrigger, WebUITrigger_Status} from './attribution_internals.mojom-webui.js';
import {AttributionInternalsTableElement} from './attribution_internals_table.js';
import {OsRegistrationResult, OsRegistrationType} from './attribution_reporting.mojom-webui.js';
import {SourceRegistrationError} from './source_registration_error.mojom-webui.js';
import {SourceType} from './source_type.mojom-webui.js';
import {StoreSourceResult} from './store_source_result.mojom-webui.js';
import {Column, TableModel} from './table_model.js';
// If kAttributionAggregatableBudgetPerSource changes, update this value
const BUDGET_PER_SOURCE = 65536;
function compareDefault<T>(a: T, b: T): number {
if (a < b) {
return -1;
}
if (a > b) {
return 1;
}
return 0;
}
function bigintReplacer(_key: string, value: any): any {
return typeof value === 'bigint' ? value.toString() : value;
}
class ValueColumn<T, V> implements Column<T> {
readonly compare?: (a: T, b: T) => number;
constructor(
private readonly header: string,
protected readonly getValue: (param: T) => V,
comparable: boolean = true) {
if (comparable) {
this.compare = (a: T, b: T) => compareDefault(getValue(a), getValue(b));
}
}
render(td: HTMLElement, row: T) {
td.innerText = `${this.getValue(row)}`;
}
renderHeader(th: HTMLElement) {
th.innerText = this.header;
}
}
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, /*comparable=*/ false);
}
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);
}
}
class ListColumn<T, V> extends ValueColumn<T, V[]> {
constructor(
header: string, getValue: (p: T) => V[],
private readonly flatten: boolean = false) {
super(header, getValue, /*comparable=*/ false);
}
override render(td: HTMLElement, row: T) {
const values = this.getValue(row);
if (values.length === 0) {
return;
}
if (this.flatten && values.length === 1) {
td.innerText = `${values[0]}`;
return;
}
const ul = td.ownerDocument.createElement('ul');
values.forEach(value => {
const li = td.ownerDocument.createElement('li');
li.innerText = `${value}`;
ul.appendChild(li);
});
td.appendChild(ul);
}
}
function renderDL<T>(td: HTMLElement, row: T, cols: Array<Column<T>>) {
const dl = td.ownerDocument.createElement('dl');
cols.forEach(col => {
const dt = td.ownerDocument.createElement('dt');
col.renderHeader(dt);
dl.appendChild(dt);
const dd = td.ownerDocument.createElement('dd');
col.render(dd, row);
dl.appendChild(dd);
});
td.appendChild(dl);
}
const debugPathPattern: RegExp =
/(?<=\/\.well-known\/attribution-reporting\/)debug(?=\/)/;
class ReportUrlColumn<T extends Report> extends ValueColumn<T, string> {
constructor() {
super('Report URL', (e) => e.reportUrl);
}
override render(td: HTMLElement, row: T) {
if (!row.isDebug) {
td.innerText = row.reportUrl;
return;
}
const [pre, post] = row.reportUrl.split(debugPathPattern, 2);
td.appendChild(new Text(pre));
const span = td.ownerDocument.createElement('span');
span.classList.add('debug-url');
span.innerText = 'debug';
td.appendChild(span);
td.appendChild(new Text(post));
}
}
class Selectable {
input: HTMLInputElement;
constructor() {
this.input = document.createElement('input');
this.input.type = 'checkbox';
}
}
class SelectionColumn<T extends Selectable> implements Column<T> {
private readonly selectAll: HTMLInputElement;
private readonly listener: () => void;
readonly selectionChangedListeners: Set<(param: boolean) => void> = new Set();
constructor(private readonly model: TableModel<T>) {
this.selectAll = document.createElement('input');
this.selectAll.type = 'checkbox';
this.selectAll.addEventListener('input', () => {
const checked = this.selectAll.checked;
this.model.getRows().forEach((row) => {
if (!row.input.disabled) {
row.input.checked = checked;
}
});
this.notifySelectionChanged(checked);
});
this.listener = () => this.onChange();
this.model.rowsChangedListeners.add(this.listener);
}
render(td: HTMLElement, row: T) {
td.appendChild(row.input);
}
renderHeader(th: HTMLElement) {
th.appendChild(this.selectAll);
}
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.input.addEventListener('input', this.listener);
if (row.input.disabled) {
return;
}
anySelectable = true;
if (row.input.checked) {
anySelected = true;
} else {
anyUnselected = true;
}
});
this.selectAll.disabled = !anySelectable;
this.selectAll.checked = anySelected && !anyUnselected;
this.selectAll.indeterminate = anySelected && anyUnselected;
this.notifySelectionChanged(anySelected);
}
notifySelectionChanged(anySelected: boolean) {
this.selectionChangedListeners.forEach((f) => f(anySelected));
}
}
class Source {
sourceEventId: bigint;
sourceOrigin: string;
destinations: string[];
reportingOrigin: string;
sourceTime: Date;
expiryTime: Date;
eventReportWindowTime: Date;
aggregatableReportWindowTime: Date;
sourceType: string;
filterData: string;
aggregationKeys: string;
debugKey: string;
dedupKeys: bigint[];
priority: bigint;
status: string;
aggregatableBudgetConsumed: bigint;
aggregatableDedupKeys: bigint[];
constructor(mojo: WebUISource) {
this.sourceEventId = mojo.sourceEventId;
this.sourceOrigin = originToText(mojo.sourceOrigin);
this.destinations =
mojo.destinations.destinations.map(d => originToText(d.siteAsOrigin));
this.reportingOrigin = originToText(mojo.reportingOrigin);
this.sourceTime = new Date(mojo.sourceTime);
this.expiryTime = new Date(mojo.expiryTime);
this.eventReportWindowTime = new Date(mojo.eventReportWindowTime);
this.aggregatableReportWindowTime =
new Date(mojo.aggregatableReportWindowTime);
this.sourceType = sourceTypeToText(mojo.sourceType);
this.priority = mojo.priority;
this.filterData = JSON.stringify(mojo.filterData, null, ' ');
this.aggregationKeys =
JSON.stringify(mojo.aggregationKeys, bigintReplacer, ' ');
this.debugKey = mojo.debugKey ? `${mojo.debugKey.value}` : '';
this.dedupKeys = mojo.dedupKeys;
this.aggregatableBudgetConsumed = mojo.aggregatableBudgetConsumed;
this.aggregatableDedupKeys = mojo.aggregatableDedupKeys;
this.status = attributabilityToText(mojo.attributability);
}
}
class SourceTableModel extends TableModel<Source> {
private storedSources: Source[] = [];
constructor() {
super(
[
new ValueColumn<Source, bigint>(
'Source Event ID', (e) => e.sourceEventId),
new ValueColumn<Source, string>('Status', (e) => e.status),
new ValueColumn<Source, string>(
'Source Origin', (e) => e.sourceOrigin),
new ListColumn<Source, string>(
'Destinations', (e) => e.destinations, /*flatten=*/ true),
new ValueColumn<Source, string>(
'Reporting Origin', (e) => e.reportingOrigin),
new DateColumn<Source>(
'Source Registration Time', (e) => e.sourceTime),
new DateColumn<Source>('Expiry Time', (e) => e.expiryTime),
new DateColumn<Source>(
'Event Report Window Time', (e) => e.eventReportWindowTime),
new DateColumn<Source>(
'Aggregatable Report Window Time',
(e) => e.aggregatableReportWindowTime),
new ValueColumn<Source, string>('Source Type', (e) => e.sourceType),
new ValueColumn<Source, bigint>('Priority', (e) => e.priority),
new CodeColumn<Source>('Filter Data', (e) => e.filterData),
new CodeColumn<Source>('Aggregation Keys', (e) => e.aggregationKeys),
new ValueColumn<Source, string>(
'Aggregatable Budget Consumed',
(e) => `${e.aggregatableBudgetConsumed} / ${BUDGET_PER_SOURCE}`),
new ValueColumn<Source, string>('Debug Key', (e) => e.debugKey),
new ListColumn<Source, bigint>('Dedup Keys', (e) => e.dedupKeys),
new ListColumn<Source, bigint>(
'Aggregatable Dedup Keys', (e) => e.aggregatableDedupKeys),
],
5, // Sort by source registration time by default.
'No sources.',
);
}
override getRows() {
return this.storedSources;
}
setStoredSources(storedSources: Source[]) {
this.storedSources = storedSources;
this.notifyRowsChanged();
}
clear() {
this.storedSources = [];
this.notifyRowsChanged();
}
}
class Registration {
readonly time: Date;
readonly contextOrigin: string;
readonly reportingOrigin: string;
readonly registrationJson: string;
readonly clearedDebugKey: string;
constructor(mojo: WebUIRegistration) {
this.time = new Date(mojo.time);
this.contextOrigin = originToText(mojo.contextOrigin);
this.reportingOrigin = originToText(mojo.reportingOrigin);
this.registrationJson = mojo.registrationJson;
this.clearedDebugKey =
mojo.clearedDebugKey ? `${mojo.clearedDebugKey.value}` : '';
}
}
function registrationTableColumns<T extends Registration>(
contextOriginTitle: string): Array<Column<T>> {
return [
new DateColumn<T>('Time', (e) => e.time),
new ValueColumn<T, string>(contextOriginTitle, (e) => e.contextOrigin),
new ValueColumn<T, string>('Reporting Origin', (e) => e.reportingOrigin),
new CodeColumn<T>('Registration JSON', (e) => e.registrationJson),
new ValueColumn<T, string>('Cleared Debug Key', (e) => e.clearedDebugKey),
];
}
class RegistrationTableModel<T extends Registration> extends TableModel<T> {
private registrations: T[] = [];
constructor(contextOriginTitle: string, cols: Array<Column<T>>) {
super(
registrationTableColumns<T>(contextOriginTitle).concat(cols),
0, // Sort by time by default.
'No registrations.',
);
}
override getRows() {
return this.registrations;
}
addRegistration(registration: T) {
// Prevent the page from consuming ever more memory if the user leaves the
// page open for a long time.
if (this.registrations.length >= 1000) {
this.registrations = [];
}
this.registrations.push(registration);
this.notifyRowsChanged();
}
clear() {
this.registrations = [];
this.notifyRowsChanged();
}
}
class Trigger extends Registration {
readonly eventLevelStatus: string;
readonly aggregatableStatus: string;
readonly verification?: TriggerVerification;
constructor(mojo: WebUITrigger) {
super(mojo.registration);
this.eventLevelStatus = triggerStatusToText(mojo.eventLevelStatus);
this.aggregatableStatus = triggerStatusToText(mojo.aggregatableStatus);
this.verification = mojo.verification;
}
}
const VERIFICATION_COLS: Array<Column<TriggerVerification>> = [
new ValueColumn<TriggerVerification, string>('Token', e => e.token),
new ValueColumn<TriggerVerification, string>(
'Report ID', e => e.aggregatableReportId),
];
class ReportVerificationColumn implements Column<Trigger> {
renderHeader(th: HTMLElement) {
th.innerText = 'Report Verification';
}
render(td: HTMLElement, row: Trigger) {
if (row.verification) {
renderDL(td, row.verification, VERIFICATION_COLS);
}
}
}
class TriggerTableModel extends RegistrationTableModel<Trigger> {
constructor() {
super('Destination', [
new ValueColumn<Trigger, string>(
'Event-Level Status', (e) => e.eventLevelStatus),
new ValueColumn<Trigger, string>(
'Aggregatable Status', (e) => e.aggregatableStatus),
new ReportVerificationColumn(),
]);
}
}
class SourceRegistration extends Registration {
readonly type: string;
readonly status: string;
constructor(mojo: WebUISourceRegistration) {
super(mojo.registration);
this.type = sourceTypeToText(mojo.type);
this.status = sourceRegistrationStatusToText(mojo.status);
}
}
class SourceRegistrationTableModel extends
RegistrationTableModel<SourceRegistration> {
constructor() {
super('Source Origin', [
new ValueColumn<SourceRegistration, string>('Type', (e) => e.type),
new ValueColumn<SourceRegistration, string>('Status', (e) => e.status),
]);
}
}
class Report extends Selectable {
id: ReportID;
reportBody: string;
reportUrl: string;
triggerTime: Date;
reportTime: Date;
isDebug: boolean;
status: string;
sendFailed: boolean;
constructor(mojo: WebUIReport) {
super();
this.id = mojo.id;
this.reportBody = mojo.reportBody;
this.reportUrl = mojo.reportUrl.url;
this.triggerTime = new Date(mojo.triggerTime);
this.reportTime = new Date(mojo.reportTime);
// Only pending reports are selectable.
if (mojo.status.pending === undefined) {
this.input.disabled = true;
}
this.isDebug = this.reportUrl.indexOf(
'/.well-known/attribution-reporting/debug/') >= 0;
this.sendFailed = false;
if (mojo.status.sent !== undefined) {
this.status = `Sent: HTTP ${mojo.status.sent}`;
this.sendFailed = mojo.status.sent < 200 || mojo.status.sent >= 400;
} else if (mojo.status.pending !== undefined) {
this.status = 'Pending';
} else if (mojo.status.replacedByHigherPriorityReport !== undefined) {
this.status = `Replaced by higher-priority report: ${
mojo.status.replacedByHigherPriorityReport}`;
} else if (mojo.status.prohibitedByBrowserPolicy !== undefined) {
this.status = 'Prohibited by browser policy';
} else if (mojo.status.networkError !== undefined) {
this.status = `Network error: ${mojo.status.networkError}`;
this.sendFailed = true;
} else if (mojo.status.failedToAssemble !== undefined) {
this.status = 'Dropped due to assembly failure';
} else {
throw new Error('invalid ReportStatus union');
}
}
}
class EventLevelReport extends Report {
reportPriority: bigint;
attributedTruthfully: boolean;
constructor(mojo: WebUIReport) {
super(mojo);
this.reportPriority = mojo.data.eventLevelData!.priority;
this.attributedTruthfully = mojo.data.eventLevelData!.attributedTruthfully;
}
}
class AggregatableAttributionReport extends Report {
contributions: string;
verificationToken: string;
aggregationCoordinator: string;
isNullReport: boolean;
constructor(mojo: WebUIReport) {
super(mojo);
this.contributions = JSON.stringify(
mojo.data.aggregatableAttributionData!.contributions, bigintReplacer,
' ');
this.verificationToken =
mojo.data.aggregatableAttributionData!.verificationToken || '';
this.aggregationCoordinator =
mojo.data.aggregatableAttributionData!.aggregationCoordinator;
this.isNullReport = mojo.data.aggregatableAttributionData!.isNullReport;
}
}
function commonReportTableColumns<T extends Report>(): Array<Column<T>> {
return [
new CodeColumn<T>('Report Body', (e) => e.reportBody),
new ValueColumn<T, string>('Status', (e) => e.status),
new ReportUrlColumn<T>(),
new DateColumn<T>('Trigger Time', (e) => e.triggerTime),
new DateColumn<T>('Report Time', (e) => e.reportTime),
];
}
class ReportTableModel<T extends Report> extends TableModel<T> {
private readonly showDebugReportsCheckbox: HTMLInputElement;
private readonly hiddenDebugReportsSpan: HTMLSpanElement;
private sentOrDroppedReports: T[] = [];
private storedReports: T[] = [];
private debugReports: T[] = [];
constructor(
cols: Array<Column<T>>, showDebugReportsContainer: HTMLElement,
private readonly sendReportsButton: HTMLButtonElement,
private readonly handler: HandlerInterface) {
super(
commonReportTableColumns<T>().concat(cols),
5, // Sort by report time by default; the extra column is added below
'No sent or pending reports.',
);
// This can't be included in the super call above, as `this` can't be
// accessed until after `super` returns.
const selectionColumn = new SelectionColumn<T>(this);
this.cols.unshift(selectionColumn);
this.showDebugReportsCheckbox =
showDebugReportsContainer.querySelector<HTMLInputElement>(
'input[type="checkbox"]')!;
this.hiddenDebugReportsSpan =
showDebugReportsContainer.querySelector('span')!;
this.showDebugReportsCheckbox.addEventListener(
'input', () => this.notifyRowsChanged());
this.sendReportsButton.addEventListener('click', () => this.sendReports_());
selectionColumn.selectionChangedListeners.add((anySelected: boolean) => {
this.sendReportsButton.disabled = !anySelected;
});
this.rowsChangedListeners.add(() => this.updateHiddenDebugReportsSpan_());
}
override styleRow(tr: HTMLElement, report: Report) {
tr.classList.toggle('send-error', report.sendFailed);
}
override getRows() {
let rows = this.sentOrDroppedReports.concat(this.storedReports);
if (this.showDebugReportsCheckbox.checked) {
rows = rows.concat(this.debugReports);
}
return rows;
}
setStoredReports(storedReports: T[]) {
this.storedReports = storedReports;
this.notifyRowsChanged();
}
addSentOrDroppedReport(report: T) {
// Prevent the page from consuming ever more memory if the user leaves the
// page open for a long time.
if (this.sentOrDroppedReports.length + this.debugReports.length >= 1000) {
this.sentOrDroppedReports = [];
this.debugReports = [];
}
if (report.isDebug) {
this.debugReports.push(report);
} else {
this.sentOrDroppedReports.push(report);
}
this.notifyRowsChanged();
}
clear() {
this.storedReports = [];
this.sentOrDroppedReports = [];
this.debugReports = [];
this.notifyRowsChanged();
}
private updateHiddenDebugReportsSpan_() {
this.hiddenDebugReportsSpan.innerText =
this.showDebugReportsCheckbox.checked ?
'' :
` (${this.debugReports.length} hidden)`;
}
/**
* Sends all selected reports.
* Disables the button while the reports are still being sent.
* Observer.onReportsChanged and Observer.onSourcesChanged will be called
* automatically as reports are deleted, so there's no need to manually
* refresh the data on completion.
*/
private sendReports_() {
const ids: ReportID[] = [];
this.storedReports.forEach((report) => {
if (!report.input.disabled && report.input.checked) {
ids.push(report.id);
}
});
if (ids.length === 0) {
return;
}
const previousText = this.sendReportsButton.innerText;
this.sendReportsButton.disabled = true;
this.sendReportsButton.innerText = 'Sending...';
this.handler.sendReports(ids).then(() => {
this.sendReportsButton.innerText = previousText;
});
}
}
class EventLevelReportTableModel extends ReportTableModel<EventLevelReport> {
constructor(
showDebugReportsContainer: HTMLElement,
sendReportsButton: HTMLButtonElement, remote: HandlerInterface) {
super(
[
new ValueColumn<EventLevelReport, bigint>(
'Report Priority', (e) => e.reportPriority),
new ValueColumn<EventLevelReport, boolean>(
'Randomized Report', (e) => !e.attributedTruthfully),
],
showDebugReportsContainer,
sendReportsButton,
remote,
);
}
}
class AggregatableAttributionReportTableModel extends
ReportTableModel<AggregatableAttributionReport> {
constructor(
showDebugReportsContainer: HTMLElement,
sendReportsButton: HTMLButtonElement, remote: HandlerInterface) {
super(
[
new CodeColumn<AggregatableAttributionReport>(
'Histograms', (e) => e.contributions),
new ValueColumn<AggregatableAttributionReport, string>(
'Verification Token', (e) => e.verificationToken),
new ValueColumn<AggregatableAttributionReport, string>(
'Aggregation Coordinator', (e) => e.aggregationCoordinator),
new ValueColumn<AggregatableAttributionReport, boolean>(
'Null Report', (e) => e.isNullReport),
],
showDebugReportsContainer,
sendReportsButton,
remote,
);
}
}
class OsRegistration {
timestamp: Date;
registrationUrl: string;
topLevelOrigin: string;
registrationType: string;
debugKeyAllowed: boolean;
result: string;
constructor(mojo: WebUIOsRegistration) {
this.timestamp = new Date(mojo.time);
this.registrationUrl = mojo.registrationUrl.url;
this.topLevelOrigin = originToText(mojo.topLevelOrigin);
this.debugKeyAllowed = mojo.isDebugKeyAllowed;
switch (mojo.type) {
case OsRegistrationType.kSource:
this.registrationType = 'OS Source';
break;
case OsRegistrationType.kTrigger:
this.registrationType = 'OS Trigger';
break;
default:
assertNotReached();
}
switch (mojo.result) {
case OsRegistrationResult.kPassedToOs:
this.result = 'Passed to OS';
break;
case OsRegistrationResult.kUnsupported:
this.result = 'Unsupported';
break;
case OsRegistrationResult.kInvalidRegistrationUrl:
this.result = 'Invalid registration URL';
break;
case OsRegistrationResult.kProhibitedByBrowserPolicy:
this.result = 'Prohibited by browser policy';
break;
case OsRegistrationResult.kExcessiveQueueSize:
this.result = 'Excessive queue size';
break;
case OsRegistrationResult.kRejectedByOs:
this.result = 'Rejected by OS';
break;
default:
assertNotReached();
}
}
}
class OsRegistrationTableModel extends TableModel<OsRegistration> {
private osRegistrations: OsRegistration[] = [];
constructor() {
super(
[
new DateColumn<OsRegistration>('Timestamp', (e) => e.timestamp),
new ValueColumn<OsRegistration, string>(
'Registration Type', (e) => e.registrationType),
new ValueColumn<OsRegistration, string>(
'Registration URL', (e) => e.registrationUrl),
new ValueColumn<OsRegistration, string>(
'Top-Level Origin', (e) => e.topLevelOrigin),
new ValueColumn<OsRegistration, boolean>(
'Debug Key Allowed', (e) => e.debugKeyAllowed),
new ValueColumn<OsRegistration, string>('Result', (e) => e.result),
],
0,
'No OS Registrations',
);
}
override getRows() {
return this.osRegistrations;
}
addOsRegistration(osRegistration: OsRegistration) {
// Prevent the page from consuming ever more memory if the user leaves the
// page open for a long time.
if (this.osRegistrations.length >= 1000) {
this.osRegistrations = [];
}
this.osRegistrations.push(osRegistration);
this.notifyRowsChanged();
}
clear() {
this.osRegistrations = [];
this.notifyRowsChanged();
}
}
class DebugReport {
body: string;
url: string;
time: Date;
status: string;
constructor(mojo: WebUIDebugReport) {
this.body = mojo.body;
this.url = mojo.url.url;
this.time = new Date(mojo.time);
if (mojo.status.httpResponseCode !== undefined) {
this.status = `HTTP ${mojo.status.httpResponseCode}`;
} else if (mojo.status.networkError !== undefined) {
this.status = `Network error: ${mojo.status.networkError}`;
} else {
throw new Error('invalid DebugReportStatus union');
}
}
}
class DebugReportTableModel extends TableModel<DebugReport> {
private debugReports: DebugReport[] = [];
constructor() {
super(
[
new DateColumn<DebugReport>('Time', (e) => e.time),
new ValueColumn<DebugReport, string>('URL', (e) => e.url),
new ValueColumn<DebugReport, string>('Status', (e) => e.status),
new CodeColumn<DebugReport>('Body', (e) => e.body),
],
0, // Sort by report time by default.
'No verbose debug reports.',
);
}
// TODO(apaseltiner): Style error rows like `ReportTableModel`
override getRows() {
return this.debugReports;
}
add(report: DebugReport) {
// Prevent the page from consuming ever more memory if the user leaves the
// page open for a long time.
if (this.debugReports.length >= 1000) {
this.debugReports = [];
}
this.debugReports.push(report);
this.notifyRowsChanged();
}
clear() {
this.debugReports = [];
this.notifyRowsChanged();
}
}
function sourceRegistrationErrorToText(error: SourceRegistrationError) {
switch (error) {
case SourceRegistrationError.kInvalidJson:
return 'invalid syntax';
case SourceRegistrationError.kRootWrongType:
return 'root JSON value has wrong type (must be a dictionary)';
case SourceRegistrationError.kDestinationMissing:
return 'destination missing';
case SourceRegistrationError.kDestinationWrongType:
return 'destination has wrong type (must be a string)';
case SourceRegistrationError.kDestinationListTooLong:
return 'number of destinations exceeds limit';
case SourceRegistrationError.kDestinationUntrustworthy:
return 'destination not potentially trustworthy';
case SourceRegistrationError.kFilterDataWrongType:
return 'filter_data has wrong type (must be a dictionary)';
case SourceRegistrationError.kFilterDataTooManyKeys:
return 'filter_data has too many keys';
case SourceRegistrationError.kFilterDataHasSourceTypeKey:
return 'filter_data must not have a source_type key';
case SourceRegistrationError.kFilterDataKeyTooLong:
return 'filter_data key too long';
case SourceRegistrationError.kFilterDataListWrongType:
return 'filter_data value has wrong type (must be a list)';
case SourceRegistrationError.kFilterDataListTooLong:
return 'filter_data list too long';
case SourceRegistrationError.kFilterDataValueWrongType:
return 'filter_data list value has wrong type (must be a string)';
case SourceRegistrationError.kFilterDataValueTooLong:
return 'filter_data list value too long';
case SourceRegistrationError.kAggregationKeysWrongType:
return 'aggregation_keys has wrong type (must be a dictionary)';
case SourceRegistrationError.kAggregationKeysTooManyKeys:
return 'aggregation_keys has too many keys';
case SourceRegistrationError.kAggregationKeysKeyTooLong:
return 'aggregation_keys key too long';
case SourceRegistrationError.kAggregationKeysValueWrongType:
return 'aggregation_keys value has wrong type (must be a string)';
case SourceRegistrationError.kAggregationKeysValueWrongFormat:
return 'aggregation_keys value must be a base-16 integer starting with 0x';
default:
return 'unknown error';
}
}
/**
* Converts a mojo origin into a user-readable string, omitting default ports.
* @param origin Origin to convert
*/
function originToText(origin: Origin): string {
if (origin.host.length === 0) {
return 'Null';
}
let result = origin.scheme + '://' + origin.host;
if ((origin.scheme === 'https' && origin.port !== 443) ||
(origin.scheme === 'http' && origin.port !== 80)) {
result += ':' + origin.port;
}
return result;
}
/**
* Converts a mojo SourceType into a user-readable string.
* @param sourceType Source type to convert
*/
function sourceTypeToText(sourceType: SourceType): string {
switch (sourceType) {
case SourceType.kNavigation:
return 'Navigation';
case SourceType.kEvent:
return 'Event';
default:
assertNotReached();
}
}
/**
* Converts a mojo Attributability into a user-readable string.
* @param attributability Attributability to convert
*/
function attributabilityToText(attributability: WebUISource_Attributability):
string {
switch (attributability) {
case WebUISource_Attributability.kAttributable:
return 'Attributable';
case WebUISource_Attributability.kNoisedNever:
return 'Unattributable: noised with no reports';
case WebUISource_Attributability.kNoisedFalsely:
return 'Unattributable: noised with fake reports';
case WebUISource_Attributability.kReachedEventLevelAttributionLimit:
return 'Attributable: reached event-level attribution limit';
default:
assertNotReached();
}
}
function sourceRegistrationStatusToText(status: SourceStatus): string {
if (status.storeSourceResult !== undefined) {
switch (status.storeSourceResult) {
case StoreSourceResult.kSuccess:
case StoreSourceResult.kSuccessNoised:
return 'Success';
case StoreSourceResult.kInternalError:
return 'Rejected: internal error';
case StoreSourceResult.kInsufficientSourceCapacity:
return 'Rejected: insufficient source capacity';
case StoreSourceResult.kInsufficientUniqueDestinationCapacity:
return 'Rejected: insufficient unique destination capacity';
case StoreSourceResult.kExcessiveReportingOrigins:
return 'Rejected: excessive reporting origins';
case StoreSourceResult.kProhibitedByBrowserPolicy:
return 'Rejected: prohibited by browser policy';
default:
return status.toString();
}
} else if (status.jsonError !== undefined) {
return `Rejected: invalid JSON: ${
sourceRegistrationErrorToText(status.jsonError)}`;
} else {
return 'Unknown';
}
}
function triggerStatusToText(status: WebUITrigger_Status): string {
switch (status) {
case WebUITrigger_Status.kSuccess:
return 'Success: Report stored';
case WebUITrigger_Status.kInternalError:
return 'Failure: Internal error';
case WebUITrigger_Status.kNoMatchingSources:
return 'Failure: No matching sources';
case WebUITrigger_Status.kNoMatchingSourceFilterData:
return 'Failure: No matching source filter data';
case WebUITrigger_Status.kNoReportCapacityForDestinationSite:
return 'Failure: No report capacity for destination site';
case WebUITrigger_Status.kExcessiveAttributions:
return 'Failure: Excessive attributions';
case WebUITrigger_Status.kExcessiveReportingOrigins:
return 'Failure: Excessive reporting origins';
case WebUITrigger_Status.kDeduplicated:
return 'Failure: Deduplicated against an earlier report';
case WebUITrigger_Status.kReportWindowPassed:
return 'Failure: Report window has passed';
case WebUITrigger_Status.kLowPriority:
return 'Failure: Priority too low';
case WebUITrigger_Status.kNoised:
return 'Failure: Noised';
case WebUITrigger_Status.kNoHistograms:
return 'Failure: No source histograms';
case WebUITrigger_Status.kInsufficientBudget:
return 'Failure: Insufficient budget';
case WebUITrigger_Status.kNotRegistered:
return 'Failure: No aggregatable data present';
case WebUITrigger_Status.kProhibitedByBrowserPolicy:
return 'Failure: Prohibited by browser policy';
case WebUITrigger_Status.kNoMatchingConfigurations:
return 'Rejected: no matching event-level configurations';
case WebUITrigger_Status.kExcessiveReports:
return 'Failure: Excessive reports';
default:
assertNotReached();
}
}
class AttributionInternals implements ObserverInterface {
private readonly sources = new SourceTableModel();
private readonly sourceRegistrations = new SourceRegistrationTableModel();
private readonly triggers = new TriggerTableModel();
private readonly debugReports = new DebugReportTableModel();
private readonly osRegistrations = new OsRegistrationTableModel();
private readonly eventLevelReports: EventLevelReportTableModel;
private readonly aggregatableReports: AggregatableAttributionReportTableModel;
private readonly handler = new HandlerRemote();
constructor() {
this.eventLevelReports = new EventLevelReportTableModel(
document.querySelector<HTMLButtonElement>('#show-debug-event-reports')!,
document.querySelector<HTMLButtonElement>('#send-reports')!,
this.handler);
this.aggregatableReports = new AggregatableAttributionReportTableModel(
document.querySelector<HTMLButtonElement>(
'#show-debug-aggregatable-reports')!,
document.querySelector<HTMLButtonElement>('#send-aggregatable-reports')!
,
this.handler);
installUnreadIndicator(
this.sources, document.querySelector<HTMLElement>('#sources-tab')!);
installUnreadIndicator(
this.sourceRegistrations,
document.querySelector<HTMLElement>('#source-registrations-tab')!);
installUnreadIndicator(
this.triggers, document.querySelector<HTMLElement>('#triggers-tab')!);
installUnreadIndicator(
this.eventLevelReports,
document.querySelector<HTMLElement>('#event-level-reports-tab')!);
installUnreadIndicator(
this.aggregatableReports,
document.querySelector<HTMLElement>('#aggregatable-reports-tab')!);
installUnreadIndicator(
this.debugReports,
document.querySelector<HTMLElement>('#debug-reports-tab')!);
installUnreadIndicator(
this.osRegistrations, document.querySelector<HTMLElement>('#os-tab')!);
document
.querySelector<AttributionInternalsTableElement<Source>>(
'#sourceTable')!.setModel(this.sources);
document
.querySelector<AttributionInternalsTableElement<SourceRegistration>>(
'#sourceRegistrationTable')!.setModel(this.sourceRegistrations);
document
.querySelector<AttributionInternalsTableElement<Trigger>>(
'#triggerTable')!.setModel(this.triggers);
document
.querySelector<AttributionInternalsTableElement<EventLevelReport>>(
'#reportTable')!.setModel(this.eventLevelReports);
document
.querySelector<
AttributionInternalsTableElement<AggregatableAttributionReport>>(
'#aggregatableReportTable')!.setModel(this.aggregatableReports);
document
.querySelector<AttributionInternalsTableElement<DebugReport>>(
'#debugReportTable')!.setModel(this.debugReports);
document
.querySelector<AttributionInternalsTableElement<OsRegistration>>(
'#osRegistrationTable')!.setModel(this.osRegistrations);
Factory.getRemote().create(
new ObserverReceiver(this).$.bindNewPipeAndPassRemote(),
this.handler.$.bindNewPipeAndPassReceiver());
}
onSourcesChanged() {
this.updateSources();
}
onReportsChanged() {
this.updateReports();
}
onReportSent(mojo: WebUIReport) {
this.addSentOrDroppedReport(mojo);
}
onDebugReportSent(mojo: WebUIDebugReport) {
this.debugReports.add(new DebugReport(mojo));
}
onReportDropped(mojo: WebUIReport) {
this.addSentOrDroppedReport(mojo);
}
onSourceHandled(mojo: WebUISourceRegistration) {
this.sourceRegistrations.addRegistration(new SourceRegistration(mojo));
}
onTriggerHandled(mojo: WebUITrigger) {
this.triggers.addRegistration(new Trigger(mojo));
}
onOsRegistration(mojo: WebUIOsRegistration) {
this.osRegistrations.addOsRegistration(new OsRegistration(mojo));
}
private addSentOrDroppedReport(mojo: WebUIReport) {
if (mojo.data.eventLevelData !== undefined) {
this.eventLevelReports.addSentOrDroppedReport(new EventLevelReport(mojo));
} else {
this.aggregatableReports.addSentOrDroppedReport(
new AggregatableAttributionReport(mojo));
}
}
/**
* Deletes all data stored by the conversions backend.
* onReportsChanged and onSourcesChanged will be called
* automatically as data is deleted, so there's no need to manually refresh
* the data on completion.
*/
clearStorage() {
this.sources.clear();
this.sourceRegistrations.clear();
this.triggers.clear();
this.eventLevelReports.clear();
this.aggregatableReports.clear();
this.debugReports.clear();
this.osRegistrations.clear();
this.handler.clearStorage();
}
refresh() {
this.handler.isAttributionReportingEnabled().then((response) => {
const featureStatusContent =
document.querySelector<HTMLElement>('#feature-status-content')!;
featureStatusContent.innerText =
response.enabled ? 'enabled' : 'disabled';
featureStatusContent.classList.toggle('disabled', !response.enabled);
const debugModeContent =
document.querySelector<HTMLElement>('#debug-mode-content')!;
const html = getTrustedHTML`The #attribution-reporting-debug-mode flag is
<strong>enabled</strong>, reports are sent immediately and never pending.`;
debugModeContent.innerHTML = html as unknown as string;
if (!response.debugMode) {
debugModeContent.innerText = '';
}
const attributionSupport = document.querySelector<HTMLElement>('#attribution-support')!;
attributionSupport.innerText = response.attributionSupport;
});
this.updateSources();
this.updateReports();
}
private updateSources() {
this.handler.getActiveSources().then((response) => {
this.sources.setStoredSources(
response.sources.map((mojo) => new Source(mojo)));
});
}
private updateReports() {
this.handler.getReports().then(response => {
const eventLevelReports: EventLevelReport[] = [];
const aggregatableReports: AggregatableAttributionReport[] = [];
response.reports.forEach(report => {
if (report.data.eventLevelData !== undefined) {
eventLevelReports.push(new EventLevelReport(report));
} else if (report.data.aggregatableAttributionData !== undefined) {
aggregatableReports.push(new AggregatableAttributionReport(report));
}
});
this.eventLevelReports.setStoredReports(eventLevelReports);
this.aggregatableReports.setStoredReports(aggregatableReports);
});
}
}
function installUnreadIndicator(model: TableModel<any>, tab: HTMLElement) {
model.rowsChangedListeners.add(() => {
if (!tab.hasAttribute('selected')) {
tab.classList.add('unread');
}
});
}
document.addEventListener('DOMContentLoaded', function() {
const tabBox = document.querySelector('cr-tab-box')!;
tabBox.addEventListener('selected-index-change', e => {
const tabs = document.querySelectorAll<HTMLElement>('div[slot=\'tab\']');
tabs[(e as CustomEvent<number>).detail]!.classList.remove('unread');
});
const internals = new AttributionInternals();
document.querySelector('#refresh')!.addEventListener(
'click', () => internals.refresh());
document.querySelector('#clear-data')!.addEventListener(
'click', () => internals.clearStorage());
tabBox.hidden = false;
internals.refresh();
});