ui improvements

This commit is contained in:
2026-05-04 22:25:23 +02:00
parent 87149026c7
commit e0e461502b
4 changed files with 472 additions and 110 deletions
+9
View File
@@ -145,6 +145,9 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
- On the dashboard overview, summarize monitoring metrics as 10-minute averages with high/low values for quick inspection. - On the dashboard overview, summarize monitoring metrics as 10-minute averages with high/low values for quick inspection.
- Show average and spike/peak values for network throughput and disk I/O. - Show average and spike/peak values for network throughput and disk I/O.
- Show used, available, and total disk space for the configured media root, falling back to `/`. - Show used, available, and total disk space for the configured media root, falling back to `/`.
- The dashboard should present disk space as a single combined card with the progress/fill bar embedded inside the card and the size breakdown laid out clearly, with centered sub-card text for the Used/Free/Total breakdown and consistent vertical spacing across the dashboard cards.
- The disk usage bar should change color as usage increases so high utilization is easy to notice at a glance.
- The disk usage card should avoid redundant percentage labels next to the bar if the bar itself already communicates the value.
- Render Monitoring charts directly with D3 so the UI can support brush-based range selection, hover tooltips with a moving vertical cursor and snapped point markers, summary chips, and moving averages without a separate wrapper library. - Render Monitoring charts directly with D3 so the UI can support brush-based range selection, hover tooltips with a moving vertical cursor and snapped point markers, summary chips, and moving averages without a separate wrapper library.
- Use a lightweight remote collector that reads Linux `/proc`, `/sys/block`, and `df` data into a JSONL file under `/tmp`. - Use a lightweight remote collector that reads Linux `/proc`, `/sys/block`, and `df` data into a JSONL file under `/tmp`.
- The collector should rotate/prune its JSONL metrics file so it does not grow unbounded; default retention is 7 days with a 70,000-line safety cap. - The collector should rotate/prune its JSONL metrics file so it does not grow unbounded; default retention is 7 days with a 70,000-line safety cap.
@@ -157,6 +160,8 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
- Network and disk throughput charts should scale values into readable units such as KB/s, MB/s, and GB/s. - Network and disk throughput charts should scale values into readable units such as KB/s, MB/s, and GB/s.
- Each Monitoring chart should show compact summary chips such as min/avg/max for quick inspection. - Each Monitoring chart should show compact summary chips such as min/avg/max for quick inspection.
- The Monitoring toolbar should offer quick time-range buttons such as 1h, 8h, 1 day, and 7 days in addition to free brush selection. - The Monitoring toolbar should offer quick time-range buttons such as 1h, 8h, 1 day, and 7 days in addition to free brush selection.
- The Monitoring brush selection should persist across data refreshes and tab reloads instead of resetting whenever new samples arrive, and the zoom buttons should stay in sync with the visible brush range.
- The Monitoring brush UI should be stable and remain visible after drags or zoom changes; it should be rendered independently from the chart redraw cycle, with usable resize handles for left/right edges.
### Remote Jobs ### Remote Jobs
@@ -178,6 +183,10 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
- 2026-05-03: Added OIDC/JWT auth support plus root-level Docker Compose deployment files for production and dev workflows. - 2026-05-03: Added OIDC/JWT auth support plus root-level Docker Compose deployment files for production and dev workflows.
- 2026-05-04: Backend Docker Compose now mounts a host SSH directory into `/root/.ssh` so Paramiko can use a private key and strict host-key checking without baking secrets into the image. - 2026-05-04: Backend Docker Compose now mounts a host SSH directory into `/root/.ssh` so Paramiko can use a private key and strict host-key checking without baking secrets into the image.
- 2026-05-04: The frontend was adjusted to be more mobile-safe by making the app shell tabs scrollable, stacking header controls on narrow screens, and hiding low-priority table columns on smaller displays. - 2026-05-04: The frontend was adjusted to be more mobile-safe by making the app shell tabs scrollable, stacking header controls on narrow screens, and hiding low-priority table columns on smaller displays.
- 2026-05-04: The dashboard disk space area was consolidated into a single combined card with the progress bar embedded inside the card and the space stats reorganized into clearer, centered sub-panels with consistent vertical spacing.
- 2026-05-04: The disk usage bar was color-coded to shift from green to yellow to red as utilization increases, and the redundant percentage label beside the bar was removed.
- 2026-05-04: The Monitoring brush now persists its selected range in browser storage, avoids resetting when fresh monitoring data streams in, keeps the zoom buttons synchronized with the brush state, and renders the brush UI independently so data refreshes do not make it disappear.
- 2026-05-04: The Monitoring brush was rebuilt as a React overlay with explicit resize handles so mouse dragging is more reliable.
- 2026-05-04: The app header was upgraded to a two-row branded layout with a left logo mark, right-side username/logout controls, and a separate navigation row. - 2026-05-04: The app header was upgraded to a two-row branded layout with a left logo mark, right-side username/logout controls, and a separate navigation row.
- 2026-05-04: Frontend OIDC storage was switched from session-only defaults to localStorage-backed user/state stores so reloads keep the auth flow intact. - 2026-05-04: Frontend OIDC storage was switched from session-only defaults to localStorage-backed user/state stores so reloads keep the auth flow intact.
- 2026-05-04: API requests now fall back to the persisted OIDC user store for the bearer token so the first render after reload can avoid spurious 401s. - 2026-05-04: API requests now fall back to the persisted OIDC user store for the bearer token so the first render after reload can avoid spurious 401s.
+11 -4
View File
@@ -9,18 +9,25 @@ interface Props {
export function MetricCard({ label, value, subtext }: Props) { export function MetricCard({ label, value, subtext }: Props) {
return ( return (
<Card variant="outlined" sx={{ height: "100%" }}> <Card variant="outlined" sx={{ height: "100%" }}>
<CardContent sx={{ p: { xs: 1.5, sm: 2 } }}> <CardContent
sx={{
p: { xs: 1.5, sm: 2 },
display: "flex",
flexDirection: "column",
gap: 0.5,
height: "100%",
}}
>
<Typography <Typography
variant="caption" variant="caption"
color="text.secondary" color="text.secondary"
sx={{ textTransform: "uppercase" }} sx={{ textTransform: "uppercase", lineHeight: 1.2 }}
> >
{label} {label}
</Typography> </Typography>
<Typography <Typography
variant="h5" variant="h5"
sx={{ sx={{
mt: 0.5,
fontWeight: 700, fontWeight: 700,
fontSize: { xs: "1.05rem", sm: "1.5rem" }, fontSize: { xs: "1.05rem", sm: "1.5rem" },
lineHeight: 1.15, lineHeight: 1.15,
@@ -32,7 +39,7 @@ export function MetricCard({ label, value, subtext }: Props) {
<Typography <Typography
variant="caption" variant="caption"
color="text.secondary" color="text.secondary"
sx={{ whiteSpace: "pre-line", display: "block", mt: 0.25 }} sx={{ whiteSpace: "pre-line", display: "block", lineHeight: 1.35 }}
> >
{subtext} {subtext}
</Typography> </Typography>
+303 -86
View File
@@ -1,4 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from "react";
import * as d3 from "d3"; import * as d3 from "d3";
import { import {
Box, Box,
@@ -62,6 +70,7 @@ const MOVING_AVG_WINDOW = 10;
const CHART_HEIGHT = 280; const CHART_HEIGHT = 280;
const BRUSH_HEIGHT = 84; const BRUSH_HEIGHT = 84;
const BRUSH_LABEL_HEIGHT = 24; const BRUSH_LABEL_HEIGHT = 24;
const PERSISTED_SELECTION_KEY = "manage.monitoring.brush.selection";
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
if (!bytes) return "0 B/s"; if (!bytes) return "0 B/s";
@@ -120,21 +129,39 @@ function MonitoringBrush({ data, selectionRange, onBrush }: BrushProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null); const svgRef = useRef<SVGSVGElement>(null);
const [width, setWidth] = useState(0); const [width, setWidth] = useState(0);
const brushGroupRef = useRef<d3.Selection< const dragRef = useRef<{
SVGGElement, mode: "new" | "move" | "left" | "right";
unknown, startRange: [number, number];
null, startTs: number;
unknown pointerId: number;
> | null>(null); } | null>(null);
const brushRef = useRef<d3.BrushBehavior<unknown> | null>(null);
const brushXRef = useRef<d3.ScaleTime<number, number> | null>(null);
const isUserBrushingRef = useRef(false);
const isProgrammaticMoveRef = useRef(false);
const margin = { top: 18, right: 24, bottom: 22, left: 48 }; const margin = { top: 18, right: 24, bottom: 22, left: 48 };
const innerHeight = BRUSH_HEIGHT - margin.top - margin.bottom; const innerHeight = BRUSH_HEIGHT - margin.top - margin.bottom;
const totalHeight = BRUSH_LABEL_HEIGHT + BRUSH_HEIGHT;
const brushedColor = "rgba(99, 102, 241, 0.25)"; const brushedColor = "rgba(99, 102, 241, 0.25)";
const minTs = useMemo(() => d3.min(data, (d) => d.ts) ?? 0, [data]);
const maxTs = useMemo(() => d3.max(data, (d) => d.ts) ?? 0, [data]);
const innerWidth = Math.max(0, width - margin.left - margin.right);
const xScale = useMemo(() => {
if (width === 0 || data.length === 0) return null;
return d3
.scaleTime()
.domain([new Date(minTs * 1000), new Date(maxTs * 1000)])
.range([0, innerWidth]);
}, [data.length, width, minTs, maxTs, innerWidth]);
const visibleSelection =
selectionRange ??
(data.length > 0 ? ([minTs, maxTs] as [number, number]) : null);
const selectionPixels = useMemo(() => {
if (!xScale || !visibleSelection) return null;
return [
xScale(new Date(visibleSelection[0] * 1000)),
xScale(new Date(visibleSelection[1] * 1000)),
] as [number, number];
}, [visibleSelection, xScale]);
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => { const observer = new ResizeObserver((entries) => {
@@ -144,11 +171,11 @@ function MonitoringBrush({ data, selectionRange, onBrush }: BrushProps) {
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, []);
// Build the brush UI when the available data or layout width changes. // Draw the mini chart and axes with D3, while the interactive brush UI is
useEffect(() => { // rendered by React so it stays visible across redraws.
useLayoutEffect(() => {
if (!svgRef.current || width === 0 || data.length === 0) return; if (!svgRef.current || width === 0 || data.length === 0) return;
const innerWidth = Math.max(0, width - margin.left - margin.right);
const svg = d3.select(svgRef.current); const svg = d3.select(svgRef.current);
svg.selectAll("*").remove(); svg.selectAll("*").remove();
@@ -156,13 +183,10 @@ function MonitoringBrush({ data, selectionRange, onBrush }: BrushProps) {
.append("g") .append("g")
.attr("transform", `translate(${margin.left},${margin.top})`); .attr("transform", `translate(${margin.left},${margin.top})`);
const minTs = d3.min(data, (d) => d.ts) ?? 0;
const maxTs = d3.max(data, (d) => d.ts) ?? 0;
const x = d3 const x = d3
.scaleTime() .scaleTime()
.domain([new Date(minTs * 1000), new Date(maxTs * 1000)]) .domain([new Date(minTs * 1000), new Date(maxTs * 1000)])
.range([0, innerWidth]); .range([0, innerWidth]);
brushXRef.current = x;
const yMax = Math.max(1, d3.max(data, (d) => Math.max(d.cpu, d.mem)) ?? 1); const yMax = Math.max(1, d3.max(data, (d) => Math.max(d.cpu, d.mem)) ?? 1);
const y = d3 const y = d3
@@ -221,75 +245,129 @@ function MonitoringBrush({ data, selectionRange, onBrush }: BrushProps) {
.attr("opacity", 0.6) .attr("opacity", 0.6)
.attr("d", line); .attr("d", line);
}); });
}, [data, width, margin.left, margin.top, innerHeight, innerWidth, minTs, maxTs]);
const brush = d3 const clampRange = useCallback(
.brushX() (range: [number, number]): [number, number] => {
.handleSize(14) if (!data.length) return range;
.extent([ let [start, end] = range[0] <= range[1] ? range : [range[1], range[0]];
[0, 0], const domainStart = minTs;
[innerWidth, innerHeight], const domainEnd = maxTs;
]) if (start < domainStart) {
.on("start", () => { end += domainStart - start;
isUserBrushingRef.current = true; start = domainStart;
}) }
.on("brush", (event: d3.D3BrushEvent<unknown>) => { if (end > domainEnd) {
if (isProgrammaticMoveRef.current) return; start -= end - domainEnd;
if (!event.selection) return; end = domainEnd;
const sel = event.selection as [number, number]; }
const start = Math.floor(x.invert(sel[0]).getTime() / 1000); start = Math.max(domainStart, start);
const end = Math.floor(x.invert(sel[1]).getTime() / 1000); end = Math.min(domainEnd, end);
onBrush([start, end]); if (end < start) end = start;
}) return [start, end];
.on("end", (event: d3.D3BrushEvent<unknown>) => { },
isUserBrushingRef.current = false; [data.length, minTs, maxTs],
if (isProgrammaticMoveRef.current) return; );
if (!event.selection) onBrush(null);
});
const brushG = root.append("g").call(brush); const clientXToTs = useCallback(
brushGroupRef.current = brushG; (clientX: number) => {
brushRef.current = brush; if (!containerRef.current || !xScale || innerWidth <= 0) return minTs;
const rect = containerRef.current.getBoundingClientRect();
const chartX = Math.max(
0,
Math.min(innerWidth, clientX - rect.left - margin.left),
);
return Math.floor(xScale.invert(chartX).getTime() / 1000);
},
[xScale, innerWidth, margin.left, minTs],
);
brushG const beginDrag = useCallback(
.selectAll("rect.selection") (event: ReactPointerEvent<HTMLDivElement>) => {
.attr("fill", brushedColor) if (!data.length || !xScale) return;
.attr("stroke", "#6366f1") const target = event.target as HTMLElement | null;
.attr("stroke-width", 1); const part = (target?.dataset.brushPart as
brushG | "background"
.selectAll("rect.handle") | "selection"
.attr("fill", "#6366f1") | "left"
.attr("stroke", "#fff") | "right"
.attr("rx", 2) | undefined) ?? "background";
.attr("ry", 2) const mode: "new" | "move" | "left" | "right" =
.style("cursor", "ew-resize"); part === "left"
}, [data, width, margin.left, margin.top, innerHeight, onBrush]); ? "left"
: part === "right"
? "right"
: part === "selection"
? "move"
: "new";
const startRange =
visibleSelection ?? ([minTs, maxTs] as [number, number]);
dragRef.current = {
mode,
startRange,
startTs: clientXToTs(event.clientX),
pointerId: event.pointerId,
};
event.currentTarget.setPointerCapture(event.pointerId);
if (mode === "new") {
const ts = clientXToTs(event.clientX);
onBrush(clampRange([ts, ts]));
}
},
[
clientXToTs,
clampRange,
data.length,
maxTs,
minTs,
onBrush,
visibleSelection,
xScale,
],
);
// Keep the brush selection in sync with external changes (zoom buttons / reset) const updateDrag = useCallback(
useEffect(() => { (event: ReactPointerEvent<HTMLDivElement>) => {
if (!brushGroupRef.current || !brushRef.current || !brushXRef.current) const drag = dragRef.current;
return; if (!drag) return;
if (isUserBrushingRef.current) return; const ts = clientXToTs(event.clientX);
let nextRange: [number, number];
if (drag.mode === "move") {
const delta = ts - drag.startTs;
nextRange = [drag.startRange[0] + delta, drag.startRange[1] + delta];
} else if (drag.mode === "left") {
nextRange = [ts, drag.startRange[1]];
} else if (drag.mode === "right") {
nextRange = [drag.startRange[0], ts];
} else {
nextRange = [drag.startRange[0], ts];
}
onBrush(clampRange(nextRange));
},
[clientXToTs, clampRange, onBrush],
);
const x = brushXRef.current; const finishDrag = useCallback(
const brush = brushRef.current; (event: ReactPointerEvent<HTMLDivElement>) => {
const brushG = brushGroupRef.current; const drag = dragRef.current;
if (!drag) return;
try {
event.currentTarget.releasePointerCapture(drag.pointerId);
} catch {
// ignore release failures
}
dragRef.current = null;
},
[],
);
const selection = selectionRange if (!data.length) {
? ([x(selectionRange[0]), x(selectionRange[1])] as [number, number]) return (
: (x.range() as unknown as [number, number]); <Typography color="text.secondary">
No monitoring samples available.
isProgrammaticMoveRef.current = true; </Typography>
const moveBrush = brush.move as unknown as ( );
group: d3.Selection<SVGGElement, unknown, null, unknown>, }
selection: d3.BrushSelection,
) => void;
moveBrush(brushG, selection as d3.BrushSelection);
window.setTimeout(() => {
isProgrammaticMoveRef.current = false;
}, 0);
}, [selectionRange, width, margin.left, margin.right]);
const totalHeight = BRUSH_LABEL_HEIGHT + BRUSH_HEIGHT;
return ( return (
<Box sx={{ width: "100%" }}> <Box sx={{ width: "100%" }}>
@@ -302,28 +380,167 @@ function MonitoringBrush({ data, selectionRange, onBrush }: BrushProps) {
</Typography> </Typography>
<Box <Box
ref={containerRef} ref={containerRef}
sx={{ width: "100%", height: totalHeight, px: 2 }} sx={{ width: "100%", height: totalHeight, px: 2, position: "relative" }}
> >
<svg <svg
ref={svgRef} ref={svgRef}
width={width} width={width}
height={totalHeight} height={totalHeight}
style={{ overflow: "visible" }} style={{ overflow: "visible", position: "absolute", inset: 0 }}
/> />
<Box
sx={{
position: "absolute",
inset: 0,
pointerEvents: "none",
}}
>
<Box
sx={{
position: "absolute",
left: margin.left,
top: margin.top,
width: innerWidth,
height: innerHeight,
pointerEvents: "auto",
touchAction: "none",
userSelect: "none",
}}
onPointerDown={beginDrag}
onPointerMove={updateDrag}
onPointerUp={finishDrag}
onPointerCancel={finishDrag}
>
<Box
data-brush-part="background"
sx={{
position: "absolute",
inset: 0,
cursor: "crosshair",
backgroundColor: "transparent",
}}
/>
{selectionPixels ? (
<>
<Box
data-brush-part="selection"
sx={{
position: "absolute",
left: Math.min(selectionPixels[0], selectionPixels[1]),
top: 0,
width: Math.max(
1,
Math.abs(selectionPixels[1] - selectionPixels[0]),
),
height: "100%",
backgroundColor: brushedColor,
border: "1px solid #6366f1",
boxSizing: "border-box",
cursor: "move",
}}
/>
<Box
data-brush-part="left"
sx={{
position: "absolute",
left: Math.min(selectionPixels[0], selectionPixels[1]) - 8,
top: 0,
width: 16,
height: "100%",
backgroundColor: "transparent",
cursor: "ew-resize",
pointerEvents: "auto",
}}
>
<Box
sx={{
position: "absolute",
top: 6,
bottom: 6,
left: 6,
width: 4,
borderRadius: 999,
backgroundColor: "#6366f1",
boxShadow: 1,
}}
/>
</Box>
<Box
data-brush-part="right"
sx={{
position: "absolute",
left: Math.max(selectionPixels[0], selectionPixels[1]) - 8,
top: 0,
width: 16,
height: "100%",
backgroundColor: "transparent",
cursor: "ew-resize",
pointerEvents: "auto",
}}
>
<Box
sx={{
position: "absolute",
top: 6,
bottom: 6,
right: 6,
width: 4,
borderRadius: 999,
backgroundColor: "#6366f1",
boxShadow: 1,
}}
/>
</Box>
</>
) : null}
</Box>
</Box>
</Box> </Box>
</Box> </Box>
); );
} }
// ══════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════
// Parent: MonitoringCharts // Parent: MonitoringCharts
// ══════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════
function loadPersistedSelection(): [number, number] | null {
if (typeof window === "undefined") return null;
const raw = window.localStorage.getItem(PERSISTED_SELECTION_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as unknown;
if (
Array.isArray(parsed) &&
parsed.length === 2 &&
typeof parsed[0] === "number" &&
typeof parsed[1] === "number"
) {
return [parsed[0], parsed[1]];
}
} catch {
return null;
}
return null;
}
export function MonitoringCharts({ samples }: Props) { export function MonitoringCharts({ samples }: Props) {
const [showAverages, setShowAverages] = useState(false); const [showAverages, setShowAverages] = useState(false);
const [selectionRange, setSelectionRange] = useState<[number, number] | null>( const [selectionRangeState, setSelectionRangeState] = useState<
null, [number, number] | null
); >(() => loadPersistedSelection());
const selectionRange = selectionRangeState;
const setSelectionRange = useCallback((range: [number, number] | null) => {
setSelectionRangeState(range);
if (typeof window === "undefined") return;
if (range) {
window.localStorage.setItem(
PERSISTED_SELECTION_KEY,
JSON.stringify(range),
);
} else {
window.localStorage.removeItem(PERSISTED_SELECTION_KEY);
}
}, []);
const baseData = useMemo<DataPoint[]>( const baseData = useMemo<DataPoint[]>(
() => () =>
+149 -20
View File
@@ -1,5 +1,14 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { Box, Divider, Grid, Stack, Typography } from "@mui/material"; import {
Box,
Card,
CardContent,
Divider,
Grid,
LinearProgress,
Stack,
Typography,
} from "@mui/material";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useCounts, useLibraries, useActivity } from "../hooks/useDashboard"; import { useCounts, useLibraries, useActivity } from "../hooks/useDashboard";
import { useMonitoringMetrics, useDiskSpace } from "../hooks/useMonitoring"; import { useMonitoringMetrics, useDiskSpace } from "../hooks/useMonitoring";
@@ -37,6 +46,135 @@ function summarize(values: number[]) {
}; };
} }
function DiskSpaceCard({
used,
available,
size,
usedPct,
}: {
used: number;
available: number;
size: number;
usedPct: string;
}) {
const pct = Math.max(0, Math.min(100, Number.parseFloat(usedPct) || 0));
const barColor = pct < 70 ? "success" : pct < 90 ? "warning" : "error";
return (
<Card variant="outlined" sx={{ height: "100%" }}>
<CardContent sx={{ p: { xs: 1.5, sm: 2 } }}>
<Stack spacing={1.5}>
<Box>
<Typography
variant="caption"
color="text.secondary"
sx={{ textTransform: "uppercase" }}
>
Disk space
</Typography>
<Typography
variant="h5"
sx={{
fontWeight: 700,
fontSize: { xs: "1.05rem", sm: "1.5rem" },
}}
>
{usedPct} used
</Typography>
</Box>
<Box sx={{ width: "100%" }}>
<LinearProgress
variant="determinate"
value={pct}
color={barColor}
sx={{
height: 12,
borderRadius: 999,
bgcolor: "action.hover",
"& .MuiLinearProgress-bar": {
borderRadius: 999,
},
}}
/>
</Box>
<Grid container spacing={1.5} sx={{ alignItems: "stretch" }}>
<Grid size={{ xs: 12, sm: 4 }}>
<Box
sx={{
p: 1.5,
borderRadius: 2,
bgcolor: "action.hover",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
gap: 0.25,
}}
>
<Typography variant="caption" color="text.secondary">
Used
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{formatBytes(used)}
</Typography>
</Box>
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<Box
sx={{
p: 1.5,
borderRadius: 2,
bgcolor: "action.hover",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
gap: 0.25,
}}
>
<Typography variant="caption" color="text.secondary">
Free
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{formatBytes(available)}
</Typography>
</Box>
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<Box
sx={{
p: 1.5,
borderRadius: 2,
bgcolor: "action.hover",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
gap: 0.25,
}}
>
<Typography variant="caption" color="text.secondary">
Total
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{formatBytes(size)}
</Typography>
</Box>
</Grid>
</Grid>
</Stack>
</CardContent>
</Card>
);
}
export function Dashboard() { export function Dashboard() {
const navigate = useNavigate(); const navigate = useNavigate();
const { data: counts } = useCounts(); const { data: counts } = useCounts();
@@ -100,7 +238,7 @@ export function Dashboard() {
<Typography variant="h5" sx={{ mb: 1.5 }}> <Typography variant="h5" sx={{ mb: 1.5 }}>
Monitoring Overview Monitoring Overview
</Typography> </Typography>
<Grid container spacing={1.5}> <Grid container spacing={2} sx={{ alignItems: "stretch" }}>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}> <Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard <MetricCard
label="CPU (10m avg)" label="CPU (10m avg)"
@@ -180,23 +318,14 @@ export function Dashboard() {
</Grid> </Grid>
</Grid> </Grid>
{disk && ( {disk && (
<Grid container spacing={1.5} sx={{ mt: 0.5 }}> <Box sx={{ mt: 0.5 }}>
<Grid size={{ xs: 6, md: 3 }}> <DiskSpaceCard
<MetricCard label="Disk used" value={formatBytes(disk.used)} /> used={disk.used}
</Grid> available={disk.available}
<Grid size={{ xs: 6, md: 3 }}> size={disk.size}
<MetricCard usedPct={disk.used_pct}
label="Disk available" />
value={formatBytes(disk.available)} </Box>
/>
</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> </Box>
@@ -207,7 +336,7 @@ export function Dashboard() {
Library Stats Library Stats
</Typography> </Typography>
{counts && ( {counts && (
<Grid container spacing={1.5} sx={{ mb: 2 }}> <Grid container spacing={2} sx={{ mb: 2, alignItems: "stretch" }}>
<Grid size={{ xs: 6, md: 3 }}> <Grid size={{ xs: 6, md: 3 }}>
<MetricCard <MetricCard
label="Total" label="Total"