Add calling tools to visualize ringrtc stats
Co-authored-by: ayumi-signal <ayumi@signal.org>
This commit is contained in:
parent
4bf08977cf
commit
8a9ab8c13f
30 changed files with 3926 additions and 0 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
62
app/main.ts
62
app/main.ts
|
@ -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,
|
||||
|
|
|
@ -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
75
calling_tools.html
Normal 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>
|
|
@ -484,6 +484,7 @@
|
|||
"screenShare.html",
|
||||
"settings.html",
|
||||
"permissions_popup.html",
|
||||
"calling_tools.html",
|
||||
"debug_log.html",
|
||||
"loading.html",
|
||||
{
|
||||
|
|
|
@ -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'),
|
||||
],
|
||||
|
|
|
@ -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',
|
||||
'',
|
||||
|
|
155
stylesheets/webrtc_internals.css
Normal file
155
stylesheets/webrtc_internals.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -25,6 +25,7 @@ export type MenuActionsType = Readonly<{
|
|||
setupAsStandalone: () => unknown;
|
||||
showAbout: () => unknown;
|
||||
showDebugLog: () => unknown;
|
||||
showCallingDevTools: () => unknown;
|
||||
showKeyboardShortcuts: () => unknown;
|
||||
showSettings: () => unknown;
|
||||
showWindow: () => unknown;
|
||||
|
|
|
@ -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
|
||||
|
|
19
ts/windows/callingtools/assert.js
Normal file
19
ts/windows/callingtools/assert.js
Normal 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);
|
||||
}
|
||||
|
219
ts/windows/callingtools/candidate_grid.js
Normal file
219
ts/windows/callingtools/candidate_grid.js
Normal 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);
|
||||
});
|
||||
}
|
||||
|
133
ts/windows/callingtools/data_series.js
Normal file
133
ts/windows/callingtools/data_series.js
Normal 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;
|
||||
}
|
||||
}
|
170
ts/windows/callingtools/dump_creator.js
Normal file
170
ts/windows/callingtools/dump_creator.js
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
263
ts/windows/callingtools/peer_connection_update_table.js
Normal file
263
ts/windows/callingtools/peer_connection_update_table.js
Normal 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'];
|
||||
}
|
||||
}
|
25
ts/windows/callingtools/preload.ts
Normal file
25
ts/windows/callingtools/preload.ts
Normal 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);
|
307
ts/windows/callingtools/stats_graph_helper.js
Normal file
307
ts/windows/callingtools/stats_graph_helper.js
Normal 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';
|
||||
}
|
||||
});
|
||||
}
|
57
ts/windows/callingtools/stats_helper.js
Normal file
57
ts/windows/callingtools/stats_helper.js
Normal 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;
|
||||
}
|
609
ts/windows/callingtools/stats_rates_calculator.js
Normal file
609
ts/windows/callingtools/stats_rates_calculator.js
Normal 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));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
219
ts/windows/callingtools/stats_table.js
Normal file
219
ts/windows/callingtools/stats_table.js
Normal 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';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
115
ts/windows/callingtools/tab_view.js
Normal file
115
ts/windows/callingtools/tab_view.js
Normal 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';
|
||||
}
|
||||
}
|
548
ts/windows/callingtools/timeline_graph_view.js
Normal file
548
ts/windows/callingtools/timeline_graph_view.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
178
ts/windows/callingtools/user_media_table.js
Normal file
178
ts/windows/callingtools/user_media_table.js
Normal file
|
@ -0,0 +1,178 @@
|
|||
// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details
|
||||
|
||||
import {$} from './util.js';
|
||||
const USER_MEDIA_TAB_ID = 'user-media-tab-id';
|
||||
|
||||
/**
|
||||
* A helper function for appending a child element to |parent|.
|
||||
*
|
||||
* @param {!Element} parent The parent element.
|
||||
* @param {string} tag The child element tag.
|
||||
* @param {string} text The textContent of the new DIV.
|
||||
* @return {!Element} the new DIV element.
|
||||
*/
|
||||
function appendChildWithText(parent, tag, text) {
|
||||
const child = document.createElement(tag);
|
||||
child.textContent = text;
|
||||
parent.appendChild(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
export class UserMediaTable {
|
||||
/**
|
||||
* @param {Object} tabView the TabView object to add the user media tab to.
|
||||
*/
|
||||
constructor(tabView) {
|
||||
this.tabView = tabView;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the tab view with a getUserMedia/getDisplayMedia tab.
|
||||
*/
|
||||
createTab() {
|
||||
const container = this.tabView.addTab(USER_MEDIA_TAB_ID,
|
||||
'getUserMedia/getDisplayMedia');
|
||||
// Create the filter input field and label.
|
||||
appendChildWithText(container, 'label', 'Filter by origin including ');
|
||||
const input = document.createElement('input');
|
||||
input.size = 30;
|
||||
input.oninput = this.filterUserMedia.bind(this);
|
||||
container.appendChild(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a filter to the user media table.
|
||||
* @param event InputEvent from the filter input field.
|
||||
* @private
|
||||
*/
|
||||
filterUserMedia(event) {
|
||||
const filter = event.target.value;
|
||||
const requests = $(USER_MEDIA_TAB_ID).childNodes;
|
||||
for (let i = 0; i < requests.length; ++i) {
|
||||
if (!requests[i]['data-origin']) {
|
||||
continue;
|
||||
}
|
||||
if (requests[i]['data-origin'].includes(filter)) {
|
||||
requests[i].style.display = 'block';
|
||||
} else {
|
||||
requests[i].style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a getUserMedia/getDisplayMedia request.
|
||||
* @param {!Object} data The object containing rid {number}, pid {number},
|
||||
* origin {string}, request_id {number}, request_type {string},
|
||||
* audio {string}, video {string}.
|
||||
*/
|
||||
addMedia(data) {
|
||||
if (!$(USER_MEDIA_TAB_ID)) {
|
||||
this.createTab();
|
||||
}
|
||||
|
||||
const requestDiv = document.createElement('div');
|
||||
requestDiv.className = 'user-media-request-div-class';
|
||||
requestDiv.id = ['gum', data.rid, data.pid, data.request_id].join('-');
|
||||
requestDiv['data-rid'] = data.rid;
|
||||
requestDiv['data-origin'] = data.origin;
|
||||
// Insert new getUserMedia calls at the top.
|
||||
$(USER_MEDIA_TAB_ID).insertBefore(requestDiv,
|
||||
$(USER_MEDIA_TAB_ID).firstChild);
|
||||
|
||||
appendChildWithText(requestDiv, 'div', 'Caller origin: ' + data.origin);
|
||||
appendChildWithText(requestDiv, 'div', 'Caller process id: ' + data.pid);
|
||||
|
||||
const el = appendChildWithText(requestDiv, 'span',
|
||||
data.request_type + ' call');
|
||||
el.style.fontWeight = 'bold';
|
||||
appendChildWithText(el, 'div', 'Time: ' +
|
||||
(new Date(data.timestamp).toTimeString()))
|
||||
.style.fontWeight = 'normal';
|
||||
if (data.audio !== undefined) {
|
||||
appendChildWithText(el, 'div', 'Audio constraints: ' +
|
||||
(data.audio || 'true'))
|
||||
.style.fontWeight = 'normal';
|
||||
}
|
||||
if (data.video !== undefined) {
|
||||
appendChildWithText(el, 'div', 'Video constraints: ' +
|
||||
(data.video || 'true'))
|
||||
.style.fontWeight = 'normal';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a getUserMedia/getDisplayMedia request with a result or error.
|
||||
*
|
||||
* @param {!Object} data The object containing rid {number}, pid {number},
|
||||
* request_id {number}, request_type {string}.
|
||||
* For results there is also the
|
||||
* stream_id {string}, audio_track_info {string} and
|
||||
* video_track_info {string}.
|
||||
* For errors the error {string} and
|
||||
* error_message {string} fields are set.
|
||||
*/
|
||||
updateMedia(data) {
|
||||
if (!$(USER_MEDIA_TAB_ID)) {
|
||||
this.createTab();
|
||||
}
|
||||
|
||||
const requestDiv = document.getElementById(
|
||||
['gum', data.rid, data.pid, data.request_id].join('-'));
|
||||
if (!requestDiv) {
|
||||
console.error('Could not update ' + data.request_type + ' request', data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.error) {
|
||||
const el = appendChildWithText(requestDiv, 'span', 'Error');
|
||||
el.style.fontWeight = 'bold';
|
||||
appendChildWithText(el, 'div', 'Time: ' +
|
||||
(new Date(data.timestamp).toTimeString()))
|
||||
.style.fontWeight = 'normal';
|
||||
appendChildWithText(el, 'div', 'Error: ' + data.error)
|
||||
.style.fontWeight = 'normal';
|
||||
appendChildWithText(el, 'div', 'Error message: ' + data.error_message)
|
||||
.style.fontWeight = 'normal';
|
||||
return;
|
||||
}
|
||||
|
||||
const el = appendChildWithText(requestDiv, 'span',
|
||||
data.request_type + ' result');
|
||||
el.style.fontWeight = 'bold';
|
||||
appendChildWithText(el, 'div', 'Time: ' +
|
||||
(new Date(data.timestamp).toTimeString()))
|
||||
.style.fontWeight = 'normal';
|
||||
appendChildWithText(el, 'div', 'Stream id: ' + data.stream_id)
|
||||
.style.fontWeight = 'normal';
|
||||
if (data.audio_track_info) {
|
||||
appendChildWithText(el, 'div', 'Audio track: ' + data.audio_track_info)
|
||||
.style.fontWeight = 'normal';
|
||||
}
|
||||
if (data.video_track_info) {
|
||||
appendChildWithText(el, 'div', 'Video track: ' + data.video_track_info)
|
||||
.style.fontWeight = 'normal';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the getUserMedia/getDisplayMedia requests from the specified |rid|.
|
||||
*
|
||||
* @param {!Object} data The object containing rid {number}, the render id.
|
||||
*/
|
||||
removeMediaForRenderer(data) {
|
||||
const requests = $(USER_MEDIA_TAB_ID).childNodes;
|
||||
for (let i = 0; i < requests.length; ++i) {
|
||||
if (!requests[i]['data-origin']) {
|
||||
continue;
|
||||
}
|
||||
if (requests[i]['data-rid'] === data.rid) {
|
||||
$(USER_MEDIA_TAB_ID).removeChild(requests[i]);
|
||||
}
|
||||
}
|
||||
// Remove the tab when only the search field and its label are left.
|
||||
if ($(USER_MEDIA_TAB_ID).childNodes.length === 2) {
|
||||
this.tabView.removeTab(USER_MEDIA_TAB_ID);
|
||||
}
|
||||
}
|
||||
}
|
88
ts/windows/callingtools/util.js
Normal file
88
ts/windows/callingtools/util.js
Normal 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'")
|
||||
}
|
||||
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))
|
||||
}
|
508
ts/windows/callingtools/webrtc_internals.js
Normal file
508
ts/windows/callingtools/webrtc_internals.js
Normal 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();
|
||||
}
|
|
@ -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'
|
||||
|
|
Loading…
Reference in a new issue