mirror of https://github.com/jetkvm/kvm.git
feat(audio): add system memory endpoint and process metrics monitoring
- Add new /system/memory endpoint to expose total system memory - Implement process metrics collection for audio and microphone processes - Update UI to display real-time process metrics with charts - Replace environment variable check with CLI flag for audio input server - Improve audio metrics broadcasting with 1-second intervals - Add memory usage capping for CPU percentage metrics
This commit is contained in:
parent
0e1c896aa2
commit
5e28a6c429
|
@ -231,6 +231,23 @@ systemctl restart jetkvm
|
|||
cd ui && npm run lint
|
||||
```
|
||||
|
||||
### Local Code Quality Tools
|
||||
|
||||
The project includes several Makefile targets for local code quality checks that mirror the GitHub Actions workflows:
|
||||
|
||||
```bash
|
||||
# Run Go linting (mirrors .github/workflows/lint.yml)
|
||||
make lint
|
||||
|
||||
# Run Go linting with auto-fix
|
||||
make lint-fix
|
||||
|
||||
# Run UI linting (mirrors .github/workflows/ui-lint.yml)
|
||||
make ui-lint
|
||||
```
|
||||
|
||||
**Note:** The `lint` and `lint-fix` targets require audio dependencies. Run `make dev_env` first if you haven't already.
|
||||
|
||||
### API Testing
|
||||
|
||||
```bash
|
||||
|
|
26
Makefile
26
Makefile
|
@ -1,5 +1,5 @@
|
|||
# --- JetKVM Audio/Toolchain Dev Environment Setup ---
|
||||
.PHONY: setup_toolchain build_audio_deps dev_env
|
||||
.PHONY: setup_toolchain build_audio_deps dev_env lint lint-fix ui-lint
|
||||
|
||||
# Clone the rv1106-system toolchain to $HOME/.jetkvm/rv1106-system
|
||||
setup_toolchain:
|
||||
|
@ -126,3 +126,27 @@ release:
|
|||
@shasum -a 256 bin/jetkvm_app | cut -d ' ' -f 1 > bin/jetkvm_app.sha256
|
||||
rclone copyto bin/jetkvm_app r2://jetkvm-update/app/$(VERSION)/jetkvm_app
|
||||
rclone copyto bin/jetkvm_app.sha256 r2://jetkvm-update/app/$(VERSION)/jetkvm_app.sha256
|
||||
|
||||
# Run golangci-lint locally with the same configuration as CI
|
||||
lint: build_audio_deps
|
||||
@echo "Running golangci-lint..."
|
||||
@mkdir -p static && touch static/.gitkeep
|
||||
CGO_ENABLED=1 \
|
||||
CGO_CFLAGS="-I$(AUDIO_LIBS_DIR)/alsa-lib-$(ALSA_VERSION)/include -I$(AUDIO_LIBS_DIR)/opus-$(OPUS_VERSION)/include -I$(AUDIO_LIBS_DIR)/opus-$(OPUS_VERSION)/celt" \
|
||||
CGO_LDFLAGS="-L$(AUDIO_LIBS_DIR)/alsa-lib-$(ALSA_VERSION)/src/.libs -lasound -L$(AUDIO_LIBS_DIR)/opus-$(OPUS_VERSION)/.libs -lopus -lm -ldl -static" \
|
||||
golangci-lint run --verbose
|
||||
|
||||
# Run golangci-lint with auto-fix
|
||||
lint-fix: build_audio_deps
|
||||
@echo "Running golangci-lint with auto-fix..."
|
||||
@mkdir -p static && touch static/.gitkeep
|
||||
CGO_ENABLED=1 \
|
||||
CGO_CFLAGS="-I$(AUDIO_LIBS_DIR)/alsa-lib-$(ALSA_VERSION)/include -I$(AUDIO_LIBS_DIR)/opus-$(OPUS_VERSION)/include -I$(AUDIO_LIBS_DIR)/opus-$(OPUS_VERSION)/celt" \
|
||||
CGO_LDFLAGS="-L$(AUDIO_LIBS_DIR)/alsa-lib-$(ALSA_VERSION)/src/.libs -lasound -L$(AUDIO_LIBS_DIR)/opus-$(OPUS_VERSION)/.libs -lopus -lm -ldl -static" \
|
||||
golangci-lint run --fix --verbose
|
||||
|
||||
# Run UI linting locally (mirrors GitHub workflow ui-lint.yml)
|
||||
ui-lint:
|
||||
@echo "Running UI lint..."
|
||||
@cd ui && npm ci
|
||||
@cd ui && npm run lint
|
||||
|
|
|
@ -12,6 +12,7 @@ func main() {
|
|||
versionPtr := flag.Bool("version", false, "print version and exit")
|
||||
versionJsonPtr := flag.Bool("version-json", false, "print version as json and exit")
|
||||
audioServerPtr := flag.Bool("audio-server", false, "Run as audio server subprocess")
|
||||
audioInputServerPtr := flag.Bool("audio-input-server", false, "Run as audio input server subprocess")
|
||||
flag.Parse()
|
||||
|
||||
if *versionPtr || *versionJsonPtr {
|
||||
|
@ -24,5 +25,5 @@ func main() {
|
|||
return
|
||||
}
|
||||
|
||||
kvm.Main(*audioServerPtr)
|
||||
kvm.Main(*audioServerPtr, *audioInputServerPtr)
|
||||
}
|
||||
|
|
|
@ -3,6 +3,13 @@ package audio
|
|||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
var (
|
||||
// Global audio output supervisor instance
|
||||
globalOutputSupervisor unsafe.Pointer // *AudioServerSupervisor
|
||||
)
|
||||
|
||||
// isAudioServerProcess detects if we're running as the audio server subprocess
|
||||
|
@ -49,3 +56,17 @@ func StartNonBlockingAudioStreaming(send func([]byte)) error {
|
|||
func StopNonBlockingAudioStreaming() {
|
||||
StopAudioOutputStreaming()
|
||||
}
|
||||
|
||||
// SetAudioOutputSupervisor sets the global audio output supervisor
|
||||
func SetAudioOutputSupervisor(supervisor *AudioServerSupervisor) {
|
||||
atomic.StorePointer(&globalOutputSupervisor, unsafe.Pointer(supervisor))
|
||||
}
|
||||
|
||||
// GetAudioOutputSupervisor returns the global audio output supervisor
|
||||
func GetAudioOutputSupervisor() *AudioServerSupervisor {
|
||||
ptr := atomic.LoadPointer(&globalOutputSupervisor)
|
||||
if ptr == nil {
|
||||
return nil
|
||||
}
|
||||
return (*AudioServerSupervisor)(ptr)
|
||||
}
|
||||
|
|
|
@ -157,9 +157,20 @@ func GetMicrophoneConfig() AudioConfig {
|
|||
|
||||
// GetAudioMetrics returns current audio metrics
|
||||
func GetAudioMetrics() AudioMetrics {
|
||||
// Get base metrics
|
||||
framesReceived := atomic.LoadInt64(&metrics.FramesReceived)
|
||||
framesDropped := atomic.LoadInt64(&metrics.FramesDropped)
|
||||
|
||||
// If audio relay is running, use relay stats instead
|
||||
if IsAudioRelayRunning() {
|
||||
relayReceived, relayDropped := GetAudioRelayStats()
|
||||
framesReceived = relayReceived
|
||||
framesDropped = relayDropped
|
||||
}
|
||||
|
||||
return AudioMetrics{
|
||||
FramesReceived: atomic.LoadInt64(&metrics.FramesReceived),
|
||||
FramesDropped: atomic.LoadInt64(&metrics.FramesDropped),
|
||||
FramesReceived: framesReceived,
|
||||
FramesDropped: framesDropped,
|
||||
BytesProcessed: atomic.LoadInt64(&metrics.BytesProcessed),
|
||||
LastFrameTime: metrics.LastFrameTime,
|
||||
ConnectionDrops: atomic.LoadInt64(&metrics.ConnectionDrops),
|
||||
|
|
|
@ -21,6 +21,8 @@ const (
|
|||
AudioEventMetricsUpdate AudioEventType = "audio-metrics-update"
|
||||
AudioEventMicrophoneState AudioEventType = "microphone-state-changed"
|
||||
AudioEventMicrophoneMetrics AudioEventType = "microphone-metrics-update"
|
||||
AudioEventProcessMetrics AudioEventType = "audio-process-metrics"
|
||||
AudioEventMicProcessMetrics AudioEventType = "microphone-process-metrics"
|
||||
)
|
||||
|
||||
// AudioEvent represents a WebSocket audio event
|
||||
|
@ -60,6 +62,17 @@ type MicrophoneMetricsData struct {
|
|||
AverageLatency string `json:"average_latency"`
|
||||
}
|
||||
|
||||
// ProcessMetricsData represents process metrics data for WebSocket events
|
||||
type ProcessMetricsData struct {
|
||||
PID int `json:"pid"`
|
||||
CPUPercent float64 `json:"cpu_percent"`
|
||||
MemoryRSS int64 `json:"memory_rss"`
|
||||
MemoryVMS int64 `json:"memory_vms"`
|
||||
MemoryPercent float64 `json:"memory_percent"`
|
||||
Running bool `json:"running"`
|
||||
ProcessName string `json:"process_name"`
|
||||
}
|
||||
|
||||
// AudioEventSubscriber represents a WebSocket connection subscribed to audio events
|
||||
type AudioEventSubscriber struct {
|
||||
conn *websocket.Conn
|
||||
|
@ -220,6 +233,25 @@ func (aeb *AudioEventBroadcaster) sendCurrentMetrics(subscriber *AudioEventSubsc
|
|||
}
|
||||
aeb.sendToSubscriber(subscriber, audioMetricsEvent)
|
||||
|
||||
// Send audio process metrics
|
||||
if outputSupervisor := GetAudioOutputSupervisor(); outputSupervisor != nil {
|
||||
if processMetrics := outputSupervisor.GetProcessMetrics(); processMetrics != nil {
|
||||
audioProcessEvent := AudioEvent{
|
||||
Type: AudioEventProcessMetrics,
|
||||
Data: ProcessMetricsData{
|
||||
PID: processMetrics.PID,
|
||||
CPUPercent: processMetrics.CPUPercent,
|
||||
MemoryRSS: processMetrics.MemoryRSS,
|
||||
MemoryVMS: processMetrics.MemoryVMS,
|
||||
MemoryPercent: processMetrics.MemoryPercent,
|
||||
Running: outputSupervisor.IsRunning(),
|
||||
ProcessName: processMetrics.ProcessName,
|
||||
},
|
||||
}
|
||||
aeb.sendToSubscriber(subscriber, audioProcessEvent)
|
||||
}
|
||||
}
|
||||
|
||||
// Send microphone metrics using session provider
|
||||
sessionProvider := GetSessionProvider()
|
||||
if sessionProvider.IsSessionActive() {
|
||||
|
@ -239,12 +271,31 @@ func (aeb *AudioEventBroadcaster) sendCurrentMetrics(subscriber *AudioEventSubsc
|
|||
aeb.sendToSubscriber(subscriber, micMetricsEvent)
|
||||
}
|
||||
}
|
||||
|
||||
// Send microphone process metrics
|
||||
if inputSupervisor := GetAudioInputIPCSupervisor(); inputSupervisor != nil {
|
||||
if processMetrics := inputSupervisor.GetProcessMetrics(); processMetrics != nil {
|
||||
micProcessEvent := AudioEvent{
|
||||
Type: AudioEventMicProcessMetrics,
|
||||
Data: ProcessMetricsData{
|
||||
PID: processMetrics.PID,
|
||||
CPUPercent: processMetrics.CPUPercent,
|
||||
MemoryRSS: processMetrics.MemoryRSS,
|
||||
MemoryVMS: processMetrics.MemoryVMS,
|
||||
MemoryPercent: processMetrics.MemoryPercent,
|
||||
Running: inputSupervisor.IsRunning(),
|
||||
ProcessName: processMetrics.ProcessName,
|
||||
},
|
||||
}
|
||||
aeb.sendToSubscriber(subscriber, micProcessEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startMetricsBroadcasting starts a goroutine that periodically broadcasts metrics
|
||||
func (aeb *AudioEventBroadcaster) startMetricsBroadcasting() {
|
||||
// Use 5-second interval instead of 2 seconds for constrained environments
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
// Use 1-second interval to match Connection Stats sidebar frequency for smooth histogram progression
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
|
@ -311,6 +362,44 @@ func (aeb *AudioEventBroadcaster) startMetricsBroadcasting() {
|
|||
aeb.broadcast(micMetricsEvent)
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast audio process metrics
|
||||
if outputSupervisor := GetAudioOutputSupervisor(); outputSupervisor != nil {
|
||||
if processMetrics := outputSupervisor.GetProcessMetrics(); processMetrics != nil {
|
||||
audioProcessEvent := AudioEvent{
|
||||
Type: AudioEventProcessMetrics,
|
||||
Data: ProcessMetricsData{
|
||||
PID: processMetrics.PID,
|
||||
CPUPercent: processMetrics.CPUPercent,
|
||||
MemoryRSS: processMetrics.MemoryRSS,
|
||||
MemoryVMS: processMetrics.MemoryVMS,
|
||||
MemoryPercent: processMetrics.MemoryPercent,
|
||||
Running: outputSupervisor.IsRunning(),
|
||||
ProcessName: processMetrics.ProcessName,
|
||||
},
|
||||
}
|
||||
aeb.broadcast(audioProcessEvent)
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast microphone process metrics
|
||||
if inputSupervisor := GetAudioInputIPCSupervisor(); inputSupervisor != nil {
|
||||
if processMetrics := inputSupervisor.GetProcessMetrics(); processMetrics != nil {
|
||||
micProcessEvent := AudioEvent{
|
||||
Type: AudioEventMicProcessMetrics,
|
||||
Data: ProcessMetricsData{
|
||||
PID: processMetrics.PID,
|
||||
CPUPercent: processMetrics.CPUPercent,
|
||||
MemoryRSS: processMetrics.MemoryRSS,
|
||||
MemoryVMS: processMetrics.MemoryVMS,
|
||||
MemoryPercent: processMetrics.MemoryPercent,
|
||||
Running: inputSupervisor.IsRunning(),
|
||||
ProcessName: processMetrics.ProcessName,
|
||||
},
|
||||
}
|
||||
aeb.broadcast(micProcessEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -10,11 +10,6 @@ import (
|
|||
"github.com/jetkvm/kvm/internal/logging"
|
||||
)
|
||||
|
||||
// IsAudioInputServerProcess detects if we're running as the audio input server subprocess
|
||||
func IsAudioInputServerProcess() bool {
|
||||
return os.Getenv("JETKVM_AUDIO_INPUT_SERVER") == "true"
|
||||
}
|
||||
|
||||
// RunAudioInputServer runs the audio input server subprocess
|
||||
// This should be called from main() when the subprocess is detected
|
||||
func RunAudioInputServer() error {
|
||||
|
|
|
@ -53,10 +53,9 @@ func (ais *AudioInputSupervisor) Start() error {
|
|||
}
|
||||
|
||||
// Create command for audio input server subprocess
|
||||
cmd := exec.CommandContext(ctx, execPath)
|
||||
cmd := exec.CommandContext(ctx, execPath, "--audio-input-server")
|
||||
cmd.Env = append(os.Environ(),
|
||||
"JETKVM_AUDIO_INPUT_SERVER=true", // Flag to indicate this is the input server process
|
||||
"JETKVM_AUDIO_INPUT_IPC=true", // Enable IPC mode
|
||||
"JETKVM_AUDIO_INPUT_IPC=true", // Enable IPC mode
|
||||
)
|
||||
|
||||
// Set process group to allow clean termination
|
||||
|
|
|
@ -208,6 +208,10 @@ func (pm *ProcessMonitor) collectMetrics(pid int, state *processState) (ProcessM
|
|||
|
||||
if timeDelta > 0 {
|
||||
metric.CPUPercent = (cpuSeconds / timeDelta) * 100.0
|
||||
// Cap CPU percentage at 100% to handle multi-core usage
|
||||
if metric.CPUPercent > 100.0 {
|
||||
metric.CPUPercent = 100.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -249,6 +253,11 @@ func (pm *ProcessMonitor) getTotalMemory() int64 {
|
|||
return 0
|
||||
}
|
||||
|
||||
// GetTotalMemory returns total system memory in bytes (public method)
|
||||
func (pm *ProcessMonitor) GetTotalMemory() int64 {
|
||||
return pm.getTotalMemory()
|
||||
}
|
||||
|
||||
// Global process monitor instance
|
||||
var globalProcessMonitor *ProcessMonitor
|
||||
var processMonitorOnce sync.Once
|
||||
|
|
19
main.go
19
main.go
|
@ -63,6 +63,9 @@ func startAudioSubprocess() error {
|
|||
// Create audio server supervisor
|
||||
audioSupervisor = audio.NewAudioServerSupervisor()
|
||||
|
||||
// Set the global supervisor for access from audio package
|
||||
audio.SetAudioOutputSupervisor(audioSupervisor)
|
||||
|
||||
// Set up callbacks for process lifecycle events
|
||||
audioSupervisor.SetCallbacks(
|
||||
// onProcessStart
|
||||
|
@ -112,7 +115,7 @@ func startAudioSubprocess() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func Main(audioServer bool) {
|
||||
func Main(audioServer bool, audioInputServer bool) {
|
||||
// Initialize channel and set audio server flag
|
||||
isAudioServer = audioServer
|
||||
audioProcessDone = make(chan struct{})
|
||||
|
@ -124,7 +127,7 @@ func Main(audioServer bool) {
|
|||
}
|
||||
|
||||
// If running as audio input server, only initialize audio input processing
|
||||
if audio.IsAudioInputServerProcess() {
|
||||
if audioInputServer {
|
||||
err := audio.RunAudioInputServer()
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("audio input server failed")
|
||||
|
@ -209,6 +212,14 @@ func Main(audioServer bool) {
|
|||
audio.InitializeAudioEventBroadcaster()
|
||||
logger.Info().Msg("audio event broadcaster initialized")
|
||||
|
||||
// Start audio input system for microphone processing
|
||||
err = audio.StartAudioInput()
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Msg("failed to start audio input system")
|
||||
} else {
|
||||
logger.Info().Msg("audio input system started")
|
||||
}
|
||||
|
||||
if err := setInitialVirtualMediaState(); err != nil {
|
||||
logger.Warn().Err(err).Msg("failed to set initial virtual media state")
|
||||
}
|
||||
|
@ -261,6 +272,10 @@ func Main(audioServer bool) {
|
|||
|
||||
// Stop audio subprocess and wait for cleanup
|
||||
if !isAudioServer {
|
||||
// Stop audio input system
|
||||
logger.Info().Msg("stopping audio input system")
|
||||
audio.StopAudioInput()
|
||||
|
||||
if audioSupervisor != nil {
|
||||
logger.Info().Msg("stopping audio supervisor")
|
||||
if err := audioSupervisor.Stop(); err != nil {
|
||||
|
|
|
@ -3,6 +3,7 @@ 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";
|
||||
|
@ -50,28 +51,165 @@ const qualityLabels = {
|
|||
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 (error) {
|
||||
console.warn('Failed to fetch system memory, using default:', error);
|
||||
}
|
||||
};
|
||||
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
|
||||
const [audioProcessMetrics, setAudioProcessMetrics] = useState<ProcessMetrics | null>(null);
|
||||
const [microphoneProcessMetrics, setMicrophoneProcessMetrics] = useState<ProcessMetrics | null>(null);
|
||||
// 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 histograms (last 60 data points, ~1 minute at 1s intervals)
|
||||
const [audioCpuHistory, setAudioCpuHistory] = useState<number[]>([]);
|
||||
const [audioMemoryHistory, setAudioMemoryHistory] = useState<number[]>([]);
|
||||
const [micCpuHistory, setMicCpuHistory] = useState<number[]>([]);
|
||||
const [micMemoryHistory, setMicMemoryHistory] = useState<number[]>([]);
|
||||
// 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);
|
||||
|
@ -81,6 +219,8 @@ export default function AudioMetricsDashboard() {
|
|||
// 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
|
||||
|
@ -147,17 +287,37 @@ export default function AudioMetricsDashboard() {
|
|||
const audioProcessResp = await api.GET("/audio/process-metrics");
|
||||
if (audioProcessResp.ok) {
|
||||
const audioProcessData = await audioProcessResp.json();
|
||||
setAudioProcessMetrics(audioProcessData);
|
||||
setFallbackAudioProcessMetrics(audioProcessData);
|
||||
|
||||
// Update historical data for histograms (keep last 60 points)
|
||||
// Update historical data for charts (keep last 120 seconds)
|
||||
if (audioProcessData.running) {
|
||||
setAudioCpuHistory(prev => {
|
||||
const newHistory = [...prev, audioProcessData.cpu_percent];
|
||||
return newHistory.slice(-60); // Keep last 60 data points
|
||||
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;
|
||||
});
|
||||
setAudioMemoryHistory(prev => {
|
||||
const newHistory = [...prev, audioProcessData.memory_percent];
|
||||
return newHistory.slice(-60);
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -182,19 +342,37 @@ export default function AudioMetricsDashboard() {
|
|||
const micProcessResp = await api.GET("/microphone/process-metrics");
|
||||
if (micProcessResp.ok) {
|
||||
const micProcessData = await micProcessResp.json();
|
||||
setMicrophoneProcessMetrics(micProcessData);
|
||||
setFallbackMicrophoneProcessMetrics(micProcessData);
|
||||
|
||||
// Update historical data for histograms (keep last 60 points)
|
||||
if (micProcessData.running) {
|
||||
setMicCpuHistory(prev => {
|
||||
const newHistory = [...prev, micProcessData.cpu_percent];
|
||||
return newHistory.slice(-60); // Keep last 60 data points
|
||||
});
|
||||
setMicMemoryHistory(prev => {
|
||||
const newHistory = [...prev, micProcessData.memory_percent];
|
||||
return newHistory.slice(-60);
|
||||
});
|
||||
}
|
||||
// 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 (micProcessError) {
|
||||
console.debug("Microphone process metrics not available:", micProcessError);
|
||||
|
@ -222,15 +400,7 @@ export default function AudioMetricsDashboard() {
|
|||
return ((metrics.frames_dropped / metrics.frames_received) * 100);
|
||||
};
|
||||
|
||||
const formatMemory = (bytes: number) => {
|
||||
if (bytes === 0) return "0 MB";
|
||||
const mb = bytes / (1024 * 1024);
|
||||
if (mb < 1024) {
|
||||
return `${mb.toFixed(1)} MB`;
|
||||
}
|
||||
const gb = mb / 1024;
|
||||
return `${gb.toFixed(2)} GB`;
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
@ -244,53 +414,6 @@ export default function AudioMetricsDashboard() {
|
|||
}
|
||||
};
|
||||
|
||||
// Histogram component for displaying historical data
|
||||
const Histogram = ({ data, title, unit, color }: {
|
||||
data: number[],
|
||||
title: string,
|
||||
unit: string,
|
||||
color: string
|
||||
}) => {
|
||||
if (data.length === 0) return null;
|
||||
|
||||
const maxValue = Math.max(...data, 1); // Avoid division by zero
|
||||
const minValue = Math.min(...data);
|
||||
const range = maxValue - minValue;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{title}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{data.length > 0 ? `${data[data.length - 1].toFixed(1)}${unit}` : `0${unit}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-end gap-0.5 h-16 bg-slate-50 dark:bg-slate-800 rounded p-2">
|
||||
{data.slice(-30).map((value, index) => { // Show last 30 points
|
||||
const height = range > 0 ? ((value - minValue) / range) * 100 : 0;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cx(
|
||||
"flex-1 rounded-sm transition-all duration-200",
|
||||
color
|
||||
)}
|
||||
style={{ height: `${Math.max(height, 2)}%` }}
|
||||
title={`${value.toFixed(1)}${unit}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-slate-400 dark:text-slate-500">
|
||||
<span>{minValue.toFixed(1)}{unit}</span>
|
||||
<span>{maxValue.toFixed(1)}{unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
|
@ -405,30 +528,41 @@ export default function AudioMetricsDashboard() {
|
|||
)} />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Histogram
|
||||
data={audioCpuHistory}
|
||||
title="CPU Usage"
|
||||
unit="%"
|
||||
color="bg-blue-500 dark:bg-blue-400"
|
||||
/>
|
||||
<Histogram
|
||||
data={audioMemoryHistory}
|
||||
title="Memory Usage"
|
||||
unit="%"
|
||||
color="bg-purple-500 dark:bg-purple-400"
|
||||
/>
|
||||
<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">
|
||||
{formatMemory(audioProcessMetrics.memory_rss)}
|
||||
{formatPercentage(audioProcessMetrics.cpu_percent)}
|
||||
</div>
|
||||
<div className="text-slate-500 dark:text-slate-400">RSS</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">
|
||||
{formatMemory(audioProcessMetrics.memory_vms)}
|
||||
{formatMemoryMB(audioProcessMetrics.memory_rss)}
|
||||
</div>
|
||||
<div className="text-slate-500 dark:text-slate-400">VMS</div>
|
||||
<div className="text-slate-500 dark:text-slate-400">Memory</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -449,30 +583,41 @@ export default function AudioMetricsDashboard() {
|
|||
)} />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Histogram
|
||||
data={micCpuHistory}
|
||||
title="CPU Usage"
|
||||
unit="%"
|
||||
color="bg-green-500 dark:bg-green-400"
|
||||
/>
|
||||
<Histogram
|
||||
data={micMemoryHistory}
|
||||
title="Memory Usage"
|
||||
unit="%"
|
||||
color="bg-orange-500 dark:bg-orange-400"
|
||||
/>
|
||||
<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">
|
||||
{formatMemory(microphoneProcessMetrics.memory_rss)}
|
||||
{formatPercentage(microphoneProcessMetrics.cpu_percent)}
|
||||
</div>
|
||||
<div className="text-slate-500 dark:text-slate-400">RSS</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">
|
||||
{formatMemory(microphoneProcessMetrics.memory_vms)}
|
||||
{formatMemoryMB(microphoneProcessMetrics.memory_rss)}
|
||||
</div>
|
||||
<div className="text-slate-500 dark:text-slate-400">VMS</div>
|
||||
<div className="text-slate-500 dark:text-slate-400">Memory</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -6,7 +6,9 @@ export type AudioEventType =
|
|||
| 'audio-mute-changed'
|
||||
| 'audio-metrics-update'
|
||||
| 'microphone-state-changed'
|
||||
| 'microphone-metrics-update';
|
||||
| 'microphone-metrics-update'
|
||||
| 'audio-process-metrics'
|
||||
| 'microphone-process-metrics';
|
||||
|
||||
// Audio event data interfaces
|
||||
export interface AudioMuteData {
|
||||
|
@ -36,10 +38,20 @@ export interface MicrophoneMetricsData {
|
|||
average_latency: string;
|
||||
}
|
||||
|
||||
export interface ProcessMetricsData {
|
||||
pid: number;
|
||||
cpu_percent: number;
|
||||
memory_rss: number;
|
||||
memory_vms: number;
|
||||
memory_percent: number;
|
||||
running: boolean;
|
||||
process_name: string;
|
||||
}
|
||||
|
||||
// Audio event structure
|
||||
export interface AudioEvent {
|
||||
type: AudioEventType;
|
||||
data: AudioMuteData | AudioMetricsData | MicrophoneStateData | MicrophoneMetricsData;
|
||||
data: AudioMuteData | AudioMetricsData | MicrophoneStateData | MicrophoneMetricsData | ProcessMetricsData;
|
||||
}
|
||||
|
||||
// Hook return type
|
||||
|
@ -56,6 +68,10 @@ export interface UseAudioEventsReturn {
|
|||
microphoneState: MicrophoneStateData | null;
|
||||
microphoneMetrics: MicrophoneMetricsData | null;
|
||||
|
||||
// Process metrics
|
||||
audioProcessMetrics: ProcessMetricsData | null;
|
||||
microphoneProcessMetrics: ProcessMetricsData | null;
|
||||
|
||||
// Manual subscription control
|
||||
subscribe: () => void;
|
||||
unsubscribe: () => void;
|
||||
|
@ -74,6 +90,8 @@ export function useAudioEvents(): UseAudioEventsReturn {
|
|||
const [audioMetrics, setAudioMetrics] = useState<AudioMetricsData | null>(null);
|
||||
const [microphoneState, setMicrophoneState] = useState<MicrophoneStateData | null>(null);
|
||||
const [microphoneMetrics, setMicrophoneMetricsData] = useState<MicrophoneMetricsData | null>(null);
|
||||
const [audioProcessMetrics, setAudioProcessMetrics] = useState<ProcessMetricsData | null>(null);
|
||||
const [microphoneProcessMetrics, setMicrophoneProcessMetrics] = useState<ProcessMetricsData | null>(null);
|
||||
|
||||
// Local subscription state
|
||||
const [isLocallySubscribed, setIsLocallySubscribed] = useState(false);
|
||||
|
@ -214,6 +232,18 @@ export function useAudioEvents(): UseAudioEventsReturn {
|
|||
break;
|
||||
}
|
||||
|
||||
case 'audio-process-metrics': {
|
||||
const audioProcessData = audioEvent.data as ProcessMetricsData;
|
||||
setAudioProcessMetrics(audioProcessData);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'microphone-process-metrics': {
|
||||
const micProcessData = audioEvent.data as ProcessMetricsData;
|
||||
setMicrophoneProcessMetrics(micProcessData);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
// Ignore other message types (WebRTC signaling, etc.)
|
||||
break;
|
||||
|
@ -275,6 +305,10 @@ export function useAudioEvents(): UseAudioEventsReturn {
|
|||
microphoneState,
|
||||
microphoneMetrics: microphoneMetrics,
|
||||
|
||||
// Process metrics
|
||||
audioProcessMetrics,
|
||||
microphoneProcessMetrics,
|
||||
|
||||
// Manual subscription control
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
|
|
10
web.go
10
web.go
|
@ -503,6 +503,16 @@ func setupRouter() *gin.Engine {
|
|||
})
|
||||
})
|
||||
|
||||
// System memory information endpoint
|
||||
protected.GET("/system/memory", func(c *gin.Context) {
|
||||
processMonitor := audio.GetProcessMonitor()
|
||||
totalMemory := processMonitor.GetTotalMemory()
|
||||
c.JSON(200, gin.H{
|
||||
"total_memory_bytes": totalMemory,
|
||||
"total_memory_mb": totalMemory / (1024 * 1024),
|
||||
})
|
||||
})
|
||||
|
||||
protected.POST("/microphone/reset", func(c *gin.Context) {
|
||||
if currentSession == nil {
|
||||
c.JSON(400, gin.H{"error": "no active session"})
|
||||
|
|
Loading…
Reference in New Issue