Fix calling tools JS

This commit is contained in:
ayumi-signal 2024-05-23 15:19:12 -07:00 committed by GitHub
parent 5f0080a7d7
commit 29eb07c159
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 27 additions and 23 deletions

View file

@ -0,0 +1,19 @@
// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details
export function assert(value, message) {
if (value) {
return;
}
throw new Error("Assertion failed" + (message ? `: ${message}` : ""));
}
export function assertInstanceof(value, type, message) {
if (value instanceof type) {
return;
}
throw new Error(
message || `Value ${value} is not of type ${type.name || typeof type}`,
);
}
export function assertNotReached(message = "Unreachable code hit") {
assert(false, message);
}

View file

@ -0,0 +1,219 @@
// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details
/**
* Creates a ICE candidate grid.
* @param {Element} peerConnectionElement
*/
import {$} from './util.js';
/**
* A helper function for appending a child element to |parent|.
* Copied from webrtc_internals.js
*
* @param {!Element} parent The parent element.
* @param {string} tag The child element tag.
* @param {string} text The textContent of the new DIV.
* @return {!Element} the new DIV element.
*/
function appendChildWithText(parent, tag, text) {
const child = document.createElement(tag);
child.textContent = text;
parent.appendChild(child);
return child;
}
export function createIceCandidateGrid(peerConnectionElement) {
const container = document.createElement('details');
appendChildWithText(container, 'summary', 'ICE candidate grid');
const table = document.createElement('table');
table.id = 'grid-' + peerConnectionElement.id;
table.className = 'candidategrid';
container.appendChild(table);
const tableHeader = document.createElement('tr');
table.append(tableHeader);
// For candidate pairs.
appendChildWithText(tableHeader, 'th', 'Candidate (pair) id');
// [1] is used for both candidate pairs and individual candidates.
appendChildWithText(tableHeader, 'th', 'State / Candidate type');
// For individual candidates.
appendChildWithText(tableHeader, 'th', 'Network type / address');
appendChildWithText(tableHeader, 'th', 'Port');
appendChildWithText(tableHeader, 'th', 'Protocol / candidate type');
appendChildWithText(tableHeader, 'th', '(Pair) Priority');
// For candidate pairs.
appendChildWithText(tableHeader, 'th', 'Bytes sent / received');
appendChildWithText(tableHeader, 'th',
'STUN requests sent / responses received');
appendChildWithText(tableHeader, 'th',
'STUN requests received / responses sent');
appendChildWithText(tableHeader, 'th', 'RTT');
appendChildWithText(tableHeader, 'th', 'Last data sent / received');
appendChildWithText(tableHeader, 'th', 'Last update');
peerConnectionElement.appendChild(container);
}
/**
* Creates or returns a table row in the ICE candidate grid.
* @param {string} peerConnectionElement id
* @param {string} stat object id
* @param {type} type of the row
*/
function findOrCreateGridRow(peerConnectionElementId, statId, type) {
const elementId = 'grid-' + peerConnectionElementId
+ '-' + statId + '-' + type;
let row = document.getElementById(elementId);
if (!row) {
row = document.createElement('tr');
row.id = elementId;
for (let i = 0; i < 12; i++) {
row.appendChild(document.createElement('td'));
}
$('grid-' + peerConnectionElementId).appendChild(row);
}
return row;
}
/**
* Updates a table row in the ICE candidate grid.
* @param {string} peerConnectionElement id
* @param {boolean} whether the pair is the selected pair of a transport
* (displayed bold)
* @param {object} candidate pair stats report
* @param {Map} full map of stats
*/
function appendRow(peerConnectionElement, active, candidatePair, stats) {
const pairRow = findOrCreateGridRow(peerConnectionElement.id,
candidatePair.id, 'candidatepair');
pairRow.classList.add('candidategrid-candidatepair')
if (active) {
pairRow.classList.add('candidategrid-active');
}
// Set transport-specific fields.
pairRow.children[0].innerText = candidatePair.id;
pairRow.children[1].innerText = candidatePair.state;
// Show (pair) priority as hex.
pairRow.children[5].innerText =
'0x' + parseInt(candidatePair.priority, 10).toString(16);
pairRow.children[6].innerText =
candidatePair.bytesSent + ' / ' + candidatePair.bytesReceived;
pairRow.children[7].innerText = candidatePair.requestsSent + ' / ' +
candidatePair.responsesReceived;
pairRow.children[8].innerText = candidatePair.requestsReceived + ' / ' +
candidatePair.responsesSent;
pairRow.children[9].innerText =
candidatePair.currentRoundTripTime !== undefined ?
candidatePair.currentRoundTripTime + 's' : '';
if (candidatePair.lastPacketSentTimestamp) {
pairRow.children[10].innerText =
(new Date(candidatePair.lastPacketSentTimestamp))
.toLocaleTimeString() + ' / ' +
(new Date(candidatePair.lastPacketReceivedTimestamp))
.toLocaleTimeString();
}
pairRow.children[11].innerText = (new Date()).toLocaleTimeString();
// Local candidate.
const localRow = findOrCreateGridRow(peerConnectionElement.id,
candidatePair.id, 'local');
localRow.className = 'candidategrid-candidate'
const localCandidate = stats.get(candidatePair.localCandidateId);
['id', 'type', 'address', 'port', 'candidateType',
'priority'].forEach((stat, index) => {
// `relayProtocol` is only set for local relay candidates.
if (stat == 'candidateType' && localCandidate.relayProtocol) {
localRow.children[index].innerText = localCandidate[stat] +
'(' + localCandidate.relayProtocol + ')';
if (localCandidate.url) {
localRow.children[index].innerText += '\n' + localCandidate.url;
}
} else if (stat === 'priority') {
const priority = parseInt(localCandidate[stat], 10) & 0xFFFFFFFF;
localRow.children[index].innerText = '0x' + priority.toString(16) +
// RFC 5245 - 4.1.2.1.
// priority = (2^24)*(type preference) +
// (2^8)*(local preference) +
// (2^0)*(256 - component ID)
'\n' + (priority >> 24) +
' | ' + ((priority >> 8) & 0xFFFF) +
' | ' + (priority & 0xFF);
} else if (stat === 'address') {
localRow.children[index].innerText = localCandidate[stat] || '(not set)';
} else {
localRow.children[index].innerText = localCandidate[stat];
}
});
// `networkType` is only known for the local candidate so put it into the
// pair row above the address. Also highlight VPN adapters.
pairRow.children[2].innerText = localCandidate.networkType;
if (localCandidate['vpn'] === true) {
pairRow.children[2].innerText += ' (VPN)';
}
// `protocol` must always be the same for the pair
// so put it into the pair row above the candidate type.
// Add `tcpType` for local candidates.
pairRow.children[4].innerText = localCandidate.protocol;
if (localCandidate.tcpType) {
pairRow.children[4].innerText += ' ' + localCandidate.tcpType;
}
// Remote candidate.
const remoteRow = findOrCreateGridRow(peerConnectionElement.id,
candidatePair.id, 'remote');
remoteRow.className = 'candidategrid-candidate'
const remoteCandidate = stats.get(candidatePair.remoteCandidateId);
['id', 'type', 'address', 'port', 'candidateType',
'priority'].forEach((stat, index) => {
if (stat === 'priority') {
remoteRow.children[index].innerText = '0x' +
parseInt(remoteCandidate[stat], 10).toString(16);
} else if (stat === 'address') {
remoteRow.children[index].innerText = remoteCandidate[stat] ||
'(not set)';
} else {
remoteRow.children[index].innerText = remoteCandidate[stat];
}
});
return pairRow;
}
/**
* Updates the (spec) ICE candidate grid.
* @param {Element} peerConnectionElement
* @param {Map} stats reconstructed stats object.
*/
export function updateIceCandidateGrid(peerConnectionElement, stats) {
const container = $('grid-' + peerConnectionElement.id);
// Remove the active/bold marker from all rows.
container.childNodes.forEach(row => {
row.classList.remove('candidategrid-active');
});
let activePairIds = [];
// Find the active transport(s), then find its candidate pair
// and display it first. Note that previously selected pairs continue to be
// shown since rows are not removed.
stats.forEach(transportReport => {
if (transportReport.type !== 'transport') {
return;
}
if (!transportReport.selectedCandidatePairId) {
return;
}
activePairIds.push(transportReport.selectedCandidatePairId);
appendRow(peerConnectionElement, true,
stats.get(transportReport.selectedCandidatePairId), stats);
});
// Then iterate over the other candidate pairs.
stats.forEach(report => {
if (report.type !== 'candidate-pair' || activePairIds.includes(report.id)) {
return;
}
appendRow(peerConnectionElement, false, report, stats);
});
}

View file

@ -0,0 +1,133 @@
// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details
// The maximum number of data points buffered for each stats. Old data points
// will be shifted out when the buffer is full.
export const MAX_STATS_DATA_POINT_BUFFER_SIZE = 1000;
/**
* A TimelineDataSeries collects an ordered series of (time, value) pairs,
* and converts them to graph points. It also keeps track of its color and
* current visibility state.
* It keeps MAX_STATS_DATA_POINT_BUFFER_SIZE data points at most. Old data
* points will be dropped when it reaches this size.
*/
export class TimelineDataSeries {
constructor(statsType) {
// List of DataPoints in chronological order.
this.dataPoints_ = [];
// Default color. Should always be overridden prior to display.
this.color_ = 'red';
// Whether or not the data series should be drawn.
this.isVisible_ = true;
this.cacheStartTime_ = null;
this.cacheStepSize_ = 0;
this.cacheValues_ = [];
this.statsType_ = statsType;
}
/**
* @override
*/
toJSON() {
if (this.dataPoints_.length < 1) {
return {};
}
const values = [];
for (let i = 0; i < this.dataPoints_.length; ++i) {
values.push(this.dataPoints_[i].value);
}
return {
startTime: this.dataPoints_[0].time,
endTime: this.dataPoints_[this.dataPoints_.length - 1].time,
statsType: this.statsType_,
values: JSON.stringify(values),
};
}
/**
* Adds a DataPoint to |this| with the specified time and value.
* DataPoints are assumed to be received in chronological order.
*/
addPoint(timeTicks, value) {
const time = new Date(timeTicks);
this.dataPoints_.push(new DataPoint(time, value));
if (this.dataPoints_.length > MAX_STATS_DATA_POINT_BUFFER_SIZE) {
this.dataPoints_.shift();
}
}
isVisible() {
return this.isVisible_;
}
show(isVisible) {
this.isVisible_ = isVisible;
}
getColor() {
return this.color_;
}
setColor(color) {
this.color_ = color;
}
getCount() {
return this.dataPoints_.length;
}
/**
* Returns a list containing the values of the data series at |count|
* points, starting at |startTime|, and |stepSize| milliseconds apart.
* Caches values, so showing/hiding individual data series is fast.
*/
getValues(startTime, stepSize, count) {
// Use cached values, if we can.
if (this.cacheStartTime_ === startTime &&
this.cacheStepSize_ === stepSize &&
this.cacheValues_.length === count) {
return this.cacheValues_;
}
// Do all the work.
this.cacheValues_ = this.getValuesInternal_(startTime, stepSize, count);
this.cacheStartTime_ = startTime;
this.cacheStepSize_ = stepSize;
return this.cacheValues_;
}
/**
* Returns the cached |values| in the specified time period.
*/
getValuesInternal_(startTime, stepSize, count) {
const values = [];
let nextPoint = 0;
let currentValue = 0;
let time = startTime;
for (let i = 0; i < count; ++i) {
while (nextPoint < this.dataPoints_.length &&
this.dataPoints_[nextPoint].time < time) {
currentValue = this.dataPoints_[nextPoint].value;
++nextPoint;
}
values[i] = currentValue;
time += stepSize;
}
return values;
}
}
/**
* A single point in a data series. Each point has a time, in the form of
* milliseconds since the Unix epoch, and a numeric value.
*/
class DataPoint {
constructor(time, value) {
this.time = time;
this.value = value;
}
}

View file

