This commit is contained in:
Gabe Kangas 2022-03-16 17:54:34 -07:00 committed by GitHub
parent ae88a38acc
commit 1ce2ee398c
6 changed files with 377 additions and 3 deletions

View file

@ -16,6 +16,8 @@ interface ChartProps {
title?: string; title?: string;
color: string; color: string;
unit: string; unit: string;
yFlipped?: boolean;
yLogarithmic?: boolean;
dataCollections?: any[]; dataCollections?: any[];
} }
@ -29,7 +31,15 @@ function createGraphDataset(dataArray) {
return dataValues; 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 = []; const renderData = [];
if (data && data.length > 0) { if (data && data.length > 0) {
@ -45,9 +55,24 @@ export default function Chart({ data, title, color, unit, dataCollections }: Cha
name: collection.name, name: collection.name,
data: createGraphDataset(collection.data), data: createGraphDataset(collection.data),
color: collection.color, 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 ( return (
<div className="line-chart-container"> <div className="line-chart-container">
<LineChart <LineChart
@ -58,6 +83,7 @@ export default function Chart({ data, title, color, unit, dataCollections }: Cha
color={color} color={color}
data={renderData} data={renderData}
download={title} download={title}
library={options}
/> />
</div> </div>
); );
@ -67,4 +93,6 @@ Chart.defaultProps = {
dataCollections: [], dataCollections: [],
data: [], data: [],
title: '', title: '',
yFlipped: false,
yLogarithmic: false,
}; };

View file

@ -206,6 +206,9 @@ export default function MainLayout(props) {
<Menu.Item key="hardware-info"> <Menu.Item key="hardware-info">
<Link href="/hardware-info">Hardware</Link> <Link href="/hardware-info">Hardware</Link>
</Menu.Item> </Menu.Item>
<Menu.Item key="stream-health">
<Link href="/stream-health">Stream Health</Link>
</Menu.Item>
<Menu.Item key="logs"> <Menu.Item key="logs">
<Link href="/logs">Logs</Link> <Link href="/logs">Logs</Link>
</Menu.Item> </Menu.Item>

View file

@ -9,6 +9,7 @@ interface StatisticItemProps {
title?: string; title?: string;
value?: any; value?: any;
prefix?: any; prefix?: any;
suffix?: string;
color?: string; color?: string;
progress?: boolean; progress?: boolean;
centered?: boolean; centered?: boolean;
@ -18,13 +19,14 @@ const defaultProps = {
title: '', title: '',
value: 0, value: 0,
prefix: null, prefix: null,
suffix: null,
color: '', color: '',
progress: false, progress: false,
centered: false, centered: false,
formatter: null, formatter: null,
}; };
function ProgressView({ title, value, prefix, color }: StatisticItemProps) { function ProgressView({ title, value, prefix, suffix, color }: StatisticItemProps) {
const endColor = value > 90 ? 'red' : color; const endColor = value > 90 ? 'red' : color;
const content = ( const content = (
<div> <div>
@ -33,7 +35,10 @@ function ProgressView({ title, value, prefix, color }: StatisticItemProps) {
<Text type="secondary">{title}</Text> <Text type="secondary">{title}</Text>
</div> </div>
<div> <div>
<Text type="secondary">{value}%</Text> <Text type="secondary">
{value}
{suffix || '%'}
</Text>
</div> </div>
</div> </div>
); );

335
web/pages/stream-health.tsx Normal file
View file

@ -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 (
<div className="description-box">
<Typography.Title>{title}</Typography.Title>
<Typography.Paragraph>{description}</Typography.Paragraph>
</div>
);
}
export default function StreamHealth() {
const [errors, setErrors] = useState<TimedValue[]>([]);
const [qualityVariantChanges, setQualityVariantChanges] = useState<TimedValue[]>([]);
const [latency, setLatency] = useState<TimedValue[]>([]);
const [segmentDownloadDurations, setSegmentDownloadDurations] = useState<TimedValue[]>([]);
const [minimumPlayerBitrate, setMinimumPlayerBitrate] = useState<TimedValue[]>([]);
const [availableBitrates, setAvailableBitrates] = useState<Number[]>([]);
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 = (
<div>
<Typography.Title>Stream Performance</Typography.Title>
<Typography.Paragraph>
Data has not yet been collected. Once a stream has begun and viewers are watching this page
will be available.
</Typography.Paragraph>
</div>
);
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 (
<>
<Typography.Title>Stream Performance</Typography.Title>
<Typography.Paragraph>
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.
</Typography.Paragraph>
<Typography.Paragraph>
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.
</Typography.Paragraph>
<Space direction="vertical" size="middle">
<Row
gutter={[16, 16]}
justify="space-around"
style={{ display: showStats ? 'flex' : 'none' }}
>
<Col>
<Card type="inner">
<div style={statStyle}>
<Statistic
title="Slowest Viewer Speed"
value={`${currentSpeed}`}
prefix={<WifiOutlined style={{ marginRight: '5px' }} />}
precision={0}
suffix="kbps"
/>
</div>
</Card>
</Col>
<Col>
<Card type="inner">
<div style={statStyle}>
<Statistic
title="Average Latency"
value={`${latencyStat}`}
prefix={<ClockCircleOutlined style={{ marginRight: '5px' }} />}
precision={0}
suffix="seconds"
/>
</div>
</Card>
</Col>
<Col>
<Card type="inner">
<div style={statStyle}>
<Statistic
title="Recent Playback Errors"
value={`${recentErrorCount || 0}`}
valueStyle={{ color: errorStatColor }}
prefix={<WarningOutlined style={{ marginRight: '5px' }} />}
suffix=""
/>{' '}
</div>
</Card>
</Col>
</Row>
<Card>
<DescriptionBox
title="Video Segment Download"
description={
<>
<Typography.Paragraph>
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.
</Typography.Paragraph>
<Typography.Paragraph>
In short, once the pink line consistently gets near the blue line, your stream is
likely experiencing problems for viewers.
</Typography.Paragraph>
</>
}
/>
{speedError && (
<Alert message="Slow downloads" description={speedError} type="error" showIcon />
)}
<Chart
title="Seconds"
dataCollections={segmentDownloadDurationChart}
color="#FF7700"
unit="s"
yLogarithmic
/>
</Card>
<Card>
<DescriptionBox
title="Player Network Speed"
description={
<>
<Typography.Paragraph>
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.
</Typography.Paragraph>
<Typography.Paragraph>
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.
</Typography.Paragraph>
</>
}
/>
{bitrateError && (
<Alert
message="Low bandwidth viewers"
description={bitrateError}
type="error"
showIcon
/>
)}
<Chart
title="Lowest Player Bitrate"
dataCollections={bitrateChart}
color="#FF7700"
unit="kbps"
yLogarithmic
/>
</Card>
<Card>
<DescriptionBox
title="Errors and Quality Changes"
description={
<>
<Typography.Paragraph>
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.
</Typography.Paragraph>
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.
<Typography.Paragraph />
</>
}
/>
<Chart title="#" dataCollections={errorChart} color="#FF7700" unit="" />
</Card>
<Card>
<DescriptionBox
title="Average Latency"
description="An approximate, averaged, seconds across all your viewers that they are behind your live video. The more people buffer the further behind they will be. High latency itself is not a problem, but some people care about this more than others."
/>
<Chart title="Seconds" dataCollections={latencyChart} color="#FF7700" unit="s" />
</Card>
</Space>
</>
);
}

View file

@ -64,6 +64,7 @@ strong {
.line-chart-container { .line-chart-container {
margin: 2em auto; margin: 2em auto;
margin-bottom: 0px;
padding: 1em; padding: 1em;
border: 1px solid var(--gray-dark); border: 1px solid var(--gray-dark);

View file

@ -106,6 +106,8 @@ export const SET_FOLLOWER_APPROVAL = `${API_LOCATION}followers/approve`;
// List of inbound federated actions that took place. // List of inbound federated actions that took place.
export const FEDERATION_ACTIONS = `${API_LOCATION}federation/actions`; 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 API_YP_RESET = `${API_LOCATION}yp/reset`;
export const TEMP_UPDATER_API = LOGS_ALL; export const TEMP_UPDATER_API = LOGS_ALL;