Phase 2: Docker and OIDC auth

This commit is contained in:
2026-05-04 13:50:53 +02:00
parent 47baee854b
commit 4226628d5a
71 changed files with 9722 additions and 1347 deletions
+207 -77
View File
@@ -1,4 +1,7 @@
import { useCounts, useLibraries, useNowPlaying } from "../hooks/useDashboard";
import { useMemo } from "react";
import { Box, Divider, Grid, 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";
@@ -20,96 +23,223 @@ 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),
};
}
export function Dashboard() {
const navigate = useNavigate();
const { data: counts } = useCounts();
const { data: libraries } = useLibraries();
const { data: nowPlaying } = useNowPlaying();
const { data: activity } = useActivity();
const { data: metrics } = useMonitoringMetrics();
const { data: disk } = useDiskSpace();
const latest = metrics?.samples?.at(-1);
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 (
<div className="space-y-8">
{/* Now Playing */}
<section>
<h2 className="text-lg font-semibold mb-3">Now playing</h2>
{nowPlaying && <NowPlaying sessions={nowPlaying} />}
</section>
<hr />
{/* Server Overview */}
<section>
<h2 className="text-lg font-semibold mb-3">Server overview</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3">
<MetricCard
label="CPU"
value={latest ? `${latest.cpu_pct.toFixed(1)}%` : "-"}
<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)}`)
}
/>
<MetricCard
label="IO Wait"
value={latest ? `${(latest.iowait_pct ?? 0).toFixed(1)}%` : "-"}
/>
<MetricCard
label="RAM"
value={latest ? `${latest.mem_pct.toFixed(1)}%` : "-"}
/>
<MetricCard
label="Net down"
value={latest ? formatRate(latest.net_rx_bytes_per_sec) : "-"}
/>
<MetricCard
label="Net up"
value={latest ? formatRate(latest.net_tx_bytes_per_sec) : "-"}
/>
<MetricCard
label="Disk read"
value={latest ? formatRate(latest.disk_read_bps) : "-"}
/>
<MetricCard
label="Disk write"
value={latest ? formatRate(latest.disk_write_bps) : "-"}
/>
</div>
{disk && (
<div className="mt-3 grid grid-cols-4 gap-3">
<MetricCard label="Disk used" value={formatBytes(disk.used)} />
<MetricCard
label="Disk available"
value={formatBytes(disk.available)}
/>
<MetricCard label="Disk total" value={formatBytes(disk.size)} />
<MetricCard label="Used %" value={disk.used_pct} />
</div>
)}
</section>
</Box>
<hr />
<Divider />
{/* Media Library Overview */}
<section>
<h2 className="text-lg font-semibold mb-3">Media library overview</h2>
<Box>
<Typography variant="h5" sx={{ mb: 1.5 }}>
Monitoring Overview
</Typography>
<Grid container spacing={1.5}>
<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 && (
<Grid container spacing={1.5} sx={{ mt: 0.5 }}>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard label="Disk used" value={formatBytes(disk.used)} />
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard
label="Disk available"
value={formatBytes(disk.available)}
/>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard label="Disk total" value={formatBytes(disk.size)} />
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard label="Used %" value={disk.used_pct} />
</Grid>
</Grid>
)}
</Box>
<Divider />
<Box>
<Typography variant="h5" sx={{ mb: 1.5 }}>
Library Stats
</Typography>
{counts && (
<div className="grid grid-cols-4 gap-3 mb-4">
<MetricCard
label="Total"
value={(
counts.movies +
counts.series +
counts.episodes
).toLocaleString()}
/>
<MetricCard label="Movies" value={counts.movies.toLocaleString()} />
<MetricCard label="Series" value={counts.series.toLocaleString()} />
<MetricCard
label="Episodes"
value={counts.episodes.toLocaleString()}
/>
</div>
<Grid container spacing={1.5} sx={{ mb: 2 }}>
<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} />}
</section>
</div>
</Box>
</Stack>
);
}
+671 -113
View File
@@ -1,5 +1,23 @@
import { useState, useCallback, useRef } from "react";
import { AgGridReact } from "ag-grid-react";
import { useState } from "react";
import { useSearchParams } from "react-router-dom";
import { DataGrid } from "@mui/x-data-grid";
import type { GridColDef, GridRowSelectionModel } from "@mui/x-data-grid";
import {
Alert,
Box,
Button,
Card,
CardContent,
Chip,
FormControl,
Grid,
InputLabel,
MenuItem,
Select,
Stack,
TextField,
Typography,
} from "@mui/material";
import {
useDirectoryListing,
useFfprobe,
@@ -8,6 +26,7 @@ import {
} from "../hooks/useFiles";
interface DisplayRow {
id: string;
type: string;
name: string;
ext: string;
@@ -16,6 +35,46 @@ interface DisplayRow {
path: string;
}
interface FfprobeStream {
index?: number;
codec_type?: string;
codec_name?: string;
codec_long_name?: string;
profile?: string;
width?: number;
height?: number;
bit_rate?: string | number;
duration?: string | number;
channels?: number;
sample_rate?: string | number;
channel_layout?: string;
pix_fmt?: string;
sample_aspect_ratio?: string;
display_aspect_ratio?: string;
field_order?: string;
level?: number | string;
color_range?: string;
color_space?: string;
color_transfer?: string;
color_primaries?: string;
tags?: Record<string, string>;
}
interface FfprobeFormat {
filename?: string;
format_name?: string;
format_long_name?: string;
duration?: string | number;
size?: string | number;
bit_rate?: string | number;
tags?: Record<string, string>;
}
interface FfprobeData {
format?: FfprobeFormat;
streams?: FfprobeStream[];
}
function formatSize(bytes: number): string {
if (bytes === 0) return "-";
const units = ["B", "KB", "MB", "GB", "TB"];
@@ -33,6 +92,45 @@ function formatTime(epoch: number): string {
return new Date(epoch * 1000).toLocaleString();
}
function humanBytes(value: string | number | undefined): string {
if (value === undefined || value === null || value === "") return "-";
const bytes = typeof value === "string" ? Number(value) : value;
if (!Number.isFinite(bytes)) return "-";
return formatSize(bytes);
}
function humanRate(value: string | number | undefined): string {
if (value === undefined || value === null || value === "") return "-";
const rate = typeof value === "string" ? Number(value) : value;
if (!Number.isFinite(rate)) return "-";
const units = ["bps", "Kbps", "Mbps", "Gbps"];
let v = rate;
let unitIdx = 0;
while (v >= 1000 && unitIdx < units.length - 1) {
v /= 1000;
unitIdx++;
}
return `${v.toFixed(1)} ${units[unitIdx]}`;
}
function humanDuration(value: string | number | undefined): string {
if (value === undefined || value === null || value === "") return "-";
const seconds = typeof value === "string" ? Number(value) : value;
if (!Number.isFinite(seconds)) return "-";
const total = Math.max(0, Math.round(seconds));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
if (hours > 0)
return `${hours}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
return `${minutes}:${String(secs).padStart(2, "0")}`;
}
function fieldLabel(_key: string, value: string | number | undefined): string {
if (value === undefined || value === null || value === "") return "-";
return String(value);
}
function isVideoFile(name: string): boolean {
const exts = [
".mkv",
@@ -48,38 +146,446 @@ function isVideoFile(name: string): boolean {
return exts.some((ext) => name.toLowerCase().endsWith(ext));
}
export function FileBrowser() {
const [currentDir, setCurrentDir] = useState("/");
const [pathInput, setPathInput] = useState("/");
const [selectedPath, setSelectedPath] = useState<string | null>(null);
function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
const format = data.format ?? {};
const streams = data.streams ?? [];
const videoStreams = streams.filter(
(stream) => stream.codec_type === "video",
);
const audioStreams = streams.filter(
(stream) => stream.codec_type === "audio",
);
const subtitleStreams = streams.filter(
(stream) => stream.codec_type === "subtitle",
);
const { data: listing, isLoading, error } = useDirectoryListing(currentDir);
const { data: ffprobeData } = useFfprobe(
return (
<Stack spacing={2}>
<Box>
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>
ffprobe details
</Typography>
<Typography variant="caption" color="text.secondary">
{path}
</Typography>
</Box>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle2" gutterBottom>
Container / format
</Typography>
<Grid container spacing={1.5}>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="body2">
<b>Format:</b> {fieldLabel("format", format.format_name)}
</Typography>
<Typography variant="body2">
<b>Long name:</b>{" "}
{fieldLabel("format_long_name", format.format_long_name)}
</Typography>
<Typography variant="body2">
<b>Duration:</b> {humanDuration(format.duration)}
</Typography>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="body2">
<b>Size:</b> {humanBytes(format.size)}
</Typography>
<Typography variant="body2">
<b>Bitrate:</b> {humanRate(format.bit_rate)}
</Typography>
<Typography variant="body2">
<b>Filename:</b> {fieldLabel("filename", format.filename)}
</Typography>
</Grid>
</Grid>
</CardContent>
</Card>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle2" gutterBottom>
Streams
</Typography>
<Stack spacing={1.5}>
{videoStreams.length > 0 && (
<Box>
<Typography variant="caption" color="text.secondary">
Video streams
</Typography>
<Stack spacing={1} sx={{ mt: 0.75 }}>
{videoStreams.map((stream, index) => (
<Box
key={`video-${stream.index ?? index}`}
sx={{
p: 1.25,
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
<Stack
direction="row"
spacing={1}
sx={{ flexWrap: "wrap", alignItems: "center" }}
>
<Chip
size="small"
label={`#${stream.index ?? index}`}
/>
<Chip
size="small"
color="primary"
label={stream.codec_type ?? "video"}
/>
<Chip
size="small"
variant="outlined"
label={stream.codec_name ?? "unknown codec"}
/>
{stream.codec_long_name && (
<Chip
size="small"
variant="outlined"
label={stream.codec_long_name}
/>
)}
{stream.profile && (
<Chip
size="small"
variant="outlined"
label={stream.profile}
/>
)}
{stream.bit_rate && (
<Chip
size="small"
variant="outlined"
label={humanRate(stream.bit_rate)}
/>
)}
{stream.duration && (
<Chip
size="small"
variant="outlined"
label={humanDuration(stream.duration)}
/>
)}
{stream.width && stream.height && (
<Chip
size="small"
variant="outlined"
label={`${stream.width}×${stream.height}`}
/>
)}
{stream.pix_fmt && (
<Chip
size="small"
variant="outlined"
label={stream.pix_fmt}
/>
)}
{stream.display_aspect_ratio && (
<Chip
size="small"
variant="outlined"
label={`DAR ${stream.display_aspect_ratio}`}
/>
)}
{stream.sample_aspect_ratio && (
<Chip
size="small"
variant="outlined"
label={`SAR ${stream.sample_aspect_ratio}`}
/>
)}
{stream.level !== undefined &&
stream.level !== null && (
<Chip
size="small"
variant="outlined"
label={`L${stream.level}`}
/>
)}
{stream.field_order &&
stream.field_order !== "unknown" && (
<Chip
size="small"
variant="outlined"
label={stream.field_order}
/>
)}
{(stream.color_range ||
stream.color_space ||
stream.color_transfer ||
stream.color_primaries) && (
<Chip
size="small"
color={
(stream.color_transfer ?? "")
.toLowerCase()
.includes("2084") ||
(stream.color_transfer ?? "")
.toLowerCase()
.includes("b67") ||
(stream.color_space ?? "")
.toLowerCase()
.includes("bt2020") ||
(stream.color_primaries ?? "")
.toLowerCase()
.includes("bt2020")
? "warning"
: "default"
}
variant="outlined"
label={[
stream.color_range,
stream.color_space,
stream.color_transfer,
stream.color_primaries,
]
.filter(Boolean)
.join(" / ")}
/>
)}
</Stack>
<Typography variant="body2" sx={{ mt: 0.75 }}>
{stream.tags?.language
? `Language: ${stream.tags.language}. `
: ""}
{stream.tags?.title
? `Title: ${stream.tags.title}.`
: ""}
</Typography>
</Box>
))}
</Stack>
</Box>
)}
{audioStreams.length > 0 && (
<Box>
<Typography variant="caption" color="text.secondary">
Audio streams
</Typography>
<Stack spacing={1} sx={{ mt: 0.75 }}>
{audioStreams.map((stream, index) => (
<Box
key={`audio-${stream.index ?? index}`}
sx={{
p: 1.25,
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
<Stack
direction="row"
spacing={1}
sx={{ flexWrap: "wrap", alignItems: "center" }}
>
<Chip
size="small"
label={`#${stream.index ?? index}`}
/>
<Chip
size="small"
color="secondary"
label={stream.codec_type ?? "audio"}
/>
<Chip
size="small"
variant="outlined"
label={stream.codec_name ?? "unknown codec"}
/>
{stream.channels && (
<Chip
size="small"
variant="outlined"
label={`${stream.channels} ch`}
/>
)}
{stream.sample_rate && (
<Chip
size="small"
variant="outlined"
label={`${stream.sample_rate} Hz`}
/>
)}
{stream.bit_rate && (
<Chip
size="small"
variant="outlined"
label={humanRate(stream.bit_rate)}
/>
)}
{stream.duration && (
<Chip
size="small"
variant="outlined"
label={humanDuration(stream.duration)}
/>
)}
</Stack>
<Typography variant="body2" sx={{ mt: 0.75 }}>
{stream.codec_long_name
? `${stream.codec_long_name}. `
: ""}
{stream.channel_layout
? `Layout: ${stream.channel_layout}. `
: ""}
{stream.tags?.language
? `Language: ${stream.tags.language}. `
: ""}
{stream.tags?.title
? `Title: ${stream.tags.title}.`
: ""}
</Typography>
</Box>
))}
</Stack>
</Box>
)}
{subtitleStreams.length > 0 && (
<Box>
<Typography variant="caption" color="text.secondary">
Subtitle streams
</Typography>
<Stack spacing={1} sx={{ mt: 0.75 }}>
{subtitleStreams.map((stream, index) => (
<Box
key={`subtitle-${stream.index ?? index}`}
sx={{
p: 1.25,
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
<Stack
direction="row"
spacing={1}
sx={{ flexWrap: "wrap", alignItems: "center" }}
>
<Chip
size="small"
label={`#${stream.index ?? index}`}
/>
<Chip
size="small"
color="info"
label={stream.codec_type ?? "subtitle"}
/>
<Chip
size="small"
variant="outlined"
label={stream.codec_name ?? "unknown codec"}
/>
{stream.tags?.language && (
<Chip
size="small"
variant="outlined"
label={stream.tags.language}
/>
)}
{stream.tags?.title && (
<Chip
size="small"
variant="outlined"
label={stream.tags.title}
/>
)}
</Stack>
</Box>
))}
</Stack>
</Box>
)}
{streams.length === 0 && (
<Typography variant="body2" color="text.secondary">
No streams found.
</Typography>
)}
</Stack>
</CardContent>
</Card>
{Object.keys(format.tags ?? {}).length > 0 && (
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle2" gutterBottom>
Tags
</Typography>
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
{Object.entries(format.tags ?? {}).map(([key, value]) => (
<Chip
key={key}
size="small"
label={`${key}: ${value}`}
variant="outlined"
/>
))}
</Stack>
</CardContent>
</Card>
)}
</Stack>
);
}
export function FileBrowser() {
const [searchParams] = useSearchParams();
const initialRequestedPath = searchParams.get("path") ?? "/";
const initialSelectedPath =
initialRequestedPath !== "/" &&
(isVideoFile(initialRequestedPath) || initialRequestedPath.includes("."))
? initialRequestedPath.replace(/\/+$/, "")
: null;
const initialCurrentDir = initialSelectedPath
? initialSelectedPath.replace(/\/[^/]+$/, "") || "/"
: initialRequestedPath.replace(/\/+$/, "") || "/";
const [currentDir, setCurrentDir] = useState(initialCurrentDir);
const [pathInput, setPathInput] = useState(initialCurrentDir);
const [selectedPath, setSelectedPath] = useState<string | null>(
initialSelectedPath,
);
const [selectedJob, setSelectedJob] = useState<string>("");
const {
data: listing,
isLoading,
error,
refetch,
} = useDirectoryListing(currentDir);
const {
data: ffprobeData,
isLoading: ffprobeLoading,
error: ffprobeError,
} = useFfprobe(
selectedPath ?? "",
!!selectedPath && isVideoFile(selectedPath),
);
const { data: templates } = useJobTemplates();
const runJob = useRunJob();
const gridRef = useRef<AgGridReact<DisplayRow>>(null);
const navigate = useCallback((path: string) => {
const navigate = (path: string) => {
setCurrentDir(path);
setPathInput(path);
setSelectedPath(null);
}, []);
const handlePathSubmit = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
navigate(pathInput || "/");
}
};
// Build display rows
const handlePathSubmit = (e: React.KeyboardEvent) => {
if (e.key === "Enter") navigate(pathInput || "/");
};
const rows: DisplayRow[] = [];
if (currentDir !== "/") {
const parent = currentDir.replace(/\/[^/]+\/?$/, "") || "/";
rows.push({
id: `up-${parent}`,
type: "up",
name: "..",
ext: "",
@@ -92,131 +598,183 @@ export function FileBrowser() {
for (const entry of listing.entries) {
const kind = entry.type === "d" ? "dir" : "file";
const ext = kind === "file" ? (entry.name.split(".").pop() ?? "") : "";
const path = `${currentDir === "/" ? "" : currentDir}/${entry.name}`;
rows.push({
id: path,
type: kind,
name: entry.name,
ext,
size: kind === "dir" ? "-" : formatSize(entry.size),
modified: formatTime(entry.mtime),
path: `${currentDir === "/" ? "" : currentDir}/${entry.name}`,
path,
});
}
}
const columnDefs = [
{ field: "type" as const, headerName: "Type", width: 80 },
{ field: "name" as const, headerName: "Name", flex: 2 },
{ field: "ext" as const, headerName: "Ext", width: 80 },
{ field: "size" as const, headerName: "Size", width: 110 },
{ field: "modified" as const, headerName: "Modified", width: 180 },
const columns: GridColDef<DisplayRow>[] = [
{ field: "type", headerName: "Type", width: 90 },
{ field: "name", headerName: "Name", flex: 1.2, minWidth: 220 },
{ field: "ext", headerName: "Ext", width: 90 },
{ field: "size", headerName: "Size", width: 120 },
{ field: "modified", headerName: "Modified", width: 190 },
];
const onRowClicked = useCallback(
(event: { data?: DisplayRow }) => {
const row = event.data;
if (!row) return;
if (row.type === "dir" || row.type === "up") {
navigate(row.path);
} else {
setSelectedPath(row.path);
}
},
[navigate],
);
const rowSelectionModel: GridRowSelectionModel = selectedPath
? { type: "include", ids: new Set([selectedPath]) }
: { type: "include", ids: new Set() };
const selectedTemplate = templates?.find((t) => t.key === selectedJob);
return (
<div className="space-y-4">
{/* Path input */}
<div className="flex gap-2">
<input
type="text"
<Stack spacing={2}>
<Typography variant="h5">File Browser</Typography>
<Stack direction="row" spacing={1}>
<TextField
fullWidth
size="small"
label="Remote path"
value={pathInput}
onChange={(e) => setPathInput(e.target.value)}
onKeyDown={handlePathSubmit}
className="border rounded px-3 py-1 text-sm flex-1"
placeholder="Remote path (press Enter to navigate)"
/>
<button
onClick={() => navigate(pathInput || "/")}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
>
<Button variant="outlined" onClick={() => navigate(pathInput || "/")}>
Open
</Button>
<Button variant="outlined" onClick={() => refetch()}>
Refresh
</button>
</div>
</Button>
</Stack>
{/* Status */}
<div className="flex gap-6 text-xs text-gray-500">
<span>
Current: <code>{currentDir}</code>
</span>
{selectedPath && (
<span>
Selected: <code>{selectedPath}</code>
</span>
)}
{listing && <span>Entries: {listing.count}</span>}
</div>
<Typography variant="caption" color="text.secondary">
Current: {currentDir}{" "}
{selectedPath ? `| Selected: ${selectedPath}` : ""}{" "}
{listing ? `| Entries: ${listing.count}` : ""}
</Typography>
{error && <p className="text-sm text-red-600">Error: {String(error)}</p>}
{error && <Alert severity="error">{String(error)}</Alert>}
{/* File listing grid */}
<div className="ag-theme-alpine" style={{ height: 400, width: "100%" }}>
<AgGridReact<DisplayRow>
ref={gridRef}
rowData={rows}
columnDefs={columnDefs}
rowSelection="single"
onRowClicked={onRowClicked}
<Box
sx={{
height: 420,
bgcolor: "background.paper",
border: 1,
borderColor: "divider",
borderRadius: 2,
}}
>
<DataGrid
rows={rows}
columns={columns}
loading={isLoading}
suppressCellFocus
animateRows={false}
rowSelectionModel={rowSelectionModel}
hideFooter
sx={{
"& .MuiDataGrid-columnHeaders": {
fontWeight: 700,
backgroundColor: "action.hover",
},
}}
onRowClick={(params) => {
const row = params.row as DisplayRow;
if (row.type === "dir" || row.type === "up") navigate(row.path);
else setSelectedPath(row.path);
}}
/>
</div>
</Box>
{/* ffprobe preview */}
{selectedPath && isVideoFile(selectedPath) && (
<section className="border rounded-lg p-4">
<h3 className="text-sm font-semibold mb-2">
ffprobe preview: <code className="text-xs">{selectedPath}</code>
</h3>
{ffprobeData ? (
<pre className="text-xs bg-gray-50 p-3 rounded overflow-auto max-h-96">
{JSON.stringify(ffprobeData, null, 2)}
</pre>
) : (
<p className="text-sm text-gray-500">Loading ffprobe data...</p>
)}
</section>
<Card variant="outlined">
<CardContent>
{ffprobeError ? (
<Alert severity="error" sx={{ mb: 2 }}>
{String(ffprobeError)}
</Alert>
) : ffprobeLoading && !ffprobeData ? (
<Typography color="text.secondary">
Loading ffprobe data...
</Typography>
) : ffprobeData ? (
<FfprobeDetails
path={selectedPath}
data={ffprobeData as FfprobeData}
/>
) : (
<Typography color="text.secondary">
No ffprobe data available.
</Typography>
)}
</CardContent>
</Card>
)}
{/* Jobs */}
{selectedPath && templates && templates.length > 0 && (
<section className="border rounded-lg p-4">
<h3 className="text-sm font-semibold mb-2">Jobs</h3>
<div className="flex gap-2 flex-wrap">
{templates.map((tpl) => (
<button
key={tpl.key}
onClick={() =>
runJob.mutate({ jobKey: tpl.key, path: selectedPath })
}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
title={tpl.description}
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 1 }}>
Jobs
</Typography>
<Grid container spacing={1.5}>
<Grid size={{ xs: 12, md: 4 }}>
<FormControl fullWidth size="small">
<InputLabel>Job template</InputLabel>
<Select
label="Job template"
value={selectedJob}
onChange={(e) => setSelectedJob(e.target.value)}
>
{templates.map((tpl) => (
<MenuItem key={tpl.key} value={tpl.key}>
{tpl.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, md: 8 }}>
<Stack direction="row" spacing={1}>
<Button
variant="contained"
disabled={!selectedJob || runJob.isPending}
onClick={() =>
runJob.mutate({ jobKey: selectedJob, path: selectedPath })
}
>
Run job
</Button>
{selectedTemplate && (
<Typography
variant="body2"
color="text.secondary"
sx={{ alignSelf: "center" }}
>
{selectedTemplate.description}
</Typography>
)}
</Stack>
</Grid>
</Grid>
{runJob.data && (
<Box
component="pre"
sx={{
mt: 1.5,
p: 1.5,
bgcolor: "action.hover",
overflow: "auto",
maxHeight: 260,
fontSize: 12,
}}
>
{tpl.name}
</button>
))}
</div>
{runJob.data && (
<pre className="text-xs bg-gray-50 p-3 rounded mt-3 overflow-auto max-h-48">
Exit: {runJob.data.exit_status}
{"\n"}
{runJob.data.stdout}
{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
</pre>
)}
</section>
Exit: {runJob.data.exit_status}
{"\n"}
{runJob.data.stdout}
{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
</Box>
)}
</CardContent>
</Card>
)}
</div>
</Stack>
);
}
+336 -142
View File
@@ -1,15 +1,49 @@
import { useState, useCallback, useRef } from "react";
import { AgGridReact } from "ag-grid-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { DataGrid } from "@mui/x-data-grid";
import type { GridColDef } from "@mui/x-data-grid";
import {
Alert,
Box,
Button,
Card,
CardContent,
LinearProgress,
FormControl,
Grid,
InputLabel,
MenuItem,
Select,
Stack,
TextField,
Typography,
} from "@mui/material";
import {
useMediaStatus,
useMediaQuery,
useBuildIndex,
useStopBuildIndex,
useForceStopBuildIndex,
} from "../hooks/useMedia";
import type { MediaItem } from "../types";
function formatDuration(seconds: number | null | undefined): string {
if (seconds == null || Number.isNaN(seconds)) return "-";
const total = Math.max(0, Math.round(seconds));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
if (hours > 0) return `${hours}h ${minutes}m ${secs}s`;
if (minutes > 0) return `${minutes}m ${secs}s`;
return `${secs}s`;
}
export function Media() {
const navigate = useNavigate();
const { data: status } = useMediaStatus();
const buildIndex = useBuildIndex();
const stopBuildIndex = useStopBuildIndex();
const forceStopBuildIndex = useForceStopBuildIndex();
const [search, setSearch] = useState("");
const [types, setTypes] = useState("Movie,Episode");
@@ -30,181 +64,341 @@ export function Media() {
enabled: status?.exists ?? false,
});
const gridRef = useRef<AgGridReact<MediaItem>>(null);
const columnDefs = [
{ field: "title" as const, headerName: "Title", minWidth: 150 },
{ field: "series" as const, headerName: "Series", minWidth: 120 },
{ field: "season" as const, headerName: "Season", maxWidth: 95 },
{ field: "episode" as const, headerName: "Episode", maxWidth: 105 },
{ field: "type" as const, headerName: "Type", maxWidth: 100 },
{ field: "year" as const, headerName: "Year", maxWidth: 90 },
{
field: "runtime_min" as const,
headerName: "Runtime (min)",
maxWidth: 125,
},
{ field: "size" as const, headerName: "Size", maxWidth: 120 },
{ field: "bitrate" as const, headerName: "Bitrate", maxWidth: 125 },
{ field: "hdr" as const, headerName: "HDR", maxWidth: 80 },
{ field: "video" as const, headerName: "Video codec", maxWidth: 120 },
{ field: "resolution" as const, headerName: "Resolution", maxWidth: 120 },
{ field: "date_added" as const, headerName: "Date added", maxWidth: 120 },
{ field: "library" as const, headerName: "Library", maxWidth: 140 },
{ field: "path" as const, headerName: "Path", minWidth: 200 },
const columns: GridColDef<MediaItem>[] = [
{ field: "title", headerName: "Title", minWidth: 180, flex: 1.2 },
{ field: "series", headerName: "Series", minWidth: 140, flex: 1 },
{ field: "season", headerName: "Season", width: 90 },
{ field: "episode", headerName: "Episode", width: 100 },
{ field: "type", headerName: "Type", width: 100 },
{ field: "year", headerName: "Year", width: 90 },
{ field: "runtime_min", headerName: "Runtime", width: 110 },
{ field: "size", headerName: "Size", width: 120 },
{ field: "bitrate", headerName: "Bitrate", width: 130 },
{ field: "hdr", headerName: "HDR", width: 80 },
{ field: "video", headerName: "Video codec", width: 130 },
{ field: "resolution", headerName: "Resolution", width: 120 },
{ field: "date_added", headerName: "Date added", width: 120 },
{ field: "library", headerName: "Library", width: 140 },
{ field: "path", headerName: "Path", minWidth: 240, flex: 1.2 },
];
const onGridReady = useCallback(() => {
gridRef.current?.api?.sizeColumnsToFit();
}, []);
const rows = useMemo(
() =>
(queryResult?.items ?? []).map((item) => ({
...item,
id: item.id || item.path,
})),
[queryResult],
);
const page = Math.floor(offset / limit) + 1;
const totalPages = queryResult ? Math.ceil(queryResult.total / limit) : 1;
const totalPages = queryResult
? Math.max(1, Math.ceil(queryResult.total / limit))
: 1;
const buildRunning = status?.build_running ?? false;
const buildProgress = status?.build_progress ?? null;
const buildLibraryProgress = status?.build_library_progress ?? null;
const buildCancelRequested = status?.build_cancel_requested ?? false;
const buildLabel = buildRunning
? status?.build_message || "Building media index..."
: status?.build_error
? `Build failed: ${status.build_error}`
: "";
const elapsedLabel = formatDuration(status?.build_elapsed_seconds);
const etaLabel =
buildRunning && status?.build_eta_seconds != null
? formatDuration(status.build_eta_seconds)
: "-";
const libraryElapsedLabel = formatDuration(
status?.build_library_elapsed_seconds,
);
const libraryEtaLabel =
buildRunning && status?.build_library_eta_seconds != null
? formatDuration(status.build_library_eta_seconds)
: "-";
const libraryLabel =
status?.build_current_library ||
(status?.build_library_index && status?.build_libraries_total
? `Library ${status.build_library_index} / ${status.build_libraries_total}`
: "Current library");
return (
<div className="space-y-4">
{/* Status and controls */}
<div className="flex items-center gap-4">
<Stack spacing={2}>
<Stack
direction="row"
spacing={1.5}
sx={{ alignItems: "center", flexWrap: "wrap" }}
>
<Typography variant="h5">Media</Typography>
{status?.exists ? (
<span className="text-sm text-gray-600">
<Typography variant="body2" color="text.secondary">
Index: {status.item_count.toLocaleString()} items
{status.updated_at_label && ` | updated ${status.updated_at_label}`}
</span>
{status.updated_at_label
? ` | updated ${status.updated_at_label}`
: ""}
</Typography>
) : (
<span className="text-sm text-amber-600">No index built yet.</span>
<Alert severity="warning" sx={{ py: 0 }}>
No index built yet.
</Alert>
)}
<button
<Button
variant="outlined"
onClick={() => buildIndex.mutate()}
disabled={buildIndex.isPending}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50 disabled:opacity-50"
disabled={
buildIndex.isPending || buildRunning || buildCancelRequested
}
>
{buildIndex.isPending ? "Building..." : "Build index"}
</button>
</div>
{buildIndex.isPending || buildRunning ? "Building..." : "Build index"}
</Button>
{buildRunning && (
<>
<Button
variant="outlined"
color="error"
onClick={() => stopBuildIndex.mutate()}
disabled={stopBuildIndex.isPending || buildCancelRequested}
>
{buildCancelRequested || stopBuildIndex.isPending
? "Stopping..."
: "Stop build"}
</Button>
<Button
variant="outlined"
color="warning"
onClick={() => forceStopBuildIndex.mutate()}
disabled={forceStopBuildIndex.isPending}
>
{forceStopBuildIndex.isPending
? "Force stopping..."
: "Force stop"}
</Button>
</>
)}
{(buildRunning || status?.build_error) && (
<Box sx={{ width: "100%", minWidth: 260, flexBasis: "100%" }}>
<Stack spacing={1}>
<Typography
variant="body2"
color={status?.build_error ? "error" : "text.secondary"}
>
{buildLabel ||
(buildRunning
? "Building media index..."
: status?.build_error || "")}
</Typography>
{/* Filters */}
<div className="flex flex-wrap gap-3 items-end">
<div>
<label className="text-xs text-gray-500 block">Search</label>
<input
type="text"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setOffset(0);
}}
className="border rounded px-2 py-1 text-sm w-48"
placeholder="Search title, series, path..."
/>
</div>
<div>
<label className="text-xs text-gray-500 block">Types</label>
<select
value={types}
onChange={(e) => {
setTypes(e.target.value);
setOffset(0);
}}
className="border rounded px-2 py-1 text-sm"
>
<option value="Movie,Episode">Movies + Episodes</option>
<option value="Movie">Movies only</option>
<option value="Episode">Episodes only</option>
<option value="Movie,Episode,Video">All video</option>
</select>
</div>
<div>
<label className="text-xs text-gray-500 block">HDR</label>
<select
value={hdrFilter}
onChange={(e) => {
setHdrFilter(e.target.value);
setOffset(0);
}}
className="border rounded px-2 py-1 text-sm"
>
<option value="All">All</option>
<option value="HDR only">HDR only</option>
<option value="SDR/unknown only">SDR/unknown only</option>
</select>
</div>
<div>
<label className="text-xs text-gray-500 block">Sort</label>
<select
value={sortKey}
onChange={(e) => setSortKey(e.target.value)}
className="border rounded px-2 py-1 text-sm"
>
<option value="title">Title</option>
<option value="series">Series</option>
<option value="size">Size</option>
<option value="bitrate">Bitrate</option>
<option value="runtime">Runtime</option>
<option value="year">Year</option>
<option value="date_added">Date added</option>
<option value="resolution">Resolution</option>
</select>
</div>
<div>
<label className="text-xs text-gray-500 block">Order</label>
<select
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value)}
className="border rounded px-2 py-1 text-sm"
>
<option value="Ascending">Ascending</option>
<option value="Descending">Descending</option>
</select>
</div>
</div>
<Stack spacing={0.35}>
<Typography variant="caption" color="text.secondary">
Overall:{" "}
{buildProgress != null
? `${Math.round(buildProgress * 100)}%`
: "pending"}
{buildRunning
? ` • elapsed ${elapsedLabel} • eta ${etaLabel}`
: ""}
</Typography>
<LinearProgress
variant={
buildProgress != null ? "determinate" : "indeterminate"
}
value={
buildProgress != null
? Math.max(0, Math.min(100, buildProgress * 100))
: undefined
}
/>
<Typography variant="caption" color="text.secondary">
{status?.build_items_processed?.toLocaleString() ?? 0}/
{status?.build_items_total?.toLocaleString() ?? 0} items
</Typography>
</Stack>
<Stack spacing={0.35}>
<Typography variant="caption" color="text.secondary">
Current: {libraryLabel}
{buildRunning
? ` • elapsed ${libraryElapsedLabel} • eta ${libraryEtaLabel}`
: ""}
</Typography>
<LinearProgress
variant={
buildLibraryProgress != null
? "determinate"
: "indeterminate"
}
value={
buildLibraryProgress != null
? Math.max(0, Math.min(100, buildLibraryProgress * 100))
: undefined
}
/>
<Typography variant="caption" color="text.secondary">
{status?.build_library_items_processed?.toLocaleString() ?? 0}
/{status?.build_library_items_total?.toLocaleString() ?? 0}{" "}
items
</Typography>
</Stack>
</Stack>
</Box>
)}
</Stack>
<Card variant="outlined">
<CardContent>
<Grid container spacing={1.5}>
<Grid size={{ xs: 12, md: 4 }}>
<TextField
fullWidth
label="Search"
size="small"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setOffset(0);
}}
/>
</Grid>
<Grid size={{ xs: 6, md: 2 }}>
<FormControl fullWidth size="small">
<InputLabel>Types</InputLabel>
<Select
label="Types"
value={types}
onChange={(e) => {
setTypes(e.target.value);
setOffset(0);
}}
>
<MenuItem value="Movie,Episode">Movies + Episodes</MenuItem>
<MenuItem value="Movie">Movies only</MenuItem>
<MenuItem value="Episode">Episodes only</MenuItem>
<MenuItem value="Movie,Episode,Video">All video</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 6, md: 2 }}>
<FormControl fullWidth size="small">
<InputLabel>HDR</InputLabel>
<Select
label="HDR"
value={hdrFilter}
onChange={(e) => {
setHdrFilter(e.target.value);
setOffset(0);
}}
>
<MenuItem value="All">All</MenuItem>
<MenuItem value="HDR only">HDR only</MenuItem>
<MenuItem value="SDR/unknown only">SDR/unknown only</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 6, md: 2 }}>
<FormControl fullWidth size="small">
<InputLabel>Sort</InputLabel>
<Select
label="Sort"
value={sortKey}
onChange={(e) => setSortKey(e.target.value)}
>
{[
["title", "Title"],
["series", "Series"],
["size", "Size"],
["bitrate", "Bitrate"],
["runtime", "Runtime"],
["year", "Year"],
["date_added", "Date added"],
["resolution", "Resolution"],
].map(([k, l]) => (
<MenuItem key={k} value={k}>
{l}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 6, md: 2 }}>
<FormControl fullWidth size="small">
<InputLabel>Order</InputLabel>
<Select
label="Order"
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value)}
>
<MenuItem value="Ascending">Ascending</MenuItem>
<MenuItem value="Descending">Descending</MenuItem>
</Select>
</FormControl>
</Grid>
</Grid>
</CardContent>
</Card>
{/* Results info */}
{queryResult && (
<p className="text-xs text-gray-500">
<Typography variant="caption" color="text.secondary">
Showing {queryResult.items.length} of{" "}
{queryResult.total.toLocaleString()} items | Page {page} of{" "}
{totalPages}
</p>
</Typography>
)}
{/* AG Grid table */}
{status?.exists && (
<div className="ag-theme-alpine" style={{ height: 600, width: "100%" }}>
<AgGridReact<MediaItem>
ref={gridRef}
rowData={queryResult?.items ?? []}
columnDefs={columnDefs}
rowSelection="single"
onGridReady={onGridReady}
<Box
sx={{
height: 640,
bgcolor: "background.paper",
border: 1,
borderColor: "divider",
borderRadius: 2,
}}
>
<DataGrid
rows={rows}
columns={columns}
loading={isLoading}
suppressCellFocus
animateRows={false}
checkboxSelection={false}
disableRowSelectionOnClick
onRowClick={(params) => {
const row = params.row as MediaItem;
navigate(`/files?path=${encodeURIComponent(row.path)}`);
}}
pageSizeOptions={[100]}
hideFooter
sx={{
"& .MuiDataGrid-columnHeaders": {
fontWeight: 700,
backgroundColor: "action.hover",
},
}}
/>
</div>
</Box>
)}
{/* Pagination */}
{queryResult && totalPages > 1 && (
<div className="flex gap-2 items-center">
<button
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Button
variant="outlined"
size="small"
onClick={() => setOffset(Math.max(0, offset - limit))}
disabled={page <= 1}
className="px-3 py-1 text-sm rounded border disabled:opacity-50"
>
Prev
</button>
<span className="text-sm">
</Button>
<Typography variant="body2">
Page {page} / {totalPages}
</span>
<button
</Typography>
<Button
variant="outlined"
size="small"
onClick={() => setOffset(offset + limit)}
disabled={page >= totalPages}
className="px-3 py-1 text-sm rounded border disabled:opacity-50"
>
Next
</button>
</div>
</Button>
</Stack>
)}
</div>
</Stack>
);
}
+67 -34
View File
@@ -1,3 +1,12 @@
import {
Box,
Button,
Chip,
Divider,
Grid,
Stack,
Typography,
} from "@mui/material";
import {
useMonitoringStatus,
useMonitoringMetrics,
@@ -32,7 +41,6 @@ export function Monitoring() {
const samples = metrics?.samples ?? [];
const latest = samples.at(-1);
// Compute averages and peaks
const avg = (arr: number[]) =>
arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
const max = (arr: number[]) => (arr.length ? Math.max(...arr) : 0);
@@ -46,95 +54,120 @@ export function Monitoring() {
const diskWriteArr = samples.map((s) => s.disk_write_bps);
return (
<div className="space-y-8">
{/* Controls */}
<section className="flex items-center gap-4">
<span className="text-sm text-gray-600">
Collector:{" "}
<code className="bg-gray-100 px-1 rounded">
{status?.status ?? "unknown"}
</code>
</span>
<button
<Stack spacing={3}>
<Stack
direction="row"
spacing={1.5}
sx={{ alignItems: "center", flexWrap: "wrap" }}
>
<Typography variant="h5">Monitoring</Typography>
<Chip
label={status?.status ?? "unknown"}
color="primary"
variant="outlined"
/>
<Button
size="small"
variant="outlined"
onClick={() => start.mutate()}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
disabled={start.isPending}
>
Start
</button>
<button
</Button>
<Button
size="small"
variant="outlined"
onClick={() => restart.mutate()}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
disabled={restart.isPending}
>
Restart
</button>
<button
</Button>
<Button
size="small"
variant="outlined"
onClick={() => stop.mutate()}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
disabled={stop.isPending}
>
Stop
</button>
</section>
</Button>
</Stack>
{/* Metrics summary */}
<section>
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3">
<Grid container spacing={1.5}>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="CPU now"
value={latest ? `${latest.cpu_pct.toFixed(1)}%` : "-"}
subtext={`avg ${avg(cpuArr).toFixed(1)}%\npeak ${max(cpuArr).toFixed(1)}%`}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="IO Wait"
value={latest ? `${(latest.iowait_pct ?? 0).toFixed(1)}%` : "-"}
subtext={`avg ${avg(iowArr).toFixed(1)}%\npeak ${max(iowArr).toFixed(1)}%`}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="RAM now"
value={latest ? `${latest.mem_pct.toFixed(1)}%` : "-"}
subtext={`avg ${avg(memArr).toFixed(1)}%\npeak ${max(memArr).toFixed(1)}%`}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="Net down"
value={latest ? formatRate(latest.net_rx_bytes_per_sec) : "-"}
subtext={`avg ${formatRate(avg(netDownArr))}\npeak ${formatRate(max(netDownArr))}`}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="Net up"
value={latest ? formatRate(latest.net_tx_bytes_per_sec) : "-"}
subtext={`avg ${formatRate(avg(netUpArr))}\npeak ${formatRate(max(netUpArr))}`}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="Disk read"
value={latest ? formatRate(latest.disk_read_bps) : "-"}
subtext={`avg ${formatRate(avg(diskReadArr))}\npeak ${formatRate(max(diskReadArr))}`}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="Disk write"
value={latest ? formatRate(latest.disk_write_bps) : "-"}
subtext={`avg ${formatRate(avg(diskWriteArr))}\npeak ${formatRate(max(diskWriteArr))}`}
/>
</div>
</section>
</Grid>
</Grid>
{/* Disk space */}
{disk && (
<section>
<div className="grid grid-cols-4 gap-3">
<Grid container spacing={1.5}>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard label="Disk used" value={formatBytes(disk.used)} />
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard
label="Disk available"
value={formatBytes(disk.available)}
/>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard label="Disk total" value={formatBytes(disk.size)} />
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard label="Used %" value={disk.used_pct} />
</div>
</section>
</Grid>
</Grid>
)}
{/* Charts */}
<section>
<Divider />
<Box>
<MonitoringCharts samples={samples} />
</section>
</div>
</Box>
</Stack>
);
}
File diff suppressed because it is too large Load Diff