@ -0,0 +1,170 @@
// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details
import {$} from './util.js';
/** A list of getUserMedia requests. */
export const userMediaRequests = [];
/** A map from peer connection id to the PeerConnectionRecord. */
export const peerConnectionDataStore = {};
// Also duplicating on window since tests access these from C++.
window.userMediaRequests = userMediaRequests;
window.peerConnectionDataStore = peerConnectionDataStore;
/**
* Provides the UI for dump creation.
*/
export class DumpCreator {
/**
* @param {Element} containerElement The parent element of the dump creation
* UI.
*/
constructor(containerElement) {
/**
* The root element of the dump creation UI.
* @type {Element}
* @private
*/
// Signal change, remove unused diagnostic UI
}
createDumpRoot(containerElement) {
this.dumpRoot_ = document.createElement('details');
this.dumpRoot_.className = 'peer-connection-dump-root';
containerElement.appendChild(this.dumpRoot_);
const summary = document.createElement('summary');
this.dumpRoot_.appendChild(summary);
summary.textContent = 'Create a WebRTC-Internals dump';
const content = document.createElement('div');
this.dumpRoot_.appendChild(content);
content.appendChild($('dump-template').content.cloneNode(true));
content.getElementsByTagName('a')[0].addEventListener(
'click', this.onDownloadData_.bind(this));
}
createAudioRecordingRoot(containerElement) {
this.audioRoot_ = document.createElement('details');
this.audioRoot_.className = 'peer-connection-dump-root';
containerElement.appendChild(this.audioRoot_);
const summary = document.createElement('summary');
this.audioRoot_.appendChild(summary);
summary.textContent = 'Create diagnostic audio recordings';
const content = document.createElement('div');
this.audioRoot_.appendChild(content);
content.appendChild($('audio-recording-template').content.cloneNode(true));
content.getElementsByTagName('input')[0].addEventListener(
'click', this.onAudioDebugRecordingsChanged_.bind(this));
}
createPacketRecordingRoot(containerElement) {
this.packetRoot_ = document.createElement('details');
this.packetRoot_.className = 'peer-connection-dump-root';
containerElement.appendChild(this.packetRoot_);
const summary = document.createElement('summary');
this.packetRoot_.appendChild(summary);
summary.textContent = 'Create diagnostic packet recordings';
const content = document.createElement('div');
this.packetRoot_.appendChild(content);
content.appendChild($('packet-recording-template').content.cloneNode(true));
content.getElementsByTagName('input')[0].addEventListener(
'click', this.onEventLogRecordingsChanged_.bind(this));
}
// Mark the diagnostic audio recording checkbox checked.
setAudioDebugRecordingsCheckbox() {
this.audioRoot_.getElementsByTagName('input')[0].checked = true;
}
// Mark the diagnostic audio recording checkbox unchecked.
clearAudioDebugRecordingsCheckbox() {
this.audioRoot_.getElementsByTagName('input')[0].checked = false;
}
// Mark the event log recording checkbox checked.
setEventLogRecordingsCheckbox() {
this.packetRoot_.getElementsByTagName('input')[0].checked = true;
}
// Mark the event log recording checkbox unchecked.
clearEventLogRecordingsCheckbox() {
this.packetRoot_.getElementsByTagName('input')[0].checked = false;
}
// Mark the event log recording checkbox as mutable/immutable.
setEventLogRecordingsCheckboxMutability(mutable) {
this.packetRoot_.getElementsByTagName('input')[0].disabled = !mutable;
if (!mutable) {
const label = this.packetRoot_.getElementsByTagName('label')[0];
label.style = 'color:red;';
label.textContent =
' WebRTC event logging\'s state was set by a command line flag.';
}
}
/**
* Downloads the PeerConnection updates and stats data as a file.
*
* @private
*/
async onDownloadData_(event) {
const useCompression = this.dumpRoot_.getElementsByTagName('input')[0].checked;
const dumpObject = {
'getUserMedia': userMediaRequests,
'PeerConnections': peerConnectionDataStore,
'UserAgent': navigator.userAgent,
};
const textBlob =
new Blob([JSON.stringify(dumpObject, null, 1)], {type: 'octet/stream'});
let url;
if (useCompression) {
const compressionStream = new CompressionStream('gzip');
const binaryStream = textBlob.stream().pipeThrough(compressionStream);
const binaryBlob = await new Response(binaryStream).blob();
url = URL.createObjectURL(binaryBlob);
// Since this is async we can't use the default event and need to click
// again (while avoiding an infinite loop).
const anchor = document.createElement('a');
anchor.download = 'webrtc_internals_dump.gz'
anchor.href = url;
anchor.click();
return;
}
url = URL.createObjectURL(textBlob);
const anchor = this.dumpRoot_.getElementsByTagName('a')[0];
anchor.download = 'webrtc_internals_dump.txt'
anchor.href = url;
// The default action of the anchor will download the url.
}
/**
* Handles the event of toggling the audio debug recordings state.
*
* @private
*/
onAudioDebugRecordingsChanged_() {
const enabled = this.audioRoot_.getElementsByTagName('input')[0].checked;
if (enabled) {
// chrome.send('enableAudioDebugRecordings');
} else {
// chrome.send('disableAudioDebugRecordings');
}
}
/**
* Handles the event of toggling the event log recordings state.
*
* @private
*/
onEventLogRecordingsChanged_() {
const enabled = this.packetRoot_.getElementsByTagName('input')[0].checked;
if (enabled) {
// chrome.send('enableEventLogRecordings');
} else {
// chrome.send('disableEventLogRecordings');
}
}
}

View file

@ -0,0 +1,263 @@
// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details
import {$} from './util.js';
const MAX_NUMBER_OF_STATE_CHANGES_DISPLAYED = 10;
const MAX_NUMBER_OF_EXPANDED_MEDIASECTIONS = 10;
/**
* The data of a peer connection update.
* @param {number} pid The id of the renderer.
* @param {number} lid The id of the peer conneciton inside a renderer.
* @param {string} type The type of the update.
* @param {string} value The details of the update.
*/
class PeerConnectionUpdateEntry {
constructor(pid, lid, type, value) {
/**
* @type {number}
*/
this.pid = pid;
/**
* @type {number}
*/
this.lid = lid;
/**
* @type {string}
*/
this.type = type;
/**
* @type {string}
*/
this.value = value;
}
}
/**
* Maintains the peer connection update log table.
*/
export class PeerConnectionUpdateTable {
constructor() {
/**
* @type {string}
* @const
* @private
*/
this.UPDATE_LOG_ID_SUFFIX_ = '-update-log';
/**
* @type {string}
* @const
* @private
*/
this.UPDATE_LOG_CONTAINER_CLASS_ = 'update-log-container';
/**
* @type {string}
* @const
* @private
*/
this.UPDATE_LOG_TABLE_CLASS = 'update-log-table';
}
/**
* Adds the update to the update table as a new row. The type of the update
* is set to the summary of the cell; clicking the cell will reveal or hide
* the details as the content of a TextArea element.
*
* @param {!Element} peerConnectionElement The root element.
* @param {!PeerConnectionUpdateEntry} update The update to add.
*/
addPeerConnectionUpdate(peerConnectionElement, update) {
const tableElement = this.ensureUpdateContainer_(peerConnectionElement);
const row = document.createElement('tr');
tableElement.firstChild.appendChild(row);
const time = new Date(parseFloat(update.time));
const timeItem = document.createElement('td');
timeItem.textContent = time.toLocaleString();
row.appendChild(timeItem);
// `type` is a display variant of update.type which does not get serialized
// into the JSON dump.
let type = update.type;
if (update.value.length === 0) {
const typeItem = document.createElement('td');
typeItem.textContent = type;
row.appendChild(typeItem);
return;
}
if (update.type === 'icecandidate' || update.type === 'addIceCandidate') {
const parts = update.value.split(', ');
type += '(' + parts[0] + ', ' + parts[1]; // show sdpMid/sdpMLineIndex.
const candidateParts = parts[2].substr(11).split(' ');
if (candidateParts && candidateParts[7]) { // show candidate type.
type += ', type: ' + candidateParts[7];
}
type += ')';
} else if (
update.type === 'createOfferOnSuccess' ||
update.type === 'createAnswerOnSuccess') {
this.setLastOfferAnswer_(tableElement, update);
} else if (update.type === 'setLocalDescription') {
const lastOfferAnswer = this.getLastOfferAnswer_(tableElement);
if (update.value.startsWith('type: rollback')) {
this.setLastOfferAnswer_(tableElement, {value: undefined})
} else if (lastOfferAnswer && update.value !== lastOfferAnswer) {
type += ' (munged)';
}
} else if (update.type === 'setConfiguration') {
// Update the configuration that is displayed at the top.
peerConnectionElement.firstChild.children[2].textContent = update.value;
} else if (['transceiverAdded',
'transceiverModified'].includes(update.type)) {
// Show the transceiver index.
const indexLine = update.value.split('\n', 3)[2];
if (indexLine.startsWith('getTransceivers()[')) {
type += ' ' + indexLine.substring(17, indexLine.length - 2);
}
const kindLine = update.value.split('\n', 5)[4].trim();
if (kindLine.startsWith('kind:')) {
type += ', ' + kindLine.substring(6, kindLine.length - 2);
}
} else if (['iceconnectionstatechange', 'connectionstatechange',
'signalingstatechange'].includes(update.type)) {
const fieldName = {
'iceconnectionstatechange' : 'iceconnectionstate',
'connectionstatechange' : 'connectionstate',
'signalingstatechange' : 'signalingstate',
}[update.type];
const el = peerConnectionElement.getElementsByClassName(fieldName)[0];
const numberOfEvents = el.textContent.split(' => ').length;
if (numberOfEvents < MAX_NUMBER_OF_STATE_CHANGES_DISPLAYED) {
el.textContent += ' => ' + update.value;
} else if (numberOfEvents >= MAX_NUMBER_OF_STATE_CHANGES_DISPLAYED) {
el.textContent += ' => ...';
}
}
const summaryItem = $('summary-template').content.cloneNode(true);
const summary = summaryItem.querySelector('summary');
summary.textContent = type;
row.appendChild(summaryItem);
const valueContainer = document.createElement('pre');
const details = row.cells[1].childNodes[0];
details.appendChild(valueContainer);
// Highlight ICE/DTLS failures and failure callbacks.
if ((update.type === 'iceconnectionstatechange' &&
update.value === 'failed') ||
(update.type === 'connectionstatechange' &&
update.value === 'failed') ||
update.type.indexOf('OnFailure') !== -1 ||
update.type === 'addIceCandidateFailed') {
valueContainer.parentElement.classList.add('update-log-failure');
}
// RTCSessionDescription is serialized as 'type: <type>, sdp:'
if (update.value.indexOf(', sdp:') !== -1) {
const [type, sdp] = update.value.substr(6).split(', sdp: ');
if (type === 'rollback') {
// Rollback has no SDP.
summary.textContent += ' (type: "rollback")';
} else {
// Create a copy-to-clipboard button.
const copyBtn = document.createElement('button');
copyBtn.textContent = 'Copy description to clipboard';
copyBtn.onclick = () => {
navigator.clipboard.writeText(JSON.stringify({type, sdp}));
};
valueContainer.appendChild(copyBtn);
// Fold the SDP sections.
const sections = sdp.split('\nm=')
.map((part, index) => (index > 0 ?
'm=' + part : part).trim() + '\r\n');
summary.textContent +=
' (type: "' + type + '", ' + sections.length + ' sections)';
sections.forEach(section => {
const lines = section.trim().split('\n');
// Extract the mid attribute.
const mid = lines
.filter(line => line.startsWith('a=mid:'))
.map(line => line.substr(6))[0];
const sectionDetails = document.createElement('details');
// Fold by default for large SDP.
sectionDetails.open =
sections.length <= MAX_NUMBER_OF_EXPANDED_MEDIASECTIONS;
sectionDetails.textContent = lines.slice(1).join('\n');
const sectionSummary = document.createElement('summary');
sectionSummary.textContent =
lines[0].trim() +
' (' + (lines.length - 1) + ' more lines)' +
(mid ? ' mid=' + mid : '');
sectionDetails.appendChild(sectionSummary);
valueContainer.appendChild(sectionDetails);
});
}
} else {
valueContainer.textContent = update.value;
}
}
/**
* Makes sure the update log table of the peer connection is created.
*
* @param {!Element} peerConnectionElement The root element.
* @return {!Element} The log table element.
* @private
*/
ensureUpdateContainer_(peerConnectionElement) {
const tableId = peerConnectionElement.id + this.UPDATE_LOG_ID_SUFFIX_;
// Disable getElementById restriction here, since |tableId| is not always
// a valid selector.
// eslint-disable-next-line no-restricted-properties
let tableElement = document.getElementById(tableId);
if (!tableElement) {
const tableContainer = document.createElement('div');
tableContainer.className = this.UPDATE_LOG_CONTAINER_CLASS_;
peerConnectionElement.appendChild(tableContainer);
tableElement = document.createElement('table');
tableElement.className = this.UPDATE_LOG_TABLE_CLASS;
tableElement.id = tableId;
tableElement.border = 1;
tableContainer.appendChild(tableElement);
tableElement.appendChild(
$('time-event-template').content.cloneNode(true));
}
return tableElement;
}
/**
* Store the last createOfferOnSuccess/createAnswerOnSuccess to compare to
* setLocalDescription and visualize SDP munging.
*
* @param {!Element} tableElement The peerconnection update element.
* @param {!PeerConnectionUpdateEntry} update The update to add.
* @private
*/
setLastOfferAnswer_(tableElement, update) {
tableElement['data-lastofferanswer'] = update.value;
}
/**
* Retrieves the last createOfferOnSuccess/createAnswerOnSuccess to compare
* to setLocalDescription and visualize SDP munging.
*
* @param {!Element} tableElement The peerconnection update element.
* @private
*/
getLastOfferAnswer_(tableElement) {
return tableElement['data-lastofferanswer'];
}
}

View file

