Files
manage/frontend/src/pages/FileBrowser.impl.tsx
T
Developer e8b0f1144b feat(frontend): slice 7a — DataTable wrapper + FileBrowser (TanStack Table)
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).
2026-06-17 18:02:47 +00:00

863 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}