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