@ -0,0 +1,307 @@
// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details
//
// This file contains helper methods to draw the stats timeline graphs.
// Each graph represents a series of stats report for a PeerConnection,
// e.g. 1234-0-ssrc-abcd123-bytesSent is the graph for the series of bytesSent
// for ssrc-abcd123 of PeerConnection 0 in process 1234.
// The graphs are drawn as CANVAS, grouped per report type per PeerConnection.
// Each group has an expand/collapse button and is collapsed initially.
//
import {$} from './util.js';
import {TimelineDataSeries} from './data_series.js';
import {peerConnectionDataStore} from './dump_creator.js';
import {generateStatsLabel} from './stats_helper.js';
import {TimelineGraphView} from './timeline_graph_view.js';
const STATS_GRAPH_CONTAINER_HEADING_CLASS = 'stats-graph-container-heading';
function isReportBlocklisted(report) {
// Codec stats reflect what has been negotiated. They don't contain
// information that is useful in graphs.
if (report.type === 'codec') {
return true;
}
// Unused data channels can stay in "connecting" indefinitely and their
// counters stay zero.
if (report.type === 'data-channel' &&
readReportStat(report, 'state') === 'connecting') {
return true;
}
// The same is true for transports and "new".
if (report.type === 'transport' &&
readReportStat(report, 'dtlsState') === 'new') {
return true;
}
// Local and remote candidates don't change over time and there are several of
// them.
if (report.type === 'local-candidate' || report.type === 'remote-candidate') {
return true;
}
return false;
}
function readReportStat(report, stat) {
const values = report.stats.values;
for (let i = 0; i < values.length; i += 2) {
if (values[i] === stat) {
return values[i + 1];
}
}
return undefined;
}
function isStatBlocklisted(report, statName) {
// The priority does not change over time on its own; plotting uninteresting.
if (report.type === 'candidate-pair' && statName === 'priority') {
return true;
}
// The mid/rid and ssrcs associated with a sender/receiver do not change
// over time; plotting uninteresting.
if (['inbound-rtp', 'outbound-rtp'].includes(report.type) &&
['mid', 'rid', 'ssrc', 'rtxSsrc', 'fecSsrc'].includes(statName)) {
return true;
}
return false;
}
const graphViews = {};
// Export on |window| since tests access this directly from C++.
window.graphViews = graphViews;
const graphElementsByPeerConnectionId = new Map();
// Returns number parsed from |value|, or NaN.
function getNumberFromValue(name, value) {
if (isNaN(value)) {
return NaN;
}
return parseFloat(value);
}
// Adds the stats report |report| to the timeline graph for the given
// |peerConnectionElement|.
export function drawSingleReport(
peerConnectionElement, report) {
const reportType = report.type;
const reportId = report.id;
const stats = report.stats;
if (!stats || !stats.values) {
return;
}
const childrenBefore = peerConnectionElement.hasChildNodes() ?
Array.from(peerConnectionElement.childNodes) :
[];
for (let i = 0; i < stats.values.length - 1; i = i + 2) {
const rawLabel = stats.values[i];
const rawDataSeriesId = reportId + '-' + rawLabel;
const rawValue = getNumberFromValue(rawLabel, stats.values[i + 1]);
if (isNaN(rawValue)) {
// We do not draw non-numerical values, but still want to record it in the
// data series.
addDataSeriesPoints(
peerConnectionElement, reportType, rawDataSeriesId, rawLabel,
[stats.timestamp], [stats.values[i + 1]]);
continue;
}
let finalDataSeriesId = rawDataSeriesId;
let finalLabel = rawLabel;
let finalValue = rawValue;
// Updates the final dataSeries to draw.
addDataSeriesPoints(
peerConnectionElement, reportType, finalDataSeriesId, finalLabel,
[stats.timestamp], [finalValue]);
if (isReportBlocklisted(report) || isStatBlocklisted(report, rawLabel)) {
// We do not want to draw certain reports but still want to
// record them in the data series.
continue;
}
// Updates the graph.
const graphType = finalLabel;
const graphViewId =
peerConnectionElement.id + '-' + reportId + '-' + graphType;
if (!graphViews[graphViewId]) {
graphViews[graphViewId] =
createStatsGraphView(peerConnectionElement, report, graphType);
const searchParameters = new URLSearchParams(window.location.search);
if (searchParameters.has('statsInterval')) {
const statsInterval = Math.max(
parseInt(searchParameters.get('statsInterval'), 10),
100);
if (isFinite(statsInterval)) {
graphViews[graphViewId].setScale(statsInterval);
}
}
const date = new Date(stats.timestamp);
graphViews[graphViewId].setDateRange(date, date);
}
// Ensures the stats graph title is up-to-date.
ensureStatsGraphContainer(peerConnectionElement, report);
// Adds the new dataSeries to the graphView. We have to do it here to cover
// both the simple and compound graph cases.
const dataSeries =
peerConnectionDataStore[peerConnectionElement.id].getDataSeries(
finalDataSeriesId);
if (!graphViews[graphViewId].hasDataSeries(dataSeries)) {
graphViews[graphViewId].addDataSeries(dataSeries);
}
graphViews[graphViewId].updateEndDate();
}
// Add a synthetic data series for the timestamp.
addDataSeriesPoints(
peerConnectionElement, reportType, reportId + '-timestamp',
reportId + '-timestamp', [stats.timestamp], [stats.timestamp]);
const childrenAfter = peerConnectionElement.hasChildNodes() ?
Array.from(peerConnectionElement.childNodes) :
[];
for (let i = 0; i < childrenAfter.length; ++i) {
if (!childrenBefore.includes(childrenAfter[i])) {
let graphElements =
graphElementsByPeerConnectionId.get(peerConnectionElement.id);
if (!graphElements) {
graphElements = [];
graphElementsByPeerConnectionId.set(
peerConnectionElement.id, graphElements);
}
graphElements.push(childrenAfter[i]);
}
}
}
export function removeStatsReportGraphs(peerConnectionElement) {
const graphElements =
graphElementsByPeerConnectionId.get(peerConnectionElement.id);
if (graphElements) {
for (let i = 0; i < graphElements.length; ++i) {
peerConnectionElement.removeChild(graphElements[i]);
}
graphElementsByPeerConnectionId.delete(peerConnectionElement.id);
}
Object.keys(graphViews).forEach(key => {
if (key.startsWith(peerConnectionElement.id)) {
delete graphViews[key];
}
});
}
// Makes sure the TimelineDataSeries with id |dataSeriesId| is created,
// and adds the new data points to it. |times| is the list of timestamps for
// each data point, and |values| is the list of the data point values.
function addDataSeriesPoints(
peerConnectionElement, reportType, dataSeriesId, label, times, values) {
let dataSeries =
peerConnectionDataStore[peerConnectionElement.id].getDataSeries(
dataSeriesId);
if (!dataSeries) {
dataSeries = new TimelineDataSeries(reportType);
peerConnectionDataStore[peerConnectionElement.id].setDataSeries(
dataSeriesId, dataSeries);
}
for (let i = 0; i < times.length; ++i) {
dataSeries.addPoint(times[i], values[i]);
}
}
// Ensures a div container to the stats graph for a peerConnectionElement is
// created as a child of the |peerConnectionElement|.
function ensureStatsGraphTopContainer(peerConnectionElement) {
const containerId = peerConnectionElement.id + '-graph-container';
let container = document.getElementById(containerId);
if (!container) {
container = document.createElement('div');
container.id = containerId;
container.className = 'stats-graph-container';
const label = document.createElement('label');
label.innerText = 'Filter statistics graphs by type including ';
container.appendChild(label);
const input = document.createElement('input');
input.placeholder = 'separate multiple values by `,`';
input.size = 25;
input.oninput = (e) => filterStats(e, container);
container.appendChild(input);
peerConnectionElement.appendChild(container);
}
return container;
}
// Ensures a div container to the stats graph for a single set of data is
// created as a child of the |peerConnectionElement|'s graph container.
function ensureStatsGraphContainer(peerConnectionElement, report) {
const topContainer = ensureStatsGraphTopContainer(peerConnectionElement);
const containerId = peerConnectionElement.id + '-' + report.type + '-' +
report.id + '-graph-container';
// Disable getElementById restriction here, since |containerId| is not always
// a valid selector.
// eslint-disable-next-line no-restricted-properties
let container = document.getElementById(containerId);
if (!container) {
container = document.createElement('details');
container.id = containerId;
container.className = 'stats-graph-container';
container.attributes['data-statsType'] = report.type;
peerConnectionElement.appendChild(container);
container.appendChild($('summary-span-template').content.cloneNode(true));
container.firstChild.firstChild.className =
STATS_GRAPH_CONTAINER_HEADING_CLASS;
topContainer.appendChild(container);
}
// Update the label all the time to account for new information.
container.firstChild.firstChild.textContent = 'Stats graphs for ' +
generateStatsLabel(report);
return container;
}
// Creates the container elements holding a timeline graph
// and the TimelineGraphView object.
function createStatsGraphView(peerConnectionElement, report, statsName) {
const topContainer =
ensureStatsGraphContainer(peerConnectionElement, report);
const graphViewId =
peerConnectionElement.id + '-' + report.id + '-' + statsName;
const divId = graphViewId + '-div';
const canvasId = graphViewId + '-canvas';
const container = document.createElement('div');
container.className = 'stats-graph-sub-container';
topContainer.appendChild(container);
const canvasDiv = $('container-template').content.cloneNode(true);
canvasDiv.querySelectorAll('div')[0].textContent = statsName;
canvasDiv.querySelectorAll('div')[1].id = divId;
canvasDiv.querySelector('canvas').id = canvasId;
container.appendChild(canvasDiv);
return new TimelineGraphView(divId, canvasId);
}
/**
* Apply a filter to the stats graphs
* @param event InputEvent from the filter input field.
* @param container stats table container element.
* @private
*/
function filterStats(event, container) {
const filter = event.target.value;
const filters = filter.split(',');
container.childNodes.forEach(node => {
if (node.nodeName !== 'DETAILS') {
return;
}
const statsType = node.attributes['data-statsType'];
if (!filter || filters.includes(statsType) ||
filters.find(f => statsType.includes(f))) {
node.style.display = 'block';
} else {
node.style.display = 'none';
}
});
}

View file

@ -0,0 +1,57 @@
// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details
/**
* @param {!Object} statsValues The object containing stats, an
* array [key1, val1, key2, val2, ...] so searching a certain
* key needs to ensure it does not collide with a value.
*/
function generateLabel(key, statsValues) {
let label = '';
const statIndex = statsValues.findIndex((value, index) => {
return value === key && index % 2 === 0;
});
if (statIndex !== -1) {
label += key + '=' + statsValues[statIndex + 1];
}
return label;
}
/**
* Formats the display text used for a stats type that is shown
* in the stats table or the stats graph.
*
* @param {!Object} report The object containing stats, which is the object
* containing timestamp and values, which is an array of strings, whose
* even index entry is the name of the stat, and the odd index entry is
* the value.
*/
export function generateStatsLabel(report) {
let label = report.type + ' (';
let labels = [];
if (['outbound-rtp', 'remote-outbound-rtp', 'inbound-rtp',
'remote-inbound-rtp'].includes(report.type) && report.stats.values) {
labels = ['kind', 'mid', 'rid', 'ssrc', 'rtxSsrc', 'fecSsrc',
'scalabilityMode',
'encoderImplementation', 'decoderImplementation',
'powerEfficientEncoder', 'powerEfficientDecoder',
'[codec]'];
} else if (['local-candidate', 'remote-candidate'].includes(report.type)) {
labels = ['candidateType', 'tcpType', 'relayProtocol'];
} else if (report.type === 'codec') {
labels = ['mimeType', 'payloadType'];
} else if (['media-playout', 'media-source'].includes(report.type)) {
labels = ['kind'];
} else if (report.type === 'candidate-pair') {
labels = ['state'];
} else if (report.type === 'transport') {
labels = ['iceState', 'dtlsState'];
}
labels = labels
.map(stat => generateLabel(stat, report.stats.values))
.filter(label => !!label);
if (labels.length) {
label += labels.join(', ') + ', ';
}
label += 'id=' + report.id + ')';
return label;
}

View file

