diff --git a/apps/mobile/src/components/server/ServerResourceCard.tsx b/apps/mobile/src/components/server/ServerResourceCard.tsx index 39174eada..dd192e3a0 100644 --- a/apps/mobile/src/components/server/ServerResourceCard.tsx +++ b/apps/mobile/src/components/server/ServerResourceCard.tsx @@ -1,5 +1,5 @@ /** - * Server resource monitoring card (CPU + RAM) + * Server resource monitoring card (CPU + RAM + Network) * Displays real-time server resource utilization with progress bars * Note: Section header is rendered by parent - this is just the card content * @@ -117,6 +117,9 @@ interface ServerResourceCardProps { processCpu: number; hostMemory: number; processMemory: number; + totalBandwidth?: number; + lanBandwidth?: number; + wanBandwidth?: number; } | null; isLoading?: boolean; error?: Error | null; diff --git a/apps/mobile/src/hooks/useServerStatistics.ts b/apps/mobile/src/hooks/useServerStatistics.ts index 82406cd68..9420e7ee4 100644 --- a/apps/mobile/src/hooks/useServerStatistics.ts +++ b/apps/mobile/src/hooks/useServerStatistics.ts @@ -1,5 +1,5 @@ /** - * Hook for fetching server resource statistics (CPU/RAM) + * Hook for fetching server resource statistics (CPU/RAM/Network) * Only polls when: * 1. App is in foreground (AppState === 'active') * 2. Dashboard tab is focused (useIsFocused) @@ -49,7 +49,9 @@ export function useServerStatistics(serverId: string | undefined, enabled: boole // Add/update data points for (const point of newData) { - map.set(point.at, point); + if (!map.has(point.at)) { + map.set(point.at, point); + } } // Sort by timestamp descending (newest first), keep DATA_POINTS @@ -103,6 +105,19 @@ export function useServerStatistics(serverId: string | undefined, enabled: boole processMemory: Math.round( dataPoints.reduce((sum: number, p) => sum + p.processMemoryUtilization, 0) / dataLength ), + totalBandwidth: + Math.round( + (dataPoints.reduce((sum: number, p) => sum + p.totalBandwidthMbps, 0) / dataLength) * + 10 + ) / 10, + lanBandwidth: + Math.round( + (dataPoints.reduce((sum: number, p) => sum + p.lanBandwidthMbps, 0) / dataLength) * 10 + ) / 10, + wanBandwidth: + Math.round( + (dataPoints.reduce((sum: number, p) => sum + p.wanBandwidthMbps, 0) / dataLength) * 10 + ) / 10, } : null; @@ -114,6 +129,9 @@ export function useServerStatistics(serverId: string | undefined, enabled: boole processCpu: Math.round(lastDataPoint.processCpuUtilization), hostMemory: Math.round(lastDataPoint.hostMemoryUtilization), processMemory: Math.round(lastDataPoint.processMemoryUtilization), + totalBandwidth: Math.round(lastDataPoint.totalBandwidthMbps * 10) / 10, + lanBandwidth: Math.round(lastDataPoint.lanBandwidthMbps * 10) / 10, + wanBandwidth: Math.round(lastDataPoint.wanBandwidthMbps * 10) / 10, } : null; diff --git a/apps/server/src/routes/servers.ts b/apps/server/src/routes/servers.ts index fabda6da3..dc9661435 100644 --- a/apps/server/src/routes/servers.ts +++ b/apps/server/src/routes/servers.ts @@ -10,6 +10,39 @@ import { servers } from '../db/schema.js'; // Token encryption removed - tokens now stored in plain text (DB is localhost-only) import { PlexClient, JellyfinClient, EmbyClient } from '../services/mediaServer/index.js'; import { syncServer } from '../services/sync.js'; +import type { MediaSession } from '../services/mediaServer/types.js'; +import { isPrivateIP } from '../jobs/poller/utils.js'; + +function calculateBandwidthMbps(sessions: MediaSession[]): { + totalMbps: number; + lanMbps: number; + wanMbps: number; +} { + let lanKbps = 0; + let wanKbps = 0; + + for (const session of sessions) { + // Skip sessions that aren't actively playing (paused/stopped won't consume bandwidth) + if (session.playback.state !== 'playing') continue; + + const bitrateKbps = session.quality.bitrate; + if (!bitrateKbps || bitrateKbps <= 0) continue; + + if (isPrivateIP(session.network.ipAddress)) { + lanKbps += bitrateKbps; + } else { + wanKbps += bitrateKbps; + } + } + + const toMbps = (kbps: number) => Math.round((kbps / 1000) * 10) / 10; + + return { + lanMbps: toMbps(lanKbps), + wanMbps: toMbps(wanKbps), + totalMbps: toMbps(lanKbps + wanKbps), + }; +} export const serverRoutes: FastifyPluginAsync = async (app) => { /** @@ -248,11 +281,23 @@ export const serverRoutes: FastifyPluginAsync = async (app) => { token: server.token, }); - const data = await client.getServerStatistics(SERVER_STATS_CONFIG.TIMESPAN_SECONDS); + const [resourceStats, sessions] = await Promise.all([ + client.getServerStatistics(SERVER_STATS_CONFIG.TIMESPAN_SECONDS), + client.getSessions(), + ]); + + const bandwidth = calculateBandwidthMbps(sessions); + + const data = resourceStats.map((point) => ({ + ...point, + totalBandwidthMbps: bandwidth.totalMbps, + lanBandwidthMbps: bandwidth.lanMbps, + wanBandwidthMbps: bandwidth.wanMbps, + })); // DEBUG: Log what we got back app.log.info( - { serverId: id, dataLength: data.length, firstItem: data[0] }, + { serverId: id, dataLength: data.length, bandwidth, firstItem: data[0] }, 'Server statistics fetched' ); diff --git a/apps/server/src/services/mediaServer/plex/parser.ts b/apps/server/src/services/mediaServer/plex/parser.ts index 05c35f9fa..3570aded3 100644 --- a/apps/server/src/services/mediaServer/plex/parser.ts +++ b/apps/server/src/services/mediaServer/plex/parser.ts @@ -574,6 +574,9 @@ export interface PlexStatisticsDataPoint { processCpuUtilization: number; hostMemoryUtilization: number; processMemoryUtilization: number; + totalBandwidthMbps: number; + lanBandwidthMbps: number; + wanBandwidthMbps: number; } /** @@ -587,6 +590,10 @@ function parseStatisticsDataPoint(raw: PlexRawStatisticsResource): PlexStatistic processCpuUtilization: parseNumber(raw.processCpuUtilization, 0), hostMemoryUtilization: parseNumber(raw.hostMemoryUtilization, 0), processMemoryUtilization: parseNumber(raw.processMemoryUtilization, 0), + // Network bandwidth is computed server-side using active sessions + totalBandwidthMbps: 0, + lanBandwidthMbps: 0, + wanBandwidthMbps: 0, }; } diff --git a/apps/web/src/components/charts/ServerResourceCharts.tsx b/apps/web/src/components/charts/ServerResourceCharts.tsx index 1bdd36b9c..1de87fabb 100644 --- a/apps/web/src/components/charts/ServerResourceCharts.tsx +++ b/apps/web/src/components/charts/ServerResourceCharts.tsx @@ -4,7 +4,7 @@ import HighchartsReact from 'highcharts-react-official'; import type { ServerResourceDataPoint } from '@tracearr/shared'; import { ChartSkeleton } from '@/components/ui/skeleton'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Cpu, MemoryStick } from 'lucide-react'; +import { Cpu, MemoryStick, Network } from 'lucide-react'; // Colors matching Plex's style const COLORS = { @@ -24,6 +24,9 @@ interface ServerResourceChartsProps { processCpu: number; hostMemory: number; processMemory: number; + totalBandwidth: number; + lanBandwidth: number; + wanBandwidth: number; } | null; } @@ -38,6 +41,13 @@ interface ResourceChartProps { isLoading?: boolean; } +interface BandwidthChartProps { + data: ServerResourceDataPoint[] | undefined; + isLoading?: boolean; + lanAvg?: number; + wanAvg?: number; +} + // Static x-axis labels (7 ticks at 20s intervals over 2 minutes) const X_LABELS: Record = { [-120]: '2m', @@ -319,12 +329,229 @@ function ResourceChart({ } /** - * Server resource monitoring charts (CPU + RAM) + * Network bandwidth chart (LAN vs WAN) + * Mirrors ResourceChart styling but formats Mbps values + */ +function BandwidthChart({ data, isLoading, lanAvg, wanAvg }: BandwidthChartProps) { + const chartOptions = useMemo(() => { + if (!data || data.length === 0) { + return {}; + } + + const lanData: [number, number][] = []; + const wanData: [number, number][] = []; + + const n = data.length; + for (let i = 0; i < n; i++) { + const point = data[i]; + if (!point) continue; + const x = n === 1 ? 0 : -120 + (i * 120) / (n - 1); + lanData.push([x, point.lanBandwidthMbps]); + wanData.push([x, point.wanBandwidthMbps]); + } + + const allValues = [...lanData, ...wanData].map(([, y]) => y); + const maxValue = Math.max(...allValues, 0); + const yMax = Math.max(5, Math.ceil(maxValue / 5) * 5); + + return { + chart: { + type: 'area', + height: 180, + backgroundColor: 'transparent', + style: { + fontFamily: 'inherit', + }, + spacing: [10, 10, 15, 10], + reflow: true, + }, + title: { text: undefined }, + credits: { enabled: false }, + legend: { + enabled: true, + align: 'left', + verticalAlign: 'top', + itemStyle: { + color: 'hsl(var(--muted-foreground))', + fontWeight: 'normal', + fontSize: '11px', + }, + itemHoverStyle: { + color: 'hsl(var(--foreground))', + }, + }, + xAxis: { + type: 'linear', + min: -120, + max: 0, + tickInterval: 20, + labels: { + style: { color: 'hsl(var(--muted-foreground))', fontSize: '10px' }, + formatter: function () { + return X_LABELS[this.value as number] || ''; + }, + }, + lineColor: 'hsl(var(--border))', + tickColor: 'hsl(var(--border))', + }, + yAxis: { + title: { text: undefined }, + labels: { + style: { color: 'hsl(var(--muted-foreground))', fontSize: '10px' }, + formatter: function () { + return `${(this.value as number).toFixed(1)} Mbps`; + }, + }, + gridLineColor: 'hsl(var(--border) / 0.5)', + min: 0, + max: yMax, + tickInterval: yMax <= 10 ? 1 : 5, + }, + plotOptions: { + area: { + marker: { + enabled: false, + states: { + hover: { enabled: true, radius: 3 }, + }, + }, + lineWidth: 2, + states: { hover: { lineWidth: 2 } }, + threshold: null, + connectNulls: false, + }, + }, + tooltip: { + shared: true, + backgroundColor: 'hsl(var(--popover))', + borderColor: 'hsl(var(--border))', + style: { color: 'hsl(var(--popover-foreground))', fontSize: '11px' }, + formatter: function () { + const points = this.points || []; + let html = ''; + for (const point of points) { + if (point.y !== null) { + const color = point.series.color; + html += ` ${point.series.name}: ${(point.y as number).toFixed(1)} Mbps
`; + } + } + return html; + }, + }, + series: [ + { + type: 'area', + name: 'LAN', + data: lanData, + color: COLORS.process, + fillColor: { + linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, + stops: [ + [0, COLORS.processGradientStart], + [1, COLORS.processGradientEnd], + ], + }, + }, + { + type: 'area', + name: 'WAN', + data: wanData, + color: COLORS.system, + fillColor: { + linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, + stops: [ + [0, COLORS.systemGradientStart], + [1, COLORS.systemGradientEnd], + ], + }, + }, + ], + }; + }, [data]); + + if (isLoading) { + return ( + + + + + + Network + + + + + + + + ); + } + + if (!data || data.length === 0) { + return ( + + + + + + Network + + + + +
+ No data available +
+
+
+ ); + } + + return ( + + + + + + Network + + + + + +
+ + Avg:{' '} + + {lanAvg != null ? `${lanAvg.toFixed(1)} Mbps` : '—'} + + + + Avg:{' '} + + {wanAvg != null ? `${wanAvg.toFixed(1)} Mbps` : '—'} + + +
+
+
+ ); +} + +/** + * Server resource monitoring charts (CPU + RAM + Network) * Displays real-time server resource utilization matching Plex's dashboard style */ export function ServerResourceCharts({ data, isLoading, averages }: ServerResourceChartsProps) { return ( -
+
} @@ -345,6 +572,12 @@ export function ServerResourceCharts({ data, isLoading, averages }: ServerResour hostAvg={averages?.hostMemory} isLoading={isLoading} /> +
); } diff --git a/apps/web/src/hooks/queries/useServers.ts b/apps/web/src/hooks/queries/useServers.ts index af049ed8a..3c2ffca97 100644 --- a/apps/web/src/hooks/queries/useServers.ts +++ b/apps/web/src/hooks/queries/useServers.ts @@ -95,7 +95,9 @@ export function useServerStatistics(serverId: string | undefined, enabled: boole // Add/update data points for (const point of newData) { - map.set(point.at, point); + if (!map.has(point.at)) { + map.set(point.at, point); + } } // Sort by timestamp descending (newest first), keep DATA_POINTS @@ -153,6 +155,19 @@ export function useServerStatistics(serverId: string | undefined, enabled: boole processMemory: Math.round( dataPoints.reduce((sum: number, p) => sum + p.processMemoryUtilization, 0) / dataLength ), + totalBandwidth: + Math.round( + (dataPoints.reduce((sum: number, p) => sum + p.totalBandwidthMbps, 0) / dataLength) * + 10 + ) / 10, + lanBandwidth: + Math.round( + (dataPoints.reduce((sum: number, p) => sum + p.lanBandwidthMbps, 0) / dataLength) * 10 + ) / 10, + wanBandwidth: + Math.round( + (dataPoints.reduce((sum: number, p) => sum + p.wanBandwidthMbps, 0) / dataLength) * 10 + ) / 10, } : null; diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index c4b6cf327..0c7257158 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -444,6 +444,9 @@ class ApiClient { processCpuUtilization: number; hostMemoryUtilization: number; processMemoryUtilization: number; + totalBandwidthMbps: number; + lanBandwidthMbps: number; + wanBandwidthMbps: number; }[]; fetchedAt: string; }>(`/servers/${id}/statistics`), diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index e8965387e..efe0d75ef 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -449,6 +449,12 @@ export interface ServerResourceDataPoint { hostMemoryUtilization: number; /** Plex process memory utilization percentage */ processMemoryUtilization: number; + /** Total active streaming bandwidth in Mbps (LAN + WAN) */ + totalBandwidthMbps: number; + /** Active streaming bandwidth from LAN/private clients in Mbps */ + lanBandwidthMbps: number; + /** Active streaming bandwidth from WAN/public clients in Mbps */ + wanBandwidthMbps: number; } export interface ServerResourceStats {