mirror of https://github.com/jetkvm/kvm.git
209 lines
7.9 KiB
TypeScript
209 lines
7.9 KiB
TypeScript
import { useInterval } from "usehooks-ts";
|
|
|
|
import SidebarHeader from "@/components/SidebarHeader";
|
|
import { useRTCStore, useUiStore } from "@/hooks/stores";
|
|
|
|
import { createChartArray, Metric } from "../Metric";
|
|
import { SettingsSectionHeader } from "../SettingsSectionHeader";
|
|
|
|
export default function ConnectionStatsSidebar() {
|
|
const inboundVideoRtpStats = useRTCStore(state => state.inboundRtpStats);
|
|
const iceCandidatePairStats = useRTCStore(state => state.candidatePairStats);
|
|
const setSidebarView = useUiStore(state => state.setSidebarView);
|
|
|
|
function isMetricSupported<T, K extends keyof T>(
|
|
stream: Map<number, T>,
|
|
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(
|
|
state => state.appendDiskDataChannelStats,
|
|
);
|
|
const appendLocalCandidateStats = useRTCStore(state => state.appendLocalCandidateStats);
|
|
const appendRemoteCandidateStats = useRTCStore(
|
|
state => state.appendRemoteCandidateStats,
|
|
);
|
|
|
|
const peerConnection = useRTCStore(state => state.peerConnection);
|
|
const mediaStream = useRTCStore(state => state.mediaStream);
|
|
const sidebarView = useUiStore(state => state.sidebarView);
|
|
|
|
useInterval(function collectWebRTCStats() {
|
|
(async () => {
|
|
if (!mediaStream) return;
|
|
const stats = await peerConnection?.getStats();
|
|
let successfulLocalCandidateId: string | null = null;
|
|
let successfulRemoteCandidateId: string | null = null;
|
|
|
|
stats?.forEach(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;
|
|
successfulRemoteCandidateId = report.remoteCandidateId;
|
|
}
|
|
|
|
appendIceCandidatePair(report);
|
|
} else if (report.type === "local-candidate") {
|
|
// We only want to append the local candidate stats that were used in nominated candidate pair
|
|
if (successfulLocalCandidateId === report.id) {
|
|
appendLocalCandidateStats(report);
|
|
}
|
|
} else if (report.type === "remote-candidate") {
|
|
if (successfulRemoteCandidateId === report.id) {
|
|
appendRemoteCandidateStats(report);
|
|
}
|
|
} else if (report.type === "data-channel" && report.label === "disk") {
|
|
appendDiskDataChannelStats(report);
|
|
}
|
|
});
|
|
})();
|
|
}, 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 (
|
|
<div className="grid h-full grid-rows-(--grid-headerBody) shadow-xs">
|
|
<SidebarHeader title="Connection Stats" setSidebarView={setSidebarView} />
|
|
<div className="h-full space-y-4 overflow-y-scroll bg-white px-4 py-2 pb-8 dark:bg-slate-900">
|
|
<div className="space-y-4">
|
|
{sidebarView === "connection-stats" && (
|
|
<div className="space-y-8">
|
|
{/* Connection Group */}
|
|
<div className="space-y-3">
|
|
<SettingsSectionHeader
|
|
title="Connection"
|
|
description="The connection between the client and the JetKVM."
|
|
/>
|
|
<Metric
|
|
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,
|
|
})}
|
|
domain={[0, 600]}
|
|
unit=" ms"
|
|
/>
|
|
</div>
|
|
|
|
{/* Video Group */}
|
|
<div className="space-y-3">
|
|
<SettingsSectionHeader
|
|
title="Video"
|
|
description="The video stream from the JetKVM to the client."
|
|
/>
|
|
|
|
{/* RTP Jitter */}
|
|
<Metric
|
|
title="Network Stability"
|
|
badge="Jitter"
|
|
badgeTheme="light"
|
|
description="How steady the flow of inbound video packets is across the network."
|
|
stream={inboundVideoRtpStats}
|
|
metric="jitter"
|
|
map={x => ({
|
|
date: x.date,
|
|
stat: x.stat ? Math.round((x.stat as number) * 1000) : null,
|
|
})}
|
|
domain={[0, 10]}
|
|
unit=" ms"
|
|
/>
|
|
|
|
{/* Playback Delay */}
|
|
<Metric
|
|
title="Playback Delay"
|
|
description="Delay added by the jitter buffer to smooth playback when frames arrive unevenly."
|
|
badge="Jitter Buffer Avg. Delay"
|
|
badgeTheme="light"
|
|
data={jitterBufferAvgDelayData}
|
|
gate={inboundVideoRtpStats}
|
|
supported={
|
|
isMetricSupported(inboundVideoRtpStats, "jitterBufferDelay") &&
|
|
isMetricSupported(inboundVideoRtpStats, "jitterBufferEmittedCount")
|
|
}
|
|
domain={[0, 30]}
|
|
unit=" ms"
|
|
referenceValue={referenceValue}
|
|
/>
|
|
|
|
{/* Packets Lost */}
|
|
<Metric
|
|
title="Packets Lost"
|
|
description="Count of lost inbound video RTP packets."
|
|
stream={inboundVideoRtpStats}
|
|
metric="packetsLost"
|
|
domain={[0, 100]}
|
|
unit=" packets"
|
|
/>
|
|
|
|
{/* Frames Per Second */}
|
|
<Metric
|
|
title="Frames per second"
|
|
description="Number of inbound video frames displayed per second."
|
|
stream={inboundVideoRtpStats}
|
|
metric="framesPerSecond"
|
|
domain={[0, 80]}
|
|
unit=" fps"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|