@ -0,0 +1,609 @@
// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details
const CalculatorModifier = Object.freeze({
kNone: Object.freeze({postfix: '', multiplier: 1}),
kMillisecondsFromSeconds:
Object.freeze({postfix: '_in_ms', multiplier: 1000}),
kBytesToBits: Object.freeze({bitrate: true, multiplier: 8}),
});
class Metric {
constructor(name, value) {
this.name = name;
this.value = value;
}
toString() {
return '{"' + this.name + '":"' + this.value + '"}';
}
}
// Represents a companion dictionary to an RTCStats object of an RTCStatsReport.
// The CalculatedStats object contains additional metrics associated with the
// original RTCStats object. Typically, the RTCStats object contains
// accumulative counters, but in chrome://webrc-internals/ we also want graphs
// for the average rate over the last second, so we have CalculatedStats
// containing calculated Metrics.
class CalculatedStats {
constructor(id) {
this.id = id;
// A map Original Name -> Array of Metrics, where Original Name refers to
// the name of the metric in the original RTCStats object, and the Metrics
// are calculated metrics. For example, if the original RTCStats report
// contains framesReceived, and from that we've calculated
// [framesReceived/s] and [framesReceived-framesDecoded], then there will be
// a mapping from "framesReceived" to an array of two Metric objects,
// "[framesReceived/s]" and "[framesReceived-framesDecoded]".
this.calculatedMetricsByOriginalName = new Map();
}
addCalculatedMetric(originalName, metric) {
let calculatedMetrics =
this.calculatedMetricsByOriginalName.get(originalName);
if (!calculatedMetrics) {
calculatedMetrics = [];
this.calculatedMetricsByOriginalName.set(originalName, calculatedMetrics);
}
calculatedMetrics.push(metric);
}
// Gets the calculated metrics associated with |originalName| in the order
// that they were added, or an empty list if there are no associated metrics.
getCalculatedMetrics(originalName) {
const calculatedMetrics =
this.calculatedMetricsByOriginalName.get(originalName);
if (!calculatedMetrics) {
return [];
}
return calculatedMetrics;
}
toString() {
let str = '{id:"' + this.id + '"';
for (const originalName of this.calculatedMetricsByOriginalName.keys()) {
const calculatedMetrics =
this.calculatedMetricsByOriginalName.get(originalName);
str += ',' + originalName + ':[';
for (let i = 0; i < calculatedMetrics.length; i++) {
str += calculatedMetrics[i].toString();
if (i + 1 < calculatedMetrics.length) {
str += ',';
}
str += ']';
}
}
str += '}';
return str;
}
}
// Contains the metrics of an RTCStatsReport, as well as calculated metrics
// associated with metrics from the original report. Convertible to and from the
// "internal reports" format used by webrtc_internals.js to pass stats from C++
// to JavaScript.
export class StatsReport {
constructor() {
// Represents an RTCStatsReport. It is a Map RTCStats.id -> RTCStats.
// https://w3c.github.io/webrtc-pc/#dom-rtcstatsreport
this.statsById = new Map();
// RTCStats.id -> CalculatedStats
this.calculatedStatsById = new Map();
}
// |internalReports| is an array, each element represents an RTCStats object,
// but the format is a little different from the spec. This is the format:
// {
// id: "string",
// type: "string",
// stats: {
// timestamp: <milliseconds>,
// values: ["member1", value1, "member2", value2...]
// }
// }
static fromInternalsReportList(internalReports) {
const result = new StatsReport();
internalReports.forEach(internalReport => {
if (!internalReport.stats || !internalReport.stats.values) {
return; // continue;
}
const stats = {
id: internalReport.id,
type: internalReport.type,
timestamp: internalReport.stats.timestamp / 1000.0 // ms -> s
};
const values = internalReport.stats.values;
for (let i = 0; i < values.length; i += 2) {
// Metric "name: value".
stats[values[i]] = values[i + 1];
}
result.statsById.set(stats.id, stats);
});
return result;
}
toInternalsReportList() {
const result = [];
for (const stats of this.statsById.values()) {
const internalReport = {
id: stats.id,
type: stats.type,
stats: {
timestamp: stats.timestamp * 1000.0, // s -> ms
values: []
}
};
Object.keys(stats).forEach(metricName => {
if (metricName === 'id' || metricName === 'type' ||
metricName === 'timestamp') {
return; // continue;
}
internalReport.stats.values.push(metricName);
internalReport.stats.values.push(stats[metricName]);
const calculatedMetrics =
this.getCalculatedMetrics(stats.id, metricName);
calculatedMetrics.forEach(calculatedMetric => {
internalReport.stats.values.push(calculatedMetric.name);
// Treat calculated metrics that are undefined as 0 to ensure graphs
// can be created anyway.
internalReport.stats.values.push(
calculatedMetric.value ? calculatedMetric.value : 0);
});
});
result.push(internalReport);
}
return result;
}
toString() {
let str = '';
for (const stats of this.statsById.values()) {
if (str !== '') {
str += ',';
}
str += JSON.stringify(stats);
}
let str2 = '';
for (const stats of this.calculatedStatsById.values()) {
if (str2 !== '') {
str2 += ',';
}
str2 += stats.toString();
}
return '[original:' + str + '],calculated:[' + str2 + ']';
}
get(id) {
return this.statsById.get(id);
}
getByType(type) {
const result = [];
for (const stats of this.statsById.values()) {
if (stats.type === type) {
result.push(stats);
}
}
return result;
}
addCalculatedMetric(id, insertAtOriginalMetricName, name, value) {
let calculatedStats = this.calculatedStatsById.get(id);
if (!calculatedStats) {
calculatedStats = new CalculatedStats(id);
this.calculatedStatsById.set(id, calculatedStats);
}
calculatedStats.addCalculatedMetric(
insertAtOriginalMetricName, new Metric(name, value));
}
getCalculatedMetrics(id, originalMetricName) {
const calculatedStats = this.calculatedStatsById.get(id);
return calculatedStats ?
calculatedStats.getCalculatedMetrics(originalMetricName) :
[];
}
}
// Shows a `DOMHighResTimeStamp` as a human readable date time.
// The metric must be a time value in milliseconds with Unix epoch as time
// origin.
class DateCalculator {
constructor(metric) {
this.metric = metric;
}
getCalculatedMetricName() {
return '[' + this.metric + ']';
}
calculate(id, previousReport, currentReport) {
const timestamp = currentReport.get(id)[this.metric];
const date = new Date(timestamp);
return date.toLocaleString();
}
}
// Calculates the rate "delta accumulative / delta samples" and returns it. If
// a rate cannot be calculated, such as the metric is missing in the current
// or previous report, undefined is returned.
class RateCalculator {
constructor(
accumulativeMetric, samplesMetric, modifier = CalculatorModifier.kNone) {
this.accumulativeMetric = accumulativeMetric;
this.samplesMetric = samplesMetric;
this.modifier = modifier;
}
getCalculatedMetricName() {
const accumulativeMetric = this.modifier.bitrate ?
this.accumulativeMetric + '_in_bits' :
this.accumulativeMetric;
if (this.samplesMetric === 'timestamp') {
return '[' + accumulativeMetric + '/s]';
}
return '[' + accumulativeMetric + '/' + this.samplesMetric +
this.modifier.postfix + ']';
}
calculate(id, previousReport, currentReport) {
return RateCalculator.calculateRate(
id, previousReport, currentReport, this.accumulativeMetric,
this.samplesMetric) *
this.modifier.multiplier;
}
static calculateRate(
id, previousReport, currentReport, accumulativeMetric, samplesMetric) {
if (!previousReport || !currentReport) {
return undefined;
}
const previousStats = previousReport.get(id);
const currentStats = currentReport.get(id);
if (!previousStats || !currentStats) {
return undefined;
}
const deltaTime = currentStats.timestamp - previousStats.timestamp;
if (deltaTime <= 0) {
return undefined;
}
// Try to convert whatever the values are to numbers. This gets around the
// fact that some types that are not supported by base::Value (e.g. uint32,
// int64, uint64 and double) are passed as strings.
const previousValue = Number(previousStats[accumulativeMetric]);
const currentValue = Number(currentStats[accumulativeMetric]);
if (typeof previousValue !== 'number' || typeof currentValue !== 'number') {
return undefined;
}
const previousSamples = Number(previousStats[samplesMetric]);
const currentSamples = Number(currentStats[samplesMetric]);
if (typeof previousSamples !== 'number' ||
typeof currentSamples !== 'number') {
return undefined;
}
const deltaValue = currentValue - previousValue;
const deltaSamples = currentSamples - previousSamples;
return deltaValue / deltaSamples;
}
}
// Looks up codec and payload type from a codecId reference, constructing an
// informative string about which codec is used.
class CodecCalculator {
getCalculatedMetricName() {
return '[codec]';
}
calculate(id, previousReport, currentReport) {
const targetStats = currentReport.get(id);
const codecStats = currentReport.get(targetStats.codecId);
if (!codecStats) {
return undefined;
}
// If mimeType is 'video/VP8' then codec is 'VP8'.
const codec =
codecStats.mimeType.substr(codecStats.mimeType.indexOf('/') + 1);
let fmtpLine = '';
if (codecStats.sdpFmtpLine) {
fmtpLine = ', ' + codecStats.sdpFmtpLine;
}
return codec + ' (' + codecStats.payloadType + fmtpLine + ')';
}
}
// Calculates "RMS" audio level, which is the average audio level between the
// previous and current report, in the interval [0,1]. Calculated per:
// https://w3c.github.io/webrtc-stats/#dom-rtcinboundrtpstreamstats-totalaudioenergy
class AudioLevelRmsCalculator {
getCalculatedMetricName() {
return '[Audio_Level_in_RMS]';
}
calculate(id, previousReport, currentReport) {
const averageAudioLevelSquared = RateCalculator.calculateRate(
id, previousReport, currentReport, 'totalAudioEnergy',
'totalSamplesDuration');
return Math.sqrt(averageAudioLevelSquared);
}
}
// Calculates "metricA - SUM(otherMetrics)", only looking at the current report.
class DifferenceCalculator {
constructor(metricA, ...otherMetrics) {
this.metricA = metricA;
this.otherMetrics = otherMetrics;
}
getCalculatedMetricName() {
return '[' + this.metricA + '-' + this.otherMetrics.join('-') + ']';
}
calculate(id, previousReport, currentReport) {
const currentStats = currentReport.get(id);
return parseInt(currentStats[this.metricA], 10)
- this.otherMetrics.map(metric => parseInt(currentStats[metric], 10))
.reduce((a, b) => a + b, 0);
}
}
// Calculates the standard deviation from a totalSquaredSum, totalSum, and
// totalCount. If the standard deviation cannot be calculated, such as the
// metric is missing in the current or previous report, undefined is returned.
class StandardDeviationCalculator {
constructor(totalSquaredSumMetric, totalSumMetric, totalCount, label) {
this.totalSquaredSumMetric = totalSquaredSumMetric;
this.totalSumMetric = totalSumMetric;
this.totalCount = totalCount;
this.label = label;
}
getCalculatedMetricName() {
return '[' + this.label + 'StDev_in_ms]';
}
calculate(id, previousReport, currentReport) {
return StandardDeviationCalculator.calculateStandardDeviation(
id, previousReport, currentReport, this.totalSquaredSumMetric,
this.totalSumMetric, this.totalCount);
}
static calculateStandardDeviation(
id, previousReport, currentReport, totalSquaredSumMetric, totalSumMetric,
totalCount) {
if (!previousReport || !currentReport) {
return undefined;
}
const previousStats = previousReport.get(id);
const currentStats = currentReport.get(id);
if (!previousStats || !currentStats) {
return undefined;
}
const deltaCount =
Number(currentStats[totalCount]) - Number(previousStats[totalCount]);
if (deltaCount <= 0) {
return undefined;
}
// Try to convert whatever the values are to numbers. This gets around the
// fact that some types that are not supported by base::Value (e.g. uint32,
// int64, uint64 and double) are passed as strings.
const previousSquaredSumValue =
Number(previousStats[totalSquaredSumMetric]);
const currentSquaredSumValue = Number(currentStats[totalSquaredSumMetric]);
if (typeof previousSquaredSumValue !== 'number' ||
typeof currentSquaredSumValue !== 'number') {
return undefined;
}
const previousSumValue = Number(previousStats[totalSumMetric]);
const currentSumValue = Number(currentStats[totalSumMetric]);
if (typeof previousSumValue !== 'number' ||
typeof currentSumValue !== 'number') {
return undefined;
}
const deltaSquaredSum = currentSquaredSumValue - previousSquaredSumValue;
const deltaSum = currentSumValue - previousSumValue;
const variance =
(deltaSquaredSum - Math.pow(deltaSum, 2) / deltaCount) / deltaCount;
if (variance < 0) {
return undefined;
}
return 1000 * Math.sqrt(variance);
}
}
// Keeps track of previous and current stats report and calculates all
// calculated metrics.
export class StatsRatesCalculator {
constructor() {
this.previousReport = null;
this.currentReport = null;
this.statsCalculators = [
{
type: 'data-channel',
metricCalculators: {
messagesSent: new RateCalculator('messagesSent', 'timestamp'),
messagesReceived: new RateCalculator('messagesReceived', 'timestamp'),
bytesSent: new RateCalculator(
'bytesSent', 'timestamp', CalculatorModifier.kBytesToBits),
bytesReceived: new RateCalculator(
'bytesReceived', 'timestamp', CalculatorModifier.kBytesToBits),
},
},
{
type: 'media-source',
metricCalculators: {
totalAudioEnergy: new AudioLevelRmsCalculator(),
},
},
{
type: 'outbound-rtp',
metricCalculators: {
bytesSent: new RateCalculator(
'bytesSent', 'timestamp', CalculatorModifier.kBytesToBits),
headerBytesSent: new RateCalculator(
'headerBytesSent', 'timestamp', CalculatorModifier.kBytesToBits),
retransmittedBytesSent: new RateCalculator(
'retransmittedBytesSent', 'timestamp',
CalculatorModifier.kBytesToBits),
packetsSent: new RateCalculator('packetsSent', 'timestamp'),
retransmittedPacketsSent:
new RateCalculator('retransmittedPacketsSent', 'timestamp'),
totalPacketSendDelay: new RateCalculator(
'totalPacketSendDelay', 'packetsSent',
CalculatorModifier.kMillisecondsFromSeconds),
framesEncoded: new RateCalculator('framesEncoded', 'timestamp'),
framesSent: new RateCalculator('framesSent', 'timestamp'),
totalEncodedBytesTarget: new RateCalculator(
'totalEncodedBytesTarget', 'timestamp',
CalculatorModifier.kBytesToBits),
totalEncodeTime: new RateCalculator(
'totalEncodeTime', 'framesEncoded',
CalculatorModifier.kMillisecondsFromSeconds),
qpSum: new RateCalculator('qpSum', 'framesEncoded'),
codecId: new CodecCalculator(),
},
},
{
type: 'inbound-rtp',
metricCalculators: {
bytesReceived: new RateCalculator(
'bytesReceived', 'timestamp', CalculatorModifier.kBytesToBits),
headerBytesReceived: new RateCalculator(
'headerBytesReceived', 'timestamp',
CalculatorModifier.kBytesToBits),
retransmittedBytesReceived: new RateCalculator(
'retransmittedBytesReceived', 'timestamp',
CalculatorModifier.kBytesToBits),
fecBytesReceived: new RateCalculator(
'fecBytesReceived', 'timestamp',
CalculatorModifier.kBytesToBits),
packetsReceived: new RateCalculator('packetsReceived', 'timestamp'),
packetsDiscarded: new RateCalculator('packetsDiscarded', 'timestamp'),
retransmittedPacketsReceived:
new RateCalculator('retransmittedPacketsReceived', 'timestamp'),
fecPacketsReceived:
new RateCalculator('fecPacketsReceived', 'timestamp'),
fecPacketsDiscarded:
new RateCalculator('fecPacketsDiscarded', 'timestamp'),
framesReceived: [
new RateCalculator('framesReceived', 'timestamp'),
new DifferenceCalculator('framesReceived',
'framesDecoded', 'framesDropped'),
],
framesDecoded: new RateCalculator('framesDecoded', 'timestamp'),
keyFramesDecoded: new RateCalculator('keyFramesDecoded', 'timestamp'),
totalDecodeTime: new RateCalculator(
'totalDecodeTime', 'framesDecoded',
CalculatorModifier.kMillisecondsFromSeconds),
totalInterFrameDelay: new RateCalculator(
'totalInterFrameDelay', 'framesDecoded',
CalculatorModifier.kMillisecondsFromSeconds),
totalSquaredInterFrameDelay: new StandardDeviationCalculator(
'totalSquaredInterFrameDelay', 'totalInterFrameDelay',
'framesDecoded', 'interFrameDelay'),
totalSamplesReceived:
new RateCalculator('totalSamplesReceived', 'timestamp'),
concealedSamples: [
new RateCalculator('concealedSamples', 'timestamp'),
new RateCalculator('concealedSamples', 'totalSamplesReceived'),
],
silentConcealedSamples:
new RateCalculator('silentConcealedSamples', 'timestamp'),
insertedSamplesForDeceleration:
new RateCalculator('insertedSamplesForDeceleration', 'timestamp'),
removedSamplesForAcceleration:
new RateCalculator('removedSamplesForAcceleration', 'timestamp'),
qpSum: new RateCalculator('qpSum', 'framesDecoded'),
codecId: new CodecCalculator(),
totalAudioEnergy: new AudioLevelRmsCalculator(),
jitterBufferDelay: new RateCalculator(
'jitterBufferDelay', 'jitterBufferEmittedCount',
CalculatorModifier.kMillisecondsFromSeconds),
jitterBufferTargetDelay: new RateCalculator(
'jitterBufferTargetDelay', 'jitterBufferEmittedCount',
CalculatorModifier.kMillisecondsFromSeconds),
jitterBufferMinimumDelay: new RateCalculator(
'jitterBufferMinimumDelay', 'jitterBufferEmittedCount',
CalculatorModifier.kMillisecondsFromSeconds),
lastPacketReceivedTimestamp: new DateCalculator(
'lastPacketReceivedTimestamp'),
estimatedPlayoutTimestamp: new DateCalculator(
'estimatedPlayoutTimestamp'),
totalProcessingDelay: new RateCalculator(
'totalProcessingDelay', 'framesDecoded',
CalculatorModifier.kMillisecondsFromSeconds),
totalAssemblyTime: new RateCalculator(
'totalAssemblyTime', 'framesAssembledFromMultiplePackets',
CalculatorModifier.kMillisecondsFromSeconds),
},
},
{
type: 'remote-outbound-rtp',
metricCalculators: {
remoteTimestamp: new DateCalculator('remoteTimestamp'),
},
},
{
type: 'transport',
metricCalculators: {
bytesSent: new RateCalculator(
'bytesSent', 'timestamp', CalculatorModifier.kBytesToBits),
bytesReceived: new RateCalculator(
'bytesReceived', 'timestamp', CalculatorModifier.kBytesToBits),
packetsSent: new RateCalculator(
'packetsSent', 'timestamp'),
packetsReceived: new RateCalculator(
'packetsReceived', 'timestamp'),
},
},
{
type: 'candidate-pair',
metricCalculators: {
bytesSent: new RateCalculator(
'bytesSent', 'timestamp', CalculatorModifier.kBytesToBits),
bytesReceived: new RateCalculator(
'bytesReceived', 'timestamp', CalculatorModifier.kBytesToBits),
packetsSent: new RateCalculator(
'packetsSent', 'timestamp'),
packetsReceived: new RateCalculator(
'packetsReceived', 'timestamp'),
totalRoundTripTime:
new RateCalculator('totalRoundTripTime', 'responsesReceived'),
lastPacketReceivedTimestamp: new DateCalculator(
'lastPacketReceivedTimestamp'),
lastPacketSentTimestamp: new DateCalculator(
'lastPacketSentTimestamp'),
},
},
];
}
addStatsReport(report) {
this.previousReport = this.currentReport;
this.currentReport = report;
this.updateCalculatedMetrics_();
}
// Updates all "calculated metrics", which are metrics derived from standard
// values, such as converting total counters (e.g. bytesSent) to rates (e.g.
// bytesSent/s).
updateCalculatedMetrics_() {
this.statsCalculators.forEach(statsCalculator => {
this.currentReport.getByType(statsCalculator.type).forEach(stats => {
Object.keys(statsCalculator.metricCalculators)
.forEach(originalMetric => {
let metricCalculators =
statsCalculator.metricCalculators[originalMetric];
if (!Array.isArray(metricCalculators)) {
metricCalculators = [metricCalculators];
}
metricCalculators.forEach(metricCalculator => {
this.currentReport.addCalculatedMetric(
stats.id, originalMetric,
metricCalculator.getCalculatedMetricName(),
metricCalculator.calculate(
stats.id, this.previousReport, this.currentReport));
});
});
});
});
}
}

