e8b0f1144b
Web UI rework. Highest-risk slice, part 1 of 2: - New components/ui/data-table.tsx: generic TanStack Table wrapper on the shadcn Table primitive. Controlled rowSelection/columnVisibility/ pagination, optional selection column (stopPropagation on cell click), row-click, column-visibility dropdown, manual-pagination support. Hard rule honored: NO getSortedRowModel, NO column resizing/sizing. - Migrate pages/FileBrowser.impl.tsx off @mui/x-data-grid + @mui/material onto DataTable: 5 columns (type/name/ext/size/modified), row-click -> ffprobe preview preserved, column-visibility toggle, no pagination. - DataTable + FileBrowser component tests (RED->GREEN). Gate: build + lint + test green (22 files / 58 tests).
863 lines
25 KiB
TypeScript
863 lines
25 KiB
TypeScript
import { useMemo, useState } from "react";
|
||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||
import type { ColumnDef, RowSelectionState } from "@tanstack/react-table";
|
||
|
||
import { DataTable } from "@/components/ui/data-table";
|
||
import { Alert, AlertAction, AlertDescription } from "@/components/ui/alert";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent } from "@/components/ui/card";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { TabsTrigger } from "@/components/ui/tabs";
|
||
import {
|
||
useDirectoryListing,
|
||
useFfprobe,
|
||
useJobTemplates,
|
||
useRunJob,
|
||
} from "../hooks/useFiles";
|
||
import { usePersistentState } from "../hooks/usePersistentState";
|
||
import { useMonitoringSettings } from "../hooks/useSettings";
|
||
import { SectionCard } from "../components/SectionCard";
|
||
import { TabbedCard } from "../components/TabbedCard";
|
||
|
||
interface DisplayRow {
|
||
id: string;
|
||
type: string;
|
||
name: string;
|
||
ext: string;
|
||
size: string;
|
||
modified: string;
|
||
path: string;
|
||
}
|
||
|
||
interface FfprobeStream {
|
||
index?: number;
|
||
codec_type?: string;
|
||
codec_name?: string;
|
||
codec_long_name?: string;
|
||
profile?: string;
|
||
width?: number;
|
||
height?: number;
|
||
bit_rate?: string | number;
|
||
duration?: string | number;
|
||
channels?: number;
|
||
sample_rate?: string | number;
|
||
channel_layout?: string;
|
||
pix_fmt?: string;
|
||
sample_aspect_ratio?: string;
|
||
display_aspect_ratio?: string;
|
||
field_order?: string;
|
||
level?: number | string;
|
||
color_range?: string;
|
||
color_space?: string;
|
||
color_transfer?: string;
|
||
color_primaries?: string;
|
||
tags?: Record<string, string>;
|
||
}
|
||
|
||
interface FfprobeFormat {
|
||
filename?: string;
|
||
format_name?: string;
|
||
format_long_name?: string;
|
||
duration?: string | number;
|
||
size?: string | number;
|
||
bit_rate?: string | number;
|
||
tags?: Record<string, string>;
|
||
}
|
||
|
||
interface FfprobeData {
|
||
format?: FfprobeFormat;
|
||
streams?: FfprobeStream[];
|
||
}
|
||
|
||
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 humanBytes(value: string | number | undefined): string {
|
||
if (value === undefined || value === null || value === "") return "-";
|
||
const bytes = typeof value === "string" ? Number(value) : value;
|
||
if (!Number.isFinite(bytes)) return "-";
|
||
return formatSize(bytes);
|
||
}
|
||
|
||
function humanRate(value: string | number | undefined): string {
|
||
if (value === undefined || value === null || value === "") return "-";
|
||
const rate = typeof value === "string" ? Number(value) : value;
|
||
if (!Number.isFinite(rate)) return "-";
|
||
const units = ["bps", "Kbps", "Mbps", "Gbps"];
|
||
let v = rate;
|
||
let unitIdx = 0;
|
||
while (v >= 1000 && unitIdx < units.length - 1) {
|
||
v /= 1000;
|
||
unitIdx++;
|
||
}
|
||
return `${v.toFixed(1)} ${units[unitIdx]}`;
|
||
}
|
||
|
||
function humanDuration(value: string | number | undefined): string {
|
||
if (value === undefined || value === null || value === "") return "-";
|
||
const seconds = typeof value === "string" ? Number(value) : value;
|
||
if (!Number.isFinite(seconds)) return "-";
|
||
const total = Math.max(0, Math.round(seconds));
|
||
const hours = Math.floor(total / 3600);
|
||
const minutes = Math.floor((total % 3600) / 60);
|
||
const secs = total % 60;
|
||
if (hours > 0)
|
||
return `${hours}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
|
||
return `${minutes}:${String(secs).padStart(2, "0")}`;
|
||
}
|
||
|
||
function fieldLabel(_key: string, value: string | number | undefined): string {
|
||
if (value === undefined || value === null || value === "") return "-";
|
||
return String(value);
|
||
}
|
||
|
||
function isVideoFile(name: string): boolean {
|
||
const exts = [
|
||
".mkv",
|
||
".mp4",
|
||
".avi",
|
||
".m4v",
|
||
".ts",
|
||
".wmv",
|
||
".mov",
|
||
".flv",
|
||
".webm",
|
||
];
|
||
return exts.some((ext) => name.toLowerCase().endsWith(ext));
|
||
}
|
||
|
||
// Design §3.2: referentially-stable column defs (a new array each render would
|
||
// destabilize the TanStack table instance and drop controlled selection).
|
||
// Visibility-only: no sorting, no sizing/resizing (design §3.3).
|
||
const fileColumns: ColumnDef<DisplayRow>[] = [
|
||
{
|
||
accessorKey: "type",
|
||
header: () => "Type",
|
||
cell: ({ row }) => (
|
||
<span className="text-muted-foreground">{row.original.type}</span>
|
||
),
|
||
},
|
||
{
|
||
accessorKey: "name",
|
||
header: () => "Name",
|
||
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
|
||
},
|
||
{
|
||
accessorKey: "ext",
|
||
header: () => "Ext",
|
||
cell: ({ row }) => row.original.ext,
|
||
},
|
||
{
|
||
accessorKey: "size",
|
||
header: () => "Size",
|
||
cell: ({ row }) => row.original.size,
|
||
},
|
||
{
|
||
accessorKey: "modified",
|
||
header: () => "Modified",
|
||
cell: ({ row }) => row.original.modified,
|
||
},
|
||
];
|
||
|
||
const FILE_BROWSER_STATE_KEY = "manage.files.browserState";
|
||
|
||
type FileBrowserState = {
|
||
currentDir: string;
|
||
pathInput: string;
|
||
selectedPath: string | null;
|
||
selectedJob: string;
|
||
};
|
||
|
||
function defaultFileBrowserState(): FileBrowserState {
|
||
return {
|
||
currentDir: "/",
|
||
pathInput: "/",
|
||
selectedPath: null,
|
||
selectedJob: "",
|
||
};
|
||
}
|
||
|
||
function FfprobeChip({
|
||
children,
|
||
variant = "outline",
|
||
}: {
|
||
children: React.ReactNode;
|
||
variant?: "outline" | "secondary" | "warning" | "default";
|
||
}) {
|
||
return <Badge variant={variant}>{children}</Badge>;
|
||
}
|
||
|
||
function StreamBlock({ children }: { children: React.ReactNode }) {
|
||
return <div className="rounded-md border p-3">{children}</div>;
|
||
}
|
||
|
||
function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
||
const format = data.format ?? {};
|
||
const streams = data.streams ?? [];
|
||
const videoStreams = streams.filter(
|
||
(stream) => stream.codec_type === "video",
|
||
);
|
||
const audioStreams = streams.filter(
|
||
(stream) => stream.codec_type === "audio",
|
||
);
|
||
const subtitleStreams = streams.filter(
|
||
(stream) => stream.codec_type === "subtitle",
|
||
);
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<div>
|
||
<div className="text-base font-semibold">ffprobe details</div>
|
||
<div className="text-xs text-muted-foreground">{path}</div>
|
||
</div>
|
||
|
||
<Card>
|
||
<CardContent className="flex flex-col gap-3">
|
||
<div className="text-sm font-semibold">Container / format</div>
|
||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||
<div className="text-sm space-y-0.5">
|
||
<div>
|
||
<span className="font-semibold">Format:</span>{" "}
|
||
{fieldLabel("format", format.format_name)}
|
||
</div>
|
||
<div>
|
||
<span className="font-semibold">Long name:</span>{" "}
|
||
{fieldLabel("format_long_name", format.format_long_name)}
|
||
</div>
|
||
<div>
|
||
<span className="font-semibold">Duration:</span>{" "}
|
||
{humanDuration(format.duration)}
|
||
</div>
|
||
</div>
|
||
<div className="text-sm space-y-0.5">
|
||
<div>
|
||
<span className="font-semibold">Size:</span>{" "}
|
||
{humanBytes(format.size)}
|
||
</div>
|
||
<div>
|
||
<span className="font-semibold">Bitrate:</span>{" "}
|
||
{humanRate(format.bit_rate)}
|
||
</div>
|
||
<div>
|
||
<span className="font-semibold">Filename:</span>{" "}
|
||
{fieldLabel("filename", format.filename)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardContent className="flex flex-col gap-3">
|
||
<div className="text-sm font-semibold">Streams</div>
|
||
|
||
{videoStreams.length > 0 && (
|
||
<div>
|
||
<div className="text-xs text-muted-foreground">Video streams</div>
|
||
<div className="mt-1.5 flex flex-col gap-2">
|
||
{videoStreams.map((stream, index) => {
|
||
const isHdr =
|
||
(stream.color_transfer ?? "")
|
||
.toLowerCase()
|
||
.includes("2084") ||
|
||
(stream.color_transfer ?? "")
|
||
.toLowerCase()
|
||
.includes("b67") ||
|
||
(stream.color_space ?? "")
|
||
.toLowerCase()
|
||
.includes("bt2020") ||
|
||
(stream.color_primaries ?? "")
|
||
.toLowerCase()
|
||
.includes("bt2020");
|
||
return (
|
||
<StreamBlock key={`video-${stream.index ?? index}`}>
|
||
<div className="flex flex-row flex-wrap items-center gap-1.5">
|
||
<FfprobeChip>#{stream.index ?? index}</FfprobeChip>
|
||
<FfprobeChip variant="default">
|
||
{stream.codec_type ?? "video"}
|
||
</FfprobeChip>
|
||
<FfprobeChip variant="outline">
|
||
{stream.codec_name ?? "unknown codec"}
|
||
</FfprobeChip>
|
||
{stream.codec_long_name && (
|
||
<FfprobeChip variant="outline">
|
||
{stream.codec_long_name}
|
||
</FfprobeChip>
|
||
)}
|
||
{stream.profile && (
|
||
<FfprobeChip variant="outline">
|
||
{stream.profile}
|
||
</FfprobeChip>
|
||
)}
|
||
{stream.bit_rate && (
|
||
<FfprobeChip variant="outline">
|
||
{humanRate(stream.bit_rate)}
|
||
</FfprobeChip>
|
||
)}
|
||
{stream.duration && (
|
||
<FfprobeChip variant="outline">
|
||
{humanDuration(stream.duration)}
|
||
</FfprobeChip>
|
||
)}
|
||
{stream.width && stream.height && (
|
||
<FfprobeChip variant="outline">
|
||
{`${stream.width}×${stream.height}`}
|
||
</FfprobeChip>
|
||
)}
|
||
{stream.pix_fmt && (
|
||
<FfprobeChip variant="outline">
|
||
{stream.pix_fmt}
|
||
</FfprobeChip>
|
||
)}
|
||
{stream.display_aspect_ratio && (
|
||
<FfprobeChip variant="outline">
|
||
{`DAR ${stream.display_aspect_ratio}`}
|
||
</FfprobeChip>
|
||
)}
|
||
{stream.sample_aspect_ratio && (
|
||
<FfprobeChip variant="outline">
|
||
{`SAR ${stream.sample_aspect_ratio}`}
|
||
</FfprobeChip>
|
||
)}
|
||
{stream.level !== undefined &&
|
||
stream.level !== null && (
|
||
<FfprobeChip variant="outline">{`L${stream.level}`}</FfprobeChip>
|
||
)}
|
||
{stream.field_order &&
|
||
stream.field_order !== "unknown" && (
|
||
<FfprobeChip variant="outline">
|
||
{stream.field_order}
|
||
</FfprobeChip>
|
||
)}
|
||
{(stream.color_range ||
|
||
stream.color_space ||
|
||
stream.color_transfer ||
|
||
stream.color_primaries) && (
|
||
<FfprobeChip variant={isHdr ? "warning" : "outline"}>
|
||
{[
|
||
stream.color_range,
|
||
stream.color_space,
|
||
stream.color_transfer,
|
||
stream.color_primaries,
|
||
]
|
||
.filter(Boolean)
|
||
.join(" / ")}
|
||
</FfprobeChip>
|
||
)}
|
||
</div>
|
||
<div className="mt-1.5 text-sm">
|
||
{stream.tags?.language
|
||
? `Language: ${stream.tags.language}. `
|
||
: ""}
|
||
{stream.tags?.title
|
||
? `Title: ${stream.tags.title}.`
|
||
: ""}
|
||
</div>
|
||
</StreamBlock>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{audioStreams.length > 0 && (
|
||
<div>
|
||
<div className="text-xs text-muted-foreground">Audio streams</div>
|
||
<div className="mt-1.5 flex flex-col gap-2">
|
||
{audioStreams.map((stream, index) => (
|
||
<StreamBlock key={`audio-${stream.index ?? index}`}>
|
||
<div className="flex flex-row flex-wrap items-center gap-1.5">
|
||
<FfprobeChip>#{stream.index ?? index}</FfprobeChip>
|
||
<FfprobeChip variant="secondary">
|
||
{stream.codec_type ?? "audio"}
|
||
</FfprobeChip>
|
||
<FfprobeChip variant="outline">
|
||
{stream.codec_name ?? "unknown codec"}
|
||
</FfprobeChip>
|
||
{stream.channels && (
|
||
<FfprobeChip variant="outline">{`${stream.channels} ch`}</FfprobeChip>
|
||
)}
|
||
{stream.sample_rate && (
|
||
<FfprobeChip variant="outline">{`${stream.sample_rate} Hz`}</FfprobeChip>
|
||
)}
|
||
{stream.bit_rate && (
|
||
<FfprobeChip variant="outline">
|
||
{humanRate(stream.bit_rate)}
|
||
</FfprobeChip>
|
||
)}
|
||
{stream.duration && (
|
||
<FfprobeChip variant="outline">
|
||
{humanDuration(stream.duration)}
|
||
</FfprobeChip>
|
||
)}
|
||
</div>
|
||
<div className="mt-1.5 text-sm">
|
||
{stream.codec_long_name
|
||
? `${stream.codec_long_name}. `
|
||
: ""}
|
||
{stream.channel_layout
|
||
? `Layout: ${stream.channel_layout}. `
|
||
: ""}
|
||
{stream.tags?.language
|
||
? `Language: ${stream.tags.language}. `
|
||
: ""}
|
||
{stream.tags?.title ? `Title: ${stream.tags.title}.` : ""}
|
||
</div>
|
||
</StreamBlock>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{subtitleStreams.length > 0 && (
|
||
<div>
|
||
<div className="text-xs text-muted-foreground">
|
||
Subtitle streams
|
||
</div>
|
||
<div className="mt-1.5 flex flex-col gap-2">
|
||
{subtitleStreams.map((stream, index) => (
|
||
<StreamBlock key={`subtitle-${stream.index ?? index}`}>
|
||
<div className="flex flex-row flex-wrap items-center gap-1.5">
|
||
<FfprobeChip>#{stream.index ?? index}</FfprobeChip>
|
||
<FfprobeChip variant="secondary">
|
||
{stream.codec_type ?? "subtitle"}
|
||
</FfprobeChip>
|
||
<FfprobeChip variant="outline">
|
||
{stream.codec_name ?? "unknown codec"}
|
||
</FfprobeChip>
|
||
{stream.tags?.language && (
|
||
<FfprobeChip variant="outline">
|
||
{stream.tags.language}
|
||
</FfprobeChip>
|
||
)}
|
||
{stream.tags?.title && (
|
||
<FfprobeChip variant="outline">
|
||
{stream.tags.title}
|
||
</FfprobeChip>
|
||
)}
|
||
</div>
|
||
</StreamBlock>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{streams.length === 0 && (
|
||
<div className="text-sm text-muted-foreground">
|
||
No streams found.
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{Object.keys(format.tags ?? {}).length > 0 && (
|
||
<Card>
|
||
<CardContent className="flex flex-col gap-2">
|
||
<div className="text-sm font-semibold">Tags</div>
|
||
<div className="flex flex-row flex-wrap gap-1.5">
|
||
{Object.entries(format.tags ?? {}).map(([key, value]) => (
|
||
<FfprobeChip key={key} variant="outline">
|
||
{`${key}: ${value}`}
|
||
</FfprobeChip>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function InfoAlert({ children }: { children: React.ReactNode }) {
|
||
return (
|
||
<Alert>
|
||
<AlertDescription>{children}</AlertDescription>
|
||
</Alert>
|
||
);
|
||
}
|
||
|
||
export function FileBrowser() {
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
const [columnVisibility, setColumnVisibility] = useState<
|
||
Record<string, boolean>
|
||
>({});
|
||
const { data: machines } = useMonitoringSettings();
|
||
const fileMachines = useMemo(
|
||
() =>
|
||
(machines ?? []).filter(
|
||
(machine) =>
|
||
machine.enabled &&
|
||
(machine.services.includes("files") ||
|
||
machine.services.includes("monitoring")),
|
||
),
|
||
[machines],
|
||
);
|
||
const initialRequestedPath = searchParams.get("path");
|
||
const initialMachineId =
|
||
searchParams.get("machine_id") || fileMachines[0]?.id || "";
|
||
const [browserState, setBrowserState] = usePersistentState<FileBrowserState>(
|
||
FILE_BROWSER_STATE_KEY,
|
||
() => {
|
||
const requestedPath = initialRequestedPath ?? "/";
|
||
const selectedPath =
|
||
requestedPath !== "/" &&
|
||
(isVideoFile(requestedPath) || requestedPath.includes("."))
|
||
? requestedPath.replace(/\/+$/, "")
|
||
: null;
|
||
const currentDir = selectedPath
|
||
? selectedPath.replace(/\/[^/]+$/, "") || "/"
|
||
: requestedPath.replace(/\/+$/, "") || "/";
|
||
return {
|
||
...defaultFileBrowserState(),
|
||
currentDir,
|
||
pathInput: requestedPath || currentDir,
|
||
selectedPath,
|
||
};
|
||
},
|
||
);
|
||
const { currentDir, pathInput, selectedPath, selectedJob } = browserState;
|
||
const selectedMachineId = searchParams.get("machine_id") || initialMachineId;
|
||
const navigateToSettings = useNavigate();
|
||
const updateBrowserState = (patch: Partial<FileBrowserState>) =>
|
||
setBrowserState((current) => ({ ...current, ...patch }));
|
||
|
||
const {
|
||
data: listing,
|
||
isLoading,
|
||
error,
|
||
refetch,
|
||
} = useDirectoryListing(currentDir, selectedMachineId || undefined);
|
||
const {
|
||
data: ffprobeData,
|
||
isLoading: ffprobeLoading,
|
||
error: ffprobeError,
|
||
} = useFfprobe(
|
||
selectedPath ?? "",
|
||
!!selectedPath && isVideoFile(selectedPath),
|
||
selectedMachineId || undefined,
|
||
);
|
||
const { data: templates } = useJobTemplates();
|
||
const runJob = useRunJob(selectedMachineId || undefined);
|
||
|
||
const navigate = (path: string) => {
|
||
updateBrowserState({
|
||
currentDir: path,
|
||
pathInput: path,
|
||
selectedPath: null,
|
||
});
|
||
};
|
||
|
||
const setMachine = (machineId: string) => {
|
||
setSearchParams(
|
||
(current) => {
|
||
const next = new URLSearchParams(current);
|
||
if (machineId) next.set("machine_id", machineId);
|
||
else next.delete("machine_id");
|
||
return next;
|
||
},
|
||
{ replace: true },
|
||
);
|
||
};
|
||
|
||
const handlePathSubmit = (e: React.KeyboardEvent) => {
|
||
if (e.key === "Enter") navigate(pathInput || "/");
|
||
};
|
||
|
||
const rows: DisplayRow[] = [];
|
||
if (currentDir !== "/") {
|
||
const parent = currentDir.replace(/\/[^/]+\/?$/, "") || "/";
|
||
rows.push({
|
||
id: `up-${parent}`,
|
||
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() ?? "") : "";
|
||
const path = `${currentDir === "/" ? "" : currentDir}/${entry.name}`;
|
||
rows.push({
|
||
id: path,
|
||
type: kind,
|
||
name: entry.name,
|
||
ext,
|
||
size: kind === "dir" ? "-" : formatSize(entry.size),
|
||
modified: formatTime(entry.mtime),
|
||
path,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Preserved row-click behavior (MUI DataGrid onRowClick): dir/up rows navigate;
|
||
// file rows select the file for ffprobe preview (also feeds pathInput).
|
||
const handleRowClick = (row: DisplayRow) => {
|
||
if (row.type === "dir" || row.type === "up") {
|
||
navigate(row.path);
|
||
return;
|
||
}
|
||
updateBrowserState({
|
||
selectedPath: row.path,
|
||
currentDir,
|
||
pathInput: row.path,
|
||
});
|
||
};
|
||
|
||
// Single-select checkbox behavior (DataTable adds a selection column under
|
||
// enableRowSelection): mirrors the row-click selection for file rows.
|
||
const rowSelection: RowSelectionState = selectedPath
|
||
? { [selectedPath]: true }
|
||
: {};
|
||
const handleSelectionChange = (
|
||
updater:
|
||
| RowSelectionState
|
||
| ((prev: RowSelectionState) => RowSelectionState),
|
||
) => {
|
||
const next =
|
||
typeof updater === "function" ? updater(rowSelection) : updater;
|
||
const selectedIds = Object.keys(next).filter((id) => next[id]);
|
||
const id = selectedIds[selectedIds.length - 1];
|
||
if (!id) {
|
||
updateBrowserState({ selectedPath: null });
|
||
return;
|
||
}
|
||
const target = rows.find((row) => row.id === id);
|
||
if (target && target.type === "file") {
|
||
updateBrowserState({ selectedPath: target.path, pathInput: target.path });
|
||
} else {
|
||
updateBrowserState({ selectedPath: null });
|
||
}
|
||
};
|
||
|
||
const selectedTemplate = templates?.find((t) => t.key === selectedJob);
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4.5">
|
||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||
<h2 className="text-xl font-semibold">File Browser</h2>
|
||
<Badge variant="outline">
|
||
{fileMachines.length
|
||
? `${fileMachines.length} machine${fileMachines.length === 1 ? "" : "s"}`
|
||
: "No file machines"}
|
||
</Badge>
|
||
</div>
|
||
|
||
<TabbedCard
|
||
value={fileMachines.length > 0 ? selectedMachineId : ""}
|
||
onChange={setMachine}
|
||
tabs={fileMachines.map((machine) => (
|
||
<TabsTrigger key={machine.id} value={machine.id}>
|
||
{`${machine.name} · ${machine.mode}`}
|
||
</TabsTrigger>
|
||
))}
|
||
>
|
||
{fileMachines.length > 0 ? (
|
||
<div className="flex flex-col gap-4">
|
||
<SectionCard
|
||
title="Browser"
|
||
description="Read-only listing with explicit open/select actions."
|
||
>
|
||
<div className="flex flex-col gap-3">
|
||
<div className="flex flex-col gap-2 md:flex-row">
|
||
<div className="flex flex-1 flex-col gap-1">
|
||
<Label htmlFor="remote-path">Remote path</Label>
|
||
<Input
|
||
id="remote-path"
|
||
value={pathInput}
|
||
onChange={(e) =>
|
||
updateBrowserState({ pathInput: e.target.value })
|
||
}
|
||
onKeyDown={handlePathSubmit}
|
||
/>
|
||
</div>
|
||
<div className="flex flex-col gap-2 md:flex-row md:items-end">
|
||
<Button
|
||
variant="outline"
|
||
className="w-full md:w-auto"
|
||
onClick={() => navigate(pathInput || "/")}
|
||
>
|
||
Open
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
className="w-full md:w-auto"
|
||
onClick={() => refetch()}
|
||
>
|
||
Refresh
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className="text-xs text-muted-foreground">
|
||
{`Current: ${currentDir} `}
|
||
{selectedPath ? `| Selected: ${selectedPath} ` : ""}
|
||
{listing ? `| Entries: ${listing.count}` : ""}
|
||
</div>
|
||
{error && (
|
||
<Alert variant="destructive">
|
||
<AlertDescription>{String(error)}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
<div className="rounded-lg border bg-card">
|
||
<DataTable
|
||
columns={fileColumns}
|
||
data={rows}
|
||
getRowId={(row) => row.id}
|
||
enableRowSelection
|
||
rowSelection={rowSelection}
|
||
onRowSelectionChange={handleSelectionChange}
|
||
onRowClick={handleRowClick}
|
||
enableColumnVisibilityToggle
|
||
columnVisibility={columnVisibility}
|
||
onColumnVisibilityChange={setColumnVisibility}
|
||
emptyMessage={
|
||
isLoading
|
||
? "Loading directory..."
|
||
: "This directory is empty."
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</SectionCard>
|
||
|
||
<SectionCard
|
||
title="Media info"
|
||
description="ffprobe metadata for the selected media file."
|
||
>
|
||
{selectedPath ? (
|
||
isVideoFile(selectedPath) ? (
|
||
ffprobeError ? (
|
||
<Alert variant="destructive">
|
||
<AlertDescription>
|
||
{String(ffprobeError)}
|
||
</AlertDescription>
|
||
</Alert>
|
||
) : ffprobeLoading && !ffprobeData ? (
|
||
<InfoAlert>Loading ffprobe data...</InfoAlert>
|
||
) : ffprobeData ? (
|
||
<FfprobeDetails
|
||
path={selectedPath}
|
||
data={ffprobeData as FfprobeData}
|
||
/>
|
||
) : (
|
||
<InfoAlert>No ffprobe data available.</InfoAlert>
|
||
)
|
||
) : (
|
||
<InfoAlert>
|
||
Select a video file to view ffprobe details.
|
||
</InfoAlert>
|
||
)
|
||
) : (
|
||
<InfoAlert>
|
||
Select a file in Browser to view ffprobe details.
|
||
</InfoAlert>
|
||
)}
|
||
</SectionCard>
|
||
|
||
<SectionCard
|
||
title="Jobs"
|
||
description="Run predefined safe jobs against the selected file."
|
||
>
|
||
{selectedPath && templates && templates.length > 0 ? (
|
||
<div className="flex flex-col gap-3">
|
||
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
||
<div className="flex flex-col gap-1.5">
|
||
<Label htmlFor="job-template">Job template</Label>
|
||
<Select
|
||
value={selectedJob}
|
||
onValueChange={(value) =>
|
||
updateBrowserState({ selectedJob: value })
|
||
}
|
||
>
|
||
<SelectTrigger id="job-template" className="w-full">
|
||
<SelectValue placeholder="Select a job" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{templates.map((tpl) => (
|
||
<SelectItem key={tpl.key} value={tpl.key}>
|
||
{tpl.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="flex flex-col gap-2 md:col-span-3 md:flex-row md:items-end">
|
||
<Button
|
||
disabled={!selectedJob || runJob.isPending}
|
||
onClick={() =>
|
||
runJob.mutate({
|
||
jobKey: selectedJob,
|
||
path: selectedPath,
|
||
})
|
||
}
|
||
>
|
||
Run job
|
||
</Button>
|
||
{selectedTemplate && (
|
||
<div className="self-center text-sm text-muted-foreground">
|
||
{selectedTemplate.description}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{runJob.data && (
|
||
<pre className="max-h-[260px] overflow-auto rounded-md bg-muted p-3 text-xs">
|
||
{`Exit: ${runJob.data.exit_status}`}
|
||
{"\n"}
|
||
{runJob.data.stdout}
|
||
{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
|
||
</pre>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<InfoAlert>Select a file in Browser to run jobs.</InfoAlert>
|
||
)}
|
||
</SectionCard>
|
||
</div>
|
||
) : (
|
||
<Alert>
|
||
<AlertDescription>
|
||
No file-capable machines are configured yet.
|
||
</AlertDescription>
|
||
<AlertAction>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => navigateToSettings("/settings")}
|
||
>
|
||
Open Settings
|
||
</Button>
|
||
</AlertAction>
|
||
</Alert>
|
||
)}
|
||
</TabbedCard>
|
||
</div>
|
||
);
|
||
}
|