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
@@ -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>
);
}