View file

@ -0,0 +1,219 @@
// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details
import {$} from './util.js';
import {generateStatsLabel} from './stats_helper.js';
/**
* Maintains the stats table.
*/
export class StatsTable {
constructor() {}
/**
* Adds |report| to the stats table of |peerConnectionElement|.
*
* @param {!Element} peerConnectionElement The root element.
* @param {!Object} report The object containing stats, which is the object
* containing timestamp and values, which is an array of strings, whose
* even index entry is the name of the stat, and the odd index entry is
* the value.
*/
addStatsReport(peerConnectionElement, report) {
const statsTable = this.ensureStatsTable_(peerConnectionElement, report);
// Update the label since information may have changed.
statsTable.parentElement.firstElementChild.innerText =
generateStatsLabel(report);
if (report.stats) {
this.addStatsToTable_(
statsTable, report.stats.timestamp, report.stats.values);
}
}
clearStatsLists(peerConnectionElement) {
const containerId = peerConnectionElement.id + '-table-container';
// Disable getElementById restriction here, since |containerId| is not
// always a valid selector.
// eslint-disable-next-line no-restricted-properties
const container = document.getElementById(containerId);
if (container) {
peerConnectionElement.removeChild(container);
this.ensureStatsTableContainer_(peerConnectionElement);
}
}
/**
* Ensure the DIV container for the stats tables is created as a child of
* |peerConnectionElement|.
*
* @param {!Element} peerConnectionElement The root element.
* @return {!Element} The stats table container.
* @private
*/
ensureStatsTableContainer_(peerConnectionElement) {
const containerId = peerConnectionElement.id + '-table-container';
// Disable getElementById restriction here, since |containerId| is not
// always a valid selector.
// eslint-disable-next-line no-restricted-properties
let container = document.getElementById(containerId);
if (!container) {
container = document.createElement('div');
container.id = containerId;
container.className = 'stats-table-container';
const head = document.createElement('div');
head.textContent = 'Stats Tables';
container.appendChild(head);
const label = document.createElement('label');
label.innerText = 'Filter statistics by type including ';
container.appendChild(label);
const input = document.createElement('input');
input.placeholder = 'separate multiple values by `,`';
input.size = 25;
input.oninput = (e) => this.filterStats(e, container);
container.appendChild(input);
peerConnectionElement.appendChild(container);
}
return container;
}
/**
* Ensure the stats table for track specified by |report| of PeerConnection
* |peerConnectionElement| is created.
*
* @param {!Element} peerConnectionElement The root element.
* @param {!Object} report The object containing stats, which is the object
* containing timestamp and values, which is an array of strings, whose
* even index entry is the name of the stat, and the odd index entry is
* the value.
* @return {!Element} The stats table element.
* @private
*/
ensureStatsTable_(peerConnectionElement, report) {
const tableId = peerConnectionElement.id + '-table-' + report.id;
// Disable getElementById restriction here, since |tableId| is not
// always a valid selector.
// eslint-disable-next-line no-restricted-properties
let table = document.getElementById(tableId);
if (!table) {
const container = this.ensureStatsTableContainer_(peerConnectionElement);
const details = document.createElement('details');
details.attributes['data-statsType'] = report.type;
container.appendChild(details);
const summary = document.createElement('summary');
summary.textContent = generateStatsLabel(report);
details.appendChild(summary);
table = document.createElement('table');
details.appendChild(table);
table.id = tableId;
table.border = 1;
table.appendChild($('trth-template').content.cloneNode(true));
table.rows[0].cells[0].textContent = 'Statistics ' + report.id;
}
return table;
}
/**
* Update |statsTable| with |time| and |statsData|.
*
* @param {!Element} statsTable Which table to update.
* @param {number} time The number of milliseconds since epoch.
* @param {Array<string>} statsData An array of stats name and value pairs.
* @private
*/
addStatsToTable_(statsTable, time, statsData) {
const definedMetrics = new Set();
for (let i = 0; i < statsData.length - 1; i = i + 2) {
definedMetrics.add(statsData[i]);
}
// For any previously reported metric that is no longer defined, replace its
// now obsolete value with the magic string "(removed)".
const metricsContainer = statsTable.firstChild;
for (let i = 0; i < metricsContainer.children.length; ++i) {
const metricElement = metricsContainer.children[i];
// `metricElement` IDs have the format `bla-bla-bla-bla-${metricName}`.
let metricName =
metricElement.id.substring(metricElement.id.lastIndexOf('-') + 1);
if (metricName.endsWith(']')) {
// Computed metrics may contain the '-' character (e.g.
// `DifferenceCalculator` based metrics) in which case `metricName` will
// not have been parsed correctly. Instead look for starting '['.
metricName =
metricElement.id.substring(metricElement.id.indexOf('['));
}
if (metricName && metricName != 'timestamp' &&
!definedMetrics.has(metricName)) {
this.updateStatsTableRow_(statsTable, metricName, '(removed)');
}
}
// Add or update all "metric: value" that have a defined value.
const date = new Date(time);
this.updateStatsTableRow_(statsTable, 'timestamp', date.toLocaleString());
for (let i = 0; i < statsData.length - 1; i = i + 2) {
this.updateStatsTableRow_(statsTable, statsData[i], statsData[i + 1]);
}
}
/**
* Update the value column of the stats row of |rowName| to |value|.
* A new row is created is this is the first report of this stats.
*
* @param {!Element} statsTable Which table to update.
* @param {string} rowName The name of the row to update.
* @param {string} value The new value to set.
* @private
*/
updateStatsTableRow_(statsTable, rowName, value) {
const trId = statsTable.id + '-' + rowName;
// Disable getElementById restriction here, since |trId| is not always
// a valid selector.
// eslint-disable-next-line no-restricted-properties
let trElement = document.getElementById(trId);
const activeConnectionClass = 'stats-table-active-connection';
if (!trElement) {
trElement = document.createElement('tr');
trElement.id = trId;
statsTable.firstChild.appendChild(trElement);
const item = $('td2-template').content.cloneNode(true);
item.querySelector('td').textContent = rowName;
trElement.appendChild(item);
}
trElement.cells[1].textContent = value;
// Highlights the table for the active connection.
if (rowName === 'googActiveConnection') {
if (value === true) {
statsTable.parentElement.classList.add(activeConnectionClass);
} else {
statsTable.parentElement.classList.remove(activeConnectionClass);
}
}
}
/**
* Apply a filter to the stats table
* @param event InputEvent from the filter input field.
* @param container stats table container element.
* @private
*/
filterStats(event, container) {
const filter = event.target.value;
const filters = filter.split(',');
container.childNodes.forEach(node => {
if (node.nodeName !== 'DETAILS') {
return;
}
const statsType = node.attributes['data-statsType'];
if (!filter || filters.includes(statsType) ||
filters.find(f => statsType.includes(f))) {
node.style.display = 'block';
} else {
node.style.display = 'none';
}
});
}
}

View file

@ -0,0 +1,115 @@
// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details
// Creates a simple object containing the tab head and body elements.
class TabDom {
constructor(h, b) {
this.head = h;
this.body = b;
}
}
/**
* A TabView provides the ability to create tabs and switch between tabs. It's
* responsible for creating the DOM and managing the visibility of each tab.
* The first added tab is active by default and the others hidden.
*/
export class TabView {
/**
* @param {Element} root The root DOM element containing the tabs.
*/
constructor(root) {
this.root_ = root;
this.ACTIVE_TAB_HEAD_CLASS_ = 'active-tab-head';
this.ACTIVE_TAB_BODY_CLASS_ = 'active-tab-body';
this.TAB_HEAD_CLASS_ = 'tab-head';
this.TAB_BODY_CLASS_ = 'tab-body';
/**
* A mapping for an id to the tab elements.
* @type {!Object<!TabDom>}
* @private
*/
this.tabElements_ = {};
this.headBar_ = null;
this.activeTabId_ = null;
this.initializeHeadBar_();
}
/**
* Adds a tab with the specified id and title.
* @param {string} id
* @param {string} title
* @return {!Element} The tab body element.
*/
addTab(id, title) {
if (this.tabElements_[id]) {
throw 'Tab already exists: ' + id;
}
const head = document.createElement('span');
head.className = this.TAB_HEAD_CLASS_;
head.textContent = title;
head.title = title;
this.headBar_.appendChild(head);
head.addEventListener('click', this.switchTab_.bind(this, id));
const body = document.createElement('div');
body.className = this.TAB_BODY_CLASS_;
body.id = id;
this.root_.appendChild(body);
this.tabElements_[id] = new TabDom(head, body);
if (!this.activeTabId_) {
this.switchTab_(id);
}
return this.tabElements_[id].body;
}
/** Removes the tab. @param {string} id */
removeTab(id) {
if (!this.tabElements_[id]) {
return;
}
this.tabElements_[id].head.parentNode.removeChild(
this.tabElements_[id].head);
this.tabElements_[id].body.parentNode.removeChild(
this.tabElements_[id].body);
delete this.tabElements_[id];
if (this.activeTabId_ === id) {
this.switchTab_(Object.keys(this.tabElements_)[0]);
}
}
/**
* Switches the specified tab into view.
*
* @param {string} activeId The id the of the tab that should be switched to
* active state.
* @private
*/
switchTab_(activeId) {
if (this.activeTabId_ && this.tabElements_[this.activeTabId_]) {
this.tabElements_[this.activeTabId_].body.classList.remove(
this.ACTIVE_TAB_BODY_CLASS_);
this.tabElements_[this.activeTabId_].head.classList.remove(
this.ACTIVE_TAB_HEAD_CLASS_);
}
this.activeTabId_ = activeId;
if (this.tabElements_[activeId]) {
this.tabElements_[activeId].body.classList.add(
this.ACTIVE_TAB_BODY_CLASS_);
this.tabElements_[activeId].head.classList.add(
this.ACTIVE_TAB_HEAD_CLASS_);
}
}
/** Initializes the bar containing the tab heads. */
initializeHeadBar_() {
this.headBar_ = document.createElement('div');
this.root_.appendChild(this.headBar_);
this.headBar_.style.textAlign = 'center';
}
}

