Files
manage/frontend/src/pages/Dashboard.tsx
T
2026-05-04 22:25:23 +02:00

375 lines
9.6 KiB
TypeScript

import { useMemo } from "react";
import {
Box,
Card,
CardContent,
Divider,
Grid,
LinearProgress,
Stack,
Typography,
} from "@mui/material";
import { useNavigate } from "react-router-dom";
import { useCounts, useLibraries, useActivity } from "../hooks/useDashboard";
import { useMonitoringMetrics, useDiskSpace } from "../hooks/useMonitoring";
import { NowPlaying } from "../components/NowPlaying";
import { MetricCard } from "../components/MetricCard";
import { LibraryOverview } from "../components/LibraryOverview";
function formatBytes(bytes: number): string {
if (!bytes || bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unitIdx = 0;
while (value >= 1000 && unitIdx < units.length - 1) {
value /= 1000;
unitIdx++;
}
return `${value.toFixed(1)} ${units[unitIdx]}`;
}
function formatRate(bytes: number): string {
return `${formatBytes(bytes)}/s`;
}
function formatPct(value: number): string {
return `${value.toFixed(1)}%`;
}
function summarize(values: number[]) {
if (values.length === 0) return null;
const total = values.reduce((sum, value) => sum + value, 0);
return {
avg: total / values.length,
min: Math.min(...values),
max: Math.max(...values),
};
}
function DiskSpaceCard({
used,
available,
size,
usedPct,
}: {
used: number;
available: number;
size: number;
usedPct: string;
}) {
const pct = Math.max(0, Math.min(100, Number.parseFloat(usedPct) || 0));
const barColor = pct < 70 ? "success" : pct < 90 ? "warning" : "error";
return (
<Card variant="outlined" sx={{ height: "100%" }}>
<CardContent sx={{ p: { xs: 1.5, sm: 2 } }}>
<Stack spacing={1.5}>
<Box>
<Typography
variant="caption"
color="text.secondary"
sx={{ textTransform: "uppercase" }}
>
Disk space
</Typography>
<Typography
variant="h5"
sx={{
fontWeight: 700,
fontSize: { xs: "1.05rem", sm: "1.5rem" },
}}
>
{usedPct} used
</Typography>
</Box>
<Box sx={{ width: "100%" }}>
<LinearProgress
variant="determinate"
value={pct}
color={barColor}
sx={{
height: 12,
borderRadius: 999,
bgcolor: "action.hover",
"& .MuiLinearProgress-bar": {
borderRadius: 999,
},
}}
/>
</Box>
<Grid container spacing={1.5} sx={{ alignItems: "stretch" }}>
<Grid size={{ xs: 12, sm: 4 }}>
<Box
sx={{
p: 1.5,
borderRadius: 2,
bgcolor: "action.hover",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
gap: 0.25,
}}
>
<Typography variant="caption" color="text.secondary">
Used
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{formatBytes(used)}
</Typography>
</Box>
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<Box
sx={{
p: 1.5,
borderRadius: 2,
bgcolor: "action.hover",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
gap: 0.25,
}}
>
<Typography variant="caption" color="text.secondary">
Free
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{formatBytes(available)}
</Typography>
</Box>
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<Box
sx={{
p: 1.5,
borderRadius: 2,
bgcolor: "action.hover",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
gap: 0.25,
}}
>
<Typography variant="caption" color="text.secondary">
Total
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{formatBytes(size)}
</Typography>
</Box>
</Grid>
</Grid>
</Stack>
</CardContent>
</Card>
);
}
export function Dashboard() {
const navigate = useNavigate();
const { data: counts } = useCounts();
const { data: libraries } = useLibraries();
const { data: activity } = useActivity();
const { data: metrics } = useMonitoringMetrics();
const { data: disk } = useDiskSpace();
const monitoringWindow = useMemo(() => {
const samples = metrics?.samples ?? [];
if (samples.length === 0) return [];
const latestTs = samples.at(-1)?.ts ?? 0;
const windowStart = latestTs - 10 * 60;
const windowed = samples.filter((sample) => sample.ts >= windowStart);
return windowed.length > 0 ? windowed : samples;
}, [metrics?.samples]);
const cpuSummary = summarize(
monitoringWindow.map((sample) => sample.cpu_pct),
);
const iowaitSummary = summarize(
monitoringWindow
.map((sample) => sample.iowait_pct)
.filter((value): value is number => value !== undefined),
);
const memSummary = summarize(
monitoringWindow.map((sample) => sample.mem_pct),
);
const netRxSummary = summarize(
monitoringWindow.map((sample) => sample.net_rx_bytes_per_sec),
);
const netTxSummary = summarize(
monitoringWindow.map((sample) => sample.net_tx_bytes_per_sec),
);
const diskReadSummary = summarize(
monitoringWindow.map((sample) => sample.disk_read_bps),
);
const diskWriteSummary = summarize(
monitoringWindow.map((sample) => sample.disk_write_bps),
);
return (
<Stack spacing={3}>
<Box>
<Typography variant="h5" sx={{ mb: 1.5 }}>
Activity
</Typography>
{activity && (
<NowPlaying
sessions={activity}
onSelectSession={(session) =>
navigate(`/users?user=${encodeURIComponent(session.user)}`)
}
/>
)}
</Box>
<Divider />
<Box>
<Typography variant="h5" sx={{ mb: 1.5 }}>
Monitoring Overview
</Typography>
<Grid container spacing={2} sx={{ alignItems: "stretch" }}>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="CPU (10m avg)"
value={cpuSummary ? formatPct(cpuSummary.avg) : "-"}
subtext={
cpuSummary
? `High: ${formatPct(cpuSummary.max)}\nLow: ${formatPct(cpuSummary.min)}`
: undefined
}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="IO Wait (10m avg)"
value={iowaitSummary ? formatPct(iowaitSummary.avg) : "-"}
subtext={
iowaitSummary
? `High: ${formatPct(iowaitSummary.max)}\nLow: ${formatPct(iowaitSummary.min)}`
: undefined
}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="RAM (10m avg)"
value={memSummary ? formatPct(memSummary.avg) : "-"}
subtext={
memSummary
? `High: ${formatPct(memSummary.max)}\nLow: ${formatPct(memSummary.min)}`
: undefined
}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="Net down (10m avg)"
value={netRxSummary ? formatRate(netRxSummary.avg) : "-"}
subtext={
netRxSummary
? `High: ${formatRate(netRxSummary.max)}\nLow: ${formatRate(netRxSummary.min)}`
: undefined
}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="Net up (10m avg)"
value={netTxSummary ? formatRate(netTxSummary.avg) : "-"}
subtext={
netTxSummary
? `High: ${formatRate(netTxSummary.max)}\nLow: ${formatRate(netTxSummary.min)}`
: undefined
}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="Disk read (10m avg)"
value={diskReadSummary ? formatRate(diskReadSummary.avg) : "-"}
subtext={
diskReadSummary
? `High: ${formatRate(diskReadSummary.max)}\nLow: ${formatRate(diskReadSummary.min)}`
: undefined
}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="Disk write (10m avg)"
value={diskWriteSummary ? formatRate(diskWriteSummary.avg) : "-"}
subtext={
diskWriteSummary
? `High: ${formatRate(diskWriteSummary.max)}\nLow: ${formatRate(diskWriteSummary.min)}`
: undefined
}
/>
</Grid>
</Grid>
{disk && (
<Box sx={{ mt: 0.5 }}>
<DiskSpaceCard
used={disk.used}
available={disk.available}
size={disk.size}
usedPct={disk.used_pct}
/>
</Box>
)}
</Box>
<Divider />
<Box>
<Typography variant="h5" sx={{ mb: 1.5 }}>
Library Stats
</Typography>
{counts && (
<Grid container spacing={2} sx={{ mb: 2, alignItems: "stretch" }}>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard
label="Total"
value={(
counts.movies +
counts.series +
counts.episodes
).toLocaleString()}
/>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard
label="Movies"
value={counts.movies.toLocaleString()}
/>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard
label="Series"
value={counts.series.toLocaleString()}
/>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard
label="Episodes"
value={counts.episodes.toLocaleString()}
/>
</Grid>
</Grid>
)}
{libraries && <LibraryOverview libraries={libraries} />}
</Box>
</Stack>
);
}