From a2d50fde5c5b1568c80404f615c48d69962fd145 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Mon, 25 Aug 2025 18:05:05 +0200 Subject: [PATCH 1/3] feat: add Metric component for data visualization --- ui/src/components/Metric.tsx | 167 +++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 ui/src/components/Metric.tsx diff --git a/ui/src/components/Metric.tsx b/ui/src/components/Metric.tsx new file mode 100644 index 0000000..737abe8 --- /dev/null +++ b/ui/src/components/Metric.tsx @@ -0,0 +1,167 @@ +import { ComponentProps } from "react"; +import { cva, cx } from "cva"; + +import { GridCard } from "./Card"; +import StatChart from "./StatChart"; + +interface ChartPoint { + date: number; + stat: number | null; +} + +interface MetricProps { + title: string; + description: string; + stream?: Map; + metric?: K; + data?: ChartPoint[]; + gate?: Map; + supported?: boolean; + map?: (p: { date: number; stat: T[K] | null }) => ChartPoint; + domain?: [number, number]; + unit?: string; + heightClassName?: string; + referenceValue?: number; + badge?: ComponentProps["badge"]; + badgeTheme?: ComponentProps["badgeTheme"]; +} + +export function createChartArray( + stream: Map, + metric: K, +): { date: number; stat: T[K] | null }[] { + const stat = Array.from(stream).map(([key, stats]) => { + return { date: key, stat: stats[metric] }; + }); + + // Sort the dates to ensure they are in chronological order + const sortedStat = stat.map(x => x.date).sort((a, b) => a - b); + + // Determine the earliest statistic date + const earliestStat = sortedStat[0]; + + // Current time in seconds since the Unix epoch + const now = Math.floor(Date.now() / 1000); + + // Determine the starting point for the chart data + const firstChartDate = earliestStat ? Math.min(earliestStat, now - 120) : now - 120; + + // Generate the chart array for the range between 'firstChartDate' and 'now' + return Array.from({ length: now - firstChartDate }, (_, i) => { + const currentDate = firstChartDate + i; + return { + date: currentDate, + // Find the statistic for 'currentDate', or use the last known statistic if none exists for that date + stat: stat.find(x => x.date === currentDate)?.stat ?? null, + }; + }); +} +const theme = { + light: + "bg-white text-black border border-slate-800/20 dark:border dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300", + danger: "bg-red-500 dark:border-red-700 dark:bg-red-800 dark:text-red-50", + primary: "bg-blue-500 dark:border-blue-700 dark:bg-blue-800 dark:text-blue-50", +}; + +interface SettingsItemProps { + readonly title: string; + readonly description: string | React.ReactNode; + readonly badge?: string; + readonly className?: string; + readonly children?: React.ReactNode; + readonly badgeTheme?: keyof typeof theme; +} + +export function MetricHeader(props: SettingsItemProps) { + const { title, description, badge } = props; + const badgeVariants = cva({ variants: { theme: theme } }); + + return ( +
+
+
+ {title} + {badge && ( + + {badge} + + )} +
+
+
{description}
+
+ ); +} + +export function Metric({ + title, + description, + stream, + metric, + data, + gate, + supported, + map, + domain = [0, 600], + unit = "", + heightClassName = "h-[127px]", + referenceValue, + badge, + badgeTheme, +}: MetricProps) { + const ready = gate ? gate.size > 0 : stream ? stream.size > 0 : true; + const supportedFinal = + supported ?? + (stream && metric + ? Array.from(stream).some(([, s]) => s[metric] !== undefined) + : true); + + const raw = stream && metric ? createChartArray(stream, metric) : []; + const dataFinal: ChartPoint[] = + data ?? + (map + ? raw.map(map) + : raw.map(x => ({ + date: x.date, + stat: typeof x.stat === "number" ? (x.stat as unknown as number) : null, + }))); + + return ( +
+ + + +
+ {!ready ? ( +
+

Waiting for data...

+
+ ) : supportedFinal ? ( + + ) : ( +
+

Metric not supported

+
+ )} +
+
+
+ ); +} From 5acfb67d29943808002832528f70c2bf893575ee Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Mon, 25 Aug 2025 18:05:16 +0200 Subject: [PATCH 2/3] refactor: update ConnectionStatsSidebar to use Metric component for improved data visualization --- ui/src/components/sidebar/connectionStats.tsx | 304 ++++++++---------- 1 file changed, 133 insertions(+), 171 deletions(-) diff --git a/ui/src/components/sidebar/connectionStats.tsx b/ui/src/components/sidebar/connectionStats.tsx index 404deb1..f0988e0 100644 --- a/ui/src/components/sidebar/connectionStats.tsx +++ b/ui/src/components/sidebar/connectionStats.tsx @@ -1,45 +1,14 @@ import { useInterval } from "usehooks-ts"; import SidebarHeader from "@/components/SidebarHeader"; -import { GridCard } from "@/components/Card"; import { useRTCStore, useUiStore } from "@/hooks/stores"; -import StatChart from "@/components/StatChart"; -function createChartArray( - stream: Map, - metric: K, -): { date: number; stat: T[K] | null }[] { - const stat = Array.from(stream).map(([key, stats]) => { - return { date: key, stat: stats[metric] }; - }); - - // Sort the dates to ensure they are in chronological order - const sortedStat = stat.map(x => x.date).sort((a, b) => a - b); - - // Determine the earliest statistic date - const earliestStat = sortedStat[0]; - - // Current time in seconds since the Unix epoch - const now = Math.floor(Date.now() / 1000); - - // Determine the starting point for the chart data - const firstChartDate = earliestStat ? Math.min(earliestStat, now - 120) : now - 120; - - // Generate the chart array for the range between 'firstChartDate' and 'now' - return Array.from({ length: now - firstChartDate }, (_, i) => { - const currentDate = firstChartDate + i; - return { - date: currentDate, - // Find the statistic for 'currentDate', or use the last known statistic if none exists for that date - stat: stat.find(x => x.date === currentDate)?.stat ?? null, - }; - }); -} +import { createChartArray, Metric } from "../Metric"; +import { SettingsSectionHeader } from "../SettingsSectionHeader"; export default function ConnectionStatsSidebar() { - const inboundRtpStats = useRTCStore(state => state.inboundRtpStats); - - const candidatePairStats = useRTCStore(state => state.candidatePairStats); + const inboundVideoRtpStats = useRTCStore(state => state.inboundRtpStats); + const iceCandidatePairStats = useRTCStore(state => state.candidatePairStats); const setSidebarView = useUiStore(state => state.setSidebarView); function isMetricSupported( @@ -49,7 +18,7 @@ export default function ConnectionStatsSidebar() { return Array.from(stream).some(([, stat]) => stat[metric] !== undefined); } - const appendInboundRtpStats = useRTCStore(state => state.appendInboundRtpStats); + const appendInboundVideoRtpStats = useRTCStore(state => state.appendInboundRtpStats); const appendIceCandidatePair = useRTCStore(state => state.appendCandidatePairStats); const appendDiskDataChannelStats = useRTCStore( state => state.appendDiskDataChannelStats, @@ -66,15 +35,13 @@ export default function ConnectionStatsSidebar() { useInterval(function collectWebRTCStats() { (async () => { if (!mediaStream) return; - const videoTrack = mediaStream.getVideoTracks()[0]; - if (!videoTrack) return; const stats = await peerConnection?.getStats(); let successfulLocalCandidateId: string | null = null; let successfulRemoteCandidateId: string | null = null; stats?.forEach(report => { - if (report.type === "inbound-rtp") { - appendInboundRtpStats(report); + if (report.type === "inbound-rtp" && report.kind === "video") { + appendInboundVideoRtpStats(report); } else if (report.type === "candidate-pair" && report.nominated) { if (report.state === "succeeded") { successfulLocalCandidateId = report.localCandidateId; @@ -98,144 +65,139 @@ export default function ConnectionStatsSidebar() { })(); }, 500); + const jitterBufferDelay = createChartArray(inboundVideoRtpStats, "jitterBufferDelay"); + const jitterBufferEmittedCount = createChartArray( + inboundVideoRtpStats, + "jitterBufferEmittedCount", + ); + + const jitterBufferAvgDelayData = jitterBufferDelay.map((d, idx) => { + if (idx === 0) return { date: d.date, stat: null }; + const prevDelay = jitterBufferDelay[idx - 1]?.stat as number | null | undefined; + const currDelay = d.stat as number | null | undefined; + const prevEmitted = + (jitterBufferEmittedCount[idx - 1]?.stat as number | null | undefined) ?? null; + const currEmitted = + (jitterBufferEmittedCount[idx]?.stat as number | null | undefined) ?? null; + + if ( + prevDelay == null || + currDelay == null || + prevEmitted == null || + currEmitted == null + ) { + return { date: d.date, stat: null }; + } + + const deltaDelay = currDelay - prevDelay; + const deltaEmitted = currEmitted - prevEmitted; + + // Guard counter resets or no emitted frames + if (deltaDelay < 0 || deltaEmitted <= 0) { + return { date: d.date, stat: null }; + } + + const valueMs = Math.round((deltaDelay / deltaEmitted) * 1000); + return { date: d.date, stat: valueMs }; + }); + + // Rolling average over the last N seconds for the reference line + const rollingWindowSeconds = 20; + const recent = jitterBufferAvgDelayData + .slice(-rollingWindowSeconds) + .filter(x => x.stat != null) as { date: number; stat: number }[]; + const referenceValue = + recent.length > 0 + ? Math.round(recent.reduce((sum, x) => sum + (x.stat as number), 0) / recent.length) + : undefined; + return (
- {/* - The entire sidebar component is always rendered, with a display none when not visible - The charts below, need a height and width, otherwise they throw. So simply don't render them unless the thing is visible - */} {sidebarView === "connection-stats" && ( -
-
-
-

- Packets Lost -

-

- Number of data packets lost during transmission. -

-
- -
- {inboundRtpStats.size === 0 ? ( -
-

Waiting for data...

-
- ) : isMetricSupported(inboundRtpStats, "packetsLost") ? ( - - ) : ( -
-

Metric not supported

-
- )} -
-
+
+ {/* Connection Group */} +
+ + ({ + date: x.date, + stat: x.stat ? Math.round((x.stat as number) * 1000) : null, + })} + domain={[0, 600]} + unit=" ms" + />
-
-
-

- Round-Trip Time -

-

- Time taken for data to travel from source to destination and back -

-
- -
- {inboundRtpStats.size === 0 ? ( -
-

Waiting for data...

-
- ) : isMetricSupported(candidatePairStats, "currentRoundTripTime") ? ( - { - return { - date: x.date, - stat: x.stat ? Math.round(x.stat * 1000) : null, - }; - })} - domain={[0, 600]} - unit=" ms" - /> - ) : ( -
-

Metric not supported

-
- )} -
-
-
-
-
-