View file

@ -0,0 +1,548 @@
// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details
// Maximum number of labels placed vertically along the sides of the graph.
const MAX_VERTICAL_LABELS = 6;
// Vertical spacing between labels and between the graph and labels.
const LABEL_VERTICAL_SPACING = 4;
// Horizontal spacing between vertically placed labels and the edges of the
// graph.
const LABEL_HORIZONTAL_SPACING = 3;
// Horizintal spacing between two horitonally placed labels along the bottom
// of the graph.
const LABEL_LABEL_HORIZONTAL_SPACING = 25;
// Length of ticks, in pixels, next to y-axis labels. The x-axis only has
// one set of labels, so it can use lines instead.
const Y_AXIS_TICK_LENGTH = 10;
const GRID_COLOR = '#CCC';
const TEXT_COLOR = '#000';
const BACKGROUND_COLOR = '#FFF';
const MAX_DECIMAL_PRECISION = 3;
/**
* A TimelineGraphView displays a timeline graph on a canvas element.
*/
export class TimelineGraphView {
constructor(divId, canvasId) {
this.scrollbar_ = {position_: 0, range_: 0};
// Disable getElementById restriction here, since |divId| and |canvasId| are
// not always valid selectors.
// eslint-disable-next-line no-restricted-properties
this.graphDiv_ = document.getElementById(divId);
// eslint-disable-next-line no-restricted-properties
this.canvas_ = document.getElementById(canvasId);
// Set the range and scale of the graph. Times are in milliseconds since
// the Unix epoch.
// All measurements we have must be after this time.
this.startTime_ = 0;
// The current rightmost position of the graph is always at most this.
this.endTime_ = 1;
this.graph_ = null;
// Horizontal scale factor, in terms of milliseconds per pixel.
this.scale_ = 1000;
// Initialize the scrollbar.
this.updateScrollbarRange_(true);
}
setScale(scale) {
this.scale_ = scale;
}
// Returns the total length of the graph, in pixels.
getLength_() {
const timeRange = this.endTime_ - this.startTime_;
// Math.floor is used to ignore the last partial area, of length less
// than this.scale_.
return Math.floor(timeRange / this.scale_);
}
/**
* Returns true if the graph is scrolled all the way to the right.
*/
graphScrolledToRightEdge_() {
return this.scrollbar_.position_ === this.scrollbar_.range_;
}
/**
* Update the range of the scrollbar. If |resetPosition| is true, also
* sets the slider to point at the rightmost position and triggers a
* repaint.
*/
updateScrollbarRange_(resetPosition) {
let scrollbarRange = this.getLength_() - this.canvas_.width;
if (scrollbarRange < 0) {
scrollbarRange = 0;
}
// If we've decreased the range to less than the current scroll position,
// we need to move the scroll position.
if (this.scrollbar_.position_ > scrollbarRange) {
resetPosition = true;
}
this.scrollbar_.range_ = scrollbarRange;
if (resetPosition) {
this.scrollbar_.position_ = scrollbarRange;
this.repaint();
}
}
/**
* Sets the date range displayed on the graph, switches to the default
* scale factor, and moves the scrollbar all the way to the right.
*/
setDateRange(startDate, endDate) {
this.startTime_ = startDate.getTime();
this.endTime_ = endDate.getTime();
// Safety check.
if (this.endTime_ <= this.startTime_) {
this.startTime_ = this.endTime_ - 1;
}
this.updateScrollbarRange_(true);
}
/**
* Updates the end time at the right of the graph to be the current time.
* Specifically, updates the scrollbar's range, and if the scrollbar is
* all the way to the right, keeps it all the way to the right. Otherwise,
* leaves the view as-is and doesn't redraw anything.
*/
updateEndDate(opt_date) {
this.endTime_ = opt_date || (new Date()).getTime();
this.updateScrollbarRange_(this.graphScrolledToRightEdge_());
}
getStartDate() {
return new Date(this.startTime_);
}
/**
* Replaces the current TimelineDataSeries with |dataSeries|.
*/
setDataSeries(dataSeries) {
// Simply recreates the Graph.
this.graph_ = new Graph();
for (let i = 0; i < dataSeries.length; ++i) {
this.graph_.addDataSeries(dataSeries[i]);
}
this.repaint();
}
/**
* Adds |dataSeries| to the current graph.
*/
addDataSeries(dataSeries) {
if (!this.graph_) {
this.graph_ = new Graph();
}
this.graph_.addDataSeries(dataSeries);
this.repaint();
}
/**
* Draws the graph on |canvas_| when visible.
*/
repaint() {
if (this.canvas_.offsetParent === null) {
return; // do not repaint graphs that are not visible.
}
this.repaintTimerRunning_ = false;
const width = this.canvas_.width;
let height = this.canvas_.height;
const context = this.canvas_.getContext('2d');
// Clear the canvas.
context.fillStyle = BACKGROUND_COLOR;
context.fillRect(0, 0, width, height);
// Try to get font height in pixels. Needed for layout.
const fontHeightString = context.font.match(/([0-9]+)px/)[1];
const fontHeight = parseInt(fontHeightString);
// Safety check, to avoid drawing anything too ugly.
if (fontHeightString.length === 0 || fontHeight <= 0 ||
fontHeight * 4 > height || width < 50) {
return;
}
// Save current transformation matrix so we can restore it later.
context.save();
// The center of an HTML canvas pixel is technically at (0.5, 0.5). This
// makes near straight lines look bad, due to anti-aliasing. This
// translation reduces the problem a little.
context.translate(0.5, 0.5);
// Figure out what time values to display.
let position = this.scrollbar_.position_;
// If the entire time range is being displayed, align the right edge of
// the graph to the end of the time range.
if (this.scrollbar_.range_ === 0) {
position = this.getLength_() - this.canvas_.width;
}
const visibleStartTime = this.startTime_ + position * this.scale_;
// Make space at the bottom of the graph for the time labels, and then
// draw the labels.
const textHeight = height;
height -= fontHeight + LABEL_VERTICAL_SPACING;
this.drawTimeLabels(context, width, height, textHeight, visibleStartTime);
// Draw outline of the main graph area.
context.strokeStyle = GRID_COLOR;
context.strokeRect(0, 0, width - 1, height - 1);
if (this.graph_) {
// Layout graph and have them draw their tick marks.
this.graph_.layout(
width, height, fontHeight, visibleStartTime, this.scale_);
this.graph_.drawTicks(context);
// Draw the lines of all graphs, and then draw their labels.
this.graph_.drawLines(context);
this.graph_.drawLabels(context);
}
// Restore original transformation matrix.
context.restore();
}
/**
* Draw time labels below the graph. Takes in start time as an argument
* since it may not be |startTime_|, when we're displaying the entire
* time range.
*/
drawTimeLabels(context, width, height, textHeight, startTime) {
// Draw the labels 1 minute apart.
const timeStep = 1000 * 60;
// Find the time for the first label. This time is a perfect multiple of
// timeStep because of how UTC times work.
let time = Math.ceil(startTime / timeStep) * timeStep;
context.textBaseline = 'bottom';
context.textAlign = 'center';
context.fillStyle = TEXT_COLOR;
context.strokeStyle = GRID_COLOR;
// Draw labels and vertical grid lines.
while (true) {
const x = Math.round((time - startTime) / this.scale_);
if (x >= width) {
break;
}
const text = (new Date(time)).toLocaleTimeString();
context.fillText(text, x, textHeight);
context.beginPath();
context.lineTo(x, 0);
context.lineTo(x, height);
context.stroke();
time += timeStep;
}
}
getDataSeriesCount() {
if (this.graph_) {
return this.graph_.dataSeries_.length;
}
return 0;
}
hasDataSeries(dataSeries) {
if (this.graph_) {
return this.graph_.hasDataSeries(dataSeries);
}
return false;
}
}
/**
* A Label is the label at a particular position along the y-axis.
*/
class Label {
constructor(height, text) {
this.height = height;
this.text = text;
}
}
/**
* A Graph is responsible for drawing all the TimelineDataSeries that have
* the same data type. Graphs are responsible for scaling the values, laying
* out labels, and drawing both labels and lines for its data series.
*/
class Graph {
constructor() {
this.dataSeries_ = [];
// Cached properties of the graph, set in layout.
this.width_ = 0;
this.height_ = 0;
this.fontHeight_ = 0;
this.startTime_ = 0;
this.scale_ = 0;
// The lowest/highest values adjusted by the vertical label step size
// in the displayed range of the graph. Used for scaling and setting
// labels. Set in layoutLabels.
this.min_ = 0;
this.max_ = 0;
// Cached text of equally spaced labels. Set in layoutLabels.
this.labels_ = [];
}
addDataSeries(dataSeries) {
this.dataSeries_.push(dataSeries);
}
hasDataSeries(dataSeries) {
for (let i = 0; i < this.dataSeries_.length; ++i) {
if (this.dataSeries_[i] === dataSeries) {
return true;
}
}
return false;
}
/**
* Returns a list of all the values that should be displayed for a given
* data series, using the current graph layout.
*/
getValues(dataSeries) {
if (!dataSeries.isVisible()) {
return null;
}
return dataSeries.getValues(this.startTime_, this.scale_, this.width_);
}
/**
* Updates the graph's layout. In particular, both the max value and
* label positions are updated. Must be called before calling any of the
* drawing functions.
*/
layout(width, height, fontHeight, startTime, scale) {
this.width_ = width;
this.height_ = height;
this.fontHeight_ = fontHeight;
this.startTime_ = startTime;
this.scale_ = scale;
// Find largest value.
let max = 0;
let min = 0;
for (let i = 0; i < this.dataSeries_.length; ++i) {
const values = this.getValues(this.dataSeries_[i]);
if (!values) {
continue;
}
for (let j = 0; j < values.length; ++j) {
if (values[j] > max) {
max = values[j];
} else if (values[j] < min) {
min = values[j];
}
}
}
this.layoutLabels_(min, max);
}
/**
* Lays out labels and sets |max_|/|min_|, taking the time units into
* consideration. |maxValue| is the actual maximum value, and
* |max_| will be set to the value of the largest label, which
* will be at least |maxValue|. Similar for |min_|.
*/
layoutLabels_(minValue, maxValue) {
if (maxValue - minValue < 1024) {
this.layoutLabelsBasic_(minValue, maxValue, MAX_DECIMAL_PRECISION);
return;
}
// Find appropriate units to use.
const units = ['', 'k', 'M', 'G', 'T', 'P'];
// Units to use for labels. 0 is '1', 1 is K, etc.
// We start with 1, and work our way up.
let unit = 1;
minValue /= 1024;
maxValue /= 1024;
while (units[unit + 1] && maxValue - minValue >= 1024) {
minValue /= 1024;
maxValue /= 1024;
++unit;
}
// Calculate labels.
this.layoutLabelsBasic_(minValue, maxValue, MAX_DECIMAL_PRECISION);
// Append units to labels.
for (let i = 0; i < this.labels_.length; ++i) {
this.labels_[i] += ' ' + units[unit];
}
// Convert |min_|/|max_| back to unit '1'.
this.min_ *= Math.pow(1024, unit);
this.max_ *= Math.pow(1024, unit);
}
/**
* Same as layoutLabels_, but ignores units. |maxDecimalDigits| is the
* maximum number of decimal digits allowed. The minimum allowed
* difference between two adjacent labels is 10^-|maxDecimalDigits|.
*/
layoutLabelsBasic_(minValue, maxValue, maxDecimalDigits) {
this.labels_ = [];
const range = maxValue - minValue;
// No labels if the range is 0.
if (range === 0) {
this.min_ = this.max_ = maxValue;
return;
}
// The maximum number of equally spaced labels allowed. |fontHeight_|
// is doubled because the top two labels are both drawn in the same
// gap.
const minLabelSpacing = 2 * this.fontHeight_ + LABEL_VERTICAL_SPACING;
// The + 1 is for the top label.
let maxLabels = 1 + this.height_ / minLabelSpacing;
if (maxLabels < 2) {
maxLabels = 2;
} else if (maxLabels > MAX_VERTICAL_LABELS) {
maxLabels = MAX_VERTICAL_LABELS;
}
// Initial try for step size between consecutive labels.
let stepSize = Math.pow(10, -maxDecimalDigits);
// Number of digits to the right of the decimal of |stepSize|.
// Used for formatting label strings.
let stepSizeDecimalDigits = maxDecimalDigits;
// Pick a reasonable step size.
while (true) {
// If we use a step size of |stepSize| between labels, we'll need:
//
// Math.ceil(range / stepSize) + 1
//
// labels. The + 1 is because we need labels at both at 0 and at
// the top of the graph.
// Check if we can use steps of size |stepSize|.
if (Math.ceil(range / stepSize) + 1 <= maxLabels) {
break;
}
// Check |stepSize| * 2.
if (Math.ceil(range / (stepSize * 2)) + 1 <= maxLabels) {
stepSize *= 2;
break;
}
// Check |stepSize| * 5.
if (Math.ceil(range / (stepSize * 5)) + 1 <= maxLabels) {
stepSize *= 5;
break;
}
stepSize *= 10;
if (stepSizeDecimalDigits > 0) {
--stepSizeDecimalDigits;
}
}
// Set the min/max so it's an exact multiple of the chosen step size.
this.max_ = Math.ceil(maxValue / stepSize) * stepSize;
this.min_ = Math.floor(minValue / stepSize) * stepSize;
// Create labels.
for (let label = this.max_; label >= this.min_; label -= stepSize) {
this.labels_.push(label.toFixed(stepSizeDecimalDigits));
}
}
/**
* Draws tick marks for each of the labels in |labels_|.
*/
drawTicks(context) {
const x1 = this.width_ - 1;
const x2 = this.width_ - 1 - Y_AXIS_TICK_LENGTH;
context.fillStyle = GRID_COLOR;
context.beginPath();
for (let i = 1; i < this.labels_.length - 1; ++i) {
// The rounding is needed to avoid ugly 2-pixel wide anti-aliased
// lines.
const y = Math.round(this.height_ * i / (this.labels_.length - 1));
context.moveTo(x1, y);
context.lineTo(x2, y);
}
context.stroke();
}
/**
* Draws a graph line for each of the data series.
*/
drawLines(context) {
// Factor by which to scale all values to convert them to a number from
// 0 to height - 1.
let scale = 0;
const bottom = this.height_ - 1;
if (this.max_) {
scale = bottom / (this.max_ - this.min_);
}
// Draw in reverse order, so earlier data series are drawn on top of
// subsequent ones.
for (let i = this.dataSeries_.length - 1; i >= 0; --i) {
const values = this.getValues(this.dataSeries_[i]);
if (!values) {
continue;
}
context.strokeStyle = this.dataSeries_[i].getColor();
context.beginPath();
for (let x = 0; x < values.length; ++x) {
// The rounding is needed to avoid ugly 2-pixel wide anti-aliased
// horizontal lines.
context.lineTo(x, bottom - Math.round((values[x] - this.min_) * scale));
}
context.stroke();
}
}
/**
* Draw labels in |labels_|.
*/
drawLabels(context) {
if (this.labels_.length === 0) {
return;
}
const x = this.width_ - LABEL_HORIZONTAL_SPACING;
// Set up the context.
context.fillStyle = TEXT_COLOR;
context.textAlign = 'right';
// Draw top label, which is the only one that appears below its tick
// mark.
context.textBaseline = 'top';
context.fillText(this.labels_[0], x, 0);
// Draw all the other labels.
context.textBaseline = 'bottom';
const step = (this.height_ - 1) / (this.labels_.length - 1);
for (let i = 1; i < this.labels_.length; ++i) {
context.fillText(this.labels_[i], x, step * i);
}
}
}

