Add FastAPI backend and React frontend subprojects

Backend:
- FastAPI app with 17 REST endpoints covering dashboard, monitoring,
  media index, file browser, and jobs
- Reuses existing clients/domain/services unchanged
- pydantic-settings config, dependency injection, CORS setup
- Auto-generated OpenAPI docs at /docs

Frontend:
- Vite + React + TypeScript SPA
- @tanstack/react-query for data fetching with polling
- ag-grid-react for media table and file browser
- recharts for monitoring charts
- Tailwind CSS styling
- 4 pages: Dashboard, Monitoring, Media, File Browser
- Typed API client matching all backend endpoints

Also:
- docs/MIGRATION_PLAN.md with full architecture plan
- Updated .gitignore for both subprojects
- Streamlit app preserved for now (can coexist)
This commit is contained in:
2026-04-30 21:40:18 +02:00
parent 1acdfbc6ba
commit 3c432473e5
63 changed files with 7778 additions and 202 deletions
+62
View File
@@ -0,0 +1,62 @@
import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Dashboard } from "./pages/Dashboard";
import { Monitoring } from "./pages/Monitoring";
import { Media } from "./pages/Media";
import { FileBrowser } from "./pages/FileBrowser";
const queryClient = new QueryClient({
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 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>
);
}
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>
);
}
+116
View File
@@ -0,0 +1,116 @@
/**
* Typed API client for the FastAPI backend.
*/
import type {
MediaCounts,
LibraryCount,
NowPlayingSession,
MonitoringStatus,
MonitoringMetrics,
DiskSpace,
MediaIndexStatus,
MediaQueryResponse,
DirectoryListing,
JobTemplate,
JobResult,
ResolvedPath,
} from "../types";
const BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";
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());
if (!response.ok) {
const detail = await response.text();
throw new Error(`${response.status}: ${detail}`);
}
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(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const detail = await response.text();
throw new Error(`${response.status}: ${detail}`);
}
return response.json();
}
// Dashboard
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");
// Monitoring
export const fetchMonitoringStatus = () =>
get<MonitoringStatus>("/api/monitoring/status");
export const fetchMonitoringMetrics = (lastSeconds = 3600) =>
get<MonitoringMetrics>("/api/monitoring/metrics", {
last_seconds: String(lastSeconds),
});
export const fetchDiskSpace = () => get<DiskSpace>("/api/monitoring/disk");
export const startCollector = () =>
post<{ message: string }>("/api/monitoring/start");
export const stopCollector = () =>
post<{ message: string }>("/api/monitoring/stop");
export const restartCollector = () =>
post<{ message: string }>("/api/monitoring/restart");
// Media
export const fetchMediaStatus = () =>
get<MediaIndexStatus>("/api/media/status");
export const buildMediaIndex = () =>
post<{ indexed_items: number }>("/api/media/build");
export const queryMedia = (params: {
libraries?: string;
types?: string;
search?: string;
hdr_filter?: string;
sort_key?: string;
sort_order?: string;
limit?: number;
offset?: number;
}) =>
get<MediaQueryResponse>("/api/media/query", {
libraries: params.libraries || "",
types: params.types || "Movie,Episode",
search: params.search || "",
hdr_filter: params.hdr_filter || "All",
sort_key: params.sort_key || "title",
sort_order: params.sort_order || "Ascending",
limit: String(params.limit || 100),
offset: String(params.offset || 0),
});
// Files
export const fetchDirectoryListing = (path: string) =>
get<DirectoryListing>("/api/files/list", { path });
export const fetchFfprobe = (path: string) =>
get<Record<string, unknown>>("/api/files/ffprobe", { path });
export const fetchStat = (path: string) =>
get<{ path: string; output: string }>("/api/files/stat", { path });
export const resolvePath = (path: string) =>
get<ResolvedPath>("/api/files/resolve-path", { path });
// Jobs
export const fetchJobTemplates = () =>
get<JobTemplate[]>("/api/jobs/templates");
export const runJob = (jobKey: string, path: string) =>
post<JobResult>("/api/jobs/run", { job_key: jobKey, path });
@@ -0,0 +1,55 @@
import type { LibraryCount } from "../types";
interface Props {
libraries: LibraryCount[];
}
export function LibraryOverview({ libraries }: Props) {
const movieLibs = libraries.filter((l) => l.type === "movies");
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>
{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>
))}
</div>
)}
{tvLibs.length > 0 && (
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide mb-2">
TV libraries
</p>
{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>
))}
</div>
)}
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
interface Props {
label: string;
value: string;
subtext?: string;
}
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>
);
}
@@ -0,0 +1,179 @@
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
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",
});
}
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B/s";
const units = ["B/s", "KB/s", "MB/s", "GB/s"];
let value = bytes;
let unitIdx = 0;
while (value >= 1000 && unitIdx < units.length - 1) {
value /= 1000;
unitIdx++;
}
return `${value.toFixed(1)} ${units[unitIdx]}`;
}
export function MonitoringCharts({ samples }: Props) {
if (samples.length === 0) {
return (
<p className="text-sm text-gray-500">No monitoring samples available.</p>
);
}
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}
/>
<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>
<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>
<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>
);
}
+46
View File
@@ -0,0 +1,46 @@
import type { NowPlayingSession } from "../types";
interface Props {
sessions: NowPlayingSession[];
}
export function NowPlaying({ sessions }: Props) {
if (sessions.length === 0) {
return (
<p className="text-sm text-gray-500">
No active playback sessions right now.
</p>
);
}
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>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { useQuery } from "@tanstack/react-query";
import { fetchCounts, fetchLibraries, fetchNowPlaying } from "../api/client";
export function useCounts() {
return useQuery({
queryKey: ["dashboard", "counts"],
queryFn: fetchCounts,
staleTime: 5 * 60 * 1000,
});
}
export function useLibraries() {
return useQuery({
queryKey: ["dashboard", "libraries"],
queryFn: fetchLibraries,
staleTime: 5 * 60 * 1000,
});
}
export function useNowPlaying() {
return useQuery({
queryKey: ["dashboard", "now-playing"],
queryFn: fetchNowPlaying,
refetchInterval: 15_000,
});
}
+49
View File
@@ -0,0 +1,49 @@
import { useQuery, useMutation } from "@tanstack/react-query";
import {
fetchDirectoryListing,
fetchFfprobe,
fetchStat,
fetchJobTemplates,
runJob,
} from "../api/client";
export function useDirectoryListing(path: string) {
return useQuery({
queryKey: ["files", "list", path],
queryFn: () => fetchDirectoryListing(path),
enabled: !!path,
staleTime: 30_000,
});
}
export function useFfprobe(path: string, enabled = false) {
return useQuery({
queryKey: ["files", "ffprobe", path],
queryFn: () => fetchFfprobe(path),
enabled: enabled && !!path,
staleTime: 5 * 60_000,
});
}
export function useStat(path: string, enabled = false) {
return useQuery({
queryKey: ["files", "stat", path],
queryFn: () => fetchStat(path),
enabled: enabled && !!path,
});
}
export function useJobTemplates() {
return useQuery({
queryKey: ["jobs", "templates"],
queryFn: fetchJobTemplates,
staleTime: 60_000,
});
}
export function useRunJob() {
return useMutation({
mutationFn: ({ jobKey, path }: { jobKey: string; path: string }) =>
runJob(jobKey, path),
});
}
+40
View File
@@ -0,0 +1,40 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { fetchMediaStatus, buildMediaIndex, queryMedia } from "../api/client";
export function useMediaStatus() {
return useQuery({
queryKey: ["media", "status"],
queryFn: fetchMediaStatus,
staleTime: 60_000,
});
}
export function useMediaQuery(params: {
libraries?: string;
types?: string;
search?: string;
hdr_filter?: string;
sort_key?: string;
sort_order?: string;
limit?: number;
offset?: number;
enabled?: boolean;
}) {
const { enabled = true, ...queryParams } = params;
return useQuery({
queryKey: ["media", "query", queryParams],
queryFn: () => queryMedia(queryParams),
enabled,
staleTime: 30_000,
});
}
export function useBuildIndex() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: buildMediaIndex,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["media"] });
},
});
}
+54
View File
@@ -0,0 +1,54 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
fetchMonitoringStatus,
fetchMonitoringMetrics,
fetchDiskSpace,
startCollector,
stopCollector,
restartCollector,
} from "../api/client";
export function useMonitoringStatus() {
return useQuery({
queryKey: ["monitoring", "status"],
queryFn: fetchMonitoringStatus,
refetchInterval: 30_000,
});
}
export function useMonitoringMetrics(lastSeconds = 3600) {
return useQuery({
queryKey: ["monitoring", "metrics", lastSeconds],
queryFn: () => fetchMonitoringMetrics(lastSeconds),
refetchInterval: 15_000,
});
}
export function useDiskSpace() {
return useQuery({
queryKey: ["monitoring", "disk"],
queryFn: fetchDiskSpace,
staleTime: 60_000,
});
}
export function useCollectorControls() {
const queryClient = useQueryClient();
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["monitoring"] });
const start = useMutation({
mutationFn: startCollector,
onSuccess: invalidate,
});
const stop = useMutation({
mutationFn: stopCollector,
onSuccess: invalidate,
});
const restart = useMutation({
mutationFn: restartCollector,
onSuccess: invalidate,
});
return { start, stop, restart };
}
+1
View File
@@ -0,0 +1 @@
@import "tailwindcss";
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+115
View File
@@ -0,0 +1,115 @@
import { useCounts, useLibraries, useNowPlaying } from "../hooks/useDashboard";
import { useMonitoringMetrics, useDiskSpace } from "../hooks/useMonitoring";
import { NowPlaying } from "../components/NowPlaying";
import { MetricCard } from "../components/MetricCard";
import { LibraryOverview } from "../components/LibraryOverview";
function formatBytes(bytes: number): string {
if (!bytes || bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unitIdx = 0;
while (value >= 1000 && unitIdx < units.length - 1) {
value /= 1000;
unitIdx++;
}
return `${value.toFixed(1)} ${units[unitIdx]}`;
}
function formatRate(bytes: number): string {
return `${formatBytes(bytes)}/s`;
}
export function Dashboard() {
const { data: counts } = useCounts();
const { data: libraries } = useLibraries();
const { data: nowPlaying } = useNowPlaying();
const { data: metrics } = useMonitoringMetrics();
const { data: disk } = useDiskSpace();
const latest = metrics?.samples?.at(-1);
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)}%` : "-"}
/>
<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>
<hr />
{/* Media Library Overview */}
<section>
<h2 className="text-lg font-semibold mb-3">Media library overview</h2>
{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>
)}
{libraries && <LibraryOverview libraries={libraries} />}
</section>
</div>
);
}
+222
View File
@@ -0,0 +1,222 @@
import { useState, useCallback, useRef } from "react";
import { AgGridReact } from "ag-grid-react";
import {
useDirectoryListing,
useFfprobe,
useJobTemplates,
useRunJob,
} from "../hooks/useFiles";
interface DisplayRow {
type: string;
name: string;
ext: string;
size: string;
modified: string;
path: string;
}
function formatSize(bytes: number): string {
if (bytes === 0) return "-";
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unitIdx = 0;
while (value >= 1000 && unitIdx < units.length - 1) {
value /= 1000;
unitIdx++;
}
return `${value.toFixed(1)} ${units[unitIdx]}`;
}
function formatTime(epoch: number): string {
if (!epoch) return "";
return new Date(epoch * 1000).toLocaleString();
}
function isVideoFile(name: string): boolean {
const exts = [
".mkv",
".mp4",
".avi",
".m4v",
".ts",
".wmv",
".mov",
".flv",
".webm",
];
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);
const { data: listing, isLoading, error } = useDirectoryListing(currentDir);
const { data: ffprobeData } = useFfprobe(
selectedPath ?? "",
!!selectedPath && isVideoFile(selectedPath),
);
const { data: templates } = useJobTemplates();
const runJob = useRunJob();
const gridRef = useRef<AgGridReact<DisplayRow>>(null);
const navigate = useCallback((path: string) => {
setCurrentDir(path);
setPathInput(path);
setSelectedPath(null);
}, []);
const handlePathSubmit = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
navigate(pathInput || "/");
}
};
// Build display rows
const rows: DisplayRow[] = [];
if (currentDir !== "/") {
const parent = currentDir.replace(/\/[^/]+\/?$/, "") || "/";
rows.push({
type: "up",
name: "..",
ext: "",
size: "-",
modified: "",
path: parent,
});
}
if (listing) {
for (const entry of listing.entries) {
const kind = entry.type === "d" ? "dir" : "file";
const ext = kind === "file" ? (entry.name.split(".").pop() ?? "") : "";
rows.push({
type: kind,
name: entry.name,
ext,
size: kind === "dir" ? "-" : formatSize(entry.size),
modified: formatTime(entry.mtime),
path: `${currentDir === "/" ? "" : currentDir}/${entry.name}`,
});
}
}
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 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],
);
return (
<div className="space-y-4">
{/* Path input */}
<div className="flex gap-2">
<input
type="text"
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"
>
Refresh
</button>
</div>
{/* 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>
{error && <p className="text-sm text-red-600">Error: {String(error)}</p>}
{/* 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}
loading={isLoading}
suppressCellFocus
animateRows={false}
/>
</div>
{/* 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>
)}
{/* 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}
>
{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>
)}
</div>
);
}
+210
View File
@@ -0,0 +1,210 @@
import { useState, useCallback, useRef } from "react";
import { AgGridReact } from "ag-grid-react";
import {
useMediaStatus,
useMediaQuery,
useBuildIndex,
} from "../hooks/useMedia";
import type { MediaItem } from "../types";
export function Media() {
const { data: status } = useMediaStatus();
const buildIndex = useBuildIndex();
const [search, setSearch] = useState("");
const [types, setTypes] = useState("Movie,Episode");
const [hdrFilter, setHdrFilter] = useState("All");
const [sortKey, setSortKey] = useState("title");
const [sortOrder, setSortOrder] = useState("Ascending");
const [limit] = useState(100);
const [offset, setOffset] = useState(0);
const { data: queryResult, isLoading } = useMediaQuery({
types,
search,
hdr_filter: hdrFilter,
sort_key: sortKey,
sort_order: sortOrder,
limit,
offset,
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 onGridReady = useCallback(() => {
gridRef.current?.api?.sizeColumnsToFit();
}, []);
const page = Math.floor(offset / limit) + 1;
const totalPages = queryResult ? Math.ceil(queryResult.total / limit) : 1;
return (
<div className="space-y-4">
{/* Status and controls */}
<div className="flex items-center gap-4">
{status?.exists ? (
<span className="text-sm text-gray-600">
Index: {status.item_count.toLocaleString()} items
{status.updated_at_label && ` | updated ${status.updated_at_label}`}
</span>
) : (
<span className="text-sm text-amber-600">No index built yet.</span>
)}
<button
onClick={() => buildIndex.mutate()}
disabled={buildIndex.isPending}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50 disabled:opacity-50"
>
{buildIndex.isPending ? "Building..." : "Build index"}
</button>
</div>
{/* 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>
{/* Results info */}
{queryResult && (
<p className="text-xs text-gray-500">
Showing {queryResult.items.length} of{" "}
{queryResult.total.toLocaleString()} items | Page {page} of{" "}
{totalPages}
</p>
)}
{/* 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}
loading={isLoading}
suppressCellFocus
animateRows={false}
/>
</div>
)}
{/* Pagination */}
{queryResult && totalPages > 1 && (
<div className="flex gap-2 items-center">
<button
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">
Page {page} / {totalPages}
</span>
<button
onClick={() => setOffset(offset + limit)}
disabled={page >= totalPages}
className="px-3 py-1 text-sm rounded border disabled:opacity-50"
>
Next
</button>
</div>
)}
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
import {
useMonitoringStatus,
useMonitoringMetrics,
useDiskSpace,
useCollectorControls,
} from "../hooks/useMonitoring";
import { MetricCard } from "../components/MetricCard";
import { MonitoringCharts } from "../components/MonitoringCharts";
function formatBytes(bytes: number): string {
if (!bytes || bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unitIdx = 0;
while (value >= 1000 && unitIdx < units.length - 1) {
value /= 1000;
unitIdx++;
}
return `${value.toFixed(1)} ${units[unitIdx]}`;
}
function formatRate(bytes: number): string {
return `${formatBytes(bytes)}/s`;
}
export function Monitoring() {
const { data: status } = useMonitoringStatus();
const { data: metrics } = useMonitoringMetrics();
const { data: disk } = useDiskSpace();
const { start, stop, restart } = useCollectorControls();
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);
const cpuArr = samples.map((s) => s.cpu_pct);
const iowArr = samples.map((s) => s.iowait_pct ?? 0);
const memArr = samples.map((s) => s.mem_pct);
const netDownArr = samples.map((s) => s.net_rx_bytes_per_sec);
const netUpArr = samples.map((s) => s.net_tx_bytes_per_sec);
const diskReadArr = samples.map((s) => s.disk_read_bps);
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
onClick={() => start.mutate()}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
>
Start
</button>
<button
onClick={() => restart.mutate()}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
>
Restart
</button>
<button
onClick={() => stop.mutate()}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
>
Stop
</button>
</section>
{/* Metrics summary */}
<section>
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3">
<MetricCard
label="CPU now"
value={latest ? `${latest.cpu_pct.toFixed(1)}%` : "-"}
subtext={`avg ${avg(cpuArr).toFixed(1)}%\npeak ${max(cpuArr).toFixed(1)}%`}
/>
<MetricCard
label="IO Wait"
value={latest ? `${(latest.iowait_pct ?? 0).toFixed(1)}%` : "-"}
subtext={`avg ${avg(iowArr).toFixed(1)}%\npeak ${max(iowArr).toFixed(1)}%`}
/>
<MetricCard
label="RAM now"
value={latest ? `${latest.mem_pct.toFixed(1)}%` : "-"}
subtext={`avg ${avg(memArr).toFixed(1)}%\npeak ${max(memArr).toFixed(1)}%`}
/>
<MetricCard
label="Net down"
value={latest ? formatRate(latest.net_rx_bytes_per_sec) : "-"}
subtext={`avg ${formatRate(avg(netDownArr))}\npeak ${formatRate(max(netDownArr))}`}
/>
<MetricCard
label="Net up"
value={latest ? formatRate(latest.net_tx_bytes_per_sec) : "-"}
subtext={`avg ${formatRate(avg(netUpArr))}\npeak ${formatRate(max(netUpArr))}`}
/>
<MetricCard
label="Disk read"
value={latest ? formatRate(latest.disk_read_bps) : "-"}
subtext={`avg ${formatRate(avg(diskReadArr))}\npeak ${formatRate(max(diskReadArr))}`}
/>
<MetricCard
label="Disk write"
value={latest ? formatRate(latest.disk_write_bps) : "-"}
subtext={`avg ${formatRate(avg(diskWriteArr))}\npeak ${formatRate(max(diskWriteArr))}`}
/>
</div>
</section>
{/* Disk space */}
{disk && (
<section>
<div className="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>
)}
{/* Charts */}
<section>
<MonitoringCharts samples={samples} />
</section>
</div>
);
}
+126
View File
@@ -0,0 +1,126 @@
/**
* Shared TypeScript interfaces matching backend API responses.
*/
export interface MediaCounts {
movies: number;
series: number;
episodes: number;
}
export interface LibraryCount {
library: string;
type: string;
movies: number;
series: number;
episodes: number;
total: number;
}
export interface NowPlayingSession {
user: string;
title: string;
type: string;
state: string;
transcoding: string;
transcoding_type: string;
device: string;
session_id: string;
}
export interface MonitoringStatus {
status: string;
}
export interface MonitoringSample {
ts: number;
cpu_pct: number;
iowait_pct?: number;
mem_pct: number;
net_rx_bytes_per_sec: number;
net_tx_bytes_per_sec: number;
disk_read_bps: number;
disk_write_bps: number;
}
export interface MonitoringMetrics {
samples: MonitoringSample[];
total_samples: number;
filtered_samples: number;
cutoff_ts: number;
}
export interface DiskSpace {
filesystem: string;
size: number;
used: number;
available: number;
used_pct: string;
mount: string;
}
export interface MediaIndexStatus {
exists: boolean;
item_count: number;
updated_at: number | null;
updated_at_label: string;
build_duration_seconds: number | null;
}
export interface MediaItem {
id: string;
title: string;
series: string;
season: string;
episode: number | null;
type: string;
year: number | null;
runtime_min: number | null;
size: string;
bitrate: string;
hdr: string;
video: string;
resolution: string;
date_added: string;
library: string;
path: string;
}
export interface MediaQueryResponse {
items: MediaItem[];
total: number;
limit: number;
offset: number;
}
export interface FileEntry {
type: string;
size: number;
mtime: number;
name: string;
}
export interface DirectoryListing {
path: string;
entries: FileEntry[];
count: number;
}
export interface JobTemplate {
key: string;
name: string;
description: string;
}
export interface JobResult {
job_key: string;
path: string;
exit_status: number;
stdout: string;
stderr: string;
}
export interface ResolvedPath {
original: string;
resolved: string;
}