diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index dd6de8b..23749bb 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -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. - 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 `/`. +- 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. - 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. @@ -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. - 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 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 @@ -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-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 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: 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. diff --git a/frontend/src/components/MetricCard.tsx b/frontend/src/components/MetricCard.tsx index 0032bcb..fe4bb33 100644 --- a/frontend/src/components/MetricCard.tsx +++ b/frontend/src/components/MetricCard.tsx @@ -9,18 +9,25 @@ interface Props { export function MetricCard({ label, value, subtext }: Props) { return ( - + {label} {subtext} diff --git a/frontend/src/components/MonitoringCharts.tsx b/frontend/src/components/MonitoringCharts.tsx index 27dde6b..ace1388 100644 --- a/frontend/src/components/MonitoringCharts.tsx +++ b/frontend/src/components/MonitoringCharts.tsx @@ -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 { Box, @@ -62,6 +70,7 @@ const MOVING_AVG_WINDOW = 10; const CHART_HEIGHT = 280; const BRUSH_HEIGHT = 84; const BRUSH_LABEL_HEIGHT = 24; +const PERSISTED_SELECTION_KEY = "manage.monitoring.brush.selection"; function formatBytes(bytes: number): string { if (!bytes) return "0 B/s"; @@ -120,21 +129,39 @@ function MonitoringBrush({ data, selectionRange, onBrush }: BrushProps) { const containerRef = useRef(null); const svgRef = useRef(null); const [width, setWidth] = useState(0); - const brushGroupRef = useRef | null>(null); - const brushRef = useRef | null>(null); - const brushXRef = useRef | null>(null); - const isUserBrushingRef = useRef(false); - const isProgrammaticMoveRef = useRef(false); + const dragRef = useRef<{ + mode: "new" | "move" | "left" | "right"; + startRange: [number, number]; + startTs: number; + pointerId: number; + } | null>(null); const margin = { top: 18, right: 24, bottom: 22, left: 48 }; const innerHeight = BRUSH_HEIGHT - margin.top - margin.bottom; + const totalHeight = BRUSH_LABEL_HEIGHT + BRUSH_HEIGHT; 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(() => { if (!containerRef.current) return; const observer = new ResizeObserver((entries) => { @@ -144,11 +171,11 @@ function MonitoringBrush({ data, selectionRange, onBrush }: BrushProps) { return () => observer.disconnect(); }, []); - // Build the brush UI when the available data or layout width changes. - useEffect(() => { + // Draw the mini chart and axes with D3, while the interactive brush UI is + // rendered by React so it stays visible across redraws. + useLayoutEffect(() => { 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); svg.selectAll("*").remove(); @@ -156,13 +183,10 @@ function MonitoringBrush({ data, selectionRange, onBrush }: BrushProps) { .append("g") .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 .scaleTime() .domain([new Date(minTs * 1000), new Date(maxTs * 1000)]) .range([0, innerWidth]); - brushXRef.current = x; const yMax = Math.max(1, d3.max(data, (d) => Math.max(d.cpu, d.mem)) ?? 1); const y = d3 @@ -221,75 +245,129 @@ function MonitoringBrush({ data, selectionRange, onBrush }: BrushProps) { .attr("opacity", 0.6) .attr("d", line); }); + }, [data, width, margin.left, margin.top, innerHeight, innerWidth, minTs, maxTs]); - const brush = d3 - .brushX() - .handleSize(14) - .extent([ - [0, 0], - [innerWidth, innerHeight], - ]) - .on("start", () => { - isUserBrushingRef.current = true; - }) - .on("brush", (event: d3.D3BrushEvent) => { - if (isProgrammaticMoveRef.current) return; - if (!event.selection) return; - const sel = event.selection as [number, number]; - const start = Math.floor(x.invert(sel[0]).getTime() / 1000); - const end = Math.floor(x.invert(sel[1]).getTime() / 1000); - onBrush([start, end]); - }) - .on("end", (event: d3.D3BrushEvent) => { - isUserBrushingRef.current = false; - if (isProgrammaticMoveRef.current) return; - if (!event.selection) onBrush(null); - }); + const clampRange = useCallback( + (range: [number, number]): [number, number] => { + if (!data.length) return range; + let [start, end] = range[0] <= range[1] ? range : [range[1], range[0]]; + const domainStart = minTs; + const domainEnd = maxTs; + if (start < domainStart) { + end += domainStart - start; + start = domainStart; + } + if (end > domainEnd) { + start -= end - domainEnd; + end = domainEnd; + } + start = Math.max(domainStart, start); + end = Math.min(domainEnd, end); + if (end < start) end = start; + return [start, end]; + }, + [data.length, minTs, maxTs], + ); - const brushG = root.append("g").call(brush); - brushGroupRef.current = brushG; - brushRef.current = brush; + const clientXToTs = useCallback( + (clientX: number) => { + 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 - .selectAll("rect.selection") - .attr("fill", brushedColor) - .attr("stroke", "#6366f1") - .attr("stroke-width", 1); - brushG - .selectAll("rect.handle") - .attr("fill", "#6366f1") - .attr("stroke", "#fff") - .attr("rx", 2) - .attr("ry", 2) - .style("cursor", "ew-resize"); - }, [data, width, margin.left, margin.top, innerHeight, onBrush]); + const beginDrag = useCallback( + (event: ReactPointerEvent) => { + if (!data.length || !xScale) return; + const target = event.target as HTMLElement | null; + const part = (target?.dataset.brushPart as + | "background" + | "selection" + | "left" + | "right" + | undefined) ?? "background"; + const mode: "new" | "move" | "left" | "right" = + part === "left" + ? "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) - useEffect(() => { - if (!brushGroupRef.current || !brushRef.current || !brushXRef.current) - return; - if (isUserBrushingRef.current) return; + const updateDrag = useCallback( + (event: ReactPointerEvent) => { + const drag = dragRef.current; + if (!drag) 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 brush = brushRef.current; - const brushG = brushGroupRef.current; + const finishDrag = useCallback( + (event: ReactPointerEvent) => { + const drag = dragRef.current; + if (!drag) return; + try { + event.currentTarget.releasePointerCapture(drag.pointerId); + } catch { + // ignore release failures + } + dragRef.current = null; + }, + [], + ); - const selection = selectionRange - ? ([x(selectionRange[0]), x(selectionRange[1])] as [number, number]) - : (x.range() as unknown as [number, number]); - - isProgrammaticMoveRef.current = true; - const moveBrush = brush.move as unknown as ( - group: d3.Selection, - 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; + if (!data.length) { + return ( + + No monitoring samples available. + + ); + } return ( @@ -302,28 +380,167 @@ function MonitoringBrush({ data, selectionRange, onBrush }: BrushProps) { + + + + {selectionPixels ? ( + <> + + + + + + + + + ) : null} + + ); } - // ══════════════════════════════════════════════════════════════════════════ // 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) { const [showAverages, setShowAverages] = useState(false); - const [selectionRange, setSelectionRange] = useState<[number, number] | null>( - null, - ); + const [selectionRangeState, setSelectionRangeState] = useState< + [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( () => diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index f1fa2a6..b8bca54 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,5 +1,14 @@ 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 { useCounts, useLibraries, useActivity } from "../hooks/useDashboard"; 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 ( + + + + + + Disk space + + + {usedPct} used + + + + + + + + + + + + Used + + + {formatBytes(used)} + + + + + + + Free + + + {formatBytes(available)} + + + + + + + Total + + + {formatBytes(size)} + + + + + + + + ); +} + export function Dashboard() { const navigate = useNavigate(); const { data: counts } = useCounts(); @@ -100,7 +238,7 @@ export function Dashboard() { Monitoring Overview - + {disk && ( - - - - - - - - - - - - - - + + + )} @@ -207,7 +336,7 @@ export function Dashboard() { Library Stats {counts && ( - +