375 lines
9.6 KiB
TypeScript
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>
|
|
);
|
|
}
|