- Jitter -

-

- Variation in packet delay, affecting video smoothness.{" "} -

-
- -
- {inboundRtpStats.size === 0 ? ( -
-

Waiting for data...

-
- ) : ( - { - return { - date: x.date, - stat: x.stat ? Math.round(x.stat * 1000) : null, - }; - })} - domain={[0, 300]} - unit=" ms" - /> - )} -
-
-
-
-
-

- Frames per second -

-

- Number of video frames displayed per second. -

-
- -
- {inboundRtpStats.size === 0 ? ( -
-

Waiting for data...

-
- ) : ( - { - return { - date: x.date, - stat: x.stat ? x.stat : null, - }; - }, - )} - domain={[0, 80]} - unit=" fps" - /> - )} -
-
+ + {/* Video Group */} +
+ + + {/* RTP Jitter */} + ({ + date: x.date, + stat: x.stat ? Math.round((x.stat as number) * 1000) : null, + })} + domain={[0, 10]} + unit=" ms" + /> + + {/* Playback Delay */} + + + {/* Packets Lost */} + + + {/* Frames Per Second */} +
)} From 3bd9f841e07c816c3a500b5d038560ed28793f43 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Tue, 26 Aug 2025 16:47:16 +0200 Subject: [PATCH 3/3] feat: add someIterable utility function and update Metric components for consistent metric handling - Introduced `someIterable` function to check for the presence of a metric in an iterable. - Updated `CustomTooltip` and `Metric` components to use `metric` instead of `stat` for improved clarity. - Refactored `StatChart` to align with the new metric naming convention. --- ui/src/components/CustomTooltip.tsx | 12 +-- ui/src/components/Metric.tsx | 91 ++++++++++--------- .../{StatChart.tsx => MetricsChart.tsx} | 11 ++- ui/src/components/sidebar/connectionStats.tsx | 50 ++++------ ui/src/routes/devices.$id.tsx | 2 +- ui/src/utils.ts | 11 +++ 6 files changed, 90 insertions(+), 87 deletions(-) rename ui/src/components/{StatChart.tsx => MetricsChart.tsx} (91%) diff --git a/ui/src/components/CustomTooltip.tsx b/ui/src/components/CustomTooltip.tsx index a27f607..5b8848f 100644 --- a/ui/src/components/CustomTooltip.tsx +++ b/ui/src/components/CustomTooltip.tsx @@ -1,25 +1,25 @@ import Card from "@components/Card"; export interface CustomTooltipProps { - payload: { payload: { date: number; stat: number }; unit: string }[]; + payload: { payload: { date: number; metric: number }; unit: string }[]; } export default function CustomTooltip({ payload }: CustomTooltipProps) { if (payload?.length) { const toolTipData = payload[0]; - const { date, stat } = toolTipData.payload; + const { date, metric } = toolTipData.payload; return ( -
-
+
+
{new Date(date * 1000).toLocaleTimeString()}
- - {stat} {toolTipData?.unit} + + {metric} {toolTipData?.unit}
diff --git a/ui/src/components/Metric.tsx b/ui/src/components/Metric.tsx index 737abe8..bd367ac 100644 --- a/ui/src/components/Metric.tsx +++ b/ui/src/components/Metric.tsx @@ -1,12 +1,14 @@ import { ComponentProps } from "react"; import { cva, cx } from "cva"; +import { someIterable } from "../utils"; + import { GridCard } from "./Card"; -import StatChart from "./StatChart"; +import MetricsChart from "./MetricsChart"; interface ChartPoint { date: number; - stat: number | null; + metric: number | null; } interface MetricProps { @@ -17,45 +19,42 @@ interface MetricProps { data?: ChartPoint[]; gate?: Map; supported?: boolean; - map?: (p: { date: number; stat: T[K] | null }) => ChartPoint; + map?: (p: { date: number; metric: number | null }) => ChartPoint; domain?: [number, number]; - unit?: string; + unit: string; heightClassName?: string; referenceValue?: number; badge?: ComponentProps["badge"]; badgeTheme?: ComponentProps["badgeTheme"]; } +/* eslint-disable-next-line */ export function createChartArray( - stream: Map, - metric: K, -): { date: number; stat: T[K] | null }[] { - const stat = Array.from(stream).map(([key, stats]) => { - return { date: key, stat: stats[metric] }; - }); - - // Sort the dates to ensure they are in chronological order - const sortedStat = stat.map(x => x.date).sort((a, b) => a - b); - - // Determine the earliest statistic date - const earliestStat = sortedStat[0]; - - // Current time in seconds since the Unix epoch + metrics: Map, + metricName: K, +) { + const result: { date: number; metric: number | null }[] = []; + const iter = metrics.entries(); + let next = iter.next() as IteratorResult<[number, T]>; const now = Math.floor(Date.now() / 1000); - // Determine the starting point for the chart data - const firstChartDate = earliestStat ? Math.min(earliestStat, now - 120) : now - 120; + // We want 120 data points, in the chart. + const firstDate = Math.min(next.value?.[0] ?? now, now - 120); - // Generate the chart array for the range between 'firstChartDate' and 'now' - return Array.from({ length: now - firstChartDate }, (_, i) => { - const currentDate = firstChartDate + i; - return { - date: currentDate, - // Find the statistic for 'currentDate', or use the last known statistic if none exists for that date - stat: stat.find(x => x.date === currentDate)?.stat ?? null, - }; - }); + for (let t = firstDate; t < now; t++) { + while (!next.done && next.value[0] < t) next = iter.next(); + const has = !next.done && next.value[0] === t; + + let metric = null; + if (has) metric = next.value[1][metricName] as number; + result.push({ date: t, metric }); + + if (has) next = iter.next(); + } + + return result; } + const theme = { light: "bg-white text-black border border-slate-800/20 dark:border dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300", @@ -110,26 +109,30 @@ export function Metric({ domain = [0, 600], unit = "", heightClassName = "h-[127px]", - referenceValue, badge, badgeTheme, }: MetricProps) { const ready = gate ? gate.size > 0 : stream ? stream.size > 0 : true; const supportedFinal = supported ?? - (stream && metric - ? Array.from(stream).some(([, s]) => s[metric] !== undefined) - : true); + (stream && metric ? someIterable(stream, ([, s]) => s[metric] !== undefined) : true); - const raw = stream && metric ? createChartArray(stream, metric) : []; - const dataFinal: ChartPoint[] = - data ?? - (map - ? raw.map(map) - : raw.map(x => ({ - date: x.date, - stat: typeof x.stat === "number" ? (x.stat as unknown as number) : null, - }))); + // Either we let the consumer provide their own chartArray, or we create one from the stream and metric. + const raw = data ?? ((stream && metric && createChartArray(stream, metric)) || []); + + // If the consumer provides a map function, we apply it to the raw data. + const dataFinal: ChartPoint[] = map ? raw.map(map) : raw; + const recent = dataFinal + .slice(-(raw.length - 1)) + .filter(x => x.metric != null) as ChartPoint[]; + + // Average the recent values + const computedReferenceValue = + recent.length > 0 + ? Math.round( + recent.reduce((sum, x) => sum + (x.metric as number), 0) / recent.length, + ) + : undefined; return (
@@ -149,11 +152,11 @@ export function Metric({

Waiting for data...

) : supportedFinal ? ( - ) : (
diff --git a/ui/src/components/StatChart.tsx b/ui/src/components/MetricsChart.tsx similarity index 91% rename from ui/src/components/StatChart.tsx rename to ui/src/components/MetricsChart.tsx index 2c403e3..853bcf3 100644 --- a/ui/src/components/StatChart.tsx +++ b/ui/src/components/MetricsChart.tsx @@ -12,13 +12,13 @@ import { import CustomTooltip, { CustomTooltipProps } from "@components/CustomTooltip"; -export default function StatChart({ +export default function MetricsChart({ data, domain, unit, referenceValue, }: { - data: { date: number; stat: number | null | undefined }[]; + data: { date: number; metric: number | null | undefined }[]; domain?: [string | number, string | number]; unit?: string; referenceValue?: number; @@ -33,7 +33,7 @@ export default function StatChart({ strokeLinecap="butt" stroke="rgba(30, 41, 59, 0.1)" /> - {referenceValue && ( + {referenceValue !== undefined && ( x.date)} /> state.inboundRtpStats); const iceCandidatePairStats = useRTCStore(state => state.candidatePairStats); const setSidebarView = useUiStore(state => state.setSidebarView); - function isMetricSupported( - stream: Map, - metric: K, - ): boolean { - return Array.from(stream).some(([, stat]) => stat[metric] !== undefined); - } - const appendInboundVideoRtpStats = useRTCStore(state => state.appendInboundRtpStats); const appendIceCandidatePair = useRTCStore(state => state.appendCandidatePairStats); const appendDiskDataChannelStats = useRTCStore( @@ -72,13 +66,13 @@ export default function ConnectionStatsSidebar() { ); const jitterBufferAvgDelayData = jitterBufferDelay.map((d, idx) => { - if (idx === 0) return { date: d.date, stat: null }; - const prevDelay = jitterBufferDelay[idx - 1]?.stat as number | null | undefined; - const currDelay = d.stat as number | null | undefined; + if (idx === 0) return { date: d.date, metric: null }; + const prevDelay = jitterBufferDelay[idx - 1]?.metric as number | null | undefined; + const currDelay = d.metric as number | null | undefined; const prevEmitted = - (jitterBufferEmittedCount[idx - 1]?.stat as number | null | undefined) ?? null; + (jitterBufferEmittedCount[idx - 1]?.metric as number | null | undefined) ?? null; const currEmitted = - (jitterBufferEmittedCount[idx]?.stat as number | null | undefined) ?? null; + (jitterBufferEmittedCount[idx]?.metric as number | null | undefined) ?? null; if ( prevDelay == null || @@ -86,7 +80,7 @@ export default function ConnectionStatsSidebar() { prevEmitted == null || currEmitted == null ) { - return { date: d.date, stat: null }; + return { date: d.date, metric: null }; } const deltaDelay = currDelay - prevDelay; @@ -94,23 +88,13 @@ export default function ConnectionStatsSidebar() { // Guard counter resets or no emitted frames if (deltaDelay < 0 || deltaEmitted <= 0) { - return { date: d.date, stat: null }; + return { date: d.date, metric: null }; } const valueMs = Math.round((deltaDelay / deltaEmitted) * 1000); - return { date: d.date, stat: valueMs }; + return { date: d.date, metric: valueMs }; }); - // Rolling average over the last N seconds for the reference line - const rollingWindowSeconds = 20; - const recent = jitterBufferAvgDelayData - .slice(-rollingWindowSeconds) - .filter(x => x.stat != null) as { date: number; stat: number }[]; - const referenceValue = - recent.length > 0 - ? Math.round(recent.reduce((sum, x) => sum + (x.stat as number), 0) / recent.length) - : undefined; - return (
@@ -128,11 +112,10 @@ export default function ConnectionStatsSidebar() { title="Round-Trip Time" description="Round-trip time for the active ICE candidate pair between peers." stream={iceCandidatePairStats} - gate={inboundVideoRtpStats} metric="currentRoundTripTime" map={x => ({ date: x.date, - stat: x.stat ? Math.round((x.stat as number) * 1000) : null, + metric: x.metric != null ? Math.round(x.metric * 1000) : null, })} domain={[0, 600]} unit=" ms" @@ -156,7 +139,7 @@ export default function ConnectionStatsSidebar() { metric="jitter" map={x => ({ date: x.date, - stat: x.stat ? Math.round((x.stat as number) * 1000) : null, + metric: x.metric != null ? Math.round(x.metric * 1000) : null, })} domain={[0, 10]} unit=" ms" @@ -171,12 +154,17 @@ export default function ConnectionStatsSidebar() { data={jitterBufferAvgDelayData} gate={inboundVideoRtpStats} supported={ - isMetricSupported(inboundVideoRtpStats, "jitterBufferDelay") && - isMetricSupported(inboundVideoRtpStats, "jitterBufferEmittedCount") + someIterable( + inboundVideoRtpStats, + ([, x]) => x.jitterBufferDelay != null, + ) && + someIterable( + inboundVideoRtpStats, + ([, x]) => x.jitterBufferEmittedCount != null, + ) } domain={[0, 30]} unit=" ms" - referenceValue={referenceValue} /> {/* Packets Lost */} diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index a4ecf3d..7552d13 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -738,7 +738,7 @@ export default function KvmIdRoute() { send("getUpdateStatus", {}, async (resp: JsonRpcResponse) => { if ("error" in resp) { notifications.error(`Failed to get device version: ${resp.error}`); - return + return } const result = resp.result as SystemVersionInfo; diff --git a/ui/src/utils.ts b/ui/src/utils.ts index 99c1a50..9e36133 100644 --- a/ui/src/utils.ts +++ b/ui/src/utils.ts @@ -94,6 +94,17 @@ export const formatters = { }, }; +export function someIterable( + iterable: Iterable, + predicate: (item: T) => boolean, +): boolean { + for (const item of iterable) { + if (predicate(item)) return true; + } + + return false; +} + export const VIDEO = new Blob( [ new Uint8Array([