View file

@ -0,0 +1,178 @@
// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details
import {$} from './util.js';
const USER_MEDIA_TAB_ID = 'user-media-tab-id';
/**
* A helper function for appending a child element to |parent|.
*
* @param {!Element} parent The parent element.
* @param {string} tag The child element tag.
* @param {string} text The textContent of the new DIV.
* @return {!Element} the new DIV element.
*/
function appendChildWithText(parent, tag, text) {
const child = document.createElement(tag);
child.textContent = text;
parent.appendChild(child);
return child;
}
export class UserMediaTable {
/**
* @param {Object} tabView the TabView object to add the user media tab to.
*/
constructor(tabView) {
this.tabView = tabView;
}
/**
* Populate the tab view with a getUserMedia/getDisplayMedia tab.
*/
createTab() {
const container = this.tabView.addTab(USER_MEDIA_TAB_ID,
'getUserMedia/getDisplayMedia');
// Create the filter input field and label.
appendChildWithText(container, 'label', 'Filter by origin including ');
const input = document.createElement('input');
input.size = 30;
input.oninput = this.filterUserMedia.bind(this);
container.appendChild(input);
}
/**
* Apply a filter to the user media table.
* @param event InputEvent from the filter input field.
* @private
*/
filterUserMedia(event) {
const filter = event.target.value;
const requests = $(USER_MEDIA_TAB_ID).childNodes;
for (let i = 0; i < requests.length; ++i) {
if (!requests[i]['data-origin']) {
continue;
}
if (requests[i]['data-origin'].includes(filter)) {
requests[i].style.display = 'block';
} else {
requests[i].style.display = 'none';
}
}
}
/**
* Adds a getUserMedia/getDisplayMedia request.
* @param {!Object} data The object containing rid {number}, pid {number},
* origin {string}, request_id {number}, request_type {string},
* audio {string}, video {string}.
*/
addMedia(data) {
if (!$(USER_MEDIA_TAB_ID)) {
this.createTab();
}
const requestDiv = document.createElement('div');
requestDiv.className = 'user-media-request-div-class';
requestDiv.id = ['gum', data.rid, data.pid, data.request_id].join('-');
requestDiv['data-rid'] = data.rid;
requestDiv['data-origin'] = data.origin;
// Insert new getUserMedia calls at the top.
$(USER_MEDIA_TAB_ID).insertBefore(requestDiv,
$(USER_MEDIA_TAB_ID).firstChild);
appendChildWithText(requestDiv, 'div', 'Caller origin: ' + data.origin);
appendChildWithText(requestDiv, 'div', 'Caller process id: ' + data.pid);
const el = appendChildWithText(requestDiv, 'span',
data.request_type + ' call');
el.style.fontWeight = 'bold';
appendChildWithText(el, 'div', 'Time: ' +
(new Date(data.timestamp).toTimeString()))
.style.fontWeight = 'normal';
if (data.audio !== undefined) {
appendChildWithText(el, 'div', 'Audio constraints: ' +
(data.audio || 'true'))
.style.fontWeight = 'normal';
}
if (data.video !== undefined) {
appendChildWithText(el, 'div', 'Video constraints: ' +
(data.video || 'true'))
.style.fontWeight = 'normal';
}
}
/**
* Update a getUserMedia/getDisplayMedia request with a result or error.
*
* @param {!Object} data The object containing rid {number}, pid {number},
* request_id {number}, request_type {string}.
* For results there is also the
* stream_id {string}, audio_track_info {string} and
* video_track_info {string}.
* For errors the error {string} and
* error_message {string} fields are set.
*/
updateMedia(data) {
if (!$(USER_MEDIA_TAB_ID)) {
this.createTab();
}
const requestDiv = document.getElementById(
['gum', data.rid, data.pid, data.request_id].join('-'));
if (!requestDiv) {
console.error('Could not update ' + data.request_type + ' request', data);
return;
}
if (data.error) {
const el = appendChildWithText(requestDiv, 'span', 'Error');
el.style.fontWeight = 'bold';
appendChildWithText(el, 'div', 'Time: ' +
(new Date(data.timestamp).toTimeString()))
.style.fontWeight = 'normal';
appendChildWithText(el, 'div', 'Error: ' + data.error)
.style.fontWeight = 'normal';
appendChildWithText(el, 'div', 'Error message: ' + data.error_message)
.style.fontWeight = 'normal';
return;
}
const el = appendChildWithText(requestDiv, 'span',
data.request_type + ' result');
el.style.fontWeight = 'bold';
appendChildWithText(el, 'div', 'Time: ' +
(new Date(data.timestamp).toTimeString()))
.style.fontWeight = 'normal';
appendChildWithText(el, 'div', 'Stream id: ' + data.stream_id)
.style.fontWeight = 'normal';
if (data.audio_track_info) {
appendChildWithText(el, 'div', 'Audio track: ' + data.audio_track_info)
.style.fontWeight = 'normal';
}
if (data.video_track_info) {
appendChildWithText(el, 'div', 'Video track: ' + data.video_track_info)
.style.fontWeight = 'normal';
}
}
/**
* Removes the getUserMedia/getDisplayMedia requests from the specified |rid|.
*
* @param {!Object} data The object containing rid {number}, the render id.
*/
removeMediaForRenderer(data) {
const requests = $(USER_MEDIA_TAB_ID).childNodes;
for (let i = 0; i < requests.length; ++i) {
if (!requests[i]['data-origin']) {
continue;
}
if (requests[i]['data-rid'] === data.rid) {
$(USER_MEDIA_TAB_ID).removeChild(requests[i]);
}
}
// Remove the tab when only the search field and its label are left.
if ($(USER_MEDIA_TAB_ID).childNodes.length === 2) {
this.tabView.removeTab(USER_MEDIA_TAB_ID);
}
}
}

88
js/calling-tools/util.js Normal file
View file

@ -0,0 +1,88 @@
// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details
import {assert} from "./assert.js";
export function $(id) {
const el = document.querySelector(`#${id}`);
if (el) {
assert(el instanceof HTMLElement);
return el
}
return null
}
export function getRequiredElement(id) {
const el = document.querySelector(`#${id}`);
assert(el);
assert(el instanceof HTMLElement);
return el
}
export function getDeepActiveElement() {
let a = document.activeElement;
while (a && a.shadowRoot && a.shadowRoot.activeElement) {
a = a.shadowRoot.activeElement
}
return a
}
export function isRTL() {
return document.documentElement.dir === "rtl"
}
export function appendParam(url, key, value) {
const param = encodeURIComponent(key) + "=" + encodeURIComponent(value);
if (url.indexOf("?") === -1) {
return url + "?" + param
}
return url + "&" + param
}
export function ensureTransitionEndEvent(el, timeOut) {
if (timeOut === undefined) {
const style = getComputedStyle(el);
timeOut = parseFloat(style.transitionDuration) * 1e3;
timeOut += 50
}
let fired = false;
el.addEventListener("transitionend", (function f() {
el.removeEventListener("transitionend", f);
fired = true
}
));
window.setTimeout((function() {
if (!fired) {
el.dispatchEvent(new CustomEvent("transitionend",{
bubbles: true,
composed: true
}))
}
}
), timeOut)
}
export function htmlEscape(original) {
return original.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;")
}
export function quoteString(str) {
return str.replace(/([\\\.\+\*\?\[\^\]\$\(\)\{\}\=\!\<\>\|\:])/g, "\\$1")
}
export function listenOnce(target, eventNames, callback) {
const eventNamesArray = Array.isArray(eventNames) ? eventNames : eventNames.split(/ +/);
const removeAllAndCallCallback = function(event) {
eventNamesArray.forEach((function(eventName) {
target.removeEventListener(eventName, removeAllAndCallCallback, false)
}
));
return callback(event)
};
eventNamesArray.forEach((function(eventName) {
target.addEventListener(eventName, removeAllAndCallCallback, false)
}
))
}
export function hasKeyModifiers(e) {
return !!(e.altKey || e.ctrlKey || e.metaKey || e.shiftKey)
}
export function isUndoKeyboardEvent(event) {
if (event.key !== "z") {
return false
}
const excludedModifiers = [event.altKey, event.shiftKey, event.ctrlKey];
let targetModifier = event.ctrlKey;
targetModifier = event.metaKey;
return targetModifier && !excludedModifiers.some((modifier=>modifier))
}

View file

