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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user