kvm/ui/src/components/AudioMetricsDashboard.tsx

887 lines
36 KiB
TypeScript

import { useEffect, useState } from "react";
import { MdGraphicEq, MdSignalWifi4Bar, MdError, MdMic } from "react-icons/md";
import { LuActivity, LuClock, LuHardDrive, LuSettings, LuCpu, LuMemoryStick } from "react-icons/lu";
import { AudioLevelMeter } from "@components/AudioLevelMeter";
import StatChart from "@components/StatChart";
import { cx } from "@/cva.config";
import { useMicrophone } from "@/hooks/useMicrophone";
import { useAudioLevel } from "@/hooks/useAudioLevel";
import { useAudioEvents } from "@/hooks/useAudioEvents";
import api from "@/api";
interface AudioMetrics {
frames_received: number;
frames_dropped: number;
bytes_processed: number;
last_frame_time: string;
connection_drops: number;
average_latency: string;
}
interface MicrophoneMetrics {
frames_sent: number;
frames_dropped: number;
bytes_processed: number;
last_frame_time: string;
connection_drops: number;
average_latency: string;
}
interface ProcessMetrics {
cpu_percent: number;
memory_percent: number;
memory_rss: number;
memory_vms: number;
running: boolean;
}
interface AudioConfig {
Quality: number;
Bitrate: number;
SampleRate: number;
Channels: number;
FrameSize: string;
}
const qualityLabels = {
0: "Low",
1: "Medium",
2: "High",
3: "Ultra"
};
// Format percentage values to 2 decimal places
function formatPercentage(value: number | null | undefined): string {
if (value === null || value === undefined || isNaN(value)) {
return "0.00%";
}
return `${value.toFixed(2)}%`;
}
function formatMemoryMB(rssBytes: number | null | undefined): string {
if (rssBytes === null || rssBytes === undefined || isNaN(rssBytes)) {
return "0.00 MB";
}
const mb = rssBytes / (1024 * 1024);
return `${mb.toFixed(2)} MB`;
}
// Default system memory estimate in MB (will be replaced by actual value from backend)
const DEFAULT_SYSTEM_MEMORY_MB = 4096; // 4GB default
// Create chart array similar to connectionStats.tsx
function createChartArray<T, K extends keyof T>(
stream: Map<number, T>,
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,
};
});
}
export default function AudioMetricsDashboard() {
// System memory state
const [systemMemoryMB, setSystemMemoryMB] = useState(DEFAULT_SYSTEM_MEMORY_MB);
// Use WebSocket-based audio events for real-time updates
const {
audioMetrics,
microphoneMetrics: wsMicrophoneMetrics,
audioProcessMetrics: wsAudioProcessMetrics,
microphoneProcessMetrics: wsMicrophoneProcessMetrics,
isConnected: wsConnected
} = useAudioEvents();
// Fetch system memory information on component mount
useEffect(() => {
const fetchSystemMemory = async () => {
try {
const response = await api.GET('/system/memory');
const data = await response.json();
setSystemMemoryMB(data.total_memory_mb);
} catch {
// Failed to fetch system memory, using default
}
};
fetchSystemMemory();
}, []);
// Update historical data when WebSocket process metrics are received
useEffect(() => {
if (wsConnected && wsAudioProcessMetrics && wsAudioProcessMetrics.running) {
const now = Math.floor(Date.now() / 1000); // Convert to seconds for StatChart
// Validate that now is a valid number
if (isNaN(now)) return;
const cpuStat = isNaN(wsAudioProcessMetrics.cpu_percent) ? null : wsAudioProcessMetrics.cpu_percent;
setAudioCpuStats(prev => {
const newMap = new Map(prev);
newMap.set(now, { cpu_percent: cpuStat });
// Keep only last 120 seconds of data for memory management
const cutoff = now - 120;
for (const [key] of newMap) {
if (key < cutoff) newMap.delete(key);
}
return newMap;
});
setAudioMemoryStats(prev => {
const newMap = new Map(prev);
const memoryRss = isNaN(wsAudioProcessMetrics.memory_rss) ? null : wsAudioProcessMetrics.memory_rss;
newMap.set(now, { memory_rss: memoryRss });
// Keep only last 120 seconds of data for memory management
const cutoff = now - 120;
for (const [key] of newMap) {
if (key < cutoff) newMap.delete(key);
}
return newMap;
});
}
}, [wsConnected, wsAudioProcessMetrics]);
useEffect(() => {
if (wsConnected && wsMicrophoneProcessMetrics) {
const now = Math.floor(Date.now() / 1000); // Convert to seconds for StatChart
// Validate that now is a valid number
if (isNaN(now)) return;
const cpuStat = isNaN(wsMicrophoneProcessMetrics.cpu_percent) ? null : wsMicrophoneProcessMetrics.cpu_percent;
setMicCpuStats(prev => {
const newMap = new Map(prev);
newMap.set(now, { cpu_percent: cpuStat });
// Keep only last 120 seconds of data for memory management
const cutoff = now - 120;
for (const [key] of newMap) {
if (key < cutoff) newMap.delete(key);
}
return newMap;
});
setMicMemoryStats(prev => {
const newMap = new Map(prev);
const memoryRss = isNaN(wsMicrophoneProcessMetrics.memory_rss) ? null : wsMicrophoneProcessMetrics.memory_rss;
newMap.set(now, { memory_rss: memoryRss });
// Keep only last 120 seconds of data for memory management
const cutoff = now - 120;
for (const [key] of newMap) {
if (key < cutoff) newMap.delete(key);
}
return newMap;
});
}
}, [wsConnected, wsMicrophoneProcessMetrics]);
// Fallback state for when WebSocket is not connected
const [fallbackMetrics, setFallbackMetrics] = useState<AudioMetrics | null>(null);
const [fallbackMicrophoneMetrics, setFallbackMicrophoneMetrics] = useState<MicrophoneMetrics | null>(null);
const [fallbackConnected, setFallbackConnected] = useState(false);
// Process metrics state (fallback for when WebSocket is not connected)
const [fallbackAudioProcessMetrics, setFallbackAudioProcessMetrics] = useState<ProcessMetrics | null>(null);
const [fallbackMicrophoneProcessMetrics, setFallbackMicrophoneProcessMetrics] = useState<ProcessMetrics | null>(null);
// Historical data for charts using Maps for better memory management
const [audioCpuStats, setAudioCpuStats] = useState<Map<number, { cpu_percent: number | null }>>(new Map());
const [audioMemoryStats, setAudioMemoryStats] = useState<Map<number, { memory_rss: number | null }>>(new Map());
const [micCpuStats, setMicCpuStats] = useState<Map<number, { cpu_percent: number | null }>>(new Map());
const [micMemoryStats, setMicMemoryStats] = useState<Map<number, { memory_rss: number | null }>>(new Map());
// Configuration state (these don't change frequently, so we can load them once)
const [config, setConfig] = useState<AudioConfig | null>(null);
const [microphoneConfig, setMicrophoneConfig] = useState<AudioConfig | null>(null);
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
// Use WebSocket data when available, fallback to polling data otherwise
const metrics = wsConnected && audioMetrics !== null ? audioMetrics : fallbackMetrics;
const microphoneMetrics = wsConnected && wsMicrophoneMetrics !== null ? wsMicrophoneMetrics : fallbackMicrophoneMetrics;
const audioProcessMetrics = wsConnected && wsAudioProcessMetrics !== null ? wsAudioProcessMetrics : fallbackAudioProcessMetrics;
const microphoneProcessMetrics = wsConnected && wsMicrophoneProcessMetrics !== null ? wsMicrophoneProcessMetrics : fallbackMicrophoneProcessMetrics;
const isConnected = wsConnected ? wsConnected : fallbackConnected;
// Microphone state for audio level monitoring
const { isMicrophoneActive, isMicrophoneMuted, microphoneStream } = useMicrophone();
const { audioLevel, isAnalyzing } = useAudioLevel(
isMicrophoneActive ? microphoneStream : null,
{
enabled: isMicrophoneActive,
updateInterval: 120,
});
useEffect(() => {
// Load initial configuration (only once)
loadAudioConfig();
// Set up fallback polling only when WebSocket is not connected
if (!wsConnected) {
loadAudioData();
const interval = setInterval(loadAudioData, 1000);
return () => clearInterval(interval);
}
}, [wsConnected]);
const loadAudioConfig = async () => {
try {
// Load config
const configResp = await api.GET("/audio/quality");
if (configResp.ok) {
const configData = await configResp.json();
setConfig(configData.current);
}
// Load microphone config
try {
const micConfigResp = await api.GET("/microphone/quality");
if (micConfigResp.ok) {
const micConfigData = await micConfigResp.json();
setMicrophoneConfig(micConfigData.current);
}
} catch {
// Microphone config not available
}
} catch (error) {
console.error("Failed to load audio config:", error);
}
};
const loadAudioData = async () => {
try {
// Load metrics
const metricsResp = await api.GET("/audio/metrics");
if (metricsResp.ok) {
const metricsData = await metricsResp.json();
setFallbackMetrics(metricsData);
// Consider connected if API call succeeds, regardless of frame count
setFallbackConnected(true);
setLastUpdate(new Date());
} else {
setFallbackConnected(false);
}
// Load audio process metrics
try {
const audioProcessResp = await api.GET("/audio/process-metrics");
if (audioProcessResp.ok) {
const audioProcessData = await audioProcessResp.json();
setFallbackAudioProcessMetrics(audioProcessData);
// Update historical data for charts (keep last 120 seconds)
if (audioProcessData.running) {
const now = Math.floor(Date.now() / 1000); // Convert to seconds for StatChart
// Validate that now is a valid number
if (isNaN(now)) return;
const cpuStat = isNaN(audioProcessData.cpu_percent) ? null : audioProcessData.cpu_percent;
const memoryRss = isNaN(audioProcessData.memory_rss) ? null : audioProcessData.memory_rss;
setAudioCpuStats(prev => {
const newMap = new Map(prev);
newMap.set(now, { cpu_percent: cpuStat });
// Keep only last 120 seconds of data for memory management
const cutoff = now - 120;
for (const [key] of newMap) {
if (key < cutoff) newMap.delete(key);
}
return newMap;
});
setAudioMemoryStats(prev => {
const newMap = new Map(prev);
newMap.set(now, { memory_rss: memoryRss });
// Keep only last 120 seconds of data for memory management
const cutoff = now - 120;
for (const [key] of newMap) {
if (key < cutoff) newMap.delete(key);
}
return newMap;
});
}
}
} catch {
// Audio process metrics not available
}
// Load microphone metrics
try {
const micResp = await api.GET("/microphone/metrics");
if (micResp.ok) {
const micData = await micResp.json();
setFallbackMicrophoneMetrics(micData);
}
} catch {
// Microphone metrics might not be available, that's okay
// Microphone metrics not available
}
// Load microphone process metrics
try {
const micProcessResp = await api.GET("/microphone/process-metrics");
if (micProcessResp.ok) {
const micProcessData = await micProcessResp.json();
setFallbackMicrophoneProcessMetrics(micProcessData);
// Update historical data for charts (keep last 120 seconds)
const now = Math.floor(Date.now() / 1000); // Convert to seconds for StatChart
// Validate that now is a valid number
if (isNaN(now)) return;
const cpuStat = isNaN(micProcessData.cpu_percent) ? null : micProcessData.cpu_percent;
const memoryRss = isNaN(micProcessData.memory_rss) ? null : micProcessData.memory_rss;
setMicCpuStats(prev => {
const newMap = new Map(prev);
newMap.set(now, { cpu_percent: cpuStat });
// Keep only last 120 seconds of data for memory management
const cutoff = now - 120;
for (const [key] of newMap) {
if (key < cutoff) newMap.delete(key);
}
return newMap;
});
setMicMemoryStats(prev => {
const newMap = new Map(prev);
newMap.set(now, { memory_rss: memoryRss });
// Keep only last 120 seconds of data for memory management
const cutoff = now - 120;
for (const [key] of newMap) {
if (key < cutoff) newMap.delete(key);
}
return newMap;
});
}
} catch {
// Microphone process metrics not available
}
} catch (error) {
console.error("Failed to load audio data:", error);
setFallbackConnected(false);
}
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
const formatNumber = (num: number) => {
return new Intl.NumberFormat().format(num);
};
const getDropRate = () => {
if (!metrics || metrics.frames_received === 0) return 0;
return ((metrics.frames_dropped / metrics.frames_received) * 100);
};
const getQualityColor = (quality: number) => {
switch (quality) {
case 0: return "text-yellow-600 dark:text-yellow-400";
case 1: return "text-blue-600 dark:text-blue-400";
case 2: return "text-green-600 dark:text-green-400";
case 3: return "text-purple-600 dark:text-purple-400";
default: return "text-slate-600 dark:text-slate-400";
}
};
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<MdGraphicEq className="h-5 w-5 text-blue-600 dark:text-blue-400" />
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Audio Metrics
</h3>
</div>
<div className="flex items-center gap-2">
<div className={cx(
"h-2 w-2 rounded-full",
isConnected ? "bg-green-500" : "bg-red-500"
)} />
<span className="text-xs text-slate-500 dark:text-slate-400">
{isConnected ? "Active" : "Inactive"}
</span>
</div>
</div>
{/* Current Configuration */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{config && (
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-700">
<div className="mb-2 flex items-center gap-2">
<LuSettings className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="font-medium text-slate-900 dark:text-slate-100">
Audio Output Config
</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-500 dark:text-slate-400">Quality:</span>
<span className={cx("font-medium", getQualityColor(config.Quality))}>
{qualityLabels[config.Quality as keyof typeof qualityLabels]}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500 dark:text-slate-400">Bitrate:</span>
<span className="font-medium text-slate-900 dark:text-slate-100">
{config.Bitrate}kbps
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500 dark:text-slate-400">Sample Rate:</span>
<span className="font-medium text-slate-900 dark:text-slate-100">
{config.SampleRate}Hz
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500 dark:text-slate-400">Channels:</span>
<span className="font-medium text-slate-900 dark:text-slate-100">
{config.Channels}
</span>
</div>
</div>
</div>
)}
{microphoneConfig && (
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-700">
<div className="mb-2 flex items-center gap-2">
<MdMic className="h-4 w-4 text-green-600 dark:text-green-400" />
<span className="font-medium text-slate-900 dark:text-slate-100">
Microphone Input Config
</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-500 dark:text-slate-400">Quality:</span>
<span className={cx("font-medium", getQualityColor(microphoneConfig.Quality))}>
{qualityLabels[microphoneConfig.Quality as keyof typeof qualityLabels]}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500 dark:text-slate-400">Bitrate:</span>
<span className="font-medium text-slate-900 dark:text-slate-100">
{microphoneConfig.Bitrate}kbps
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500 dark:text-slate-400">Sample Rate:</span>
<span className="font-medium text-slate-900 dark:text-slate-100">
{microphoneConfig.SampleRate}Hz
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500 dark:text-slate-400">Channels:</span>
<span className="font-medium text-slate-900 dark:text-slate-100">
{microphoneConfig.Channels}
</span>
</div>
</div>
</div>
)}
</div>
{/* Subprocess Resource Usage - Histogram View */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Audio Output Subprocess */}
{audioProcessMetrics && (
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-700">
<div className="mb-3 flex items-center gap-2">
<LuCpu className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="font-medium text-slate-900 dark:text-slate-100">
Audio Output Process
</span>
<div className={cx(
"h-2 w-2 rounded-full ml-auto",
audioProcessMetrics.running ? "bg-green-500" : "bg-red-500"
)} />
</div>
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-slate-900 dark:text-slate-100 mb-2">CPU Usage</h4>
<div className="h-24">
<StatChart
data={createChartArray(audioCpuStats, 'cpu_percent')}
unit="%"
domain={[0, 100]}
/>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-slate-900 dark:text-slate-100 mb-2">Memory Usage</h4>
<div className="h-24">
<StatChart
data={createChartArray(audioMemoryStats, 'memory_rss').map(item => ({
date: item.date,
stat: item.stat ? item.stat / (1024 * 1024) : null // Convert bytes to MB
}))}
unit="MB"
domain={[0, systemMemoryMB]}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="text-center p-2 bg-slate-50 dark:bg-slate-800 rounded">
<div className="font-medium text-slate-900 dark:text-slate-100">
{formatPercentage(audioProcessMetrics.cpu_percent)}
</div>
<div className="text-slate-500 dark:text-slate-400">CPU</div>
</div>
<div className="text-center p-2 bg-slate-50 dark:bg-slate-800 rounded">
<div className="font-medium text-slate-900 dark:text-slate-100">
{formatMemoryMB(audioProcessMetrics.memory_rss)}
</div>
<div className="text-slate-500 dark:text-slate-400">Memory</div>
</div>
</div>
</div>
</div>
)}
{/* Microphone Input Subprocess */}
{microphoneProcessMetrics && (
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-700">
<div className="mb-3 flex items-center gap-2">
<LuMemoryStick className="h-4 w-4 text-green-600 dark:text-green-400" />
<span className="font-medium text-slate-900 dark:text-slate-100">
Microphone Input Process
</span>
<div className={cx(
"h-2 w-2 rounded-full ml-auto",
microphoneProcessMetrics.running ? "bg-green-500" : "bg-red-500"
)} />
</div>
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-slate-900 dark:text-slate-100 mb-2">CPU Usage</h4>
<div className="h-24">
<StatChart
data={createChartArray(micCpuStats, 'cpu_percent')}
unit="%"
domain={[0, 100]}
/>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-slate-900 dark:text-slate-100 mb-2">Memory Usage</h4>
<div className="h-24">
<StatChart
data={createChartArray(micMemoryStats, 'memory_rss').map(item => ({
date: item.date,
stat: item.stat ? item.stat / (1024 * 1024) : null // Convert bytes to MB
}))}
unit="MB"
domain={[0, systemMemoryMB]}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="text-center p-2 bg-slate-50 dark:bg-slate-800 rounded">
<div className="font-medium text-slate-900 dark:text-slate-100">
{formatPercentage(microphoneProcessMetrics.cpu_percent)}
</div>
<div className="text-slate-500 dark:text-slate-400">CPU</div>
</div>
<div className="text-center p-2 bg-slate-50 dark:bg-slate-800 rounded">
<div className="font-medium text-slate-900 dark:text-slate-100">
{formatMemoryMB(microphoneProcessMetrics.memory_rss)}
</div>
<div className="text-slate-500 dark:text-slate-400">Memory</div>
</div>
</div>
</div>
</div>
)}
</div>
{/* Performance Metrics */}
{metrics && (
<div className="space-y-3">
{/* Audio Output Frames */}
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-700">
<div className="mb-2 flex items-center gap-2">
<LuActivity className="h-4 w-4 text-green-600 dark:text-green-400" />
<span className="font-medium text-slate-900 dark:text-slate-100">
Audio Output
</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="text-center">
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
{formatNumber(metrics.frames_received)}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
Frames Received
</div>
</div>
<div className="text-center">
<div className={cx(
"text-2xl font-bold",
metrics.frames_dropped > 0
? "text-red-600 dark:text-red-400"
: "text-green-600 dark:text-green-400"
)}>
{formatNumber(metrics.frames_dropped)}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
Frames Dropped
</div>
</div>
</div>
{/* Drop Rate */}
<div className="mt-3 rounded-md bg-slate-50 p-2 dark:bg-slate-700">
<div className="flex items-center justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Drop Rate
</span>
<span className={cx(
"font-bold",
getDropRate() > 5
? "text-red-600 dark:text-red-400"
: getDropRate() > 1
? "text-yellow-600 dark:text-yellow-400"
: "text-green-600 dark:text-green-400"
)}>
{getDropRate().toFixed(2)}%
</span>
</div>
<div className="mt-1 h-2 w-full rounded-full bg-slate-200 dark:bg-slate-600">
<div
className={cx(
"h-2 rounded-full transition-all duration-300",
getDropRate() > 5
? "bg-red-500"
: getDropRate() > 1
? "bg-yellow-500"
: "bg-green-500"
)}
style={{ width: `${Math.min(getDropRate(), 100)}%` }}
/>
</div>
</div>
</div>
{/* Microphone Input Metrics */}
{microphoneMetrics && (
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-700">
<div className="mb-2 flex items-center gap-2">
<MdMic className="h-4 w-4 text-orange-600 dark:text-orange-400" />
<span className="font-medium text-slate-900 dark:text-slate-100">
Microphone Input
</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="text-center">
<div className="text-2xl font-bold text-orange-600 dark:text-orange-400">
{formatNumber(microphoneMetrics.frames_sent)}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
Frames Sent
</div>
</div>
<div className="text-center">
<div className={cx(
"text-2xl font-bold",
microphoneMetrics.frames_dropped > 0
? "text-red-600 dark:text-red-400"
: "text-green-600 dark:text-green-400"
)}>
{formatNumber(microphoneMetrics.frames_dropped)}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
Frames Dropped
</div>
</div>
</div>
{/* Microphone Drop Rate */}
<div className="mt-3 rounded-md bg-slate-50 p-2 dark:bg-slate-700">
<div className="flex items-center justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Drop Rate
</span>
<span className={cx(
"font-bold",
(microphoneMetrics.frames_sent > 0 ? (microphoneMetrics.frames_dropped / microphoneMetrics.frames_sent) * 100 : 0) > 5
? "text-red-600 dark:text-red-400"
: (microphoneMetrics.frames_sent > 0 ? (microphoneMetrics.frames_dropped / microphoneMetrics.frames_sent) * 100 : 0) > 1
? "text-yellow-600 dark:text-yellow-400"
: "text-green-600 dark:text-green-400"
)}>
{microphoneMetrics.frames_sent > 0 ? ((microphoneMetrics.frames_dropped / microphoneMetrics.frames_sent) * 100).toFixed(2) : "0.00"}%
</span>
</div>
<div className="mt-1 h-2 w-full rounded-full bg-slate-200 dark:bg-slate-600">
<div
className={cx(
"h-2 rounded-full transition-all duration-300",
(microphoneMetrics.frames_sent > 0 ? (microphoneMetrics.frames_dropped / microphoneMetrics.frames_sent) * 100 : 0) > 5
? "bg-red-500"
: (microphoneMetrics.frames_sent > 0 ? (microphoneMetrics.frames_dropped / microphoneMetrics.frames_sent) * 100 : 0) > 1
? "bg-yellow-500"
: "bg-green-500"
)}
style={{
width: `${Math.min(microphoneMetrics.frames_sent > 0 ? (microphoneMetrics.frames_dropped / microphoneMetrics.frames_sent) * 100 : 0, 100)}%`
}}
/>
</div>
</div>
{/* Microphone Audio Level */}
{isMicrophoneActive && (
<div className="mt-3 rounded-md bg-slate-50 p-2 dark:bg-slate-700">
<AudioLevelMeter
level={audioLevel}
isActive={isMicrophoneActive && !isMicrophoneMuted && isAnalyzing}
size="sm"
showLabel={true}
/>
</div>
)}
{/* Microphone Connection Health */}
<div className="mt-3 rounded-md bg-slate-50 p-2 dark:bg-slate-700">
<div className="mb-2 flex items-center gap-2">
<MdSignalWifi4Bar className="h-3 w-3 text-purple-600 dark:text-purple-400" />
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">
Connection Health
</span>
</div>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-xs text-slate-500 dark:text-slate-400">
Connection Drops:
</span>
<span className={cx(
"text-xs font-medium",
microphoneMetrics.connection_drops > 0
? "text-red-600 dark:text-red-400"
: "text-green-600 dark:text-green-400"
)}>
{formatNumber(microphoneMetrics.connection_drops)}
</span>
</div>
{microphoneMetrics.average_latency && (
<div className="flex justify-between">
<span className="text-xs text-slate-500 dark:text-slate-400">
Avg Latency:
</span>
<span className="text-xs font-medium text-slate-900 dark:text-slate-100">
{microphoneMetrics.average_latency}
</span>
</div>
)}
</div>
</div>
</div>
)}
{/* Data Transfer */}
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-700">
<div className="mb-2 flex items-center gap-2">
<LuHardDrive className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="font-medium text-slate-900 dark:text-slate-100">
Data Transfer
</span>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{formatBytes(metrics.bytes_processed)}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
Total Processed
</div>
</div>
</div>
{/* Connection Health */}
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-700">
<div className="mb-2 flex items-center gap-2">
<MdSignalWifi4Bar className="h-4 w-4 text-purple-600 dark:text-purple-400" />
<span className="font-medium text-slate-900 dark:text-slate-100">
Connection Health
</span>
</div>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-slate-500 dark:text-slate-400">
Connection Drops:
</span>
<span className={cx(
"font-medium",
metrics.connection_drops > 0
? "text-red-600 dark:text-red-400"
: "text-green-600 dark:text-green-400"
)}>
{formatNumber(metrics.connection_drops)}
</span>
</div>
{metrics.average_latency && (
<div className="flex justify-between">
<span className="text-sm text-slate-500 dark:text-slate-400">
Avg Latency:
</span>
<span className="font-medium text-slate-900 dark:text-slate-100">
{metrics.average_latency}
</span>
</div>
)}
</div>
</div>
</div>
)}
{/* Last Update */}
<div className="flex items-center justify-center gap-2 text-xs text-slate-500 dark:text-slate-400">
<LuClock className="h-3 w-3" />
<span>Last updated: {lastUpdate.toLocaleTimeString()}</span>
</div>
{/* No Data State */}
{!metrics && (
<div className="flex flex-col items-center justify-center py-8 text-center">
<MdError className="h-12 w-12 text-slate-400 dark:text-slate-600" />
<h3 className="mt-2 text-sm font-medium text-slate-900 dark:text-slate-100">
No Audio Data
</h3>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Audio metrics will appear when audio streaming is active.
</p>
</div>
)}
</div>
);
}