Add calling tools to visualize ringrtc stats

Co-authored-by: ayumi-signal <ayumi@signal.org>
This commit is contained in:
adel-signal 2024-05-22 17:28:01 -07:00 committed by GitHub
parent 4bf08977cf
commit 8a9ab8c13f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 3926 additions and 0 deletions

4
.gitignore vendored
View file

@ -27,6 +27,7 @@ js/components.js
js/util_worker.js
libtextsecure/components.js
stylesheets/*.css
!stylesheets/webrtc_internals.css
/storybook-static/
preload.bundle.*
bundles/
@ -37,6 +38,9 @@ build/ICUMessageParams.d.ts
app/*.js
ts/**/*.js
ts/protobuf/*.d.ts
# allow js from callingtools
!ts/windows/callingtools/**/*.js
ts/windows/callingtools/preload.js
# CSS Modules
**/*.scss.d.ts

View file

@ -3668,6 +3668,36 @@ Signal Desktop makes use of the following open source projects.
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## Chromium WebRTC Internals Dashboard
Copyright 2015 The Chromium Authors
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google LLC nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
## Kyber Patent License
<https://csrc.nist.gov/csrc/media/Projects/post-quantum-cryptography/documents/selected-algos-2022/nist-pqc-license-summary-and-excerpts.pdf>

View file

@ -247,6 +247,10 @@
"messageformat": "Toggle Developer Tools",
"description": "View menu command to show or hide the developer tools"
},
"icu:viewMenuOpenCallingDevTools": {
"messageformat": "Open Calling Developer Tools",
"description": "View menu command to open calling developer tools window"
},
"icu:menuSetupAsNewDevice": {
"messageformat": "Set Up as New Device",
"description": "When the application is not yet set up, menu option to start up the set up as fresh device"
@ -871,6 +875,14 @@
"messageformat": "Sharing screen",
"description": "Title for screen sharing window"
},
"icu:callingDeveloperTools": {
"messageformat": "Calling Developer Tools",
"description": "Title for calling developer tools window"
},
"icu:callingDeveloperToolsDescription": {
"messageformat": "This window is used during development to display diagnostics from ongoing calls.",
"description": "Description displayed for calling developer tools window"
},
"icu:speech": {
"messageformat": "Speech",
"description": "Item under the Edit menu, with 'start/stop speaking' items below it"

View file

@ -1237,6 +1237,67 @@ async function showScreenShareWindow(sourceName: string) {
);
}
let callingDevToolsWindow: BrowserWindow | undefined;
async function showCallingDevToolsWindow() {
if (callingDevToolsWindow) {
callingDevToolsWindow.show();
return;
}
const options = {
height: 1200,
width: 1000,
alwaysOnTop: false,
autoHideMenuBar: true,
backgroundColor: '#ffffff',
darkTheme: false,
frame: true,
fullscreenable: true,
maximizable: true,
minimizable: true,
resizable: true,
show: false,
title: getResolvedMessagesLocale().i18n('icu:callingDeveloperTools'),
titleBarStyle: nonMainTitleBarStyle,
webPreferences: {
...defaultWebPrefs,
nodeIntegration: false,
nodeIntegrationInWorker: false,
sandbox: true,
contextIsolation: true,
nativeWindowOpen: true,
preload: join(__dirname, '../bundles/callingtools/preload.js'),
},
};
callingDevToolsWindow = new BrowserWindow(options);
await handleCommonWindowEvents(callingDevToolsWindow);
callingDevToolsWindow.once('closed', () => {
callingDevToolsWindow = undefined;
mainWindow?.webContents.send('calling:set-rtc-stats-interval', null);
});
ipc.on('calling:set-rtc-stats-interval', (_, intervalMillis: number) => {
mainWindow?.webContents.send(
'calling:set-rtc-stats-interval',
intervalMillis
);
});
ipc.on('calling:rtc-stats-report', (_, report) => {
callingDevToolsWindow?.webContents.send('calling:rtc-stats-report', report);
});
await safeLoadURL(
callingDevToolsWindow,
await prepareFileUrl([__dirname, '../calling_tools.html'])
);
callingDevToolsWindow.show();
}
let aboutWindow: BrowserWindow | undefined;
async function showAbout() {
if (aboutWindow) {
@ -2054,6 +2115,7 @@ function setupMenu(options?: Partial<CreateTemplateOptionsType>) {
setupAsStandalone,
showAbout,
showDebugLog: showDebugLogWindow,
showCallingDevTools: showCallingDevToolsWindow,
showKeyboardShortcuts,
showSettings: showSettingsWindow,
showWindow,

View file

@ -35,6 +35,7 @@ export const createTemplate = (
forceUpdate,
showAbout,
showDebugLog,
showCallingDevTools,
showKeyboardShortcuts,
showSettings,
openArtCreator,
@ -146,6 +147,10 @@ export const createTemplate = (
role: 'toggleDevTools' as const,
label: i18n('icu:viewMenuToggleDevTools'),
},
{
label: i18n('icu:viewMenuOpenCallingDevTools'),
click: showCallingDevTools,
},
]
: []),
...(devTools && platform !== 'linux'

75
calling_tools.html Normal file
View file

@ -0,0 +1,75 @@
<!-- Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details -->
<!DOCTYPE html>
<html dir="auto">
<head>
<meta charset="utf-8" />
<meta
content="width=device-width, user-scalable=no, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0"
name="viewport"
/>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="description" content="" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none';
child-src 'self';
connect-src 'self' https: wss:;
font-src 'self';
form-action 'self';
frame-src 'none';
img-src 'self' blob: data:;
media-src 'self' blob:;
object-src 'none';
script-src 'self' 'sha256-Qu05oqDmBO5fZacm7tr/oerJcqsW0G/XqP4PRCziovc=' 'sha256-eLeGwSfPmXJ+EUiLfIeXABvLiUqDbiKgNLpHITaabgQ=';
style-src 'self' 'unsafe-inline';"
/>
<link
href="stylesheets/webrtc_internals.css"
rel="stylesheet"
type="text/css"
/>
<script
type="module"
src="bundles/callingtools/webrtc_internals.js"
></script>
</head>
<body>
<p id="content-root"></p>
<h3 id="placeholder-title"></h3>
<p id="placeholder-description"></p>
<template id="td2-template"
><td></td>
<td></td
></template>
<template id="summary-template"
><td>
<details><summary></summary></details></td
></template>
<template id="container-template"
><div></div>
<div><canvas></canvas></div
></template>
<template id="summary-span-template"
><summary><span></span></summary
></template>
<template id="checkbox-template"
><input type="checkbox" checked
/></template>
<template id="trth-template"
><tbody>
<tr>
<th colspan="2"></th>
</tr></tbody
></template>
<template id="td-colspan-template"><td colspan="2"></td></template>
<template id="time-event-template"
><tbody>
<tr>
<th>Time</th>
<th class="update-log-header-event">Event</th>
</tr>
</tbody></template
>
</body>
</html>

View file

@ -484,6 +484,7 @@
"screenShare.html",
"settings.html",
"permissions_popup.html",
"calling_tools.html",
"debug_log.html",
"loading.html",
{

View file

@ -132,6 +132,13 @@ async function sandboxedEnv() {
path.join(ROOT_DIR, 'ts', 'windows', 'permissions', 'app.tsx'),
path.join(ROOT_DIR, 'ts', 'windows', 'screenShare', 'app.tsx'),
path.join(ROOT_DIR, 'ts', 'windows', 'settings', 'app.tsx'),
path.join(
ROOT_DIR,
'ts',
'windows',
'callingtools',
'webrtc_internals.js'
),
],
},
preloadConfig: {
@ -142,6 +149,7 @@ async function sandboxedEnv() {
path.join(ROOT_DIR, 'ts', 'windows', 'debuglog', 'preload.ts'),
path.join(ROOT_DIR, 'ts', 'windows', 'loading', 'preload.ts'),
path.join(ROOT_DIR, 'ts', 'windows', 'permissions', 'preload.ts'),
path.join(ROOT_DIR, 'ts', 'windows', 'callingtools', 'preload.ts'),
path.join(ROOT_DIR, 'ts', 'windows', 'screenShare', 'preload.ts'),
path.join(ROOT_DIR, 'ts', 'windows', 'settings', 'preload.ts'),
],

View file

@ -139,6 +139,42 @@ async function main() {
}
);
const markdownForChromiumDashboard = [
'## Chromium WebRTC Internals Dashboard',
'',
[
'Copyright 2015 The Chromium Authors',
'',
'Redistribution and use in source and binary forms, with or without',
'modification, are permitted provided that the following conditions are',
'met:',
'',
' * Redistributions of source code must retain the above copyright',
'notice, this list of conditions and the following disclaimer.',
' * Redistributions in binary form must reproduce the above',
'copyright notice, this list of conditions and the following disclaimer',
'in the documentation and/or other materials provided with the',
'distribution.',
' * Neither the name of Google LLC nor the names of its',
'contributors may be used to endorse or promote products derived from',
'this software without specific prior written permission.',
'',
'THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS',
'"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT',
'LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR',
'A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT',
'OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,',
'SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT',
'LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,',
'DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY',
'THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT',
'(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE',
'OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.',
]
.map(line => `\t${line}`)
.join('\n'),
].join('\n');
const unformattedOutput = [
'<!-- Copyright 2020 Signal Messenger, LLC -->',
'<!-- SPDX-License-Identifier: AGPL-3.0-only -->',
@ -147,6 +183,7 @@ async function main() {
'Signal Desktop makes use of the following open source projects.',
'',
markdownsForDependency.join('\n\n'),
markdownForChromiumDashboard,
'',
'## Kyber Patent License',
'',

View file

@ -0,0 +1,155 @@
/* 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. */
.peer-connection-dump-root {
font-size: 0.8em;
padding-bottom: 3px;
}
.update-log-container {
float: left;
width: 50em;
overflow: auto;
}
.update-log-failure {
background-color: #be2026;
}
.stats-graph-container {
clear: both;
margin: 0.5em 0 0.5em 0;
}
.stats-graph-sub-container {
float: left;
margin: 0.5em;
}
.stats-graph-sub-container > div {
float: left;
}
.stats-graph-sub-container > div:first-child {
float: none;
}
.stats-table-container {
float: left;
padding: 0 0 0 0;
overflow: auto;
}
.stats-table-container >div:first-child {
font-size: 0.8em;
font-weight: bold;
text-align: center;
padding: 0 0 1em 0;
}
.stats-table-active-connection {
font-weight: bold;
}
body {
font-family: 'Lucida Grande', sans-serif;
}
table {
border: none;
margin: 0 1em 1em 0;
}
td {
border: none;
font-size: 0.8em;
padding: 0 1em 0.5em 0;
min-width: 10em;
word-break: break-all;
}
table > tr {
vertical-align: top;
}
th {
border: none;
font-size: 0.8em;
padding: 0 0 0.5em 0;
}
.tab-head {
background-color: rgb(220, 220, 220);
margin: 10px 2px 0 2px;
text-decoration: underline;
cursor: pointer;
display: inline-block;
overflow: hidden;
width: 20em;
height: 3em;
}
.active-tab-head {
background-color: turquoise;
font-weight: bold;
}
.tab-body {
border: 1px solid turquoise;
border-top-width: 3px;
padding: 0 10px 500px 10px;
display: none;
}
.active-tab-body {
display: block;
}
.user-media-request-div-class {
background-color: lightgray;
margin: 10px 0 10px 0;
}
.user-media-request-div-class > div {
margin: 5px 0 5px 0;
}
.dumps-info {
max-width: 60em;
}
details[open] details summary {
background-color: rgb(220, 220, 220);
}
.peerconnection-deprecations {
font-weight: bold;
}
.candidategrid tr {
text-align: center;
word-break: break-word;
}
.candidategrid-active {
font-weight: bold;
}
.candidategrid-candidatepair {
background-color: #ccc;
}
.candidategrid-candidatepair td:first-of-type {
text-align: left;
}
.candidategrid-candidate {
background-color: #ddd;
}
.candidategrid-candidate td:first-of-type {
text-align: right;
}

