Phase 2: Docker and OIDC auth
This commit is contained in:
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user