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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user