View file

@ -159,6 +159,7 @@ const RINGRTC_HTTP_METHOD_TO_OUR_HTTP_METHOD: Map<
const CLEAN_EXPIRED_GROUP_CALL_RINGS_INTERVAL = 10 * durations.MINUTE;
const ICE_SERVER_IS_IP_LIKE = /(turn|turns|stun):[.\d]+/;
const MAX_CALL_DEBUG_STATS_TABS = 5;
// We send group call update messages to tell other clients to peek, which triggers
// notifications, timeline messages, big green "Join" buttons, and so on. This enum
@ -346,6 +347,10 @@ export class CallingClass {
private hadLocalVideoBeforePresenting?: boolean;
private currentRtcStatsInterval: number | null = null;
private callDebugNumber: number = 0;
constructor() {
this.videoCapturer = new GumVideoCapturer({
maxWidth: REQUESTED_VIDEO_WIDTH,
@ -381,6 +386,7 @@ export class CallingClass {
this.handleSendCallMessageToGroup.bind(this);
RingRTC.handleGroupCallRingUpdate =
this.handleGroupCallRingUpdate.bind(this);
RingRTC.handleRtcStatsReport = this.handleRtcStatsReport.bind(this);
this.attemptToGiveOurServiceIdToRingRtc();
window.Whisper.events.on('userChanged', () => {
@ -390,6 +396,12 @@ export class CallingClass {
ipcRenderer.on('stop-screen-share', () => {
reduxInterface.setPresenting();
});
ipcRenderer.on(
'calling:set-rtc-stats-interval',
(_, intervalMillis: number | null) => {
this.setAllRtcStatsInterval(intervalMillis);
}
);
drop(this.cleanExpiredGroupCallRingsAndLoop());
drop(this.cleanupStaleRingingCalls());
@ -399,6 +411,16 @@ export class CallingClass {
}
}
private maybeUpdateRtcLogging(groupCall: GroupCall): void {
if (!this.currentRtcStatsInterval) {
return;
}
groupCall.setRtcStatsInterval(this.currentRtcStatsInterval);
this.callDebugNumber =
(this.callDebugNumber + 1) % MAX_CALL_DEBUG_STATS_TABS;
}
private attemptToGiveOurServiceIdToRingRtc(): void {
const ourAci = window.textsecure.storage.user.getAci();
if (!ourAci) {
@ -911,6 +933,7 @@ export class CallingClass {
outerGroupCall.connect();
this.maybeUpdateRtcLogging(outerGroupCall);
this.syncGroupCallToRedux(conversationId, outerGroupCall, CallMode.Group);
return outerGroupCall;
@ -966,6 +989,7 @@ export class CallingClass {
outerGroupCall.connect();
this.maybeUpdateRtcLogging(outerGroupCall);
this.syncGroupCallToRedux(roomId, outerGroupCall, CallMode.Adhoc);
return outerGroupCall;
@ -1513,6 +1537,27 @@ export class CallingClass {
groupCall.react(value);
}
// configures how often call stats are computed
public setAllRtcStatsInterval(intervalMillis: number | null): void {
if (this.currentRtcStatsInterval === intervalMillis) {
return;
}
this.currentRtcStatsInterval = intervalMillis;
// GroupCall.setRtcStatsInterval resets to the default when interval == 0
// so set it to 0 when intervalMillis is undefined
const statsInterval = intervalMillis ?? 0;
for (const conversationId of Object.keys(this.callsLookup)) {
const groupCall = this.getGroupCall(conversationId);
if (!groupCall) {
continue;
}
log.info('Setting rtc stats interval:', conversationId, statsInterval);
groupCall.setRtcStatsInterval(statsInterval);
}
}
private syncGroupCallToRedux(
conversationId: string,
groupCall: GroupCall,
@ -2688,6 +2733,18 @@ export class CallingClass {
}
}
private async handleRtcStatsReport(reportJson: string) {
// assumes one active call
const conversationId = Object.keys(this.callsLookup)[0] ?? '';
const callId = this.callDebugNumber;
ipcRenderer.send('calling:rtc-stats-report', {
conversationId,
callId,
reportJson,
});
}
private async handleSendHttpRequest(
requestId: number,
url: string,

View file

@ -22,6 +22,7 @@ const setupAsNewDevice = stub();
const setupAsStandalone = stub();
const showAbout = stub();
const showDebugLog = stub();
const showCallingDevTools = stub();
const showKeyboardShortcuts = stub();
const showSettings = stub();
const showWindow = stub();
@ -70,6 +71,7 @@ const getExpectedViewMenu = (): MenuItemConstructorOptions => ({
{ label: 'Debug Log', click: showDebugLog },
{ type: 'separator' },
{ label: 'Toggle Developer Tools', role: 'toggleDevTools' },
{ label: 'Open Calling Developer Tools', click: showCallingDevTools },
{ label: 'Force Update', click: forceUpdate },
],
});
@ -227,6 +229,7 @@ describe('createTemplate', () => {
setupAsStandalone,
showAbout,
showDebugLog,
showCallingDevTools,
showKeyboardShortcuts,
showSettings,
showWindow,

View file

@ -25,6 +25,7 @@ export type MenuActionsType = Readonly<{
setupAsStandalone: () => unknown;
showAbout: () => unknown;
showDebugLog: () => unknown;
showCallingDevTools: () => unknown;
showKeyboardShortcuts: () => unknown;
showSettings: () => unknown;
showWindow: () => unknown;

View file

@ -50,6 +50,22 @@ const FILES_TO_IGNORE = new Set(
'js/WebAudioRecorderMp3.js',
'sticker-creator/src/util/protos.d.ts',
'sticker-creator/src/util/protos.js',
// ignore calling developer tools licensing which use Chromium license
'calling_tools.html',
'ts/windows/callingtools/assert.js',
'ts/windows/callingtools/candidate_grid.js',
'ts/windows/callingtools/data_series.js',
'ts/windows/callingtools/dump_creator.js',
'ts/windows/callingtools/peer_connection_update_table.js',
'ts/windows/callingtools/stats_graph_helper.js',
'ts/windows/callingtools/stats_helper.js',
'ts/windows/callingtools/stats_rates_calculator.js',
'ts/windows/callingtools/stats_table.js',
'ts/windows/callingtools/tab_view.js',
'ts/windows/callingtools/timeline_graph_view.js',
'ts/windows/callingtools/user_media_table.js',
'ts/windows/callingtools/util.js',
'ts/windows/callingtools/webrtc_internals.js',
].map(
// This makes sure the files are correct on Windows.
path.normalize

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,25 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { contextBridge, ipcRenderer } from 'electron';
import type { Event } from 'electron/renderer';
import { MinimalSignalContext } from '../minimalContext';
type RtcStatsReport = {
conversationId: string;
callId: string;
reportJson: string;
};
const Signal = {
CallingToolsProps: {
onRtcStatsReport: (
callback: (event: Event, value: RtcStatsReport) => void
) => ipcRenderer.on('calling:rtc-stats-report', callback),
setRtcStatsInterval: (intervalMillis: number) => {
ipcRenderer.send('calling:set-rtc-stats-interval', intervalMillis);
},
},
};
contextBridge.exposeInMainWorld('Signal', Signal);
contextBridge.exposeInMainWorld('SignalContext', MinimalSignalContext);

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);
}
}
}

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

View file

@ -87,6 +87,8 @@ if (!isProduction(window.SignalContext.getVersion())) {
window.Signal.Services.calling._iceServerOverride = override;
},
setRtcStatsInterval: (intervalMillis: number) =>
window.Signal.Services.calling.setAllRtcStatsInterval(intervalMillis),
sqlCall: (name: string, ...args: ReadonlyArray<unknown>) =>
ipcInvoke(name, args),
...(window.SignalContext.config.ciMode === 'benchmark'