blob: 37178bc189caaf468ce63342a25b8a65165aa00b [file] [log] [blame]
// Copyright 2013 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {addWebUIListener, sendWithPromise} from 'chrome://resources/js/cr.m.js';
import {$} from 'chrome://resources/js/util.m.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 {SsrcInfoManager} from './ssrc_info_manager.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 {createIceCandidateGrid, updateIceCandidateGrid} from './candidate_grid.js';
const USER_MEDIA_TAB_ID = 'user-media-tab-id';
const OPTION_GETSTATS_STANDARD = 'Standardized (promise-based) getStats() API';
const OPTION_GETSTATS_LEGACY =
'Legacy Non-Standard (callback-based) getStats() API';
let currentGetStatsMethod = OPTION_GETSTATS_STANDARD;
let tabView = null;
let ssrcInfoManager = null;
let peerConnectionUpdateTable = null;
let statsTable = null;
let dumpCreator = null;
// Exporting these on window since they are directly accessed by tests.
window.setCurrentGetStatsMethod = function(method) {
currentGetStatsMethod = method;
};
window.OPTION_GETSTATS_LEGACY = OPTION_GETSTATS_LEGACY;
/** 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 initialize() {
dumpCreator = new DumpCreator($('content-root'));
$('content-root').appendChild(createStatsSelectionOptionElements());
tabView = new TabView($('content-root'));
ssrcInfoManager = new SsrcInfoManager();
window.ssrcInfoManager = ssrcInfoManager;
peerConnectionUpdateTable = new PeerConnectionUpdateTable();
statsTable = new StatsTable(ssrcInfoManager);
// Add listeners for all the updates that get sent from webrtc_internals.cc.
addWebUIListener('add-peer-connection', addPeerConnection);
addWebUIListener('update-peer-connection', updatePeerConnection);
addWebUIListener('update-all-peer-connections', updateAllPeerConnections);
addWebUIListener('remove-peer-connection', removePeerConnection);
addWebUIListener('add-standard-stats', addStandardStats);
addWebUIListener('add-legacy-stats', addLegacyStats);
addWebUIListener('add-get-user-media', addGetUserMedia);
addWebUIListener('update-get-user-media', updateGetUserMedia);
addWebUIListener(
'remove-get-user-media-for-renderer', removeGetUserMediaForRenderer);
addWebUIListener(
'event-log-recordings-file-selection-cancelled',
eventLogRecordingsFileSelectionCancelled);
addWebUIListener(
'audio-debug-recordings-file-selection-cancelled',
audioDebugRecordingsFileSelectionCancelled);
// Request initial startup parameters.
sendWithPromise('finishedDOMLoad').then(params => {
if (params.audioDebugRecordingsEnabled) {
dumpCreator.setAudioDebugRecordingsCheckbox();
}
if (params.eventLogRecordingsEnabled) {
dumpCreator.setEventLogRecordingsCheckbox();
}
dumpCreator.setEventLogRecordingsCheckboxMutability(
params.eventLogRecordingsToggleable);
});
// Requests stats from all peer connections every second unless specified via
// ?statsInterval=(milliseconds >= 100ms)
const searchParameters = new URLSearchParams(window.location.search);
let statsInterval = 1000;
if (searchParameters.has('statsInterval')) {
statsInterval = Math.max(
parseInt(searchParameters.get('statsInterval'), 10),
100);
if (!isFinite(statsInterval)) {
statsInterval = 1000;
}
}
window.setInterval(requestStats, statsInterval);
}
document.addEventListener('DOMContentLoaded', initialize);
function createStatsSelectionOptionElements() {
const statsElement = $('stats-template').content.cloneNode(true);
const selectElement = statsElement.getElementById('statsSelectElement');
const legacyStatsElement = statsElement.getElementById(
'legacy-stats-warning');
selectElement.onchange = () => {
currentGetStatsMethod = selectElement.value;
legacyStatsElement.style.display =
currentGetStatsMethod === OPTION_GETSTATS_LEGACY ? 'block' : 'none';
Object.keys(peerConnectionDataStore).forEach(id => {
const peerConnectionElement = $(id);
statsTable.clearStatsLists(peerConnectionElement);
removeStatsReportGraphs(peerConnectionElement);
peerConnectionDataStore[id].resetStats();
});
};
[OPTION_GETSTATS_STANDARD, OPTION_GETSTATS_LEGACY].forEach(option => {
const optionElement = document.createElement('option');
optionElement.setAttribute('value', option);
optionElement.appendChild(document.createTextNode(option));
selectElement.appendChild(optionElement);
});
selectElement.value = currentGetStatsMethod;
return statsElement;
}
function requestStats() {
if (currentGetStatsMethod === OPTION_GETSTATS_STANDARD) {
requestStandardStats();
} else if (currentGetStatsMethod === OPTION_GETSTATS_LEGACY) {
requestLegacyStats();
}
}
/**
* Sends a request to the browser to get peer connection statistics from the
* standard getStats() API (promise-based).
*/
function requestStandardStats() {
if (Object.keys(peerConnectionDataStore).length > 0) {
chrome.send('getStandardStats');
}
}
/**
* Sends a request to the browser to get peer connection statistics from the
* legacy getStats() API (callback-based non-standard API with goog-stats).
*/
function requestLegacyStats() {
if (Object.keys(peerConnectionDataStore).length > 0) {
chrome.send('getLegacyStats');
}
}
/*
* Change to use the legacy getStats() API instead. This is used for a
* work-around for https://crbug.com/999136.
* TODO(https://crbug.com/1004239): Delete this method.
*/
function changeToLegacyGetStats() {
currentGetStatsMethod = OPTION_GETSTATS_LEGACY;
const selectElement = $('statsSelectElement');
selectElement.value = currentGetStatsMethod;
requestStats();
}
window.changeToLegacyGetStats = changeToLegacyGetStats;
/**
* 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;
}
/**
* Extracts ssrc info from a setLocal/setRemoteDescription update.
*
* @param {!PeerConnectionUpdateEntry} data The peer connection update data.
*/
function extractSsrcInfo(data) {
if (data.type === 'setLocalDescription' ||
data.type === 'setRemoteDescription') {
ssrcInfoManager.addSsrcStreamInfo(data.value);
}
}
/**
* 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);
extractSsrcInfo(update);
peerConnectionDataStore[peerConnectionElement.id].addUpdate(update);
}
/** Browser message handlers. */
/**
* Removes all information about a peer connection.
*
* @param {!Object<number>} data The object containing the rid and lid of a peer
* connection.
*/
function removePeerConnection(data) {
const element = $(getPeerConnectionId(data));
if (element) {
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);
let peerConnectionElement = $(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';
}
if (data.constraints) {
if (data.constraints.indexOf('enableDtlsSrtp:') !== -1) {
if (data.constraints.indexOf('enableDtlsSrtp: {exact: false}') !== -1) {
appendChildWithText(deprecationNotices, 'li',
'The constraint "DtlsSrtpKeyAgreement" will be removed. You have ' +
'specified a "false" value for this constraint, which is ' +
'interpreted as an attempt to use the deprecated "SDES" key ' +
'negotiation method. This functionality will be removed; use a ' +
'service that supports DTLS key negotiation instead.');
} else {
appendChildWithText(deprecationNotices, 'li',
'The constraint "DtlsSrtpKeyAgreement" will be removed. You have ' +
'specified a "true" value for this constraint, which has no ' +
'effect, but you can remove this constraint for tidiness.');
}
}
}
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) {
const peerConnectionElement = $(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]);
}
}
requestStats();
}
/**
* 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) {
if (currentGetStatsMethod != OPTION_GETSTATS_STANDARD) {
return; // Obsolete!
}
const peerConnectionElement = $(getPeerConnectionId(data));
if (!peerConnectionElement) {
return;
}
const pcId = getPeerConnectionId(data);
let statsRatesCalculator = statsRatesCalculatorById.get(pcId);
if (!statsRatesCalculator) {
statsRatesCalculator = new StatsRatesCalculator();
statsRatesCalculatorById.set(pcId, statsRatesCalculator);
}
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, false);
}
// 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);
}
if (localCandidate && localCandidate.address &&
localCandidate.address.indexOf(':') !== -1) {
// Show IPv6 in []
candidateElement.innerText =
'[' + localCandidate.address + ']:' + localCandidate.port
+ ' <=> [' + remoteCandidate.address + ']:' + remoteCandidate.port;
} else {
candidateElement.innerText =
localCandidate.address + ':' + localCandidate.port
+ ' <=> ' + remoteCandidate.address + ':' + remoteCandidate.port;
}
// Mark active local-candidate, remote candidate and candidate pair
// bold in the table.
const statsContainer =
document.getElementById(peerConnectionElement.id + '-table-container');
const activeConnectionClass = 'stats-table-active-connection';
statsContainer.childNodes.forEach(node => {
if (node.nodeName !== 'DETAILS') {
return;
}
const innerText = node.firstElementChild.innerText;
if (innerText.startsWith(activeCandidatePair.id)
|| innerText.startsWith(localCandidate.id)
|| innerText.startsWith(remoteCandidate.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);
}
/**
* Handles the report of stats originating from the legacy 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 addLegacyStats(data) {
if (currentGetStatsMethod != OPTION_GETSTATS_LEGACY) {
return; // Obsolete!
}
const peerConnectionElement = $(getPeerConnectionId(data));
if (!peerConnectionElement) {
return;
}
for (let i = 0; i < data.reports.length; ++i) {
const report = data.reports[i];
statsTable.addStatsReport(peerConnectionElement, report);
drawSingleReport(peerConnectionElement, report, true);
}
}
/**
* Adds a getUserMedia request.
*
* @param {!Object} data The object containing rid {number}, pid {number},
* origin {string}, request_id {number}, audio {string}, video {string}.
*/
function addGetUserMedia(data) {
userMediaRequests.push(data);
if (!$(USER_MEDIA_TAB_ID)) {
tabView.addTab(USER_MEDIA_TAB_ID, 'GetUserMedia Requests');
}
const requestDiv = document.createElement('div');
requestDiv.className = 'user-media-request-div-class';
requestDiv.id = ['gum', data.rid, data.pid, data.request_id].join('-');
requestDiv.rid = data.rid;
$(USER_MEDIA_TAB_ID).appendChild(requestDiv);
appendChildWithText(requestDiv, 'div', 'Caller origin: ' + data.origin);
appendChildWithText(requestDiv, 'div', 'Caller process id: ' + data.pid);
const el = appendChildWithText(requestDiv, 'span', 'getUserMedia 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 request with a result or error.
*
* @param {!Object} data The object containing rid {number}, pid {number},
* request_id {number}. For getUserMedia 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.
*/
function updateGetUserMedia(data) {
userMediaRequests.push(data);
if (!$(USER_MEDIA_TAB_ID)) {
tabView.addTab(USER_MEDIA_TAB_ID, 'GetUserMedia Requests');
}
const requestDiv = document.getElementById(
['gum', data.rid, data.pid, data.request_id].join('-'));
if (!requestDiv) {
console.error('Could not update getUserMedia 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', 'getUserMedia 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 requests from the specified |rid|.
*
* @param {!Object} data The object containing rid {number}, the render id.
*/
function removeGetUserMediaForRenderer(data) {
for (let i = userMediaRequests.length - 1; i >= 0; --i) {
if (userMediaRequests[i].rid === data.rid) {
userMediaRequests.splice(i, 1);
}
}
const requests = $(USER_MEDIA_TAB_ID).childNodes;
for (let i = 0; i < requests.length; ++i) {
if (requests[i].rid === data.rid) {
$(USER_MEDIA_TAB_ID).removeChild(requests[i]);
}
}
if ($(USER_MEDIA_TAB_ID).childNodes.length === 0) {
tabView.removeTab(USER_MEDIA_TAB_ID);
}
}
/**
* 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();
}