3c432473e5
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)
223 lines
5.6 KiB
TypeScript
223 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|