From 1ce2ee398c6a98a65d598a91be24124923ffe58d Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Wed, 16 Mar 2022 17:54:34 -0700 Subject: [PATCH] Admin UI for playback metrics. For https://github.com/owncast/owncast/issues/793 (#462) --- web/components/chart.tsx | 30 ++- web/components/main-layout.tsx | 3 + web/components/statistic.tsx | 9 +- web/pages/stream-health.tsx | 335 +++++++++++++++++++++++++++++++++ web/styles/globals.scss | 1 + web/utils/apis.ts | 2 + 6 files changed, 377 insertions(+), 3 deletions(-) create mode 100644 web/pages/stream-health.tsx diff --git a/web/components/chart.tsx b/web/components/chart.tsx index a97520c34..dc9ce8fc0 100644 --- a/web/components/chart.tsx +++ b/web/components/chart.tsx @@ -16,6 +16,8 @@ interface ChartProps { title?: string; color: string; unit: string; + yFlipped?: boolean; + yLogarithmic?: boolean; dataCollections?: any[]; } @@ -29,7 +31,15 @@ function createGraphDataset(dataArray) { return dataValues; } -export default function Chart({ data, title, color, unit, dataCollections }: ChartProps) { +export default function Chart({ + data, + title, + color, + unit, + dataCollections, + yFlipped, + yLogarithmic, +}: ChartProps) { const renderData = []; if (data && data.length > 0) { @@ -45,9 +55,24 @@ export default function Chart({ data, title, color, unit, dataCollections }: Cha name: collection.name, data: createGraphDataset(collection.data), color: collection.color, + dataset: collection.options, }); }); + // ChartJs.defaults.scales.linear.reverse = true; + + const options = { + scales: { + y: { reverse: false, type: 'linear' }, + x: { + type: 'time', + }, + }, + }; + + options.scales.y.reverse = yFlipped; + options.scales.y.type = yLogarithmic ? 'logarithmic' : 'linear'; + return (
); @@ -67,4 +93,6 @@ Chart.defaultProps = { dataCollections: [], data: [], title: '', + yFlipped: false, + yLogarithmic: false, }; diff --git a/web/components/main-layout.tsx b/web/components/main-layout.tsx index 17b059751..cef189b45 100644 --- a/web/components/main-layout.tsx +++ b/web/components/main-layout.tsx @@ -206,6 +206,9 @@ export default function MainLayout(props) { Hardware + + Stream Health + Logs diff --git a/web/components/statistic.tsx b/web/components/statistic.tsx index f1e81e2c6..a1e8875b8 100644 --- a/web/components/statistic.tsx +++ b/web/components/statistic.tsx @@ -9,6 +9,7 @@ interface StatisticItemProps { title?: string; value?: any; prefix?: any; + suffix?: string; color?: string; progress?: boolean; centered?: boolean; @@ -18,13 +19,14 @@ const defaultProps = { title: '', value: 0, prefix: null, + suffix: null, color: '', progress: false, centered: false, formatter: null, }; -function ProgressView({ title, value, prefix, color }: StatisticItemProps) { +function ProgressView({ title, value, prefix, suffix, color }: StatisticItemProps) { const endColor = value > 90 ? 'red' : color; const content = (
@@ -33,7 +35,10 @@ function ProgressView({ title, value, prefix, color }: StatisticItemProps) { {title}
- {value}% + + {value} + {suffix || '%'} +
); diff --git a/web/pages/stream-health.tsx b/web/pages/stream-health.tsx new file mode 100644 index 000000000..eff07a336 --- /dev/null +++ b/web/pages/stream-health.tsx @@ -0,0 +1,335 @@ +/* eslint-disable react/no-unescaped-entities */ +// import { BulbOutlined, LaptopOutlined, SaveOutlined } from '@ant-design/icons'; +import { Row, Col, Typography, Space, Statistic, Card, Alert } from 'antd'; +import React, { ReactNode, useEffect, useState } from 'react'; +import { ClockCircleOutlined, WarningOutlined, WifiOutlined } from '@ant-design/icons'; +import { fetchData, FETCH_INTERVAL, API_STREAM_HEALTH_METRICS } from '../utils/apis'; +import Chart from '../components/chart'; + +interface TimedValue { + time: Date; + value: Number; +} + +interface DescriptionBoxProps { + title: String; + description: ReactNode; +} + +function DescriptionBox({ title, description }: DescriptionBoxProps) { + return ( +
+ {title} + {description} +
+ ); +} + +export default function StreamHealth() { + const [errors, setErrors] = useState([]); + const [qualityVariantChanges, setQualityVariantChanges] = useState([]); + const [latency, setLatency] = useState([]); + const [segmentDownloadDurations, setSegmentDownloadDurations] = useState([]); + const [minimumPlayerBitrate, setMinimumPlayerBitrate] = useState([]); + const [availableBitrates, setAvailableBitrates] = useState([]); + const [segmentLength, setSegmentLength] = useState(0); + + const getMetrics = async () => { + try { + const result = await fetchData(API_STREAM_HEALTH_METRICS); + setErrors(result.errors); + setQualityVariantChanges(result.qualityVariantChanges); + setLatency(result.latency); + setSegmentDownloadDurations(result.segmentDownloadDuration); + setMinimumPlayerBitrate(result.minPlayerBitrate); + setAvailableBitrates(result.availableBitrates); + setSegmentLength(result.segmentLength - 0.3); + } catch (error) { + console.error(error); + } + }; + + useEffect(() => { + let getStatusIntervalId = null; + + getMetrics(); + getStatusIntervalId = setInterval(getMetrics, FETCH_INTERVAL); // runs every 1 min. + + // returned function will be called on component unmount + return () => { + clearInterval(getStatusIntervalId); + }; + }, []); + + const noData = ( +
+ Stream Performance + + Data has not yet been collected. Once a stream has begun and viewers are watching this page + will be available. + +
+ ); + if (!errors?.length) { + return noData; + } + + if (!latency?.length) { + return noData; + } + + if (!segmentDownloadDurations?.length) { + return noData; + } + + const errorChart = [ + { + name: 'Errors', + color: '#B63FFF', + options: { radius: 3 }, + data: errors, + }, + { + name: 'Quality changes', + color: '#2087E2', + options: { radius: 2 }, + data: qualityVariantChanges, + }, + ]; + + const latencyChart = [ + { + name: 'Average stream latency', + color: '#B63FFF', + options: { radius: 2 }, + data: latency, + }, + ]; + + const segmentDownloadDurationChart = [ + { + name: 'Average download duration', + color: '#B63FFF', + options: { radius: 2 }, + data: segmentDownloadDurations, + }, + { + name: `Approximate limit`, + color: '#003FFF', + data: segmentDownloadDurations.map(item => ({ + time: item.time, + value: segmentLength, + })), + options: { radius: 0 }, + }, + ]; + + const bitrateChart = [ + { + name: 'Lowest viewer bitrate', + color: '#B63FFF', + data: minimumPlayerBitrate, + options: { radius: 2 }, + }, + ]; + + availableBitrates.forEach(bitrate => { + bitrateChart.push({ + name: `${bitrate} kbps stream`, + color: '#003FFF', + data: minimumPlayerBitrate.map(item => ({ + time: item.time, + value: 1200, + })), + options: { radius: 0 }, + }); + }); + + const currentSpeed = bitrateChart[0]?.data[bitrateChart[0].data.length - 1]?.value; + const currentDownloadSeconds = + segmentDownloadDurations[segmentDownloadDurations.length - 1]?.value; + const lowestVariant = availableBitrates[0]; // TODO: get lowest bitrate from available bitrates + const latencyStat = latencyChart[0]?.data[latencyChart[0].data.length - 1]?.value || 0; + + let recentErrorCount = 0; + const errorValueCount = errorChart[0]?.data.length || 0; + if (errorValueCount > 5) { + const values = errorChart[0].data.slice(-5); + recentErrorCount = values.reduce((acc, curr) => acc + Number(curr.value), 0); + } else { + recentErrorCount = errorChart[0].data.reduce((acc, curr) => acc + Number(curr.value), 0); + } + const showStats = currentSpeed > 0 || currentDownloadSeconds > 0 || recentErrorCount > 0; + let bitrateError = null; + let speedError = null; + + if (currentSpeed !== 0 && currentSpeed < lowestVariant) { + bitrateError = `At least one of your viewers is playing your stream at ${currentSpeed}kbps, slower than ${lowestVariant}kbps, the lowest quality you made available, experiencing buffering. Consider adding a lower quality with a lower bitrate if the errors over time warrant this.`; + } + + if (currentDownloadSeconds > segmentLength) { + speedError = + 'Your viewers may be consuming your video slower than required. This may be due to slow networks or your latency configuration. Consider adding a lower quality with a lower bitrate or experiment with increasing the latency buffer setting.'; + } + + const errorStatColor = recentErrorCount > 0 ? '#B63FFF' : '#FFFFFF'; + const statStyle = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '80px', + }; + return ( + <> + Stream Performance + + This tool hopes to help you identify and troubleshoot problems you may be experiencing with + your stream. It aims to aggregate experiences across your viewers, meaning one viewer with + an exceptionally bad experience may throw off numbers for the whole, especially with a low + number of viewers. + + + The data is only collected by those using the Owncast web interface and is unable to gain + insight into external players people may be using such as VLC, MPV, QuickTime, etc. + + + + + +
+ } + precision={0} + suffix="kbps" + /> +
+
+ + + +
+ } + precision={0} + suffix="seconds" + /> +
+
+ + + +
+ } + suffix="" + />{' '} +
+
+ +
+ + + + + Once a video segment takes too long to download a viewer will experience + buffering. If you see slow downloads you should offer a lower quality for your + viewers, or find other ways, possibly an external storage provider, a CDN or a + faster network, to improve your stream quality. Increasing your latency buffer can + also help for some viewers. + + + In short, once the pink line consistently gets near the blue line, your stream is + likely experiencing problems for viewers. + + + } + /> + {speedError && ( + + )} + + + + + + The slowest bitrate of any of your viewers. Once somebody's bitrate drops below + the lowest video variant bitrate they will experience buffering. If you see + viewers with slow connections trying to play your video you should consider + offering an additional, lower quality. + + + In short, once the pink line gets near the lowest blue line, your stream is likely + experiencing problems for at least one of your viewers. + + + } + /> + {bitrateError && ( + + )} + + + + + + Recent number of errors, including buffering, and quality changes from across all + your viewers. Errors can occur for many reasons, including browser issues, + plugins, wifi problems, and they don't all represent fatal issues or something you + have control over. + + A quality change is not necessarily a negative thing, but if it's excessive and + coinciding with errors you should consider adding another quality variant. + + + } + /> + + + + + + +
+ + ); +} diff --git a/web/styles/globals.scss b/web/styles/globals.scss index c444c28bd..49d63e870 100644 --- a/web/styles/globals.scss +++ b/web/styles/globals.scss @@ -64,6 +64,7 @@ strong { .line-chart-container { margin: 2em auto; + margin-bottom: 0px; padding: 1em; border: 1px solid var(--gray-dark); diff --git a/web/utils/apis.ts b/web/utils/apis.ts index 74e3c658c..b8f792524 100644 --- a/web/utils/apis.ts +++ b/web/utils/apis.ts @@ -106,6 +106,8 @@ export const SET_FOLLOWER_APPROVAL = `${API_LOCATION}followers/approve`; // List of inbound federated actions that took place. export const FEDERATION_ACTIONS = `${API_LOCATION}federation/actions`; +export const API_STREAM_HEALTH_METRICS = `${API_LOCATION}metrics/video`; + export const API_YP_RESET = `${API_LOCATION}yp/reset`; export const TEMP_UPDATER_API = LOGS_ALL;