You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

ConnectionStatsTable.tsx 21KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834
  1. /* eslint-disable react/no-multi-comp */
  2. import React from 'react';
  3. import { useTranslation } from 'react-i18next';
  4. import { makeStyles } from 'tss-react/mui';
  5. import { isMobileBrowser } from '../../base/environment/utils';
  6. import Icon from '../../base/icons/components/Icon';
  7. import { IconGear } from '../../base/icons/svg';
  8. import ContextMenu from '../../base/ui/components/web/ContextMenu';
  9. type DownloadUpload = {
  10. download: number;
  11. upload: number;
  12. };
  13. /**
  14. * The type of the React {@code Component} props of
  15. * {@link ConnectionStatsTable}.
  16. */
  17. interface IProps {
  18. /**
  19. * The audio SSRC of this client.
  20. */
  21. audioSsrc: number;
  22. /**
  23. * Statistics related to bandwidth.
  24. * {{
  25. * download: Number,
  26. * upload: Number
  27. * }}.
  28. */
  29. bandwidth: DownloadUpload;
  30. /**
  31. * Statistics related to bitrate.
  32. * {{
  33. * download: Number,
  34. * upload: Number
  35. * }}.
  36. */
  37. bitrate: DownloadUpload;
  38. /**
  39. * The number of bridges (aka media servers) currently used in the
  40. * conference.
  41. */
  42. bridgeCount: number;
  43. /**
  44. * Audio/video codecs in use for the connection.
  45. */
  46. codec: {
  47. [key: string]: {
  48. audio: string | undefined;
  49. video: string | undefined;
  50. };
  51. };
  52. /**
  53. * A message describing the connection quality.
  54. */
  55. connectionSummary: string;
  56. /**
  57. * Whether or not should display the "Show More" link.
  58. */
  59. disableShowMoreStats: boolean;
  60. /**
  61. * Whether or not the participant was verified.
  62. */
  63. e2eeVerified?: boolean;
  64. /**
  65. * Whether to enable assumed bandwidth.
  66. */
  67. enableAssumedBandwidth?: boolean;
  68. /**
  69. * Whether or not should display the "Save Logs" link.
  70. */
  71. enableSaveLogs: boolean;
  72. /**
  73. * Statistics related to frame rates for each ssrc.
  74. * {{
  75. * [ ssrc ]: Number
  76. * }}.
  77. */
  78. framerate: {
  79. [ssrc: string]: number;
  80. };
  81. /**
  82. * Whether or not the statistics are for local video.
  83. */
  84. isLocalVideo: boolean;
  85. /**
  86. * Whether we are in narrow layout mode or not.
  87. */
  88. isNarrowLayout: boolean;
  89. /**
  90. * Whether or not the statistics are for screen share.
  91. */
  92. isVirtualScreenshareParticipant: boolean;
  93. /**
  94. * The send-side max enabled resolution (aka the highest layer that is not
  95. * suspended on the send-side).
  96. */
  97. maxEnabledResolution: number;
  98. /**
  99. * Callback to invoke when the user clicks on the open bandwidth settings dialog icon.
  100. */
  101. onOpenBandwidthDialog: () => void;
  102. /**
  103. * Callback to invoke when the user clicks on the download logs link.
  104. */
  105. onSaveLogs: () => void;
  106. /**
  107. * Callback to invoke when the show additional stats link is clicked.
  108. */
  109. onShowMore: (e?: React.MouseEvent) => void;
  110. /**
  111. * Statistics related to packet loss.
  112. * {{
  113. * download: Number,
  114. * upload: Number
  115. * }}.
  116. */
  117. packetLoss: DownloadUpload;
  118. /**
  119. * The endpoint id of this client.
  120. */
  121. participantId: string;
  122. /**
  123. * The region that we think the client is in.
  124. */
  125. region: string;
  126. /**
  127. * Statistics related to display resolutions for each ssrc.
  128. * {{
  129. * [ ssrc ]: {
  130. * height: Number,
  131. * width: Number
  132. * }
  133. * }}.
  134. */
  135. resolution: {
  136. [ssrc: string]: {
  137. height: number;
  138. width: number;
  139. };
  140. };
  141. /**
  142. * The region of the media server that we are connected to.
  143. */
  144. serverRegion: string;
  145. /**
  146. * Whether or not additional stats about bandwidth and transport should be
  147. * displayed. Will not display even if true for remote participants.
  148. */
  149. shouldShowMore: boolean;
  150. /**
  151. * Statistics related to transports.
  152. */
  153. transport: Array<{
  154. ip: string;
  155. localCandidateType: string;
  156. localip: string;
  157. p2p: boolean;
  158. remoteCandidateType: string;
  159. transportType: string;
  160. type: string;
  161. }>;
  162. /**
  163. * The video SSRC of this client.
  164. */
  165. videoSsrc: number;
  166. }
  167. /**
  168. * Click handler.
  169. *
  170. * @param {SyntheticEvent} event - The click event.
  171. * @returns {void}
  172. */
  173. function onClick(event: React.MouseEvent) {
  174. // If the event is propagated to the thumbnail container the participant will be pinned. That's why the propagation
  175. // needs to be stopped.
  176. event.stopPropagation();
  177. }
  178. const useStyles = makeStyles()(theme => {
  179. return {
  180. actions: {
  181. margin: '10px auto',
  182. textAlign: 'center'
  183. },
  184. assumedBandwidth: {
  185. cursor: 'pointer',
  186. margin: '0 5px'
  187. },
  188. bandwidth: {
  189. alignItems: 'center',
  190. display: 'flex'
  191. },
  192. connectionStatsTable: {
  193. '&, & > table': {
  194. fontSize: '12px',
  195. fontWeight: 400,
  196. '& td': {
  197. padding: '2px 0'
  198. }
  199. },
  200. '& > table': {
  201. whiteSpace: 'nowrap'
  202. },
  203. '& td:nth-child(n-1)': {
  204. paddingLeft: '5px'
  205. },
  206. '& $upload, & $download': {
  207. marginRight: '2px'
  208. }
  209. },
  210. contextMenu: {
  211. position: 'relative',
  212. margin: 0,
  213. right: 'auto',
  214. padding: `${theme.spacing(2)} ${theme.spacing(1)}`
  215. },
  216. download: {},
  217. mobile: {
  218. margin: theme.spacing(3)
  219. },
  220. status: {
  221. fontWeight: 'bold'
  222. },
  223. upload: {},
  224. link: {
  225. cursor: 'pointer',
  226. color: theme.palette.link01,
  227. transition: 'color .2s ease',
  228. border: 0,
  229. background: 0,
  230. padding: 0,
  231. display: 'inline',
  232. fontWeight: 'bold',
  233. '&:hover': {
  234. color: theme.palette.link01Hover,
  235. textDecoration: 'underline'
  236. },
  237. '&:active': {
  238. color: theme.palette.link01Active
  239. }
  240. }
  241. };
  242. });
  243. const ConnectionStatsTable = ({
  244. audioSsrc,
  245. bandwidth,
  246. bitrate,
  247. bridgeCount,
  248. codec,
  249. connectionSummary,
  250. disableShowMoreStats,
  251. e2eeVerified,
  252. enableAssumedBandwidth,
  253. enableSaveLogs,
  254. framerate,
  255. isVirtualScreenshareParticipant,
  256. isLocalVideo,
  257. isNarrowLayout,
  258. maxEnabledResolution,
  259. onOpenBandwidthDialog,
  260. onSaveLogs,
  261. onShowMore,
  262. packetLoss,
  263. participantId,
  264. region,
  265. resolution,
  266. serverRegion,
  267. shouldShowMore,
  268. transport,
  269. videoSsrc
  270. }: IProps) => {
  271. const { classes, cx } = useStyles();
  272. const { t } = useTranslation();
  273. const _renderResolution = () => {
  274. let resolutionString = 'N/A';
  275. if (resolution && videoSsrc) {
  276. const { width, height } = resolution[videoSsrc] ?? {};
  277. if (width && height) {
  278. resolutionString = `${width}x${height}`;
  279. if (maxEnabledResolution && maxEnabledResolution < 720 && !isVirtualScreenshareParticipant) {
  280. const maxEnabledResolutionTitle = t('connectionindicator.maxEnabledResolution');
  281. resolutionString += ` (${maxEnabledResolutionTitle} ${maxEnabledResolution}p)`;
  282. }
  283. }
  284. }
  285. return (
  286. <tr>
  287. <td>
  288. <span>{t('connectionindicator.resolution')}</span>
  289. </td>
  290. <td>{resolutionString}</td>
  291. </tr>
  292. );
  293. };
  294. const _renderFrameRate = () => {
  295. let frameRateString = 'N/A';
  296. if (framerate) {
  297. frameRateString = String(framerate[videoSsrc] ?? 'N/A');
  298. }
  299. return (
  300. <tr>
  301. <td>
  302. <span>{t('connectionindicator.framerate')}</span>
  303. </td>
  304. <td>{frameRateString}</td>
  305. </tr>
  306. );
  307. };
  308. const _renderScreenShareStatus = () => {
  309. const className = cx(classes.connectionStatsTable, { [classes.mobile]: isMobileBrowser() });
  310. return (<ContextMenu
  311. className = { classes.contextMenu }
  312. hidden = { false }
  313. inDrawer = { true }>
  314. <div
  315. className = { className }
  316. onClick = { onClick }>
  317. <tbody>
  318. {_renderResolution()}
  319. {_renderFrameRate()}
  320. </tbody>
  321. </div>
  322. </ContextMenu>);
  323. };
  324. const _renderBandwidth = () => {
  325. const { download, upload } = bandwidth || {};
  326. return (
  327. <tr>
  328. <td>
  329. {t('connectionindicator.bandwidth')}
  330. </td>
  331. <td className = { classes.bandwidth }>
  332. <span className = { classes.download }>
  333. &darr;
  334. </span>
  335. {download ? `${download} Kbps` : 'N/A'}
  336. <span className = { classes.upload }>
  337. &uarr;
  338. </span>
  339. {upload ? `${upload} Kbps` : 'N/A'}
  340. {enableAssumedBandwidth && (
  341. <div
  342. className = { classes.assumedBandwidth }
  343. onClick = { onOpenBandwidthDialog }>
  344. <Icon
  345. size = { 10 }
  346. src = { IconGear } />
  347. </div>
  348. )}
  349. </td>
  350. </tr>
  351. );
  352. };
  353. const _renderTransportTableRow = (config: any) => {
  354. const { additionalData, data, key, label } = config;
  355. return (
  356. <tr key = { key }>
  357. <td>
  358. <span>
  359. {label}
  360. </span>
  361. </td>
  362. <td>
  363. {getStringFromArray(data)}
  364. {additionalData || null}
  365. </td>
  366. </tr>
  367. );
  368. };
  369. const _renderTransport = () => {
  370. if (!transport || transport.length === 0) {
  371. const NA = (
  372. <tr key = 'address'>
  373. <td>
  374. <span>{t('connectionindicator.address')}</span>
  375. </td>
  376. <td>
  377. N/A
  378. </td>
  379. </tr>
  380. );
  381. return [ NA ];
  382. }
  383. const data: {
  384. localIP: string[];
  385. localPort: string[];
  386. remoteIP: string[];
  387. remotePort: string[];
  388. transportType: string[];
  389. } = {
  390. localIP: [],
  391. localPort: [],
  392. remoteIP: [],
  393. remotePort: [],
  394. transportType: []
  395. };
  396. for (let i = 0; i < transport.length; i++) {
  397. const ip = getIP(transport[i].ip);
  398. const localIP = getIP(transport[i].localip);
  399. const localPort = getPort(transport[i].localip);
  400. const port = getPort(transport[i].ip);
  401. if (!data.remoteIP.includes(ip)) {
  402. data.remoteIP.push(ip);
  403. }
  404. if (!data.localIP.includes(localIP)) {
  405. data.localIP.push(localIP);
  406. }
  407. if (!data.localPort.includes(localPort)) {
  408. data.localPort.push(localPort);
  409. }
  410. if (!data.remotePort.includes(port)) {
  411. data.remotePort.push(port);
  412. }
  413. if (!data.transportType.includes(transport[i].type)) {
  414. data.transportType.push(transport[i].type);
  415. }
  416. }
  417. // All of the transports should be either P2P or JVB
  418. let isP2P = false, isTURN = false;
  419. if (transport.length) {
  420. isP2P = transport[0].p2p;
  421. isTURN = transport[0].localCandidateType === 'relay'
  422. || transport[0].remoteCandidateType === 'relay';
  423. }
  424. const additionalData = [];
  425. if (isP2P) {
  426. additionalData.push(
  427. <span> (p2p)</span>);
  428. }
  429. if (isTURN) {
  430. additionalData.push(<span> (turn)</span>);
  431. }
  432. // First show remote statistics, then local, and then transport type.
  433. const tableRowConfigurations = [
  434. {
  435. additionalData,
  436. data: data.remoteIP,
  437. key: 'remoteaddress',
  438. label: t('connectionindicator.remoteaddress',
  439. { count: data.remoteIP.length })
  440. },
  441. {
  442. data: data.remotePort,
  443. key: 'remoteport',
  444. label: t('connectionindicator.remoteport',
  445. { count: transport.length })
  446. },
  447. {
  448. data: data.localIP,
  449. key: 'localaddress',
  450. label: t('connectionindicator.localaddress',
  451. { count: data.localIP.length })
  452. },
  453. {
  454. data: data.localPort,
  455. key: 'localport',
  456. label: t('connectionindicator.localport',
  457. { count: transport.length })
  458. },
  459. {
  460. data: data.transportType,
  461. key: 'transport',
  462. label: t('connectionindicator.transport',
  463. { count: data.transportType.length })
  464. }
  465. ];
  466. return tableRowConfigurations.map(_renderTransportTableRow);
  467. };
  468. const _renderRegion = () => {
  469. let str = serverRegion;
  470. if (!serverRegion) {
  471. return;
  472. }
  473. if (region && serverRegion && region !== serverRegion) {
  474. str += ` from ${region}`;
  475. }
  476. return (
  477. <tr>
  478. <td>
  479. <span>{t('connectionindicator.connectedTo')}</span>
  480. </td>
  481. <td>{str}</td>
  482. </tr>
  483. );
  484. };
  485. const _renderBridgeCount = () => {
  486. // 0 is valid, but undefined/null/NaN aren't.
  487. if (!bridgeCount && bridgeCount !== 0) {
  488. return;
  489. }
  490. return (
  491. <tr>
  492. <td>
  493. <span>{t('connectionindicator.bridgeCount')}</span>
  494. </td>
  495. <td>{bridgeCount}</td>
  496. </tr>
  497. );
  498. };
  499. const _renderAudioSsrc = () => (
  500. <tr>
  501. <td>
  502. <span>{t('connectionindicator.audio_ssrc')}</span>
  503. </td>
  504. <td>{audioSsrc || 'N/A'}</td>
  505. </tr>
  506. );
  507. const _renderVideoSsrc = () => (
  508. <tr>
  509. <td>
  510. <span>{t('connectionindicator.video_ssrc')}</span>
  511. </td>
  512. <td>{videoSsrc || 'N/A'}</td>
  513. </tr>
  514. );
  515. const _renderParticipantId = () => (
  516. <tr>
  517. <td>
  518. <span>{t('connectionindicator.participant_id')}</span>
  519. </td>
  520. <td>{participantId || 'N/A'}</td>
  521. </tr>
  522. );
  523. const _renderE2EEVerified = () => {
  524. if (e2eeVerified === undefined) {
  525. return;
  526. }
  527. return (
  528. <tr>
  529. <td>
  530. <span>{t('connectionindicator.e2eeVerified')}</span>
  531. </td>
  532. <td>{t(`connectionindicator.${e2eeVerified ? 'yes' : 'no'}`)}</td>
  533. </tr>
  534. );
  535. };
  536. const _renderAdditionalStats = () => (
  537. <table>
  538. <tbody>
  539. {isLocalVideo ? _renderBandwidth() : null}
  540. {isLocalVideo ? _renderTransport() : null}
  541. {_renderRegion()}
  542. {isLocalVideo ? _renderBridgeCount() : null}
  543. {_renderAudioSsrc()}
  544. {_renderVideoSsrc()}
  545. {_renderParticipantId()}
  546. {_renderE2EEVerified()}
  547. </tbody>
  548. </table>
  549. );
  550. const _renderBitrate = () => {
  551. const { download, upload } = bitrate || {};
  552. return (
  553. <tr>
  554. <td>
  555. <span>
  556. {t('connectionindicator.bitrate')}
  557. </span>
  558. </td>
  559. <td>
  560. <span className = { classes.download }>
  561. &darr;
  562. </span>
  563. {download ? `${download} Kbps` : 'N/A'}
  564. <span className = { classes.upload }>
  565. &uarr;
  566. </span>
  567. {upload ? `${upload} Kbps` : 'N/A'}
  568. </td>
  569. </tr>
  570. );
  571. };
  572. const _renderCodecs = () => {
  573. let codecString = 'N/A';
  574. if (codec) {
  575. const audioCodec = codec[audioSsrc]?.audio;
  576. const videoCodec = codec[videoSsrc]?.video;
  577. if (audioCodec || videoCodec) {
  578. codecString = [ audioCodec, videoCodec ].filter(Boolean).join(', ');
  579. }
  580. }
  581. return (
  582. <tr>
  583. <td>
  584. <span>{t('connectionindicator.codecs')}</span>
  585. </td>
  586. <td>{codecString}</td>
  587. </tr>
  588. );
  589. };
  590. const _renderConnectionSummary = () => (
  591. <tr className = { classes.status }>
  592. <td>
  593. <span>{t('connectionindicator.status')}</span>
  594. </td>
  595. <td>{connectionSummary}</td>
  596. </tr>
  597. );
  598. const _renderPacketLoss = () => {
  599. let packetLossTableData;
  600. if (packetLoss) {
  601. const { download, upload } = packetLoss;
  602. packetLossTableData = (
  603. <td>
  604. <span className = { classes.download }>
  605. &darr;
  606. </span>
  607. {download === null ? 'N/A' : `${download}%`}
  608. <span className = { classes.upload }>
  609. &uarr;
  610. </span>
  611. {upload === null ? 'N/A' : `${upload}%`}
  612. </td>
  613. );
  614. } else {
  615. packetLossTableData = <td>N/A</td>;
  616. }
  617. return (
  618. <tr>
  619. <td>
  620. <span>
  621. {t('connectionindicator.packetloss')}
  622. </span>
  623. </td>
  624. {packetLossTableData}
  625. </tr>
  626. );
  627. };
  628. const _renderSaveLogs = () => (
  629. <span>
  630. <button
  631. className = { cx(classes.link, 'savelogs') }
  632. onClick = { onSaveLogs }
  633. type = 'button'>
  634. {t('connectionindicator.savelogs')}
  635. </button>
  636. <span> | </span>
  637. </span>
  638. );
  639. const _renderShowMoreLink = () => {
  640. const translationKey
  641. = shouldShowMore
  642. ? 'connectionindicator.less'
  643. : 'connectionindicator.more';
  644. return (
  645. <button
  646. className = { cx(classes.link, 'showmore') }
  647. onClick = { onShowMore }
  648. type = 'button'>
  649. {t(translationKey)}
  650. </button>
  651. );
  652. };
  653. const _renderStatistics = () => (
  654. <table>
  655. <tbody>
  656. {_renderConnectionSummary()}
  657. {_renderBitrate()}
  658. {_renderPacketLoss()}
  659. {_renderResolution()}
  660. {_renderFrameRate()}
  661. {_renderCodecs()}
  662. </tbody>
  663. </table>
  664. );
  665. if (isVirtualScreenshareParticipant) {
  666. return _renderScreenShareStatus();
  667. }
  668. return (
  669. <ContextMenu
  670. className = { classes.contextMenu }
  671. hidden = { false }
  672. inDrawer = { true }>
  673. <div
  674. className = { cx(classes.connectionStatsTable, {
  675. [classes.mobile]: isMobileBrowser() || isNarrowLayout }) }
  676. onClick = { onClick }>
  677. {_renderStatistics()}
  678. <div className = { classes.actions }>
  679. {isLocalVideo && enableSaveLogs ? _renderSaveLogs() : null}
  680. {!disableShowMoreStats && _renderShowMoreLink()}
  681. </div>
  682. {shouldShowMore ? _renderAdditionalStats() : null}
  683. </div>
  684. </ContextMenu>
  685. );
  686. };
  687. /**
  688. * Utility for getting the IP from a transport statistics object's
  689. * representation of an IP.
  690. *
  691. * @param {string} value - The transport's IP to parse.
  692. * @private
  693. * @returns {string}
  694. */
  695. function getIP(value: string) {
  696. if (!value) {
  697. return '';
  698. }
  699. return value.substring(0, value.lastIndexOf(':'));
  700. }
  701. /**
  702. * Utility for getting the port from a transport statistics object's
  703. * representation of an IP.
  704. *
  705. * @param {string} value - The transport's IP to parse.
  706. * @private
  707. * @returns {string}
  708. */
  709. function getPort(value: string) {
  710. if (!value) {
  711. return '';
  712. }
  713. return value.substring(value.lastIndexOf(':') + 1, value.length);
  714. }
  715. /**
  716. * Utility for concatenating values in an array into a comma separated string.
  717. *
  718. * @param {Array} array - Transport statistics to concatenate.
  719. * @private
  720. * @returns {string}
  721. */
  722. function getStringFromArray(array: string[]) {
  723. let res = '';
  724. for (let i = 0; i < array.length; i++) {
  725. res += (i === 0 ? '' : ', ') + array[i];
  726. }
  727. return res;
  728. }
  729. export default ConnectionStatsTable;