@ -0,0 +1,508 @@
// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details
import {$} from './util.js';
import {createIceCandidateGrid, updateIceCandidateGrid} from './candidate_grid.js';
import {MAX_STATS_DATA_POINT_BUFFER_SIZE} from './data_series.js';
import {DumpCreator, peerConnectionDataStore, userMediaRequests} from './dump_creator.js';
import {PeerConnectionUpdateTable} from './peer_connection_update_table.js';
import {drawSingleReport, removeStatsReportGraphs} from './stats_graph_helper.js';
import {StatsRatesCalculator, StatsReport} from './stats_rates_calculator.js';
import {StatsTable} from './stats_table.js';
import {TabView} from './tab_view.js';
import {UserMediaTable} from './user_media_table.js';
import { i18n } from '../../ts/windows/sandboxedInit.js';
let tabView = null;
let peerConnectionUpdateTable = null;
let statsTable = null;
let userMediaTable = null;
let dumpCreator = null;
let requestedStatsInterval = 2000;
// Start Signal Change
let stats_queue = [];
function onRtcStatsReport(event, report) {
const rs = JSON.parse(report.reportJson);
const mungedReports = rs.map(r => ({
id: r.id,
type: r.type,
stats: {
timestamp: r.timestamp / 1000,
values: Object
.keys(r)
.filter(k => !['id', 'type', 'timestamp'].includes(k))
.reduce((acc, k) => {
acc.push(k, r[k]);
return acc;
}, []),
},
}));
// fake since we can only have 1 call going at a time
// pid should be related to call ID
// lid is only one peer connection per call currently
stats_queue.push(
{
pid: 100,
rid: report.conversationId,
lid: report.callId,
reports: mungedReports,
}
)
}
window.Signal.CallingToolsProps.onRtcStatsReport(onRtcStatsReport);
// End Signal Change
const searchParameters = new URLSearchParams(window.location.search);
/** Maps from id (see getPeerConnectionId) to StatsRatesCalculator. */
const statsRatesCalculatorById = new Map();
/** A simple class to store the updates and stats data for a peer connection. */
/** @constructor */
class PeerConnectionRecord {
constructor() {
/** @private */
this.record_ = {
pid: -1,
constraints: {},
rtcConfiguration: [],
stats: {},
updateLog: [],
url: '',
};
}
/** @override */
toJSON() {
return this.record_;
}
/**
* Adds the initialization info of the peer connection.
* @param {number} pid The pid of the process hosting the peer connection.
* @param {string} url The URL of the web page owning the peer connection.
* @param {Array} rtcConfiguration
* @param {!Object} constraints Media constraints.
*/
initialize(pid, url, rtcConfiguration, constraints) {
this.record_.pid = pid;
this.record_.url = url;
this.record_.rtcConfiguration = rtcConfiguration;
this.record_.constraints = constraints;
}
resetStats() {
this.record_.stats = {};
}
/**
* @param {string} dataSeriesId The TimelineDataSeries identifier.
* @return {!TimelineDataSeries}
*/
getDataSeries(dataSeriesId) {
return this.record_.stats[dataSeriesId];
}
/**
* @param {string} dataSeriesId The TimelineDataSeries identifier.
* @param {!TimelineDataSeries} dataSeries The TimelineDataSeries to set to.
*/
setDataSeries(dataSeriesId, dataSeries) {
this.record_.stats[dataSeriesId] = dataSeries;
}
/**
* @param {!Object} update The object contains keys "time", "type", and
* "value".
*/
addUpdate(update) {
const time = new Date(parseFloat(update.time));
this.record_.updateLog.push({
time: time.toLocaleString(),
type: update.type,
value: update.value,
});
}
}
function addMedia(data) {
userMediaRequests.push(data);
userMediaTable.addMedia(data)
}
function updateMedia(data) {
userMediaRequests.push(data);
userMediaTable.updateMedia(data);
}
function removeMediaForRenderer(data) {
for (let i = userMediaRequests.length - 1; i >= 0; --i) {
if (userMediaRequests[i].rid === data.rid) {
userMediaRequests.splice(i, 1);
}
}
userMediaTable.removeMediaForRenderer(data);
}
function setRtcStatsInterval() {
window.Signal.CallingToolsProps.setRtcStatsInterval(requestedStatsInterval);
}
function initialize() {
let placeholderTitle = i18n('icu:callingDeveloperTools');
let placeholderDescription = i18n('icu:callingDeveloperToolsDescription');
$('placeholder-title').innerText = placeholderTitle;
$('placeholder-description').innerText = placeholderDescription;
dumpCreator = new DumpCreator($('content-root'));
tabView = new TabView($('content-root'));
peerConnectionUpdateTable = new PeerConnectionUpdateTable();
statsTable = new StatsTable();
userMediaTable = new UserMediaTable(tabView, userMediaRequests);
let processStatsInterval = 1000;
window.setInterval(processStats, processStatsInterval);
setRtcStatsInterval(2000);
}
document.addEventListener('DOMContentLoaded', initialize);
/**
* Sends a request to the browser to get peer connection statistics from the
* standard getStats() API (promise-based).
*/
function processStats() {
// Start Signal Change
for(let i = 0; i < 10 && stats_queue.length > 0; i++) {
addStandardStats(stats_queue.shift());
}
// End Signal Change
}
/**
* A helper function for getting a peer connection element id.
*
* @param {!Object<number>} data The object containing the rid and lid of the
* peer connection.
* @return {string} The peer connection element id.
*/
function getPeerConnectionId(data) {
return data.rid + '-' + data.lid;
}
/**
* A helper function for appending a child element to |parent|.
*
* @param {!Element} parent The parent element.
* @param {string} tag The child element tag.
* @param {string} text The textContent of the new DIV.
* @return {!Element} the new DIV element.
*/
function appendChildWithText(parent, tag, text) {
const child = document.createElement(tag);
child.textContent = text;
parent.appendChild(child);
return child;
}
/**
* Helper for adding a peer connection update.
*
* @param {Element} peerConnectionElement
* @param {!PeerConnectionUpdateEntry} update The peer connection update data.
*/
function addPeerConnectionUpdate(peerConnectionElement, update) {
peerConnectionUpdateTable.addPeerConnectionUpdate(
peerConnectionElement, update);
peerConnectionDataStore[peerConnectionElement.id].addUpdate(update);
}
/** Browser message handlers. */
/**
* Removes all information about a peer connection.
* Use ?keepRemovedConnections url parameter to prevent the removal.
*
* @param {!Object<number>} data The object containing the rid and lid of a peer
* connection.
*/
function removePeerConnection(data) {
// Disable getElementById restriction here, since |getPeerConnectionId| does
// not return valid selectors.
// eslint-disable-next-line no-restricted-properties
const element = document.getElementById(getPeerConnectionId(data));
if (element && !searchParameters.has('keepRemovedConnections')) {
delete peerConnectionDataStore[element.id];
tabView.removeTab(element.id);
}
}
/**
* Adds a peer connection.
*
* @param {!Object} data The object containing the rid, lid, pid, url,
* rtcConfiguration, and constraints of a peer connection.
*/
function addPeerConnection(data) {
const id = getPeerConnectionId(data);
if (!peerConnectionDataStore[id]) {
peerConnectionDataStore[id] = new PeerConnectionRecord();
}
peerConnectionDataStore[id].initialize(
data.pid, data.url, data.rtcConfiguration, data.constraints);
// Disable getElementById restriction here, since |id| is not always
// a valid selector.
// eslint-disable-next-line no-restricted-properties
let peerConnectionElement = document.getElementById(id);
if (!peerConnectionElement) {
const details = `[ rid: ${data.rid}, lid: ${data.lid}, pid: ${data.pid} ]`;
peerConnectionElement = tabView.addTab(id, data.url + " " + details);
}
const p = document.createElement('p');
appendChildWithText(p, 'span', data.url);
appendChildWithText(p, 'span', ', ');
appendChildWithText(p, 'span', data.rtcConfiguration);
if (data.constraints !== '') {
appendChildWithText(p, 'span', ', ');
appendChildWithText(p, 'span', data.constraints);
}
peerConnectionElement.appendChild(p);
// Show deprecation notices as a list.
// Note: data.rtcConfiguration is not in JSON format and may
// not be defined in tests.
const deprecationNotices = document.createElement('ul');
if (data.rtcConfiguration) {
deprecationNotices.className = 'peerconnection-deprecations';
}
peerConnectionElement.appendChild(deprecationNotices);
const iceConnectionStates = document.createElement('div');
iceConnectionStates.textContent = 'ICE connection state: new';
iceConnectionStates.className = 'iceconnectionstate';
peerConnectionElement.appendChild(iceConnectionStates);
const connectionStates = document.createElement('div');
connectionStates.textContent = 'Connection state: new';
connectionStates.className = 'connectionstate';
peerConnectionElement.appendChild(connectionStates);
const signalingStates = document.createElement('div');
signalingStates.textContent = 'Signaling state: new';
signalingStates.className = 'signalingstate';
peerConnectionElement.appendChild(signalingStates);
const candidatePair = document.createElement('div');
candidatePair.textContent = 'ICE Candidate pair: ';
candidatePair.className = 'candidatepair';
candidatePair.appendChild(document.createElement('span'));
peerConnectionElement.appendChild(candidatePair);
createIceCandidateGrid(peerConnectionElement);
return peerConnectionElement;
}
/**
* Adds a peer connection update.
*
* @param {!PeerConnectionUpdateEntry} data The peer connection update data.
*/
function updatePeerConnection(data) {
// Disable getElementById restriction here, since |getPeerConnectionId| does
// not return valid selectors.
const peerConnectionElement =
// eslint-disable-next-line no-restricted-properties
document.getElementById(getPeerConnectionId(data));
addPeerConnectionUpdate(peerConnectionElement, data);
}
/**
* Adds the information of all peer connections created so far.
*
* @param {Array<!Object>} data An array of the information of all peer
* connections. Each array item contains rid, lid, pid, url,
* rtcConfiguration, constraints, and an array of updates as the log.
*/
function updateAllPeerConnections(data) {
for (let i = 0; i < data.length; ++i) {
const peerConnection = addPeerConnection(data[i]);
const log = data[i].log;
if (!log) {
continue;
}
for (let j = 0; j < log.length; ++j) {
addPeerConnectionUpdate(peerConnection, log[j]);
}
}
processStats();
}
/**
* Handles the report of stats originating from the standard getStats() API.
*
* @param {!Object} data The object containing rid, lid, and reports, where
* reports is an array of stats reports. Each report contains id, type,
* and stats, where stats is the object containing timestamp and values,
* which is an array of strings, whose even index entry is the name of the
* stat, and the odd index entry is the value.
*/
function addStandardStats(data) {
// Disable getElementById restriction here, since |getPeerConnectionId| does
// not return valid selectors.
// eslint-disable-next-line no-restricted-properties
let peerConnectionElement =
// eslint-disable-next-line no-restricted-properties
document.getElementById(getPeerConnectionId(data));
if (!peerConnectionElement) {
// fake the add peer event
peerConnectionElement = addPeerConnection({
connected: false,
isOpen: true,
lid: data.lid,
rid: data.rid,
rtcConfiguration: "{ iceServers: [], iceTransportPolicy: all, bundlePolicy: balanced, rtcpMuxPolicy: require, iceCandidatePoolSize: 0 }",
url: "groupcall"
});
// eslint-disable-next-line no-restricted-properties
if(!peerConnectionElement) {
console.error("Failed to create peerConnection Element");
}
}
const pcId = getPeerConnectionId(data);
let statsRatesCalculator = statsRatesCalculatorById.get(pcId);
if (!statsRatesCalculator) {
statsRatesCalculator = new StatsRatesCalculator();
statsRatesCalculatorById.set(pcId, statsRatesCalculator);
}
// This just changes the reports from their array format into an object format, then adds it to statsByAdd
const r = StatsReport.fromInternalsReportList(data.reports);
statsRatesCalculator.addStatsReport(r);
data.reports = statsRatesCalculator.currentReport.toInternalsReportList();
for (let i = 0; i < data.reports.length; ++i) {
const report = data.reports[i];
statsTable.addStatsReport(peerConnectionElement, report);
drawSingleReport(peerConnectionElement, report);
}
// Determine currently connected candidate pair.
const stats = r.statsById;
let activeCandidatePair = null;
let remoteCandidate = null;
let localCandidate = null;
// Get the first active candidate pair. This ignores the rare case of
// non-bundled connections.
stats.forEach(report => {
if (report.type === 'transport' && !activeCandidatePair) {
activeCandidatePair = stats.get(report.selectedCandidatePairId);
}
});
const candidateElement = peerConnectionElement
.getElementsByClassName('candidatepair')[0].firstElementChild;
if (activeCandidatePair) {
if (activeCandidatePair.remoteCandidateId) {
remoteCandidate = stats.get(activeCandidatePair.remoteCandidateId);
}
if (activeCandidatePair.localCandidateId) {
localCandidate = stats.get(activeCandidatePair.localCandidateId);
}
candidateElement.innerText = '';
if (localCandidate && remoteCandidate) {
if (localCandidate.address &&
localCandidate.address.indexOf(':') !== -1) {
// Show IPv6 in []
candidateElement.innerText +='[' + localCandidate.address + ']';
} else {
candidateElement.innerText += localCandidate.address || '(not set)';
}
candidateElement.innerText += ':' + localCandidate.port + ' <=> ';
if (remoteCandidate.address &&
remoteCandidate.address.indexOf(':') !== -1) {
// Show IPv6 in []
candidateElement.innerText +='[' + remoteCandidate.address + ']';
} else {
candidateElement.innerText += remoteCandidate.address || '(not set)';
}
candidateElement.innerText += ':' + remoteCandidate.port;
}
// Mark active local-candidate, remote candidate and candidate pair
// bold in the table.
// Disable getElementById restriction here, since |peerConnectionElement|
// doesn't always have a valid selector ID.
const statsContainer =
// eslint-disable-next-line no-restricted-properties
document.getElementById(peerConnectionElement.id + '-table-container');
const activeConnectionClass = 'stats-table-active-connection';
statsContainer.childNodes.forEach(node => {
if (node.nodeName !== 'DETAILS' || !node.children[1]) {
return;
}
const ids = [
peerConnectionElement.id + '-table-' + activeCandidatePair.id,
peerConnectionElement.id + '-table-' + localCandidate.id,
peerConnectionElement.id + '-table-' + remoteCandidate.id,
];
if (ids.includes(node.children[1].id)) {
node.firstElementChild.classList.add(activeConnectionClass);
} else {
node.firstElementChild.classList.remove(activeConnectionClass);
}
});
// Mark active candidate-pair graph bold.
const statsGraphContainers = peerConnectionElement
.getElementsByClassName('stats-graph-container');
for (let i = 0; i < statsGraphContainers.length; i++) {
const node = statsGraphContainers[i];
if (node.nodeName !== 'DETAILS') {
continue;
}
if (!node.id.startsWith(pcId + '-candidate-pair')) {
continue;
}
if (node.id === pcId + '-candidate-pair-' + activeCandidatePair.id
+ '-graph-container') {
node.firstElementChild.classList.add(activeConnectionClass);
} else {
node.firstElementChild.classList.remove(activeConnectionClass);
}
}
} else {
candidateElement.innerText = '(not connected)';
}
updateIceCandidateGrid(peerConnectionElement, r.statsById);
}
/**
* Notification that the audio debug recordings file selection dialog was
* cancelled, i.e. recordings have not been enabled.
*/
function audioDebugRecordingsFileSelectionCancelled() {
dumpCreator.clearAudioDebugRecordingsCheckbox();
}
/**
* Notification that the event log recordings file selection dialog was
* cancelled, i.e. recordings have not been enabled.
*/
function eventLogRecordingsFileSelectionCancelled() {
dumpCreator.clearEventLogRecordingsCheckbox();
}