Phase 2: Docker and OIDC auth

This commit is contained in:
2026-05-04 13:50:53 +02:00
parent 47baee854b
commit 4226628d5a
71 changed files with 9722 additions and 1347 deletions
+220 -46
View File
@@ -1,62 +1,236 @@
import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom";
import {
BrowserRouter,
Routes,
Route,
NavLink,
useLocation,
} from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "@mui/material/styles";
import {
AppBar,
Toolbar,
Typography,
Box,
Tabs,
Tab,
Container,
CssBaseline,
Chip,
useMediaQuery,
Button,
Stack,
Card,
CardContent,
CircularProgress,
} from "@mui/material";
import { useEffect, useMemo } from "react";
import { AuthProvider, useAuth } from "react-oidc-context";
import { Dashboard } from "./pages/Dashboard";
import { Monitoring } from "./pages/Monitoring";
import { Media } from "./pages/Media";
import { UsersPage } from "./pages/Users";
import { FileBrowser } from "./pages/FileBrowser";
import { getAppTheme } from "./theme";
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
},
},
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
});
const navLinks = [
{ to: "/", label: "Dashboard" },
{ to: "/monitoring", label: "Monitoring" },
{ to: "/media", label: "Media" },
{ to: "/files", label: "File Browser" },
];
function Shell({
darkMode,
authLabel,
onSignOut,
}: {
darkMode: boolean;
authLabel?: string;
onSignOut?: () => void;
}) {
const location = useLocation();
const current = location.pathname;
function NavBar() {
return (
<nav className="border-b px-6 py-3 flex gap-6 items-center bg-white sticky top-0 z-10">
<span className="font-bold text-lg mr-4">Media Library Viewer</span>
{navLinks.map((link) => (
<NavLink
key={link.to}
to={link.to}
end={link.to === "/"}
className={({ isActive }) =>
`text-sm px-2 py-1 rounded ${isActive ? "bg-gray-100 font-medium" : "text-gray-600 hover:text-gray-900"}`
}
>
{link.label}
</NavLink>
))}
</nav>
<>
<CssBaseline />
<AppBar position="sticky" color="inherit" elevation={0}>
<Toolbar sx={{ display: "flex", gap: 2, minHeight: 68 }}>
<Typography variant="h6" sx={{ mr: 2, fontWeight: 700 }}>
Media Library Viewer
</Typography>
<Tabs
value={current}
textColor="primary"
indicatorColor="primary"
sx={{ flex: 1 }}
>
<Tab value="/" label="Dashboard" component={NavLink} to="/" />
<Tab
value="/monitoring"
label="Monitoring"
component={NavLink}
to="/monitoring"
/>
<Tab value="/media" label="Media" component={NavLink} to="/media" />
<Tab value="/users" label="Users" component={NavLink} to="/users" />
<Tab
value="/files"
label="File Browser"
component={NavLink}
to="/files"
/>
</Tabs>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
{authLabel && (
<Chip size="small" variant="outlined" label={authLabel} />
)}
<Chip
size="small"
variant="outlined"
label={darkMode ? "Dark" : "Light"}
/>
{onSignOut && (
<Button size="small" variant="text" onClick={onSignOut}>
Sign out
</Button>
)}
</Stack>
</Toolbar>
</AppBar>
<Container maxWidth={false} sx={{ py: 3 }}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/monitoring" element={<Monitoring />} />
<Route path="/media" element={<Media />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/files" element={<FileBrowser />} />
</Routes>
</Container>
</>
);
}
function LoadingScreen({ label }: { label: string }) {
return (
<Box
sx={{
minHeight: "100vh",
display: "grid",
placeItems: "center",
p: 2,
}}
>
<Card variant="outlined" sx={{ maxWidth: 420, width: "100%" }}>
<CardContent>
<Stack spacing={2} sx={{ alignItems: "center", textAlign: "center" }}>
<CircularProgress />
<Typography variant="h6">{label}</Typography>
</Stack>
</CardContent>
</Card>
</Box>
);
}
function SignInScreen({ onSignIn }: { onSignIn: () => void }) {
return (
<Box
sx={{
minHeight: "100vh",
display: "grid",
placeItems: "center",
p: 2,
}}
>
<Card variant="outlined" sx={{ maxWidth: 460, width: "100%" }}>
<CardContent>
<Stack spacing={2} sx={{ alignItems: "center", textAlign: "center" }}>
<Typography variant="h5">Sign in required</Typography>
<Typography variant="body2" color="text.secondary">
Use your Authentik account to access the media library viewer.
</Typography>
<Button variant="contained" onClick={onSignIn}>
Sign in with OIDC
</Button>
</Stack>
</CardContent>
</Card>
</Box>
);
}
function AuthenticatedApp({ darkMode }: { darkMode: boolean }) {
const auth = useAuth();
useEffect(() => {
setAccessToken(auth.user?.access_token ?? null);
}, [auth.user?.access_token]);
const authLabel = useMemo(() => {
const profile = auth.user?.profile as Record<string, unknown> | undefined;
return String(
profile?.name ??
profile?.preferred_username ??
profile?.email ??
auth.user?.profile?.sub ??
"Authenticated",
);
}, [auth.user]);
if (auth.isLoading || auth.activeNavigator) {
return <LoadingScreen label="Checking sign-in…" />;
}
if (auth.error) {
return (
<LoadingScreen
label={`Authentication error: ${auth.error.message || "Unable to sign in"}`}
/>
);
}
if (!auth.isAuthenticated) {
return <SignInScreen onSignIn={() => void auth.signinRedirect()} />;
}
return (
<Box sx={{ minHeight: "100vh", bgcolor: "background.default" }}>
<BrowserRouter>
<Shell
darkMode={darkMode}
authLabel={authLabel}
onSignOut={() => void auth.signoutRedirect()}
/>
</BrowserRouter>
</Box>
);
}
function AppInner() {
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
const theme = useMemo(
() => getAppTheme(prefersDarkMode ? "dark" : "light"),
[prefersDarkMode],
);
return (
<ThemeProvider theme={theme}>
<QueryClientProvider client={queryClient}>
{isOidcConfigured() ? (
<AuthProvider {...getOidcConfig()}>
<AuthenticatedApp darkMode={prefersDarkMode} />
</AuthProvider>
) : (
<Box sx={{ minHeight: "100vh", bgcolor: "background.default" }}>
<BrowserRouter>
<Shell darkMode={prefersDarkMode} />
</BrowserRouter>
</Box>
)}
</QueryClientProvider>
</ThemeProvider>
);
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<div className="min-h-screen bg-gray-50">
<NavBar />
<main className="max-w-screen-2xl mx-auto px-6 py-6">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/monitoring" element={<Monitoring />} />
<Route path="/media" element={<Media />} />
<Route path="/files" element={<FileBrowser />} />
</Routes>
</main>
</div>
</BrowserRouter>
</QueryClientProvider>
);
return <AppInner />;
}
+98 -18
View File
@@ -2,14 +2,20 @@
* Typed API client for the FastAPI backend.
*/
import { getAccessToken } from "../auth";
import type {
MediaCounts,
LibraryCount,
UserDirectoryResponse,
UserMessageResponse,
UserMessageQueueStatus,
SmtpTestResponse,
NowPlayingSession,
MonitoringStatus,
MonitoringMetrics,
DiskSpace,
MediaIndexStatus,
MediaIndexActionResponse,
MediaQueryResponse,
DirectoryListing,
JobTemplate,
@@ -17,36 +23,89 @@ import type {
ResolvedPath,
} from "../types";
const BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";
const BASE_URL = import.meta.env.VITE_API_URL || "/api";
function isAbsoluteUrl(value: string): boolean {
return /^https?:\/\//i.test(value) || value.startsWith("//");
}
function buildUrl(path: string, params?: Record<string, string>): string {
if (!isAbsoluteUrl(BASE_URL)) {
const url = new URL(path, window.location.origin);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== "")
url.searchParams.set(key, value);
});
}
return url.toString();
}
async function get<T>(
path: string,
params?: Record<string, string>,
): Promise<T> {
const url = new URL(path, BASE_URL);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== "") url.searchParams.set(key, value);
});
}
const response = await fetch(url.toString());
return url.toString();
}
async function readErrorDetail(response: Response): Promise<string> {
const text = await response.text();
try {
const parsed = JSON.parse(text) as { detail?: unknown; message?: unknown };
const detail = parsed.detail ?? parsed.message;
if (typeof detail === "string" && detail.trim()) {
return detail;
}
} catch {
// Fall back to the raw response body below.
}
return text;
}
function buildHeaders(isJsonBody: boolean): Headers {
const headers = new Headers();
const token = getAccessToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
if (isJsonBody) headers.set("Content-Type", "application/json");
return headers;
}
async function get<T>(
path: string,
params?: Record<string, string>,
): Promise<T> {
const response = await fetch(buildUrl(path, params), {
headers: buildHeaders(false),
});
if (!response.ok) {
const detail = await response.text();
throw new Error(`${response.status}: ${detail}`);
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
}
return response.json();
}
async function post<T>(path: string, body?: unknown): Promise<T> {
const url = new URL(path, BASE_URL);
const response = await fetch(url.toString(), {
const response = await fetch(buildUrl(path), {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: buildHeaders(true),
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const detail = await response.text();
throw new Error(`${response.status}: ${detail}`);
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
}
return response.json();
}
async function postForm<T>(path: string, body: FormData): Promise<T> {
const headers = buildHeaders(false);
const response = await fetch(buildUrl(path), {
method: "POST",
headers,
body,
});
if (!response.ok) {
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
}
return response.json();
}
@@ -55,15 +114,23 @@ async function post<T>(path: string, body?: unknown): Promise<T> {
export const fetchCounts = () => get<MediaCounts>("/api/dashboard/counts");
export const fetchLibraries = () =>
get<LibraryCount[]>("/api/dashboard/libraries");
export const fetchNowPlaying = () =>
get<NowPlayingSession[]>("/api/dashboard/now-playing");
export const fetchActivity = () =>
get<NowPlayingSession[]>("/api/dashboard/activity");
export const fetchUsers = () => get<UserDirectoryResponse>("/api/users");
// Backward-compatible alias used by older hooks/components.
export const fetchNowPlaying = fetchActivity;
// Monitoring
export const fetchMonitoringStatus = () =>
get<MonitoringStatus>("/api/monitoring/status");
export const fetchMonitoringMetrics = (lastSeconds = 3600) =>
export const fetchMonitoringMetrics = (
lastSeconds?: number | null,
maxLines = 70_000,
) =>
get<MonitoringMetrics>("/api/monitoring/metrics", {
last_seconds: String(lastSeconds),
...(lastSeconds == null ? {} : { last_seconds: String(lastSeconds) }),
max_lines: String(maxLines),
});
export const fetchDiskSpace = () => get<DiskSpace>("/api/monitoring/disk");
export const startCollector = () =>
@@ -77,7 +144,11 @@ export const restartCollector = () =>
export const fetchMediaStatus = () =>
get<MediaIndexStatus>("/api/media/status");
export const buildMediaIndex = () =>
post<{ indexed_items: number }>("/api/media/build");
post<MediaIndexActionResponse>("/api/media/build");
export const stopMediaIndexBuild = () =>
post<MediaIndexActionResponse>("/api/media/stop");
export const forceStopMediaIndexBuild = () =>
post<MediaIndexActionResponse>("/api/media/force-stop");
export const queryMedia = (params: {
libraries?: string;
types?: string;
@@ -114,3 +185,12 @@ export const fetchJobTemplates = () =>
get<JobTemplate[]>("/api/jobs/templates");
export const runJob = (jobKey: string, path: string) =>
post<JobResult>("/api/jobs/run", { job_key: jobKey, path });
export const fetchUserMessageQueueStatus = () =>
get<UserMessageQueueStatus>("/api/users/message/status");
export const testUserSmtpConnection = () =>
post<SmtpTestResponse>("/api/users/message/test-smtp");
export const sendUserMessage = (formData: FormData) =>
postForm<UserMessageResponse>("/api/users/message", formData);
+42
View File
@@ -0,0 +1,42 @@
let accessToken: string | null = null;
export function isOidcConfigured(): boolean {
const enabled =
(import.meta.env.VITE_OIDC_ENABLED ?? "true").toLowerCase() !== "false";
return Boolean(
enabled &&
import.meta.env.VITE_OIDC_ISSUER &&
import.meta.env.VITE_OIDC_CLIENT_ID,
);
}
export function getOidcConfig() {
return {
authority: import.meta.env.VITE_OIDC_ISSUER as string,
client_id: import.meta.env.VITE_OIDC_CLIENT_ID as string,
redirect_uri:
import.meta.env.VITE_OIDC_REDIRECT_URI || window.location.origin,
post_logout_redirect_uri:
import.meta.env.VITE_OIDC_POST_LOGOUT_REDIRECT_URI ||
window.location.origin,
scope: import.meta.env.VITE_OIDC_SCOPE || "openid profile email",
response_type: "code" as const,
automaticSilentRenew: false,
loadUserInfo: true,
onSigninCallback: () => {
window.history.replaceState(
{},
document.title,
window.location.pathname + window.location.search,
);
},
};
}
export function setAccessToken(token: string | null | undefined) {
accessToken = token ?? null;
}
export function getAccessToken(): string | null {
return accessToken;
}
+39 -38
View File
@@ -1,3 +1,4 @@
import { Card, CardContent, Grid, Stack, Typography } from "@mui/material";
import type { LibraryCount } from "../types";
interface Props {
@@ -9,47 +10,47 @@ export function LibraryOverview({ libraries }: Props) {
const tvLibs = libraries.filter((l) => l.type === "tvshows");
return (
<div className="grid grid-cols-2 gap-6">
{movieLibs.length > 0 && (
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide mb-2">
Movie libraries
</p>
<Grid container spacing={2}>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
Movie libraries
</Typography>
<Stack spacing={1.5}>
{movieLibs.map((lib) => (
<div key={lib.library} className="rounded-lg border p-4 mb-2">
<p className="font-semibold">{lib.library}</p>
<div className="flex gap-6 mt-2 text-sm">
<span>
Total: <strong>{lib.total.toLocaleString()}</strong>
</span>
<span>
Movies: <strong>{lib.movies.toLocaleString()}</strong>
</span>
</div>
</div>
<Card key={lib.library} variant="outlined">
<CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{lib.library}
</Typography>
<Typography variant="body2" color="text.secondary">
Total: {lib.total.toLocaleString()} | Movies:{" "}
{lib.movies.toLocaleString()}
</Typography>
</CardContent>
</Card>
))}
</div>
)}
{tvLibs.length > 0 && (
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide mb-2">
TV libraries
</p>
</Stack>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
TV libraries
</Typography>
<Stack spacing={1.5}>
{tvLibs.map((lib) => (
<div key={lib.library} className="rounded-lg border p-4 mb-2">
<p className="font-semibold">{lib.library}</p>
<div className="flex gap-6 mt-2 text-sm">
<span>
Total: <strong>{lib.total.toLocaleString()}</strong>
</span>
<span>
Series: <strong>{lib.series.toLocaleString()}</strong>
</span>
</div>
</div>
<Card key={lib.library} variant="outlined">
<CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{lib.library}
</Typography>
<Typography variant="body2" color="text.secondary">
Total: {lib.total.toLocaleString()} | Series:{" "}
{lib.series.toLocaleString()}
</Typography>
</CardContent>
</Card>
))}
</div>
)}
</div>
</Stack>
</Grid>
</Grid>
);
}
+25 -9
View File
@@ -1,3 +1,5 @@
import { Card, CardContent, Typography } from "@mui/material";
interface Props {
label: string;
value: string;
@@ -6,14 +8,28 @@ interface Props {
export function MetricCard({ label, value, subtext }: Props) {
return (
<div className="rounded-lg border p-4">
<p className="text-xs text-gray-500 uppercase tracking-wide">{label}</p>
<p className="text-2xl font-bold mt-1">{value}</p>
{subtext && (
<p className="text-xs text-gray-400 mt-1 whitespace-pre-line">
{subtext}
</p>
)}
</div>
<Card variant="outlined">
<CardContent>
<Typography
variant="caption"
color="text.secondary"
sx={{ textTransform: "uppercase" }}
>
{label}
</Typography>
<Typography variant="h5" sx={{ mt: 0.5, fontWeight: 700 }}>
{value}
</Typography>
{subtext && (
<Typography
variant="caption"
color="text.secondary"
sx={{ whiteSpace: "pre-line" }}
>
{subtext}
</Typography>
)}
</CardContent>
</Card>
);
}
+784 -148
View File
@@ -1,28 +1,70 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import * as d3 from "d3";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
Box,
Button,
Card,
CardContent,
Checkbox,
Chip,
FormControlLabel,
Grid,
Typography,
} from "@mui/material";
import type { MonitoringSample } from "../types";
interface Props {
samples: MonitoringSample[];
}
function formatTime(ts: number) {
return new Date(ts * 1000).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
type MetricKey =
| "cpu"
| "iowait"
| "mem"
| "netDown"
| "netUp"
| "diskRead"
| "diskWrite";
interface DataPoint {
ts: number;
cpu: number;
iowait: number;
mem: number;
netDown: number;
netUp: number;
diskRead: number;
diskWrite: number;
}
interface MetricConfig {
key: MetricKey;
label: string;
color: string;
}
interface ChartProps {
title: string;
data: DataPoint[];
metrics: MetricConfig[];
showAverages: boolean;
averages: Record<MetricKey, number[]>;
yFormatter?: (v: number) => string;
}
interface BrushProps {
data: DataPoint[];
selectionRange: [number, number] | null;
onBrush: (range: [number, number] | null) => void;
}
const MOVING_AVG_WINDOW = 10;
const CHART_HEIGHT = 280;
const BRUSH_HEIGHT = 84;
const BRUSH_LABEL_HEIGHT = 24;
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B/s";
if (!bytes) return "0 B/s";
const units = ["B/s", "KB/s", "MB/s", "GB/s"];
let value = bytes;
let unitIdx = 0;
@@ -33,147 +75,741 @@ function formatBytes(bytes: number): string {
return `${value.toFixed(1)} ${units[unitIdx]}`;
}
function formatTime(ts: number) {
return new Date(ts * 1000).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
}
function movingAverage(values: number[]): number[] {
if (values.length === 0) return [];
return values.map((_, index) => {
const start = Math.max(0, index - MOVING_AVG_WINDOW + 1);
const slice = values.slice(start, index + 1);
return slice.reduce((sum, value) => sum + value, 0) / slice.length;
});
}
function buildAverages(samples: DataPoint[]): Record<string, number[]> {
return {
cpu: movingAverage(samples.map((sample) => sample.cpu)),
iowait: movingAverage(samples.map((sample) => sample.iowait)),
mem: movingAverage(samples.map((sample) => sample.mem)),
netDown: movingAverage(samples.map((sample) => sample.netDown)),
netUp: movingAverage(samples.map((sample) => sample.netUp)),
diskRead: movingAverage(samples.map((sample) => sample.diskRead)),
diskWrite: movingAverage(samples.map((sample) => sample.diskWrite)),
};
}
function formatRangeLabel(range: [number, number] | null) {
if (!range) return "Full range";
return `${formatTime(range[0])} ${formatTime(range[1])}`;
}
function metricsKey(metrics: MetricConfig[]) {
return metrics.map((m) => `${m.key}:${m.label}:${m.color}`).join("|");
}
// ══════════════════════════════════════════════════════════════════════════
// Shared brush slider (<MonitoringBrush>)
// ══════════════════════════════════════════════════════════════════════════
function MonitoringBrush({ data, selectionRange, onBrush }: BrushProps) {
const containerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const [width, setWidth] = useState(0);
const brushGroupRef = useRef<d3.Selection<
SVGGElement,
unknown,
null,
unknown
> | 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 innerHeight = BRUSH_HEIGHT - margin.top - margin.bottom;
const brushedColor = "rgba(99, 102, 241, 0.25)";
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) setWidth(entry.contentRect.width);
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
// Build the brush UI when the available data or layout width changes.
useEffect(() => {
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();
const root = svg
.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
.scaleLinear()
.domain([0, yMax * 1.1])
.range([innerHeight, 0])
.nice();
root
.append("g")
.call(d3.axisLeft(y).ticks(3))
.selectAll("text")
.style("font-size", "9px");
root
.append("g")
.attr("transform", `translate(0,${innerHeight})`)
.call(
d3
.axisBottom(x)
.ticks(Math.min(data.length || 1, 12))
.tickFormat((v) => d3.timeFormat("%H:%M")(v as Date)),
)
.selectAll("text")
.style("font-size", "8.5px");
root
.append("g")
.attr("stroke", "currentColor")
.attr("stroke-opacity", 0.08)
.call(
d3
.axisLeft(y)
.ticks(3)
.tickSize(-innerWidth)
.tickFormat(() => ""),
);
const overviewMetrics: Array<{ key: MetricKey; color: string }> = [
{ key: "cpu", color: "#2563eb" },
{ key: "mem", color: "#16a34a" },
];
overviewMetrics.forEach(({ key, color }) => {
const line = d3
.line<DataPoint>()
.x((d) => x(new Date(d.ts * 1000)))
.y((d) => y((d[key] as number) || 0))
.curve(d3.curveMonotoneX);
root
.append("path")
.datum(data)
.attr("fill", "none")
.attr("stroke", color)
.attr("stroke-width", 1.2)
.attr("opacity", 0.6)
.attr("d", line);
});
const brush = d3
.brushX()
.handleSize(14)
.extent([
[0, 0],
[innerWidth, innerHeight],
])
.on("start", () => {
isUserBrushingRef.current = true;
})
.on("brush", (event: d3.D3BrushEvent<unknown>) => {
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<unknown>) => {
isUserBrushingRef.current = false;
if (isProgrammaticMoveRef.current) return;
if (!event.selection) onBrush(null);
});
const brushG = root.append("g").call(brush);
brushGroupRef.current = brushG;
brushRef.current = brush;
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]);
// 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 x = brushXRef.current;
const brush = brushRef.current;
const brushG = brushGroupRef.current;
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<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 (
<Box sx={{ width: "100%" }}>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", mb: 0.5, pl: 0.5 }}
>
Time range drag the left/right ends or the middle
</Typography>
<Box
ref={containerRef}
sx={{ width: "100%", height: totalHeight, px: 2 }}
>
<svg
ref={svgRef}
width={width}
height={totalHeight}
style={{ overflow: "visible" }}
/>
</Box>
</Box>
);
}
// ══════════════════════════════════════════════════════════════════════════
// Parent: MonitoringCharts
// ══════════════════════════════════════════════════════════════════════════
export function MonitoringCharts({ samples }: Props) {
if (samples.length === 0) {
const [showAverages, setShowAverages] = useState(false);
const [selectionRange, setSelectionRange] = useState<[number, number] | null>(
null,
);
const baseData = useMemo<DataPoint[]>(
() =>
samples.map((sample) => ({
ts: sample.ts,
cpu: sample.cpu_pct,
iowait: sample.iowait_pct ?? 0,
mem: sample.mem_pct,
netDown: sample.net_rx_bytes_per_sec,
netUp: sample.net_tx_bytes_per_sec,
diskRead: sample.disk_read_bps,
diskWrite: sample.disk_write_bps,
})),
[samples],
);
const averages = useMemo(() => buildAverages(baseData), [baseData]);
const displayData = useMemo(() => {
if (!selectionRange) return baseData;
const [start, end] = selectionRange;
return baseData.filter((sample) => sample.ts >= start && sample.ts <= end);
}, [baseData, selectionRange]);
const zoomOptions = useMemo(
() => [
{ label: "1h", seconds: 60 * 60 },
{ label: "8h", seconds: 8 * 60 * 60 },
{ label: "1 day", seconds: 24 * 60 * 60 },
{ label: "7 days", seconds: 7 * 24 * 60 * 60 },
],
[],
);
const zoomTo = useCallback(
(seconds: number) => {
if (!baseData.length) return;
const start = Math.max(
baseData[0].ts,
baseData[baseData.length - 1].ts - seconds,
);
setSelectionRange([start, baseData[baseData.length - 1].ts]);
},
[baseData],
);
const selectionLabel = useMemo(
() =>
selectionRange
? `${formatRangeLabel(selectionRange)} · ${displayData.length} samples`
: `All ${baseData.length} samples`,
[selectionRange, displayData.length, baseData.length],
);
if (!samples.length) {
return (
<p className="text-sm text-gray-500">No monitoring samples available.</p>
<Typography color="text.secondary">
No monitoring samples available.
</Typography>
);
}
const data = samples.map((s) => ({
time: formatTime(s.ts),
ts: s.ts,
cpu: s.cpu_pct,
iowait: s.iowait_pct ?? 0,
mem: s.mem_pct,
net_down: s.net_rx_bytes_per_sec,
net_up: s.net_tx_bytes_per_sec,
disk_read: s.disk_read_bps,
disk_write: s.disk_write_bps,
}));
return (
<div className="space-y-8">
<div>
<h3 className="text-sm font-semibold mb-2">
CPU, IO Wait, and RAM - last hour
</h3>
<ResponsiveContainer width="100%" height={250}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" tick={{ fontSize: 11 }} />
<YAxis unit="%" domain={[0, 100]} />
<Tooltip />
<Legend />
<Line
type="monotone"
dataKey="cpu"
name="CPU %"
stroke="#2563eb"
dot={false}
strokeWidth={1.5}
<Box sx={{ width: "100%" }}>
{/* Toolbar */}
<Box
sx={{
mb: 2,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 2,
}}
>
<FormControlLabel
control={
<Checkbox
checked={showAverages}
onChange={(event) => setShowAverages(event.target.checked)}
/>
<Line
type="monotone"
dataKey="iowait"
name="IO Wait %"
stroke="#dc2626"
dot={false}
strokeWidth={1.5}
/>
<Line
type="monotone"
dataKey="mem"
name="RAM %"
stroke="#16a34a"
dot={false}
strokeWidth={1.5}
/>
</LineChart>
</ResponsiveContainer>
</div>
}
label={`Show ${MOVING_AVG_WINDOW}-point moving average`}
/>
<Box
sx={{
display: "flex",
gap: 1,
alignItems: "center",
flexWrap: "wrap",
}}
>
<Chip size="small" label={selectionLabel} variant="outlined" />
{zoomOptions.map((option) => (
<Button
key={option.label}
variant="outlined"
size="small"
onClick={() => zoomTo(option.seconds)}
>
{option.label}
</Button>
))}
<Button
variant="outlined"
size="small"
disabled={!selectionRange}
onClick={() => setSelectionRange(null)}
>
Reset zoom
</Button>
</Box>
</Box>
<div className="grid grid-cols-2 gap-6">
<div>
<h3 className="text-sm font-semibold mb-2">Network download</h3>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={(v) => formatBytes(v)} />
<Tooltip formatter={(v) => formatBytes(Number(v))} />
<Line
type="monotone"
dataKey="net_down"
name="Download"
stroke="#2563eb"
dot={false}
strokeWidth={1.5}
/>
</LineChart>
</ResponsiveContainer>
</div>
<div>
<h3 className="text-sm font-semibold mb-2">Network upload</h3>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={(v) => formatBytes(v)} />
<Tooltip formatter={(v) => formatBytes(Number(v))} />
<Line
type="monotone"
dataKey="net_up"
name="Upload"
stroke="#9333ea"
dot={false}
strokeWidth={1.5}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
{/* Shared brush slider above all graphs */}
<MonitoringBrush
data={baseData}
selectionRange={selectionRange}
onBrush={setSelectionRange}
/>
<div className="grid grid-cols-2 gap-6">
<div>
<h3 className="text-sm font-semibold mb-2">Disk read</h3>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={(v) => formatBytes(v)} />
<Tooltip formatter={(v) => formatBytes(Number(v))} />
<Line
type="monotone"
dataKey="disk_read"
name="Read"
stroke="#ea580c"
dot={false}
strokeWidth={1.5}
/>
</LineChart>
</ResponsiveContainer>
</div>
<div>
<h3 className="text-sm font-semibold mb-2">Disk write</h3>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={(v) => formatBytes(v)} />
<Tooltip formatter={(v) => formatBytes(Number(v))} />
<Line
type="monotone"
dataKey="disk_write"
name="Write"
stroke="#0891b2"
dot={false}
strokeWidth={1.5}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* Chart grid */}
<Grid container spacing={3}>
<Grid size={12}>
<MonitoringD3Chart
title="CPU, IO Wait, and RAM"
data={displayData}
metrics={[
{ key: "cpu", label: "CPU %", color: "#2563eb" },
{ key: "iowait", label: "IO Wait %", color: "#dc2626" },
{ key: "mem", label: "RAM %", color: "#16a34a" },
]}
showAverages={showAverages}
averages={averages}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<MonitoringD3Chart
title="Network traffic"
data={displayData}
metrics={[
{ key: "netDown", label: "Download", color: "#2563eb" },
{ key: "netUp", label: "Upload", color: "#9333ea" },
]}
showAverages={showAverages}
averages={averages}
yFormatter={formatBytes}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<MonitoringD3Chart
title="Disk I/O"
data={displayData}
metrics={[
{ key: "diskRead", label: "Read", color: "#ea580c" },
{ key: "diskWrite", label: "Write", color: "#0891b2" },
]}
showAverages={showAverages}
averages={averages}
yFormatter={formatBytes}
/>
</Grid>
</Grid>
</Box>
);
}
// ══════════════════════════════════════════════════════════════════════════
// MonitoringD3Chart single chart (lines + hover, no brush)
// ══════════════════════════════════════════════════════════════════════════
function MonitoringD3Chart({
title,
data,
metrics,
showAverages,
averages,
yFormatter,
}: ChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const [width, setWidth] = useState(0);
const mk = metricsKey(metrics);
const margin = useMemo(
() => ({ top: 18, right: 24, bottom: 26, left: 56 }),
[],
);
const innerHeight = CHART_HEIGHT - margin.top - margin.bottom;
const summary = useMemo(() => {
const values = metrics.flatMap((metric) =>
data.map((sample) => (sample[metric.key] as number) || 0),
);
const avg = values.length
? values.reduce((sum, value) => sum + value, 0) / values.length
: 0;
return { min: d3.min(values) ?? 0, avg, max: d3.max(values) ?? 0 };
}, [data, metrics]);
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) setWidth(entry.contentRect.width);
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
// One effect rebuild chart layer only
useEffect(() => {
if (!svgRef.current || width === 0) return;
const innerWidth = Math.max(0, width - margin.left - margin.right);
const svg = d3.select(svgRef.current);
svg.selectAll("*").remove();
const root = svg
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
if (data.length === 0) return;
const x = d3
.scaleTime()
.domain(d3.extent(data, (d) => new Date(d.ts * 1000)) as [Date, Date])
.range([0, innerWidth]);
const yMax =
d3.max(data, (d) =>
Math.max(...metrics.map((m) => (d[m.key] as number) || 0)),
) ?? 1;
const y = d3
.scaleLinear()
.domain([0, yMax * 1.1])
.nice()
.range([innerHeight, 0]);
// X axis
root
.append("g")
.attr("transform", `translate(0,${innerHeight})`)
.call(
d3
.axisBottom(x)
.ticks(Math.min(data.length || 1, 10))
.tickFormat((v) => d3.timeFormat("%H:%M")(v as Date)),
)
.selectAll("text")
.style("font-size", "10px");
// Y axis
const yAxis = d3.axisLeft(y).ticks(5);
if (yFormatter) yAxis.tickFormat((v) => yFormatter(Number(v)));
root.append("g").call(yAxis).selectAll("text").style("font-size", "10px");
// Grid
root
.append("g")
.attr("stroke", "currentColor")
.attr("stroke-opacity", 0.1)
.call(
d3
.axisLeft(y)
.ticks(5)
.tickSize(-innerWidth)
.tickFormat(() => ""),
);
// ── Lines ───────────────────────────────────────────
metrics.forEach((metric) => {
const line = d3
.line<DataPoint>()
.x((d) => x(new Date(d.ts * 1000)))
.y((d) => y((d[metric.key] as number) || 0))
.curve(d3.curveMonotoneX);
root
.append("path")
.datum(data)
.attr("fill", "none")
.attr("stroke", metric.color)
.attr("stroke-width", 1.6)
.attr("d", line);
if (showAverages && averages?.[metric.key]) {
const avgLine = d3
.line<DataPoint>()
.x((d) => x(new Date(d.ts * 1000)))
.y((_, i) =>
y(averages[metric.key as keyof typeof averages]?.[i] || 0),
)
.curve(d3.curveMonotoneX);
root
.append("path")
.datum(data)
.attr("fill", "none")
.attr("stroke", metric.color)
.attr("stroke-width", 1.4)
.attr("stroke-dasharray", "5,3")
.attr("opacity", 0.7)
.attr("d", avgLine);
}
});
// ── Cursor line ─────────────────────────────────────
const cursorLine = root
.append("line")
.attr("y1", 0)
.attr("y2", innerHeight)
.attr("stroke", "currentColor")
.attr("stroke-opacity", 0.45)
.attr("stroke-dasharray", "4,4")
.style("display", "none");
// ── Cursor markers ──────────────────────────────────
const cursorMarkers = root
.append("g")
.attr("pointer-events", "none")
.style("display", "none");
cursorMarkers
.selectAll<SVGCircleElement, MetricConfig>("circle")
.data(metrics)
.join("circle")
.attr("r", 4.5)
.attr("stroke", "#fff")
.attr("stroke-width", 1.4);
// ── Tooltip ─────────────────────────────────────────
const tooltip = root
.append("g")
.attr("pointer-events", "none")
.style("display", "none");
tooltip
.append("rect")
.attr("rx", 6)
.attr("ry", 6)
.attr("fill", "rgba(15,23,42,0.92)");
const tooltipText = tooltip
.append("text")
.attr("fill", "#fff")
.attr("font-size", 11)
.attr("font-family", "monospace");
// ── Hit area ────────────────────────────────────────
const bisect = d3.bisector((d: DataPoint) => d.ts).center;
root
.append("rect")
.attr("width", innerWidth)
.attr("height", innerHeight)
.attr("fill", "transparent")
.attr("pointer-events", "all")
.on("mousemove", (event) => {
const [mx, my] = d3.pointer(event, root.node() as SVGGElement);
const ts = x.invert(mx).getTime() / 1000;
const idx = bisect(data, ts);
const sample = data[Math.max(0, Math.min(data.length - 1, idx))];
if (!sample) return;
const xP = x(new Date(sample.ts * 1000));
cursorLine.style("display", null).attr("x1", xP).attr("x2", xP);
cursorMarkers
.style("display", null)
.attr("transform", `translate(${xP},0)`)
.selectAll<SVGCircleElement, MetricConfig>("circle")
.data(metrics)
.attr("cx", 0)
.attr("cy", (m) => y((sample[m.key] as number) || 0))
.attr("fill", (m) => m.color);
const lines = [
formatTime(sample.ts),
...metrics.map((m) => {
const raw = (sample[m.key] as number) || 0;
const avgV =
showAverages &&
averages?.[m.key as keyof typeof averages]?.[idx] != null
? averages[m.key as keyof typeof averages][idx]
: null;
const fmt = yFormatter ? yFormatter(raw) : `${raw.toFixed(1)}%`;
return avgV == null
? `${m.label}: ${fmt}`
: `${m.label}: ${fmt} (avg ${yFormatter ? yFormatter(avgV) : avgV.toFixed(1)})`;
}),
];
const lh = 14,
pad = 8;
const bw = Math.min(
Math.max(...lines.map((l) => l.length)) * 6.5 + pad * 2,
260,
);
const bh = lines.length * lh + pad * 2;
const px = Math.min(mx + 12, innerWidth - bw - 4);
const py = Math.max(4, Math.min(my - bh - 12, innerHeight - bh - 4));
tooltip
.style("display", null)
.attr("transform", `translate(${px},${py})`);
tooltip.select("rect").attr("width", bw).attr("height", bh);
tooltipText.selectAll("tspan").remove();
lines.forEach((line, i) =>
tooltipText
.append("tspan")
.attr("x", pad)
.attr("y", pad + 12 + i * lh)
.text(line),
);
})
.on("mouseleave", () => {
tooltip.style("display", "none");
cursorLine.style("display", "none");
cursorMarkers.style("display", "none");
});
}, [
data,
mk,
showAverages,
averages,
width,
yFormatter,
margin.left,
margin.top,
innerHeight,
]);
// ── JSX ───────────────────────────────────────────────
return (
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle2" gutterBottom>
{title}
</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 1 }}>
<Chip
size="small"
label={`Min ${yFormatter ? yFormatter(summary.min) : summary.min.toFixed(1)}`}
variant="outlined"
/>
<Chip
size="small"
label={`Avg ${yFormatter ? yFormatter(summary.avg) : summary.avg.toFixed(1)}`}
variant="outlined"
/>
<Chip
size="small"
label={`Max ${yFormatter ? yFormatter(summary.max) : summary.max.toFixed(1)}`}
variant="outlined"
/>
</Box>
<Box ref={containerRef} sx={{ width: "100%", height: CHART_HEIGHT }}>
<svg
ref={svgRef}
width={width}
height={CHART_HEIGHT}
style={{ overflow: "visible" }}
/>
</Box>
<Box sx={{ mt: 1, display: "flex", gap: 2, flexWrap: "wrap" }}>
{metrics.map((metric) => (
<Box
key={metric.key}
sx={{ display: "flex", alignItems: "center", gap: 0.5 }}
>
<Box
sx={{
width: 12,
height: 12,
bgcolor: metric.color,
borderRadius: "2px",
}}
/>
<Typography variant="caption">{metric.label}</Typography>
</Box>
))}
{showAverages ? (
<Typography variant="caption">Dashed = moving average</Typography>
) : null}
</Box>
</CardContent>
</Card>
);
}
+13 -37
View File
@@ -1,46 +1,22 @@
import { Card, CardContent } from "@mui/material";
import type { NowPlayingSession } from "../types";
import { SessionActivityPanel } from "./SessionActivityPanel";
interface Props {
sessions: NowPlayingSession[];
onSelectSession?: (session: NowPlayingSession) => void;
}
export function NowPlaying({ sessions }: Props) {
if (sessions.length === 0) {
return (
<p className="text-sm text-gray-500">
No active playback sessions right now.
</p>
);
}
export function NowPlaying({ sessions, onSelectSession }: Props) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b text-left text-gray-600">
<th className="py-2 pr-4">User</th>
<th className="py-2 pr-4">Title</th>
<th className="py-2 pr-4">Type</th>
<th className="py-2 pr-4">State</th>
<th className="py-2 pr-4">Transcoding</th>
<th className="py-2 pr-4">Transcode type</th>
<th className="py-2 pr-4">Device</th>
</tr>
</thead>
<tbody>
{sessions.map((s) => (
<tr key={s.session_id} className="border-b hover:bg-gray-50">
<td className="py-2 pr-4 font-medium">{s.user}</td>
<td className="py-2 pr-4">{s.title}</td>
<td className="py-2 pr-4">{s.type}</td>
<td className="py-2 pr-4">{s.state}</td>
<td className="py-2 pr-4">{s.transcoding}</td>
<td className="py-2 pr-4">{s.transcoding_type}</td>
<td className="py-2 pr-4">{s.device}</td>
</tr>
))}
</tbody>
</table>
</div>
<Card variant="outlined">
<CardContent>
<SessionActivityPanel
sessions={sessions}
onSelectSession={onSelectSession}
emptyMessage="No recent user activity sessions right now."
/>
</CardContent>
</Card>
);
}
@@ -0,0 +1,248 @@
import {
Button,
Chip,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import type { NowPlayingSession } from "../types";
interface Props {
sessions: NowPlayingSession[];
emptyMessage?: string;
selectedUserLabel?: string;
onSelectSession?: (session: NowPlayingSession) => void;
}
function formatStateLabel(state: string): string {
const normalized = String(state || "")
.trim()
.toLowerCase();
if (normalized === "playing") {
return "Playing";
}
if (normalized === "paused") {
return "Paused";
}
if (normalized === "idle") {
return "Idle";
}
return normalized
? normalized.charAt(0).toUpperCase() + normalized.slice(1)
: "Unknown";
}
function buildStatusSummary(sessions: NowPlayingSession[]) {
const playing = sessions.filter(
(session) =>
String(session.state || "")
.trim()
.toLowerCase() === "playing",
).length;
const paused = sessions.filter(
(session) =>
String(session.state || "")
.trim()
.toLowerCase() === "paused",
).length;
const idle = sessions.filter(
(session) =>
String(session.state || "")
.trim()
.toLowerCase() === "idle",
).length;
return `${sessions.length} session${sessions.length === 1 ? "" : "s"} · ${playing} playing · ${paused} paused · ${idle} idle`;
}
export function SessionActivityPanel({
sessions,
emptyMessage = "No live sessions matched to this user.",
selectedUserLabel,
onSelectSession,
}: Props) {
const userFallback = selectedUserLabel || "Unknown user";
if (!sessions.length) {
return (
<Typography variant="body2" color="text.secondary">
{emptyMessage}
</Typography>
);
}
return (
<TableContainer
component={Paper}
variant="outlined"
sx={{ maxHeight: 280, borderColor: "divider", borderRadius: 1 }}
>
<Table size="small" stickyHeader aria-label="Session activity details">
<TableHead>
<TableRow>
<TableCell
sx={{
fontWeight: 700,
bgcolor: "background.default",
minWidth: 160,
}}
>
User
</TableCell>
<TableCell
sx={{ fontWeight: 700, bgcolor: "background.default", width: 82 }}
>
State
</TableCell>
<TableCell
sx={{
fontWeight: 700,
bgcolor: "background.default",
minWidth: 140,
}}
>
Title / Type
</TableCell>
<TableCell
sx={{
fontWeight: 700,
bgcolor: "background.default",
minWidth: 140,
}}
>
Device
</TableCell>
<TableCell
sx={{
fontWeight: 700,
bgcolor: "background.default",
width: 118,
}}
>
Transcoding
</TableCell>
{onSelectSession ? (
<TableCell
sx={{
fontWeight: 700,
bgcolor: "background.default",
width: 150,
}}
>
Action
</TableCell>
) : null}
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell
colSpan={onSelectSession ? 6 : 5}
sx={{ py: 0.75, bgcolor: "background.paper" }}
>
<Typography variant="caption" color="text.secondary">
{buildStatusSummary(sessions)}
</Typography>
</TableCell>
</TableRow>
{sessions.map((session) => {
const state = String(session.state || "")
.trim()
.toLowerCase();
const sessionLabel = formatStateLabel(session.state);
return (
<TableRow
key={session.session_id}
hover
sx={{ cursor: onSelectSession ? "pointer" : "default" }}
onClick={
onSelectSession ? () => onSelectSession(session) : undefined
}
>
<TableCell sx={{ py: 0.75, minWidth: 160 }}>
<Typography
variant="body2"
noWrap
title={session.user || userFallback}
>
{session.user || userFallback}
</Typography>
<Typography
variant="caption"
color="text.secondary"
noWrap
title={session.session_id}
>
{session.session_id}
</Typography>
</TableCell>
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
<Chip
size="small"
label={sessionLabel}
color={
state === "playing"
? "primary"
: state === "paused"
? "warning"
: "default"
}
variant={
state === "playing" || state === "paused"
? "filled"
: "outlined"
}
/>
</TableCell>
<TableCell sx={{ py: 0.75, minWidth: 140 }}>
<Typography
variant="body2"
noWrap
title={session.title || ""}
>
{session.title || "(idle)"}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap>
{session.type || "—"}
</Typography>
</TableCell>
<TableCell sx={{ py: 0.75, minWidth: 140 }}>
<Typography variant="body2" noWrap>
{session.device || "Unknown device"}
</Typography>
</TableCell>
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
<Typography variant="body2" noWrap>
{session.transcoding === "yes"
? session.transcoding_type
? `yes (${session.transcoding_type})`
: "yes"
: "no"}
</Typography>
</TableCell>
{onSelectSession ? (
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
<Button
size="small"
variant="outlined"
onClick={(event) => {
event.stopPropagation();
onSelectSession(session);
}}
>
Open in Users
</Button>
</TableCell>
) : null}
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
);
}
+7 -4
View File
@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { fetchCounts, fetchLibraries, fetchNowPlaying } from "../api/client";
import { fetchCounts, fetchLibraries, fetchActivity } from "../api/client";
export function useCounts() {
return useQuery({
@@ -17,10 +17,13 @@ export function useLibraries() {
});
}
export function useNowPlaying() {
export function useActivity() {
return useQuery({
queryKey: ["dashboard", "now-playing"],
queryFn: fetchNowPlaying,
queryKey: ["dashboard", "activity"],
queryFn: fetchActivity,
refetchInterval: 15_000,
});
}
// Backward-compatible alias used by older code.
export const useNowPlaying = useActivity;
+38 -3
View File
@@ -1,11 +1,20 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { fetchMediaStatus, buildMediaIndex, queryMedia } from "../api/client";
import {
fetchMediaStatus,
buildMediaIndex,
queryMedia,
stopMediaIndexBuild,
forceStopMediaIndexBuild,
} from "../api/client";
export function useMediaStatus() {
return useQuery({
queryKey: ["media", "status"],
queryFn: fetchMediaStatus,
staleTime: 60_000,
staleTime: 5_000,
refetchInterval: (query) =>
query.state.data?.build_running ? 1000 : false,
refetchIntervalInBackground: true,
});
}
@@ -21,6 +30,8 @@ export function useMediaQuery(params: {
enabled?: boolean;
}) {
const { enabled = true, ...queryParams } = params;
// Feature: Sync file browser with selected media path
return useQuery({
queryKey: ["media", "query", queryParams],
queryFn: () => queryMedia(queryParams),
@@ -29,12 +40,36 @@ export function useMediaQuery(params: {
});
}
function invalidateMedia(queryClient: ReturnType<typeof useQueryClient>) {
queryClient.invalidateQueries({ queryKey: ["media"] });
}
export function useBuildIndex() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: buildMediaIndex,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["media"] });
invalidateMedia(queryClient);
},
});
}
export function useStopBuildIndex() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: stopMediaIndexBuild,
onSuccess: () => {
invalidateMedia(queryClient);
},
});
}
export function useForceStopBuildIndex() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: forceStopMediaIndexBuild,
onSuccess: () => {
invalidateMedia(queryClient);
},
});
}
+3 -3
View File
@@ -16,10 +16,10 @@ export function useMonitoringStatus() {
});
}
export function useMonitoringMetrics(lastSeconds = 3600) {
export function useMonitoringMetrics() {
return useQuery({
queryKey: ["monitoring", "metrics", lastSeconds],
queryFn: () => fetchMonitoringMetrics(lastSeconds),
queryKey: ["monitoring", "metrics"],
queryFn: () => fetchMonitoringMetrics(),
refetchInterval: 15_000,
});
}
+13
View File
@@ -0,0 +1,13 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { sendUserMessage } from "../api/client";
export function useSendUserMessage() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: sendUserMessage,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users", "message-queue"] });
},
});
}
+8
View File
@@ -0,0 +1,8 @@
import { useMutation } from "@tanstack/react-query";
import { testUserSmtpConnection } from "../api/client";
export function useTestUserSmtp() {
return useMutation({
mutationFn: testUserSmtpConnection,
});
}
@@ -0,0 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { fetchUserMessageQueueStatus } from "../api/client";
export function useUserMessageQueueStatus() {
return useQuery({
queryKey: ["users", "message-queue"],
queryFn: fetchUserMessageQueueStatus,
refetchInterval: 5_000,
staleTime: 0,
});
}
+10
View File
@@ -0,0 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import { fetchUsers } from "../api/client";
export function useUsers() {
return useQuery({
queryKey: ["users"],
queryFn: fetchUsers,
staleTime: 30_000,
});
}
+7 -1
View File
@@ -1 +1,7 @@
@import "tailwindcss";
html,
body,
#root {
margin: 0;
width: 100%;
min-height: 100%;
}
+207 -77
View File
@@ -1,4 +1,7 @@
import { useCounts, useLibraries, useNowPlaying } from "../hooks/useDashboard";
import { useMemo } from "react";
import { Box, Divider, Grid, Stack, Typography } from "@mui/material";
import { useNavigate } from "react-router-dom";
import { useCounts, useLibraries, useActivity } from "../hooks/useDashboard";
import { useMonitoringMetrics, useDiskSpace } from "../hooks/useMonitoring";
import { NowPlaying } from "../components/NowPlaying";
import { MetricCard } from "../components/MetricCard";
@@ -20,96 +23,223 @@ function formatRate(bytes: number): string {
return `${formatBytes(bytes)}/s`;
}
function formatPct(value: number): string {
return `${value.toFixed(1)}%`;
}
function summarize(values: number[]) {
if (values.length === 0) return null;
const total = values.reduce((sum, value) => sum + value, 0);
return {
avg: total / values.length,
min: Math.min(...values),
max: Math.max(...values),
};
}
export function Dashboard() {
const navigate = useNavigate();
const { data: counts } = useCounts();
const { data: libraries } = useLibraries();
const { data: nowPlaying } = useNowPlaying();
const { data: activity } = useActivity();
const { data: metrics } = useMonitoringMetrics();
const { data: disk } = useDiskSpace();
const latest = metrics?.samples?.at(-1);
const monitoringWindow = useMemo(() => {
const samples = metrics?.samples ?? [];
if (samples.length === 0) return [];
const latestTs = samples.at(-1)?.ts ?? 0;
const windowStart = latestTs - 10 * 60;
const windowed = samples.filter((sample) => sample.ts >= windowStart);
return windowed.length > 0 ? windowed : samples;
}, [metrics?.samples]);
const cpuSummary = summarize(
monitoringWindow.map((sample) => sample.cpu_pct),
);
const iowaitSummary = summarize(
monitoringWindow
.map((sample) => sample.iowait_pct)
.filter((value): value is number => value !== undefined),
);
const memSummary = summarize(
monitoringWindow.map((sample) => sample.mem_pct),
);
const netRxSummary = summarize(
monitoringWindow.map((sample) => sample.net_rx_bytes_per_sec),
);
const netTxSummary = summarize(
monitoringWindow.map((sample) => sample.net_tx_bytes_per_sec),
);
const diskReadSummary = summarize(
monitoringWindow.map((sample) => sample.disk_read_bps),
);
const diskWriteSummary = summarize(
monitoringWindow.map((sample) => sample.disk_write_bps),
);
return (
<div className="space-y-8">
{/* Now Playing */}
<section>
<h2 className="text-lg font-semibold mb-3">Now playing</h2>
{nowPlaying && <NowPlaying sessions={nowPlaying} />}
</section>
<hr />
{/* Server Overview */}
<section>
<h2 className="text-lg font-semibold mb-3">Server overview</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3">
<MetricCard
label="CPU"
value={latest ? `${latest.cpu_pct.toFixed(1)}%` : "-"}
<Stack spacing={3}>
<Box>
<Typography variant="h5" sx={{ mb: 1.5 }}>
Activity
</Typography>
{activity && (
<NowPlaying
sessions={activity}
onSelectSession={(session) =>
navigate(`/users?user=${encodeURIComponent(session.user)}`)
}
/>
<MetricCard
label="IO Wait"
value={latest ? `${(latest.iowait_pct ?? 0).toFixed(1)}%` : "-"}
/>
<MetricCard
label="RAM"
value={latest ? `${latest.mem_pct.toFixed(1)}%` : "-"}
/>
<MetricCard
label="Net down"
value={latest ? formatRate(latest.net_rx_bytes_per_sec) : "-"}
/>
<MetricCard
label="Net up"
value={latest ? formatRate(latest.net_tx_bytes_per_sec) : "-"}
/>
<MetricCard
label="Disk read"
value={latest ? formatRate(latest.disk_read_bps) : "-"}
/>
<MetricCard
label="Disk write"
value={latest ? formatRate(latest.disk_write_bps) : "-"}
/>
</div>
{disk && (
<div className="mt-3 grid grid-cols-4 gap-3">
<MetricCard label="Disk used" value={formatBytes(disk.used)} />
<MetricCard
label="Disk available"
value={formatBytes(disk.available)}
/>
<MetricCard label="Disk total" value={formatBytes(disk.size)} />
<MetricCard label="Used %" value={disk.used_pct} />
</div>
)}
</section>
</Box>
<hr />
<Divider />
{/* Media Library Overview */}
<section>
<h2 className="text-lg font-semibold mb-3">Media library overview</h2>
<Box>
<Typography variant="h5" sx={{ mb: 1.5 }}>
Monitoring Overview
</Typography>
<Grid container spacing={1.5}>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="CPU (10m avg)"
value={cpuSummary ? formatPct(cpuSummary.avg) : "-"}
subtext={
cpuSummary
? `High: ${formatPct(cpuSummary.max)}\nLow: ${formatPct(cpuSummary.min)}`
: undefined
}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="IO Wait (10m avg)"
value={iowaitSummary ? formatPct(iowaitSummary.avg) : "-"}
subtext={
iowaitSummary
? `High: ${formatPct(iowaitSummary.max)}\nLow: ${formatPct(iowaitSummary.min)}`
: undefined
}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="RAM (10m avg)"
value={memSummary ? formatPct(memSummary.avg) : "-"}
subtext={
memSummary
? `High: ${formatPct(memSummary.max)}\nLow: ${formatPct(memSummary.min)}`
: undefined
}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="Net down (10m avg)"
value={netRxSummary ? formatRate(netRxSummary.avg) : "-"}
subtext={
netRxSummary
? `High: ${formatRate(netRxSummary.max)}\nLow: ${formatRate(netRxSummary.min)}`
: undefined
}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="Net up (10m avg)"
value={netTxSummary ? formatRate(netTxSummary.avg) : "-"}
subtext={
netTxSummary
? `High: ${formatRate(netTxSummary.max)}\nLow: ${formatRate(netTxSummary.min)}`
: undefined
}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="Disk read (10m avg)"
value={diskReadSummary ? formatRate(diskReadSummary.avg) : "-"}
subtext={
diskReadSummary
? `High: ${formatRate(diskReadSummary.max)}\nLow: ${formatRate(diskReadSummary.min)}`
: undefined
}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="Disk write (10m avg)"
value={diskWriteSummary ? formatRate(diskWriteSummary.avg) : "-"}
subtext={
diskWriteSummary
? `High: ${formatRate(diskWriteSummary.max)}\nLow: ${formatRate(diskWriteSummary.min)}`
: undefined
}
/>
</Grid>
</Grid>
{disk && (
<Grid container spacing={1.5} sx={{ mt: 0.5 }}>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard label="Disk used" value={formatBytes(disk.used)} />
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard
label="Disk available"
value={formatBytes(disk.available)}
/>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard label="Disk total" value={formatBytes(disk.size)} />
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard label="Used %" value={disk.used_pct} />
</Grid>
</Grid>
)}
</Box>
<Divider />
<Box>
<Typography variant="h5" sx={{ mb: 1.5 }}>
Library Stats
</Typography>
{counts && (
<div className="grid grid-cols-4 gap-3 mb-4">
<MetricCard
label="Total"
value={(
counts.movies +
counts.series +
counts.episodes
).toLocaleString()}
/>
<MetricCard label="Movies" value={counts.movies.toLocaleString()} />
<MetricCard label="Series" value={counts.series.toLocaleString()} />
<MetricCard
label="Episodes"
value={counts.episodes.toLocaleString()}
/>
</div>
<Grid container spacing={1.5} sx={{ mb: 2 }}>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard
label="Total"
value={(
counts.movies +
counts.series +
counts.episodes
).toLocaleString()}
/>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard
label="Movies"
value={counts.movies.toLocaleString()}
/>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard
label="Series"
value={counts.series.toLocaleString()}
/>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard
label="Episodes"
value={counts.episodes.toLocaleString()}
/>
</Grid>
</Grid>
)}
{libraries && <LibraryOverview libraries={libraries} />}
</section>
</div>
</Box>
</Stack>
);
}
+671 -113
View File
@@ -1,5 +1,23 @@
import { useState, useCallback, useRef } from "react";
import { AgGridReact } from "ag-grid-react";
import { useState } from "react";
import { useSearchParams } from "react-router-dom";
import { DataGrid } from "@mui/x-data-grid";
import type { GridColDef, GridRowSelectionModel } from "@mui/x-data-grid";
import {
Alert,
Box,
Button,
Card,
CardContent,
Chip,
FormControl,
Grid,
InputLabel,
MenuItem,
Select,
Stack,
TextField,
Typography,
} from "@mui/material";
import {
useDirectoryListing,
useFfprobe,
@@ -8,6 +26,7 @@ import {
} from "../hooks/useFiles";
interface DisplayRow {
id: string;
type: string;
name: string;
ext: string;
@@ -16,6 +35,46 @@ interface DisplayRow {
path: string;
}
interface FfprobeStream {
index?: number;
codec_type?: string;
codec_name?: string;
codec_long_name?: string;
profile?: string;
width?: number;
height?: number;
bit_rate?: string | number;
duration?: string | number;
channels?: number;
sample_rate?: string | number;
channel_layout?: string;
pix_fmt?: string;
sample_aspect_ratio?: string;
display_aspect_ratio?: string;
field_order?: string;
level?: number | string;
color_range?: string;
color_space?: string;
color_transfer?: string;
color_primaries?: string;
tags?: Record<string, string>;
}
interface FfprobeFormat {
filename?: string;
format_name?: string;
format_long_name?: string;
duration?: string | number;
size?: string | number;
bit_rate?: string | number;
tags?: Record<string, string>;
}
interface FfprobeData {
format?: FfprobeFormat;
streams?: FfprobeStream[];
}
function formatSize(bytes: number): string {
if (bytes === 0) return "-";
const units = ["B", "KB", "MB", "GB", "TB"];
@@ -33,6 +92,45 @@ function formatTime(epoch: number): string {
return new Date(epoch * 1000).toLocaleString();
}
function humanBytes(value: string | number | undefined): string {
if (value === undefined || value === null || value === "") return "-";
const bytes = typeof value === "string" ? Number(value) : value;
if (!Number.isFinite(bytes)) return "-";
return formatSize(bytes);
}
function humanRate(value: string | number | undefined): string {
if (value === undefined || value === null || value === "") return "-";
const rate = typeof value === "string" ? Number(value) : value;
if (!Number.isFinite(rate)) return "-";
const units = ["bps", "Kbps", "Mbps", "Gbps"];
let v = rate;
let unitIdx = 0;
while (v >= 1000 && unitIdx < units.length - 1) {
v /= 1000;
unitIdx++;
}
return `${v.toFixed(1)} ${units[unitIdx]}`;
}
function humanDuration(value: string | number | undefined): string {
if (value === undefined || value === null || value === "") return "-";
const seconds = typeof value === "string" ? Number(value) : value;
if (!Number.isFinite(seconds)) return "-";
const total = Math.max(0, Math.round(seconds));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
if (hours > 0)
return `${hours}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
return `${minutes}:${String(secs).padStart(2, "0")}`;
}
function fieldLabel(_key: string, value: string | number | undefined): string {
if (value === undefined || value === null || value === "") return "-";
return String(value);
}
function isVideoFile(name: string): boolean {
const exts = [
".mkv",
@@ -48,38 +146,446 @@ function isVideoFile(name: string): boolean {
return exts.some((ext) => name.toLowerCase().endsWith(ext));
}
export function FileBrowser() {
const [currentDir, setCurrentDir] = useState("/");
const [pathInput, setPathInput] = useState("/");
const [selectedPath, setSelectedPath] = useState<string | null>(null);
function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
const format = data.format ?? {};
const streams = data.streams ?? [];
const videoStreams = streams.filter(
(stream) => stream.codec_type === "video",
);
const audioStreams = streams.filter(
(stream) => stream.codec_type === "audio",
);
const subtitleStreams = streams.filter(
(stream) => stream.codec_type === "subtitle",
);
const { data: listing, isLoading, error } = useDirectoryListing(currentDir);
const { data: ffprobeData } = useFfprobe(
return (
<Stack spacing={2}>
<Box>
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>
ffprobe details
</Typography>
<Typography variant="caption" color="text.secondary">
{path}
</Typography>
</Box>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle2" gutterBottom>
Container / format
</Typography>
<Grid container spacing={1.5}>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="body2">
<b>Format:</b> {fieldLabel("format", format.format_name)}
</Typography>
<Typography variant="body2">
<b>Long name:</b>{" "}
{fieldLabel("format_long_name", format.format_long_name)}
</Typography>
<Typography variant="body2">
<b>Duration:</b> {humanDuration(format.duration)}
</Typography>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="body2">
<b>Size:</b> {humanBytes(format.size)}
</Typography>
<Typography variant="body2">
<b>Bitrate:</b> {humanRate(format.bit_rate)}
</Typography>
<Typography variant="body2">
<b>Filename:</b> {fieldLabel("filename", format.filename)}
</Typography>
</Grid>
</Grid>
</CardContent>
</Card>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle2" gutterBottom>
Streams
</Typography>
<Stack spacing={1.5}>
{videoStreams.length > 0 && (
<Box>
<Typography variant="caption" color="text.secondary">
Video streams
</Typography>
<Stack spacing={1} sx={{ mt: 0.75 }}>
{videoStreams.map((stream, index) => (
<Box
key={`video-${stream.index ?? index}`}
sx={{
p: 1.25,
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
<Stack
direction="row"
spacing={1}
sx={{ flexWrap: "wrap", alignItems: "center" }}
>
<Chip
size="small"
label={`#${stream.index ?? index}`}
/>
<Chip
size="small"
color="primary"
label={stream.codec_type ?? "video"}
/>
<Chip
size="small"
variant="outlined"
label={stream.codec_name ?? "unknown codec"}
/>
{stream.codec_long_name && (
<Chip
size="small"
variant="outlined"
label={stream.codec_long_name}
/>
)}
{stream.profile && (
<Chip
size="small"
variant="outlined"
label={stream.profile}
/>
)}
{stream.bit_rate && (
<Chip
size="small"
variant="outlined"
label={humanRate(stream.bit_rate)}
/>
)}
{stream.duration && (
<Chip
size="small"
variant="outlined"
label={humanDuration(stream.duration)}
/>
)}
{stream.width && stream.height && (
<Chip
size="small"
variant="outlined"
label={`${stream.width}×${stream.height}`}
/>
)}
{stream.pix_fmt && (
<Chip
size="small"
variant="outlined"
label={stream.pix_fmt}
/>
)}
{stream.display_aspect_ratio && (
<Chip
size="small"
variant="outlined"
label={`DAR ${stream.display_aspect_ratio}`}
/>
)}
{stream.sample_aspect_ratio && (
<Chip
size="small"
variant="outlined"
label={`SAR ${stream.sample_aspect_ratio}`}
/>
)}
{stream.level !== undefined &&
stream.level !== null && (
<Chip
size="small"
variant="outlined"
label={`L${stream.level}`}
/>
)}
{stream.field_order &&
stream.field_order !== "unknown" && (
<Chip
size="small"
variant="outlined"
label={stream.field_order}
/>
)}
{(stream.color_range ||
stream.color_space ||
stream.color_transfer ||
stream.color_primaries) && (
<Chip
size="small"
color={
(stream.color_transfer ?? "")
.toLowerCase()
.includes("2084") ||
(stream.color_transfer ?? "")
.toLowerCase()
.includes("b67") ||
(stream.color_space ?? "")
.toLowerCase()
.includes("bt2020") ||
(stream.color_primaries ?? "")
.toLowerCase()
.includes("bt2020")
? "warning"
: "default"
}
variant="outlined"
label={[
stream.color_range,
stream.color_space,
stream.color_transfer,
stream.color_primaries,
]
.filter(Boolean)
.join(" / ")}
/>
)}
</Stack>
<Typography variant="body2" sx={{ mt: 0.75 }}>
{stream.tags?.language
? `Language: ${stream.tags.language}. `
: ""}
{stream.tags?.title
? `Title: ${stream.tags.title}.`
: ""}
</Typography>
</Box>
))}
</Stack>
</Box>
)}
{audioStreams.length > 0 && (
<Box>
<Typography variant="caption" color="text.secondary">
Audio streams
</Typography>
<Stack spacing={1} sx={{ mt: 0.75 }}>
{audioStreams.map((stream, index) => (
<Box
key={`audio-${stream.index ?? index}`}
sx={{
p: 1.25,
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
<Stack
direction="row"
spacing={1}
sx={{ flexWrap: "wrap", alignItems: "center" }}
>
<Chip
size="small"
label={`#${stream.index ?? index}`}
/>
<Chip
size="small"
color="secondary"
label={stream.codec_type ?? "audio"}
/>
<Chip
size="small"
variant="outlined"
label={stream.codec_name ?? "unknown codec"}
/>
{stream.channels && (
<Chip
size="small"
variant="outlined"
label={`${stream.channels} ch`}
/>
)}
{stream.sample_rate && (
<Chip
size="small"
variant="outlined"
label={`${stream.sample_rate} Hz`}
/>
)}
{stream.bit_rate && (
<Chip
size="small"
variant="outlined"
label={humanRate(stream.bit_rate)}
/>
)}
{stream.duration && (
<Chip
size="small"
variant="outlined"
label={humanDuration(stream.duration)}
/>
)}
</Stack>
<Typography variant="body2" sx={{ mt: 0.75 }}>
{stream.codec_long_name
? `${stream.codec_long_name}. `
: ""}
{stream.channel_layout
? `Layout: ${stream.channel_layout}. `
: ""}
{stream.tags?.language
? `Language: ${stream.tags.language}. `
: ""}
{stream.tags?.title
? `Title: ${stream.tags.title}.`
: ""}
</Typography>
</Box>
))}
</Stack>
</Box>
)}
{subtitleStreams.length > 0 && (
<Box>
<Typography variant="caption" color="text.secondary">
Subtitle streams
</Typography>
<Stack spacing={1} sx={{ mt: 0.75 }}>
{subtitleStreams.map((stream, index) => (
<Box
key={`subtitle-${stream.index ?? index}`}
sx={{
p: 1.25,
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
<Stack
direction="row"
spacing={1}
sx={{ flexWrap: "wrap", alignItems: "center" }}
>
<Chip
size="small"
label={`#${stream.index ?? index}`}
/>
<Chip
size="small"
color="info"
label={stream.codec_type ?? "subtitle"}
/>
<Chip
size="small"
variant="outlined"
label={stream.codec_name ?? "unknown codec"}
/>
{stream.tags?.language && (
<Chip
size="small"
variant="outlined"
label={stream.tags.language}
/>
)}
{stream.tags?.title && (
<Chip
size="small"
variant="outlined"
label={stream.tags.title}
/>
)}
</Stack>
</Box>
))}
</Stack>
</Box>
)}
{streams.length === 0 && (
<Typography variant="body2" color="text.secondary">
No streams found.
</Typography>
)}
</Stack>
</CardContent>
</Card>
{Object.keys(format.tags ?? {}).length > 0 && (
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle2" gutterBottom>
Tags
</Typography>
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
{Object.entries(format.tags ?? {}).map(([key, value]) => (
<Chip
key={key}
size="small"
label={`${key}: ${value}`}
variant="outlined"
/>
))}
</Stack>
</CardContent>
</Card>
)}
</Stack>
);
}
export function FileBrowser() {
const [searchParams] = useSearchParams();
const initialRequestedPath = searchParams.get("path") ?? "/";
const initialSelectedPath =
initialRequestedPath !== "/" &&
(isVideoFile(initialRequestedPath) || initialRequestedPath.includes("."))
? initialRequestedPath.replace(/\/+$/, "")
: null;
const initialCurrentDir = initialSelectedPath
? initialSelectedPath.replace(/\/[^/]+$/, "") || "/"
: initialRequestedPath.replace(/\/+$/, "") || "/";
const [currentDir, setCurrentDir] = useState(initialCurrentDir);
const [pathInput, setPathInput] = useState(initialCurrentDir);
const [selectedPath, setSelectedPath] = useState<string | null>(
initialSelectedPath,
);
const [selectedJob, setSelectedJob] = useState<string>("");
const {
data: listing,
isLoading,
error,
refetch,
} = useDirectoryListing(currentDir);
const {
data: ffprobeData,
isLoading: ffprobeLoading,
error: ffprobeError,
} = useFfprobe(
selectedPath ?? "",
!!selectedPath && isVideoFile(selectedPath),
);
const { data: templates } = useJobTemplates();
const runJob = useRunJob();
const gridRef = useRef<AgGridReact<DisplayRow>>(null);
const navigate = useCallback((path: string) => {
const navigate = (path: string) => {
setCurrentDir(path);
setPathInput(path);
setSelectedPath(null);
}, []);
const handlePathSubmit = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
navigate(pathInput || "/");
}
};
// Build display rows
const handlePathSubmit = (e: React.KeyboardEvent) => {
if (e.key === "Enter") navigate(pathInput || "/");
};
const rows: DisplayRow[] = [];
if (currentDir !== "/") {
const parent = currentDir.replace(/\/[^/]+\/?$/, "") || "/";
rows.push({
id: `up-${parent}`,
type: "up",
name: "..",
ext: "",
@@ -92,131 +598,183 @@ export function FileBrowser() {
for (const entry of listing.entries) {
const kind = entry.type === "d" ? "dir" : "file";
const ext = kind === "file" ? (entry.name.split(".").pop() ?? "") : "";
const path = `${currentDir === "/" ? "" : currentDir}/${entry.name}`;
rows.push({
id: path,
type: kind,
name: entry.name,
ext,
size: kind === "dir" ? "-" : formatSize(entry.size),
modified: formatTime(entry.mtime),
path: `${currentDir === "/" ? "" : currentDir}/${entry.name}`,
path,
});
}
}
const columnDefs = [
{ field: "type" as const, headerName: "Type", width: 80 },
{ field: "name" as const, headerName: "Name", flex: 2 },
{ field: "ext" as const, headerName: "Ext", width: 80 },
{ field: "size" as const, headerName: "Size", width: 110 },
{ field: "modified" as const, headerName: "Modified", width: 180 },
const columns: GridColDef<DisplayRow>[] = [
{ field: "type", headerName: "Type", width: 90 },
{ field: "name", headerName: "Name", flex: 1.2, minWidth: 220 },
{ field: "ext", headerName: "Ext", width: 90 },
{ field: "size", headerName: "Size", width: 120 },
{ field: "modified", headerName: "Modified", width: 190 },
];
const onRowClicked = useCallback(
(event: { data?: DisplayRow }) => {
const row = event.data;
if (!row) return;
if (row.type === "dir" || row.type === "up") {
navigate(row.path);
} else {
setSelectedPath(row.path);
}
},
[navigate],
);
const rowSelectionModel: GridRowSelectionModel = selectedPath
? { type: "include", ids: new Set([selectedPath]) }
: { type: "include", ids: new Set() };
const selectedTemplate = templates?.find((t) => t.key === selectedJob);
return (
<div className="space-y-4">
{/* Path input */}
<div className="flex gap-2">
<input
type="text"
<Stack spacing={2}>
<Typography variant="h5">File Browser</Typography>
<Stack direction="row" spacing={1}>
<TextField
fullWidth
size="small"
label="Remote path"
value={pathInput}
onChange={(e) => setPathInput(e.target.value)}
onKeyDown={handlePathSubmit}
className="border rounded px-3 py-1 text-sm flex-1"
placeholder="Remote path (press Enter to navigate)"
/>
<button
onClick={() => navigate(pathInput || "/")}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
>
<Button variant="outlined" onClick={() => navigate(pathInput || "/")}>
Open
</Button>
<Button variant="outlined" onClick={() => refetch()}>
Refresh
</button>
</div>
</Button>
</Stack>
{/* Status */}
<div className="flex gap-6 text-xs text-gray-500">
<span>
Current: <code>{currentDir}</code>
</span>
{selectedPath && (
<span>
Selected: <code>{selectedPath}</code>
</span>
)}
{listing && <span>Entries: {listing.count}</span>}
</div>
<Typography variant="caption" color="text.secondary">
Current: {currentDir}{" "}
{selectedPath ? `| Selected: ${selectedPath}` : ""}{" "}
{listing ? `| Entries: ${listing.count}` : ""}
</Typography>
{error && <p className="text-sm text-red-600">Error: {String(error)}</p>}
{error && <Alert severity="error">{String(error)}</Alert>}
{/* File listing grid */}
<div className="ag-theme-alpine" style={{ height: 400, width: "100%" }}>
<AgGridReact<DisplayRow>
ref={gridRef}
rowData={rows}
columnDefs={columnDefs}
rowSelection="single"
onRowClicked={onRowClicked}
<Box
sx={{
height: 420,
bgcolor: "background.paper",
border: 1,
borderColor: "divider",
borderRadius: 2,
}}
>
<DataGrid
rows={rows}
columns={columns}
loading={isLoading}
suppressCellFocus
animateRows={false}
rowSelectionModel={rowSelectionModel}
hideFooter
sx={{
"& .MuiDataGrid-columnHeaders": {
fontWeight: 700,
backgroundColor: "action.hover",
},
}}
onRowClick={(params) => {
const row = params.row as DisplayRow;
if (row.type === "dir" || row.type === "up") navigate(row.path);
else setSelectedPath(row.path);
}}
/>
</div>
</Box>
{/* ffprobe preview */}
{selectedPath && isVideoFile(selectedPath) && (
<section className="border rounded-lg p-4">
<h3 className="text-sm font-semibold mb-2">
ffprobe preview: <code className="text-xs">{selectedPath}</code>
</h3>
{ffprobeData ? (
<pre className="text-xs bg-gray-50 p-3 rounded overflow-auto max-h-96">
{JSON.stringify(ffprobeData, null, 2)}
</pre>
) : (
<p className="text-sm text-gray-500">Loading ffprobe data...</p>
)}
</section>
<Card variant="outlined">
<CardContent>
{ffprobeError ? (
<Alert severity="error" sx={{ mb: 2 }}>
{String(ffprobeError)}
</Alert>
) : ffprobeLoading && !ffprobeData ? (
<Typography color="text.secondary">
Loading ffprobe data...
</Typography>
) : ffprobeData ? (
<FfprobeDetails
path={selectedPath}
data={ffprobeData as FfprobeData}
/>
) : (
<Typography color="text.secondary">
No ffprobe data available.
</Typography>
)}
</CardContent>
</Card>
)}
{/* Jobs */}
{selectedPath && templates && templates.length > 0 && (
<section className="border rounded-lg p-4">
<h3 className="text-sm font-semibold mb-2">Jobs</h3>
<div className="flex gap-2 flex-wrap">
{templates.map((tpl) => (
<button
key={tpl.key}
onClick={() =>
runJob.mutate({ jobKey: tpl.key, path: selectedPath })
}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
title={tpl.description}
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 1 }}>
Jobs
</Typography>
<Grid container spacing={1.5}>
<Grid size={{ xs: 12, md: 4 }}>
<FormControl fullWidth size="small">
<InputLabel>Job template</InputLabel>
<Select
label="Job template"
value={selectedJob}
onChange={(e) => setSelectedJob(e.target.value)}
>
{templates.map((tpl) => (
<MenuItem key={tpl.key} value={tpl.key}>
{tpl.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, md: 8 }}>
<Stack direction="row" spacing={1}>
<Button
variant="contained"
disabled={!selectedJob || runJob.isPending}
onClick={() =>
runJob.mutate({ jobKey: selectedJob, path: selectedPath })
}
>
Run job
</Button>
{selectedTemplate && (
<Typography
variant="body2"
color="text.secondary"
sx={{ alignSelf: "center" }}
>
{selectedTemplate.description}
</Typography>
)}
</Stack>
</Grid>
</Grid>
{runJob.data && (
<Box
component="pre"
sx={{
mt: 1.5,
p: 1.5,
bgcolor: "action.hover",
overflow: "auto",
maxHeight: 260,
fontSize: 12,
}}
>
{tpl.name}
</button>
))}
</div>
{runJob.data && (
<pre className="text-xs bg-gray-50 p-3 rounded mt-3 overflow-auto max-h-48">
Exit: {runJob.data.exit_status}
{"\n"}
{runJob.data.stdout}
{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
</pre>
)}
</section>
Exit: {runJob.data.exit_status}
{"\n"}
{runJob.data.stdout}
{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
</Box>
)}
</CardContent>
</Card>
)}
</div>
</Stack>
);
}
+336 -142
View File
@@ -1,15 +1,49 @@
import { useState, useCallback, useRef } from "react";
import { AgGridReact } from "ag-grid-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { DataGrid } from "@mui/x-data-grid";
import type { GridColDef } from "@mui/x-data-grid";
import {
Alert,
Box,
Button,
Card,
CardContent,
LinearProgress,
FormControl,
Grid,
InputLabel,
MenuItem,
Select,
Stack,
TextField,
Typography,
} from "@mui/material";
import {
useMediaStatus,
useMediaQuery,
useBuildIndex,
useStopBuildIndex,
useForceStopBuildIndex,
} from "../hooks/useMedia";
import type { MediaItem } from "../types";
function formatDuration(seconds: number | null | undefined): string {
if (seconds == null || Number.isNaN(seconds)) return "-";
const total = Math.max(0, Math.round(seconds));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
if (hours > 0) return `${hours}h ${minutes}m ${secs}s`;
if (minutes > 0) return `${minutes}m ${secs}s`;
return `${secs}s`;
}
export function Media() {
const navigate = useNavigate();
const { data: status } = useMediaStatus();
const buildIndex = useBuildIndex();
const stopBuildIndex = useStopBuildIndex();
const forceStopBuildIndex = useForceStopBuildIndex();
const [search, setSearch] = useState("");
const [types, setTypes] = useState("Movie,Episode");
@@ -30,181 +64,341 @@ export function Media() {
enabled: status?.exists ?? false,
});
const gridRef = useRef<AgGridReact<MediaItem>>(null);
const columnDefs = [
{ field: "title" as const, headerName: "Title", minWidth: 150 },
{ field: "series" as const, headerName: "Series", minWidth: 120 },
{ field: "season" as const, headerName: "Season", maxWidth: 95 },
{ field: "episode" as const, headerName: "Episode", maxWidth: 105 },
{ field: "type" as const, headerName: "Type", maxWidth: 100 },
{ field: "year" as const, headerName: "Year", maxWidth: 90 },
{
field: "runtime_min" as const,
headerName: "Runtime (min)",
maxWidth: 125,
},
{ field: "size" as const, headerName: "Size", maxWidth: 120 },
{ field: "bitrate" as const, headerName: "Bitrate", maxWidth: 125 },
{ field: "hdr" as const, headerName: "HDR", maxWidth: 80 },
{ field: "video" as const, headerName: "Video codec", maxWidth: 120 },
{ field: "resolution" as const, headerName: "Resolution", maxWidth: 120 },
{ field: "date_added" as const, headerName: "Date added", maxWidth: 120 },
{ field: "library" as const, headerName: "Library", maxWidth: 140 },
{ field: "path" as const, headerName: "Path", minWidth: 200 },
const columns: GridColDef<MediaItem>[] = [
{ field: "title", headerName: "Title", minWidth: 180, flex: 1.2 },
{ field: "series", headerName: "Series", minWidth: 140, flex: 1 },
{ field: "season", headerName: "Season", width: 90 },
{ field: "episode", headerName: "Episode", width: 100 },
{ field: "type", headerName: "Type", width: 100 },
{ field: "year", headerName: "Year", width: 90 },
{ field: "runtime_min", headerName: "Runtime", width: 110 },
{ field: "size", headerName: "Size", width: 120 },
{ field: "bitrate", headerName: "Bitrate", width: 130 },
{ field: "hdr", headerName: "HDR", width: 80 },
{ field: "video", headerName: "Video codec", width: 130 },
{ field: "resolution", headerName: "Resolution", width: 120 },
{ field: "date_added", headerName: "Date added", width: 120 },
{ field: "library", headerName: "Library", width: 140 },
{ field: "path", headerName: "Path", minWidth: 240, flex: 1.2 },
];
const onGridReady = useCallback(() => {
gridRef.current?.api?.sizeColumnsToFit();
}, []);
const rows = useMemo(
() =>
(queryResult?.items ?? []).map((item) => ({
...item,
id: item.id || item.path,
})),
[queryResult],
);
const page = Math.floor(offset / limit) + 1;
const totalPages = queryResult ? Math.ceil(queryResult.total / limit) : 1;
const totalPages = queryResult
? Math.max(1, Math.ceil(queryResult.total / limit))
: 1;
const buildRunning = status?.build_running ?? false;
const buildProgress = status?.build_progress ?? null;
const buildLibraryProgress = status?.build_library_progress ?? null;
const buildCancelRequested = status?.build_cancel_requested ?? false;
const buildLabel = buildRunning
? status?.build_message || "Building media index..."
: status?.build_error
? `Build failed: ${status.build_error}`
: "";
const elapsedLabel = formatDuration(status?.build_elapsed_seconds);
const etaLabel =
buildRunning && status?.build_eta_seconds != null
? formatDuration(status.build_eta_seconds)
: "-";
const libraryElapsedLabel = formatDuration(
status?.build_library_elapsed_seconds,
);
const libraryEtaLabel =
buildRunning && status?.build_library_eta_seconds != null
? formatDuration(status.build_library_eta_seconds)
: "-";
const libraryLabel =
status?.build_current_library ||
(status?.build_library_index && status?.build_libraries_total
? `Library ${status.build_library_index} / ${status.build_libraries_total}`
: "Current library");
return (
<div className="space-y-4">
{/* Status and controls */}
<div className="flex items-center gap-4">
<Stack spacing={2}>
<Stack
direction="row"
spacing={1.5}
sx={{ alignItems: "center", flexWrap: "wrap" }}
>
<Typography variant="h5">Media</Typography>
{status?.exists ? (
<span className="text-sm text-gray-600">
<Typography variant="body2" color="text.secondary">
Index: {status.item_count.toLocaleString()} items
{status.updated_at_label && ` | updated ${status.updated_at_label}`}
</span>
{status.updated_at_label
? ` | updated ${status.updated_at_label}`
: ""}
</Typography>
) : (
<span className="text-sm text-amber-600">No index built yet.</span>
<Alert severity="warning" sx={{ py: 0 }}>
No index built yet.
</Alert>
)}
<button
<Button
variant="outlined"
onClick={() => buildIndex.mutate()}
disabled={buildIndex.isPending}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50 disabled:opacity-50"
disabled={
buildIndex.isPending || buildRunning || buildCancelRequested
}
>
{buildIndex.isPending ? "Building..." : "Build index"}
</button>
</div>
{buildIndex.isPending || buildRunning ? "Building..." : "Build index"}
</Button>
{buildRunning && (
<>
<Button
variant="outlined"
color="error"
onClick={() => stopBuildIndex.mutate()}
disabled={stopBuildIndex.isPending || buildCancelRequested}
>
{buildCancelRequested || stopBuildIndex.isPending
? "Stopping..."
: "Stop build"}
</Button>
<Button
variant="outlined"
color="warning"
onClick={() => forceStopBuildIndex.mutate()}
disabled={forceStopBuildIndex.isPending}
>
{forceStopBuildIndex.isPending
? "Force stopping..."
: "Force stop"}
</Button>
</>
)}
{(buildRunning || status?.build_error) && (
<Box sx={{ width: "100%", minWidth: 260, flexBasis: "100%" }}>
<Stack spacing={1}>
<Typography
variant="body2"
color={status?.build_error ? "error" : "text.secondary"}
>
{buildLabel ||
(buildRunning
? "Building media index..."
: status?.build_error || "")}
</Typography>
{/* Filters */}
<div className="flex flex-wrap gap-3 items-end">
<div>
<label className="text-xs text-gray-500 block">Search</label>
<input
type="text"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setOffset(0);
}}
className="border rounded px-2 py-1 text-sm w-48"
placeholder="Search title, series, path..."
/>
</div>
<div>
<label className="text-xs text-gray-500 block">Types</label>
<select
value={types}
onChange={(e) => {
setTypes(e.target.value);
setOffset(0);
}}
className="border rounded px-2 py-1 text-sm"
>
<option value="Movie,Episode">Movies + Episodes</option>
<option value="Movie">Movies only</option>
<option value="Episode">Episodes only</option>
<option value="Movie,Episode,Video">All video</option>
</select>
</div>
<div>
<label className="text-xs text-gray-500 block">HDR</label>
<select
value={hdrFilter}
onChange={(e) => {
setHdrFilter(e.target.value);
setOffset(0);
}}
className="border rounded px-2 py-1 text-sm"
>
<option value="All">All</option>
<option value="HDR only">HDR only</option>
<option value="SDR/unknown only">SDR/unknown only</option>
</select>
</div>
<div>
<label className="text-xs text-gray-500 block">Sort</label>
<select
value={sortKey}
onChange={(e) => setSortKey(e.target.value)}
className="border rounded px-2 py-1 text-sm"
>
<option value="title">Title</option>
<option value="series">Series</option>
<option value="size">Size</option>
<option value="bitrate">Bitrate</option>
<option value="runtime">Runtime</option>
<option value="year">Year</option>
<option value="date_added">Date added</option>
<option value="resolution">Resolution</option>
</select>
</div>
<div>
<label className="text-xs text-gray-500 block">Order</label>
<select
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value)}
className="border rounded px-2 py-1 text-sm"
>
<option value="Ascending">Ascending</option>
<option value="Descending">Descending</option>
</select>
</div>
</div>
<Stack spacing={0.35}>
<Typography variant="caption" color="text.secondary">
Overall:{" "}
{buildProgress != null
? `${Math.round(buildProgress * 100)}%`
: "pending"}
{buildRunning
? ` • elapsed ${elapsedLabel} • eta ${etaLabel}`
: ""}
</Typography>
<LinearProgress
variant={
buildProgress != null ? "determinate" : "indeterminate"
}
value={
buildProgress != null
? Math.max(0, Math.min(100, buildProgress * 100))
: undefined
}
/>
<Typography variant="caption" color="text.secondary">
{status?.build_items_processed?.toLocaleString() ?? 0}/
{status?.build_items_total?.toLocaleString() ?? 0} items
</Typography>
</Stack>
<Stack spacing={0.35}>
<Typography variant="caption" color="text.secondary">
Current: {libraryLabel}
{buildRunning
? ` • elapsed ${libraryElapsedLabel} • eta ${libraryEtaLabel}`
: ""}
</Typography>
<LinearProgress
variant={
buildLibraryProgress != null
? "determinate"
: "indeterminate"
}
value={
buildLibraryProgress != null
? Math.max(0, Math.min(100, buildLibraryProgress * 100))
: undefined
}
/>
<Typography variant="caption" color="text.secondary">
{status?.build_library_items_processed?.toLocaleString() ?? 0}
/{status?.build_library_items_total?.toLocaleString() ?? 0}{" "}
items
</Typography>
</Stack>
</Stack>
</Box>
)}
</Stack>
<Card variant="outlined">
<CardContent>
<Grid container spacing={1.5}>
<Grid size={{ xs: 12, md: 4 }}>
<TextField
fullWidth
label="Search"
size="small"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setOffset(0);
}}
/>
</Grid>
<Grid size={{ xs: 6, md: 2 }}>
<FormControl fullWidth size="small">
<InputLabel>Types</InputLabel>
<Select
label="Types"
value={types}
onChange={(e) => {
setTypes(e.target.value);
setOffset(0);
}}
>
<MenuItem value="Movie,Episode">Movies + Episodes</MenuItem>
<MenuItem value="Movie">Movies only</MenuItem>
<MenuItem value="Episode">Episodes only</MenuItem>
<MenuItem value="Movie,Episode,Video">All video</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 6, md: 2 }}>
<FormControl fullWidth size="small">
<InputLabel>HDR</InputLabel>
<Select
label="HDR"
value={hdrFilter}
onChange={(e) => {
setHdrFilter(e.target.value);
setOffset(0);
}}
>
<MenuItem value="All">All</MenuItem>
<MenuItem value="HDR only">HDR only</MenuItem>
<MenuItem value="SDR/unknown only">SDR/unknown only</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 6, md: 2 }}>
<FormControl fullWidth size="small">
<InputLabel>Sort</InputLabel>
<Select
label="Sort"
value={sortKey}
onChange={(e) => setSortKey(e.target.value)}
>
{[
["title", "Title"],
["series", "Series"],
["size", "Size"],
["bitrate", "Bitrate"],
["runtime", "Runtime"],
["year", "Year"],
["date_added", "Date added"],
["resolution", "Resolution"],
].map(([k, l]) => (
<MenuItem key={k} value={k}>
{l}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 6, md: 2 }}>
<FormControl fullWidth size="small">
<InputLabel>Order</InputLabel>
<Select
label="Order"
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value)}
>
<MenuItem value="Ascending">Ascending</MenuItem>
<MenuItem value="Descending">Descending</MenuItem>
</Select>
</FormControl>
</Grid>
</Grid>
</CardContent>
</Card>
{/* Results info */}
{queryResult && (
<p className="text-xs text-gray-500">
<Typography variant="caption" color="text.secondary">
Showing {queryResult.items.length} of{" "}
{queryResult.total.toLocaleString()} items | Page {page} of{" "}
{totalPages}
</p>
</Typography>
)}
{/* AG Grid table */}
{status?.exists && (
<div className="ag-theme-alpine" style={{ height: 600, width: "100%" }}>
<AgGridReact<MediaItem>
ref={gridRef}
rowData={queryResult?.items ?? []}
columnDefs={columnDefs}
rowSelection="single"
onGridReady={onGridReady}
<Box
sx={{
height: 640,
bgcolor: "background.paper",
border: 1,
borderColor: "divider",
borderRadius: 2,
}}
>
<DataGrid
rows={rows}
columns={columns}
loading={isLoading}
suppressCellFocus
animateRows={false}
checkboxSelection={false}
disableRowSelectionOnClick
onRowClick={(params) => {
const row = params.row as MediaItem;
navigate(`/files?path=${encodeURIComponent(row.path)}`);
}}
pageSizeOptions={[100]}
hideFooter
sx={{
"& .MuiDataGrid-columnHeaders": {
fontWeight: 700,
backgroundColor: "action.hover",
},
}}
/>
</div>
</Box>
)}
{/* Pagination */}
{queryResult && totalPages > 1 && (
<div className="flex gap-2 items-center">
<button
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Button
variant="outlined"
size="small"
onClick={() => setOffset(Math.max(0, offset - limit))}
disabled={page <= 1}
className="px-3 py-1 text-sm rounded border disabled:opacity-50"
>
Prev
</button>
<span className="text-sm">
</Button>
<Typography variant="body2">
Page {page} / {totalPages}
</span>
<button
</Typography>
<Button
variant="outlined"
size="small"
onClick={() => setOffset(offset + limit)}
disabled={page >= totalPages}
className="px-3 py-1 text-sm rounded border disabled:opacity-50"
>
Next
</button>
</div>
</Button>
</Stack>
)}
</div>
</Stack>
);
}
+67 -34
View File
@@ -1,3 +1,12 @@
import {
Box,
Button,
Chip,
Divider,
Grid,
Stack,
Typography,
} from "@mui/material";
import {
useMonitoringStatus,
useMonitoringMetrics,
@@ -32,7 +41,6 @@ export function Monitoring() {
const samples = metrics?.samples ?? [];
const latest = samples.at(-1);
// Compute averages and peaks
const avg = (arr: number[]) =>
arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
const max = (arr: number[]) => (arr.length ? Math.max(...arr) : 0);
@@ -46,95 +54,120 @@ export function Monitoring() {
const diskWriteArr = samples.map((s) => s.disk_write_bps);
return (
<div className="space-y-8">
{/* Controls */}
<section className="flex items-center gap-4">
<span className="text-sm text-gray-600">
Collector:{" "}
<code className="bg-gray-100 px-1 rounded">
{status?.status ?? "unknown"}
</code>
</span>
<button
<Stack spacing={3}>
<Stack
direction="row"
spacing={1.5}
sx={{ alignItems: "center", flexWrap: "wrap" }}
>
<Typography variant="h5">Monitoring</Typography>
<Chip
label={status?.status ?? "unknown"}
color="primary"
variant="outlined"
/>
<Button
size="small"
variant="outlined"
onClick={() => start.mutate()}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
disabled={start.isPending}
>
Start
</button>
<button
</Button>
<Button
size="small"
variant="outlined"
onClick={() => restart.mutate()}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
disabled={restart.isPending}
>
Restart
</button>
<button
</Button>
<Button
size="small"
variant="outlined"
onClick={() => stop.mutate()}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
disabled={stop.isPending}
>
Stop
</button>
</section>
</Button>
</Stack>
{/* Metrics summary */}
<section>
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3">
<Grid container spacing={1.5}>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="CPU now"
value={latest ? `${latest.cpu_pct.toFixed(1)}%` : "-"}
subtext={`avg ${avg(cpuArr).toFixed(1)}%\npeak ${max(cpuArr).toFixed(1)}%`}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="IO Wait"
value={latest ? `${(latest.iowait_pct ?? 0).toFixed(1)}%` : "-"}
subtext={`avg ${avg(iowArr).toFixed(1)}%\npeak ${max(iowArr).toFixed(1)}%`}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="RAM now"
value={latest ? `${latest.mem_pct.toFixed(1)}%` : "-"}
subtext={`avg ${avg(memArr).toFixed(1)}%\npeak ${max(memArr).toFixed(1)}%`}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="Net down"
value={latest ? formatRate(latest.net_rx_bytes_per_sec) : "-"}
subtext={`avg ${formatRate(avg(netDownArr))}\npeak ${formatRate(max(netDownArr))}`}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="Net up"
value={latest ? formatRate(latest.net_tx_bytes_per_sec) : "-"}
subtext={`avg ${formatRate(avg(netUpArr))}\npeak ${formatRate(max(netUpArr))}`}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="Disk read"
value={latest ? formatRate(latest.disk_read_bps) : "-"}
subtext={`avg ${formatRate(avg(diskReadArr))}\npeak ${formatRate(max(diskReadArr))}`}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="Disk write"
value={latest ? formatRate(latest.disk_write_bps) : "-"}
subtext={`avg ${formatRate(avg(diskWriteArr))}\npeak ${formatRate(max(diskWriteArr))}`}
/>
</div>
</section>
</Grid>
</Grid>
{/* Disk space */}
{disk && (
<section>
<div className="grid grid-cols-4 gap-3">
<Grid container spacing={1.5}>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard label="Disk used" value={formatBytes(disk.used)} />
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard
label="Disk available"
value={formatBytes(disk.available)}
/>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard label="Disk total" value={formatBytes(disk.size)} />
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard label="Used %" value={disk.used_pct} />
</div>
</section>
</Grid>
</Grid>
)}
{/* Charts */}
<section>
<Divider />
<Box>
<MonitoringCharts samples={samples} />
</section>
</div>
</Box>
</Stack>
);
}
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
import { createTheme, type PaletteMode } from "@mui/material/styles";
export function getAppTheme(mode: PaletteMode) {
const isDark = mode === "dark";
return createTheme({
palette: {
mode,
primary: { main: "#4f8cff" },
background: {
default: isDark ? "#0f172a" : "#f4f6fb",
paper: isDark ? "#111827" : "#ffffff",
},
},
shape: { borderRadius: 12 },
typography: {
fontFamily:
'Inter, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
h5: { fontWeight: 700 },
},
components: {
MuiAppBar: {
styleOverrides: {
root: {
backdropFilter: "blur(8px)",
backgroundImage: "none",
},
},
},
MuiCard: {
styleOverrides: {
root: {
borderColor: isDark
? "rgba(148,163,184,0.25)"
: "rgba(15,23,42,0.08)",
boxShadow: isDark
? "0 2px 12px rgba(2,6,23,0.35)"
: "0 2px 8px rgba(15,23,42,0.04)",
},
},
},
},
});
}
+135
View File
@@ -17,6 +17,99 @@ export interface LibraryCount {
total: number;
}
export interface UserDirectoryItem {
jellyfin_id: string;
username: string;
display_name: string;
email: string;
email_source: string;
avatar: string;
avatar_source: string;
contactable: boolean;
source: string;
source_summary: string;
name_source: string;
access_source: string;
jellyseerr_user_id: number | null;
jellyseerr_username: string;
user_type: number | null;
user_type_label: string;
role: string;
permissions: number;
permissions_label: string;
request_count: number | null;
}
export interface UserDirectoryResponse {
items: UserDirectoryItem[];
total: number;
jellyseerr_configured: boolean;
jellyseerr_available: boolean;
jellyseerr_error: string;
jellyseerr_jellyfin_user_count: number;
jellyseerr_user_count: number;
enriched_count: number;
}
export interface UserMessageResponse {
status: string;
request_id: string;
subject: string;
from_address: string;
recipient_count: number;
attachment_count: number;
recipient_labels: string[];
skipped: Array<{ jellyfin_id: string; reason: string }>;
}
export interface UserMessageQueueStatus {
state: "idle" | "busy" | "error" | "stopped";
worker_running: boolean;
stop_requested: boolean;
pending_count: number;
active_request_id: string | null;
last_request_id: string | null;
last_result: string | null;
last_error: string;
last_error_at: number | null;
last_success_at: number | null;
last_activity_at: number | null;
sent_count: number;
failed_count: number;
}
export interface SmtpTestAttempt {
label: string;
smtp_host: string;
smtp_port: number;
use_tls: boolean;
use_ssl: boolean;
status: "ok" | "failed";
error?: string;
}
export interface SmtpTestSelectedMode {
label: string;
smtp_host: string;
smtp_port: number;
use_tls: boolean;
use_ssl: boolean;
}
export interface SmtpTestResponse {
status: "ok" | "error";
message: string;
from_address: string;
from_name: string;
smtp_host: string;
smtp_port: number;
use_tls: boolean;
use_ssl: boolean;
authenticated: boolean;
selected_mode: SmtpTestSelectedMode | null;
attempts: SmtpTestAttempt[];
}
export interface NowPlayingSession {
user: string;
title: string;
@@ -65,6 +158,48 @@ export interface MediaIndexStatus {
updated_at: number | null;
updated_at_label: string;
build_duration_seconds: number | null;
build_running: boolean;
build_stage: string;
build_message: string;
build_progress: number | null;
build_items_processed: number;
build_items_total: number;
build_current_library: string;
build_library_index: number;
build_libraries_total: number;
build_library_progress: number | null;
build_library_items_processed: number;
build_library_items_total: number;
build_elapsed_seconds: number | null;
build_eta_seconds: number | null;
build_library_elapsed_seconds: number | null;
build_library_eta_seconds: number | null;
build_cancel_requested: boolean;
build_pid: number | null;
build_error: string;
}
export interface MediaIndexActionResponse {
status: string;
build_running: boolean;
build_stage: string;
build_message: string;
build_progress: number | null;
build_items_processed: number;
build_items_total: number;
build_current_library: string;
build_library_index: number;
build_libraries_total: number;
build_library_progress: number | null;
build_library_items_processed: number;
build_library_items_total: number;
build_elapsed_seconds: number | null;
build_eta_seconds: number | null;
build_library_elapsed_seconds: number | null;
build_library_eta_seconds: number | null;
build_cancel_requested: boolean;
build_pid: number | null;
build_error: string;
}
export interface MediaItem {
+29
View File
@@ -0,0 +1,29 @@
import type { NowPlayingSession, UserDirectoryItem } from "./types";
export interface UserActivitySummary {
sessions: NowPlayingSession[];
active_count: number;
idle_count: number;
label: string;
summary: string;
primary_session: NowPlayingSession | null;
}
export interface UserStateItem extends UserDirectoryItem {
activity: UserActivitySummary;
activity_label: string;
activity_summary: string;
activity_count: number;
activity_active_count: number;
activity_idle_count: number;
}
export declare function mergeUsersWithActivity(
users: UserDirectoryItem[],
sessions: NowPlayingSession[],
): UserStateItem[];
export declare function resolveUserSelection(
users: Array<UserDirectoryItem | UserStateItem>,
identifier: string,
): UserStateItem | UserDirectoryItem | null;
+100
View File
@@ -0,0 +1,100 @@
function normalize(value) {
return String(value ?? "")
.trim()
.toLowerCase();
}
function userKeys(user) {
return [user.jellyfin_id, user.username, user.display_name]
.map(normalize)
.filter(Boolean);
}
function sessionMatchesUser(user, session) {
const sessionUser = normalize(session.user);
if (!sessionUser) {
return false;
}
return userKeys(user).some((key) => key === sessionUser);
}
function buildActivitySummary(sessions) {
const activeSessions = sessions.filter((session) => {
const state = String(session.state || "")
.trim()
.toLowerCase();
return state === "playing" || state === "paused";
});
const idleSessions = sessions.filter((session) => {
const state = String(session.state || "")
.trim()
.toLowerCase();
return state === "idle";
});
const primarySession = activeSessions[0] || sessions[0] || null;
const hasPlaying = activeSessions.some((session) => {
const state = String(session.state || "")
.trim()
.toLowerCase();
return state === "playing";
});
const hasPaused = activeSessions.some((session) => {
const state = String(session.state || "")
.trim()
.toLowerCase();
return state === "paused";
});
const label = !sessions.length
? "No sessions"
: hasPlaying
? "Playing"
: hasPaused
? "Paused"
: "Idle";
const summary =
sessions.length === 0
? "No live sessions"
: [
`${sessions.length} session${sessions.length === 1 ? "" : "s"}`,
activeSessions.length ? `${activeSessions.length} active` : null,
idleSessions.length ? `${idleSessions.length} idle` : null,
]
.filter(Boolean)
.join(" · ");
return {
sessions,
active_count: activeSessions.length,
idle_count: idleSessions.length,
label,
summary,
primary_session: primarySession,
};
}
export function mergeUsersWithActivity(users, sessions) {
return users.map((user) => {
const matchingSessions = sessions.filter((session) =>
sessionMatchesUser(user, session),
);
const activity = buildActivitySummary(matchingSessions);
return {
...user,
activity,
activity_label: activity.label,
activity_summary: activity.summary,
activity_count: activity.sessions.length,
activity_active_count: activity.active_count,
activity_idle_count: activity.idle_count,
};
});
}
export function resolveUserSelection(users, identifier) {
const needle = normalize(identifier);
if (!needle) {
return null;
}
return (
users.find((user) => userKeys(user).some((key) => key === needle)) || null
);
}
+32
View File
@@ -0,0 +1,32 @@
import type { UserDirectoryItem } from "./types";
export interface UserDetailField {
label: string;
value: string;
}
export interface UserContactAction {
label: string;
enabled: boolean;
hint: string;
}
export interface UserContactState {
label: string;
description: string;
}
export interface UserDrawerModel {
title: string;
subtitle: string;
contactState: UserContactState;
contactActions: UserContactAction[];
identity: UserDetailField[];
permissions: string[];
syncStatus: string;
source: string;
}
export declare function buildUserDrawerModel(
user: UserDirectoryItem,
): UserDrawerModel;
+90
View File
@@ -0,0 +1,90 @@
function displayName(user) {
return user.display_name || user.username || user.jellyfin_id;
}
function splitValues(value) {
return String(value || "")
.split(",")
.map((part) => part.trim())
.filter(Boolean);
}
function syncStatus(user) {
if (
user.jellyseerr_user_id !== null &&
user.jellyseerr_user_id !== undefined
) {
return "Linked with Jellyseerr";
}
if (String(user.source_summary || user.source || "").includes("jellyseerr")) {
return "Enriched via Jellyseerr";
}
return "Jellyfin only";
}
export function buildUserDrawerModel(user) {
const title = displayName(user);
const subtitle = user.email || "No email address available";
const permissions = splitValues(user.permissions_label);
const source = String(user.source_summary || user.source || "jellyfin");
const roleLabel = user.role
? user.role.charAt(0).toUpperCase() + user.role.slice(1)
: "Unknown";
const contactLabel = user.contactable ? "Contactable" : "Read-only";
const contactDescription = user.contactable
? "An email address is available for future communication workflows."
: "No direct contact route is available yet.";
return {
title,
subtitle,
contactState: {
label: contactLabel,
description: contactDescription,
},
contactActions: [
{
label: "Email",
enabled: false,
hint: user.contactable
? "Email action is planned for a future release."
: "Disabled until an email address is available.",
},
{
label: "Notify",
enabled: false,
hint: "Notification workflows are not wired up yet.",
},
],
identity: [
{ label: "Email", value: user.email || "—" },
{ label: "Email source", value: user.email_source || "none" },
{ label: "Avatar source", value: user.avatar_source || "none" },
{ label: "Jellyfin ID", value: user.jellyfin_id },
{ label: "Name source", value: user.name_source || "jellyfin" },
{ label: "Access source", value: user.access_source || "none" },
{
label: "Jellyseerr user ID",
value:
user.jellyseerr_user_id === null ||
user.jellyseerr_user_id === undefined
? "Not linked"
: `#${user.jellyseerr_user_id}`,
},
{ label: "Account type", value: user.user_type_label || "unknown" },
{ label: "Role", value: roleLabel },
{ label: "Permissions", value: permissions.join(", ") || "none" },
{
label: "Requests",
value:
user.request_count === null || user.request_count === undefined
? "—"
: String(user.request_count),
},
{ label: "Source", value: source },
],
permissions,
syncStatus: syncStatus(user),
source,
};
}