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
|
js/util_worker.js
|
||||||
libtextsecure/components.js
|
libtextsecure/components.js
|
||||||
stylesheets/*.css
|
stylesheets/*.css
|
||||||
|
!stylesheets/webrtc_internals.css
|
||||||
/storybook-static/
|
/storybook-static/
|
||||||
preload.bundle.*
|
preload.bundle.*
|
||||||
bundles/
|
bundles/
|
||||||
|
@ -37,6 +38,9 @@ build/ICUMessageParams.d.ts
|
||||||
app/*.js
|
app/*.js
|
||||||
ts/**/*.js
|
ts/**/*.js
|
||||||
ts/protobuf/*.d.ts
|
ts/protobuf/*.d.ts
|
||||||
|
# allow js from callingtools
|
||||||
|
!ts/windows/callingtools/**/*.js
|
||||||
|
ts/windows/callingtools/preload.js
|
||||||
|
|
||||||
# CSS Modules
|
# CSS Modules
|
||||||
**/*.scss.d.ts
|
**/*.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
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
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
|
## Kyber Patent License
|
||||||
|
|
||||||
<https://csrc.nist.gov/csrc/media/Projects/post-quantum-cryptography/documents/selected-algos-2022/nist-pqc-license-summary-and-excerpts.pdf>
|
<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",
|
"messageformat": "Toggle Developer Tools",
|
||||||
"description": "View menu command to show or hide the 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": {
|
"icu:menuSetupAsNewDevice": {
|
||||||
"messageformat": "Set Up as New Device",
|
"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"
|
"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",
|
"messageformat": "Sharing screen",
|
||||||
"description": "Title for screen sharing window"
|
"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": {
|
"icu:speech": {
|
||||||
"messageformat": "Speech",
|
"messageformat": "Speech",
|
||||||
"description": "Item under the Edit menu, with 'start/stop speaking' items below it"
|
"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;
|
let aboutWindow: BrowserWindow | undefined;
|
||||||
async function showAbout() {
|
async function showAbout() {
|
||||||
if (aboutWindow) {
|
if (aboutWindow) {
|
||||||
|
@ -2054,6 +2115,7 @@ function setupMenu(options?: Partial<CreateTemplateOptionsType>) {
|
||||||
setupAsStandalone,
|
setupAsStandalone,
|
||||||
showAbout,
|
showAbout,
|
||||||
showDebugLog: showDebugLogWindow,
|
showDebugLog: showDebugLogWindow,
|
||||||
|
showCallingDevTools: showCallingDevToolsWindow,
|
||||||
showKeyboardShortcuts,
|
showKeyboardShortcuts,
|
||||||
showSettings: showSettingsWindow,
|
showSettings: showSettingsWindow,
|
||||||
showWindow,
|
showWindow,
|
||||||
|
|
|
@ -35,6 +35,7 @@ export const createTemplate = (
|
||||||
forceUpdate,
|
forceUpdate,
|
||||||
showAbout,
|
showAbout,
|
||||||
showDebugLog,
|
showDebugLog,
|
||||||
|
showCallingDevTools,
|
||||||
showKeyboardShortcuts,
|
showKeyboardShortcuts,
|
||||||
showSettings,
|
showSettings,
|
||||||
openArtCreator,
|
openArtCreator,
|
||||||
|
@ -146,6 +147,10 @@ export const createTemplate = (
|
||||||
role: 'toggleDevTools' as const,
|
role: 'toggleDevTools' as const,
|
||||||
label: i18n('icu:viewMenuToggleDevTools'),
|
label: i18n('icu:viewMenuToggleDevTools'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: i18n('icu:viewMenuOpenCallingDevTools'),
|
||||||
|
click: showCallingDevTools,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(devTools && platform !== 'linux'
|
...(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",
|
"screenShare.html",
|
||||||
"settings.html",
|
"settings.html",
|
||||||
"permissions_popup.html",
|
"permissions_popup.html",
|
||||||
|
"calling_tools.html",
|
||||||
"debug_log.html",
|
"debug_log.html",
|
||||||
"loading.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', 'permissions', 'app.tsx'),
|
||||||
path.join(ROOT_DIR, 'ts', 'windows', 'screenShare', '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', 'settings', 'app.tsx'),
|
||||||
|
path.join(
|
||||||
|
ROOT_DIR,
|
||||||
|
'ts',
|
||||||
|
'windows',
|
||||||
|
'callingtools',
|
||||||
|
'webrtc_internals.js'
|
||||||
|
),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
preloadConfig: {
|
preloadConfig: {
|
||||||
|
@ -142,6 +149,7 @@ async function sandboxedEnv() {
|
||||||
path.join(ROOT_DIR, 'ts', 'windows', 'debuglog', 'preload.ts'),
|
path.join(ROOT_DIR, 'ts', 'windows', 'debuglog', 'preload.ts'),
|
||||||
path.join(ROOT_DIR, 'ts', 'windows', 'loading', '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', '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', 'screenShare', 'preload.ts'),
|
||||||
path.join(ROOT_DIR, 'ts', 'windows', 'settings', '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 = [
|
const unformattedOutput = [
|
||||||
'<!-- Copyright 2020 Signal Messenger, LLC -->',
|
'<!-- Copyright 2020 Signal Messenger, LLC -->',
|
||||||
'<!-- SPDX-License-Identifier: AGPL-3.0-only -->',
|
'<!-- SPDX-License-Identifier: AGPL-3.0-only -->',
|
||||||
|
@ -147,6 +183,7 @@ async function main() {
|
||||||
'Signal Desktop makes use of the following open source projects.',
|
'Signal Desktop makes use of the following open source projects.',
|
||||||
'',
|
'',
|
||||||
markdownsForDependency.join('\n\n'),
|
markdownsForDependency.join('\n\n'),
|
||||||
|
markdownForChromiumDashboard,
|
||||||
'',
|
'',
|
||||||
'## Kyber Patent License',
|
'## 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 CLEAN_EXPIRED_GROUP_CALL_RINGS_INTERVAL = 10 * durations.MINUTE;
|
||||||
|
|
||||||
const ICE_SERVER_IS_IP_LIKE = /(turn|turns|stun):[.\d]+/;
|
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
|
// 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
|
// notifications, timeline messages, big green "Join" buttons, and so on. This enum
|
||||||
|
@ -346,6 +347,10 @@ export class CallingClass {
|
||||||
|
|
||||||
private hadLocalVideoBeforePresenting?: boolean;
|
private hadLocalVideoBeforePresenting?: boolean;
|
||||||
|
|
||||||
|
private currentRtcStatsInterval: number | null = null;
|
||||||
|
|
||||||
|
private callDebugNumber: number = 0;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.videoCapturer = new GumVideoCapturer({
|
this.videoCapturer = new GumVideoCapturer({
|
||||||
maxWidth: REQUESTED_VIDEO_WIDTH,
|
maxWidth: REQUESTED_VIDEO_WIDTH,
|
||||||
|
@ -381,6 +386,7 @@ export class CallingClass {
|
||||||
this.handleSendCallMessageToGroup.bind(this);
|
this.handleSendCallMessageToGroup.bind(this);
|
||||||
RingRTC.handleGroupCallRingUpdate =
|
RingRTC.handleGroupCallRingUpdate =
|
||||||
this.handleGroupCallRingUpdate.bind(this);
|
this.handleGroupCallRingUpdate.bind(this);
|
||||||
|
RingRTC.handleRtcStatsReport = this.handleRtcStatsReport.bind(this);
|
||||||
|
|
||||||
this.attemptToGiveOurServiceIdToRingRtc();
|
this.attemptToGiveOurServiceIdToRingRtc();
|
||||||
window.Whisper.events.on('userChanged', () => {
|
window.Whisper.events.on('userChanged', () => {
|
||||||
|
@ -390,6 +396,12 @@ export class CallingClass {
|
||||||
ipcRenderer.on('stop-screen-share', () => {
|
ipcRenderer.on('stop-screen-share', () => {
|
||||||
reduxInterface.setPresenting();
|
reduxInterface.setPresenting();
|
||||||
});
|
});
|
||||||
|
ipcRenderer.on(
|
||||||
|
'calling:set-rtc-stats-interval',
|
||||||
|
(_, intervalMillis: number | null) => {
|
||||||
|
this.setAllRtcStatsInterval(intervalMillis);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
drop(this.cleanExpiredGroupCallRingsAndLoop());
|
drop(this.cleanExpiredGroupCallRingsAndLoop());
|
||||||
drop(this.cleanupStaleRingingCalls());
|
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 {
|
private attemptToGiveOurServiceIdToRingRtc(): void {
|
||||||
const ourAci = window.textsecure.storage.user.getAci();
|
const ourAci = window.textsecure.storage.user.getAci();
|
||||||
if (!ourAci) {
|
if (!ourAci) {
|
||||||
|
@ -911,6 +933,7 @@ export class CallingClass {
|
||||||
|
|
||||||
outerGroupCall.connect();
|
outerGroupCall.connect();
|
||||||
|
|
||||||
|
this.maybeUpdateRtcLogging(outerGroupCall);
|
||||||
this.syncGroupCallToRedux(conversationId, outerGroupCall, CallMode.Group);
|
this.syncGroupCallToRedux(conversationId, outerGroupCall, CallMode.Group);
|
||||||
|
|
||||||
return outerGroupCall;
|
return outerGroupCall;
|
||||||
|
@ -966,6 +989,7 @@ export class CallingClass {
|
||||||
|
|
||||||
outerGroupCall.connect();
|
outerGroupCall.connect();
|
||||||
|
|
||||||
|
this.maybeUpdateRtcLogging(outerGroupCall);
|
||||||
this.syncGroupCallToRedux(roomId, outerGroupCall, CallMode.Adhoc);
|
this.syncGroupCallToRedux(roomId, outerGroupCall, CallMode.Adhoc);
|
||||||
|
|
||||||
return outerGroupCall;
|
return outerGroupCall;
|
||||||
|
@ -1513,6 +1537,27 @@ export class CallingClass {
|
||||||
groupCall.react(value);
|
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(
|
private syncGroupCallToRedux(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
groupCall: GroupCall,
|
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(
|
private async handleSendHttpRequest(
|
||||||
requestId: number,
|
requestId: number,
|
||||||
url: string,
|
url: string,
|
||||||
|
|
|
@ -22,6 +22,7 @@ const setupAsNewDevice = stub();
|
||||||
const setupAsStandalone = stub();
|
const setupAsStandalone = stub();
|
||||||
const showAbout = stub();
|
const showAbout = stub();
|
||||||
const showDebugLog = stub();
|
const showDebugLog = stub();
|
||||||
|
const showCallingDevTools = stub();
|
||||||
const showKeyboardShortcuts = stub();
|
const showKeyboardShortcuts = stub();
|
||||||
const showSettings = stub();
|
const showSettings = stub();
|
||||||
const showWindow = stub();
|
const showWindow = stub();
|
||||||
|
@ -70,6 +71,7 @@ const getExpectedViewMenu = (): MenuItemConstructorOptions => ({
|
||||||
{ label: 'Debug Log', click: showDebugLog },
|
{ label: 'Debug Log', click: showDebugLog },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ label: 'Toggle Developer Tools', role: 'toggleDevTools' },
|
{ label: 'Toggle Developer Tools', role: 'toggleDevTools' },
|
||||||
|
{ label: 'Open Calling Developer Tools', click: showCallingDevTools },
|
||||||
{ label: 'Force Update', click: forceUpdate },
|
{ label: 'Force Update', click: forceUpdate },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
@ -227,6 +229,7 @@ describe('createTemplate', () => {
|
||||||
setupAsStandalone,
|
setupAsStandalone,
|
||||||
showAbout,
|
showAbout,
|
||||||
showDebugLog,
|
showDebugLog,
|
||||||
|
showCallingDevTools,
|
||||||
showKeyboardShortcuts,
|
showKeyboardShortcuts,
|
||||||
showSettings,
|
showSettings,
|
||||||
showWindow,
|
showWindow,
|
||||||
|
|
|
@ -25,6 +25,7 @@ export type MenuActionsType = Readonly<{
|
||||||
setupAsStandalone: () => unknown;
|
setupAsStandalone: () => unknown;
|
||||||
showAbout: () => unknown;
|
showAbout: () => unknown;
|
||||||
showDebugLog: () => unknown;
|
showDebugLog: () => unknown;
|
||||||
|
showCallingDevTools: () => unknown;
|
||||||
showKeyboardShortcuts: () => unknown;
|
showKeyboardShortcuts: () => unknown;
|
||||||
showSettings: () => unknown;
|
showSettings: () => unknown;
|
||||||
showWindow: () => unknown;
|
showWindow: () => unknown;
|
||||||
|
|
|
@ -50,6 +50,22 @@ const FILES_TO_IGNORE = new Set(
|
||||||
'js/WebAudioRecorderMp3.js',
|
'js/WebAudioRecorderMp3.js',
|
||||||
'sticker-creator/src/util/protos.d.ts',
|
'sticker-creator/src/util/protos.d.ts',
|
||||||
'sticker-creator/src/util/protos.js',
|
'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(
|
].map(
|
||||||
// This makes sure the files are correct on Windows.
|
// This makes sure the files are correct on Windows.
|
||||||
path.normalize
|
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;
|
window.Signal.Services.calling._iceServerOverride = override;
|
||||||
},
|
},
|
||||||
|
setRtcStatsInterval: (intervalMillis: number) =>
|
||||||
|
window.Signal.Services.calling.setAllRtcStatsInterval(intervalMillis),
|
||||||
sqlCall: (name: string, ...args: ReadonlyArray<unknown>) =>
|
sqlCall: (name: string, ...args: ReadonlyArray<unknown>) =>
|
||||||
ipcInvoke(name, args),
|
ipcInvoke(name, args),
|
||||||
...(window.SignalContext.config.ciMode === 'benchmark'
|
...(window.SignalContext.config.ciMode === 'benchmark'
|
||||||
|
|
Loading…
Reference in a new issue