Frontend: ssh_tasks Files + Actions tabs (Slice 6)
Replace the FilesTab and ActionsTab stubs with real implementations on the ssh_tasks service page. FilesTab (pages/service-tabs/FilesTab.tsx): lifts the operational content from the top-level FileBrowser page into an instance-scoped tab. Directory listing, path bar, ffprobe preview, and job execution all use instance.id as the machine id (replacing the old machine-tab selector + machine_id search param). The initial path is read from ?path= search param so deep links work (resolves the MediaTab row-click navigation). ActionsTab (pages/service-tabs/ActionsTab.tsx): lifts the saved-tasks CRUD + run + history content from the top-level Actions page. instance.id is the fixed default run service -- the old service-selector dropdown is removed (the instance is implicit; switch instances via the service page switcher to run on a different one). MediaTab row-click cross-slice fix: navigates to /services/ssh_tasks/<first-enabled-id>?path=<encoded> when an enabled ssh_tasks instance exists, else falls back to /services/ssh_tasks (which shows the ServiceTypePage resolver/empty state). Resolves the 404 flag from slice 5. service-tabs/index.ts wires the new components; FilesTab/ActionsTab stubs removed. Note: this branch is based on main, not on mobile-responsive-parity, so the tabs lift main's DataTable + column-visibility pattern (no MobileCardRow -- reconciles when the branches merge). Tests: FilesTab (instance-scoped hooks + ?path= deep-link), ActionsTab (instance-scoped task list), MediaTab test mock updated. 94 tests pass (+4); lint/build green. Refs openspec/changes/services-as-hub-ia/ (spec R2.4, tasks slice 6).
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* ActionsTab — operational content for the ssh_tasks service page.
|
||||
*
|
||||
* Lifted from the old top-level `pages/Actions.tsx`. The `instance` prop
|
||||
* provides the active ssh_tasks service id, which is used as the default run
|
||||
* service. The page-level header is removed (the service page provides it).
|
||||
* The task editor dialog, saved-task rail, and run history are preserved.
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { SavedTask, SavedTaskInput, ServiceInstance } from "../../types";
|
||||
import {
|
||||
useDeleteTask,
|
||||
useRunTask,
|
||||
useSaveTask,
|
||||
useTaskRuns,
|
||||
useTasks,
|
||||
} from "../../hooks/useSettings";
|
||||
import { DialogFooter } from "../../components/DialogFooter";
|
||||
import { HoverEditButton } from "../../components/HoverEditButton";
|
||||
import { SectionCard } from "../../components/SectionCard";
|
||||
import { SelectionRailCard } from "../../components/SelectionRailCard";
|
||||
import { Alert, 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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
type ActionTab = "new" | string;
|
||||
|
||||
function FormField({
|
||||
label,
|
||||
htmlFor,
|
||||
helperText,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
htmlFor?: string;
|
||||
helperText?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Label htmlFor={htmlFor} className="mb-1">
|
||||
{label}
|
||||
</Label>
|
||||
{children}
|
||||
{helperText ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{helperText}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function emptyTask(): SavedTaskInput {
|
||||
return {
|
||||
id: null,
|
||||
name: "",
|
||||
task_type: "shell",
|
||||
content: "",
|
||||
enabled: true,
|
||||
default_service_id: "",
|
||||
notes: "",
|
||||
};
|
||||
}
|
||||
|
||||
function sameTask(a: SavedTaskInput, b: SavedTaskInput) {
|
||||
return (
|
||||
a.id === b.id &&
|
||||
a.name === b.name &&
|
||||
a.task_type === b.task_type &&
|
||||
a.content === b.content &&
|
||||
a.enabled === b.enabled &&
|
||||
a.default_service_id === b.default_service_id &&
|
||||
a.notes === b.notes
|
||||
);
|
||||
}
|
||||
|
||||
function initialFromTask(task: SavedTask): SavedTaskInput {
|
||||
return {
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
task_type: task.task_type,
|
||||
content: task.content,
|
||||
enabled: task.enabled,
|
||||
default_service_id: task.default_service_id,
|
||||
notes: task.notes,
|
||||
};
|
||||
}
|
||||
|
||||
function TaskEditor({
|
||||
task,
|
||||
onChange,
|
||||
}: {
|
||||
task: SavedTaskInput;
|
||||
onChange: (task: SavedTaskInput) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-semibold">
|
||||
{task.id ? "Edit action" : "New action"}
|
||||
</p>
|
||||
<Badge variant="outline">{task.task_type}</Badge>
|
||||
<Badge variant="outline">{task.enabled ? "enabled" : "disabled"}</Badge>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<FormField label="Name" htmlFor="task-name">
|
||||
<Input
|
||||
id="task-name"
|
||||
value={task.name}
|
||||
onChange={(e) => onChange({ ...task, name: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex flex-row flex-wrap gap-2">
|
||||
<div className="min-w-[180px] flex-1">
|
||||
<FormField label="Type">
|
||||
<Select
|
||||
value={task.task_type}
|
||||
onValueChange={(value) =>
|
||||
onChange({
|
||||
...task,
|
||||
task_type: value as SavedTaskInput["task_type"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="shell">Shell</SelectItem>
|
||||
<SelectItem value="python">Python</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
<FormField label="Notes">
|
||||
<Input
|
||||
value={task.notes}
|
||||
onChange={(e) => onChange({ ...task, notes: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={
|
||||
task.task_type === "python" ? "Python script" : "Shell command"
|
||||
}
|
||||
helperText={
|
||||
task.task_type === "python"
|
||||
? "Python is run as `python3 -c`."
|
||||
: "Shell commands are run through `/bin/sh -c`."
|
||||
}
|
||||
>
|
||||
<Textarea
|
||||
rows={9}
|
||||
value={task.content}
|
||||
onChange={(e) => onChange({ ...task, content: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskDialog({
|
||||
open,
|
||||
task,
|
||||
baseline,
|
||||
onClose,
|
||||
onChange,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: {
|
||||
open: boolean;
|
||||
task: SavedTaskInput;
|
||||
baseline: SavedTaskInput;
|
||||
onClose: () => void;
|
||||
onChange: (task: SavedTaskInput) => void;
|
||||
onSave: () => void;
|
||||
onDelete?: () => void;
|
||||
}) {
|
||||
const requestClose = () => {
|
||||
if (
|
||||
!sameTask(task, baseline) &&
|
||||
!window.confirm("Discard unsaved changes?")
|
||||
)
|
||||
return;
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) requestClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{task.id ? "Edit action" : "New action"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Save a reusable server task. Shell commands run via{" "}
|
||||
<code>/bin/sh -c</code>; Python runs via <code>python3 -c</code>.
|
||||
Runs execute on this SSH task service instance.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<TaskEditor task={task} onChange={onChange} />
|
||||
<DialogFooter
|
||||
onCancel={requestClose}
|
||||
cancelLabel="Cancel"
|
||||
onConfirm={onSave}
|
||||
confirmLabel="Save action"
|
||||
confirmBusyLabel="Save action"
|
||||
secondaryAction={
|
||||
onDelete ? (
|
||||
<Button variant="destructive" onClick={onDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActionsTab({ instance }: { instance: ServiceInstance }) {
|
||||
const { data: tasks = [] } = useTasks();
|
||||
const saveTask = useSaveTask();
|
||||
const deleteTask = useDeleteTask();
|
||||
const runTask = useRunTask();
|
||||
const [tab, setTab] = useState<ActionTab>("new");
|
||||
const [draft, setDraft] = useState<SavedTaskInput>(emptyTask());
|
||||
const [draftBaseline, setDraftBaseline] = useState<SavedTaskInput>(
|
||||
emptyTask(),
|
||||
);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
||||
// Default to this instance's service id for task runs.
|
||||
const runServiceId = instance.id;
|
||||
|
||||
const selectedTask = useMemo(
|
||||
() => tasks.find((task) => task.id === tab) ?? null,
|
||||
[tasks, tab],
|
||||
);
|
||||
const selectedRuns = useTaskRuns(selectedTask?.id);
|
||||
|
||||
const openEdit = (initial: SavedTaskInput) => {
|
||||
setDraft(initial);
|
||||
setDraftBaseline(initial);
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const saveDraft = async () => {
|
||||
const saved = await saveTask.mutateAsync(draft);
|
||||
setTab(saved.id);
|
||||
setEditOpen(false);
|
||||
const nextDraft = {
|
||||
id: saved.id,
|
||||
name: saved.name,
|
||||
task_type: saved.task_type,
|
||||
content: saved.content,
|
||||
enabled: saved.enabled,
|
||||
default_service_id: saved.default_service_id,
|
||||
notes: saved.notes,
|
||||
};
|
||||
setDraft(nextDraft);
|
||||
setDraftBaseline(nextDraft);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{saveTask.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{String(saveTask.error)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{deleteTask.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{String(deleteTask.error)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{runTask.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{String(runTask.error)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<SelectionRailCard
|
||||
title="Saved actions"
|
||||
description="Pick a saved task, then edit or run it from the detail pane."
|
||||
contentSx={{}}
|
||||
footer={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => openEdit(emptyTask())}
|
||||
>
|
||||
Add action
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(value) => setTab(value)}
|
||||
orientation="vertical"
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList variant="line" className="h-fit w-full justify-start">
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="group relative w-full group-hover:[&_.rail-edit]:opacity-100"
|
||||
>
|
||||
<TabsTrigger
|
||||
value={task.id}
|
||||
className="w-full justify-start pr-9"
|
||||
onClick={() => setTab(task.id)}
|
||||
onDoubleClick={() => openEdit(initialFromTask(task))}
|
||||
>
|
||||
{task.name}
|
||||
</TabsTrigger>
|
||||
<div className="absolute top-1/2 right-1 -translate-y-1/2">
|
||||
<HoverEditButton
|
||||
onClick={() => openEdit(initialFromTask(task))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</SelectionRailCard>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{selectedTask ? (
|
||||
<SectionCard
|
||||
title={selectedTask.name}
|
||||
description="Open the editor popup to modify this action."
|
||||
action={
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => openEdit(initialFromTask(selectedTask))}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
disabled={runTask.isPending}
|
||||
onClick={async () => {
|
||||
await runTask.mutateAsync({
|
||||
taskId: selectedTask.id,
|
||||
serviceId: runServiceId,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{runTask.isPending ? "Running..." : "Run action"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Separator />
|
||||
<p className="text-sm font-semibold">Recent runs</p>
|
||||
{selectedRuns.data?.items?.length ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{selectedRuns.data.items.map((run) => (
|
||||
<Card key={run.id}>
|
||||
<CardContent className="flex flex-col gap-2 p-3">
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{run.status}</Badge>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(run.created_at * 1000).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
{run.stdout_tail && (
|
||||
<div className="rounded-lg border border-border px-3 py-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
stdout
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap break-words font-mono text-sm">
|
||||
{run.stdout_tail}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{run.stderr_tail && (
|
||||
<div className="rounded-lg border border-border px-3 py-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
stderr
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap break-words font-mono text-sm">
|
||||
{run.stderr_tail}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{run.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{run.error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No runs yet.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</SectionCard>
|
||||
) : (
|
||||
<SectionCard
|
||||
title="No action selected"
|
||||
description="Select a saved action from the list on the left to view its details, run it, or open the editor popup."
|
||||
>
|
||||
{tasks[0] && (
|
||||
<Button variant="outline" onClick={() => setTab(tasks[0].id)}>
|
||||
Select first action
|
||||
</Button>
|
||||
)}
|
||||
</SectionCard>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskDialog
|
||||
open={editOpen}
|
||||
task={draft}
|
||||
baseline={draftBaseline}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onChange={setDraft}
|
||||
onSave={saveDraft}
|
||||
onDelete={
|
||||
draft.id ? () => deleteTask.mutate(String(draft.id)) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,790 @@
|
||||
/**
|
||||
* FilesTab — operational content for the ssh_tasks service page.
|
||||
*
|
||||
* Lifted from the old top-level `pages/FileBrowser.impl.tsx`. The machine
|
||||
* selector and `useMonitoringSettings` are removed; the active ssh_tasks
|
||||
* instance id (from the `instance` prop) replaces the machine_id. The initial
|
||||
* path is read from `?path=` search param for deep-link support (resolves the
|
||||
* MediaTab row-click navigation from slice 5). Everything else — directory
|
||||
* listing, path bar, ffprobe preview, job execution — is preserved.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type { ColumnDef, RowSelectionState } from "@tanstack/react-table";
|
||||
|
||||
import { DataTable } from "@/components/ui/data-table";
|
||||
import { Alert, 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 {
|
||||
useDirectoryListing,
|
||||
useFfprobe,
|
||||
useJobTemplates,
|
||||
useRunJob,
|
||||
} from "../../hooks/useFiles";
|
||||
import { usePersistentState } from "../../hooks/usePersistentState";
|
||||
import { SectionCard } from "../../components/SectionCard";
|
||||
import type { ServiceInstance } from "../../types";
|
||||
|
||||
// --- Types (lifted verbatim) ---
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
// --- Helpers (lifted verbatim) ---
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
// --- Column defs (lifted verbatim) ---
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
// --- State + helpers (lifted) ---
|
||||
|
||||
const FILE_TAB_STATE_KEY = "manage.files.tabState";
|
||||
|
||||
type FileBrowserState = {
|
||||
currentDir: string;
|
||||
pathInput: string;
|
||||
selectedPath: string | null;
|
||||
selectedJob: string;
|
||||
};
|
||||
|
||||
function defaultFileBrowserState(): FileBrowserState {
|
||||
return {
|
||||
currentDir: "/",
|
||||
pathInput: "/",
|
||||
selectedPath: null,
|
||||
selectedJob: "",
|
||||
};
|
||||
}
|
||||
|
||||
// --- Ffprobe rendering (lifted verbatim) ---
|
||||
|
||||
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((s) => s.codec_type === "video");
|
||||
const audioStreams = streams.filter((s) => s.codec_type === "audio");
|
||||
const subtitleStreams = streams.filter((s) => s.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>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Component ---
|
||||
|
||||
export function FilesTab({ instance }: { instance: ServiceInstance }) {
|
||||
const machineId = instance.id;
|
||||
const [searchParams] = useSearchParams();
|
||||
const requestedPath = searchParams.get("path");
|
||||
const [columnVisibility, setColumnVisibility] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [browserState, setBrowserState] = usePersistentState<FileBrowserState>(
|
||||
`${FILE_TAB_STATE_KEY}.${instance.id}`,
|
||||
() => {
|
||||
const path = requestedPath ?? "/";
|
||||
const selectedPath =
|
||||
path !== "/" && (isVideoFile(path) || path.includes("."))
|
||||
? path.replace(/\/+$/, "")
|
||||
: null;
|
||||
const currentDir = selectedPath
|
||||
? selectedPath.replace(/\/[^/]+$/, "") || "/"
|
||||
: path.replace(/\/+$/, "") || "/";
|
||||
return {
|
||||
...defaultFileBrowserState(),
|
||||
currentDir,
|
||||
pathInput: path || currentDir,
|
||||
selectedPath,
|
||||
};
|
||||
},
|
||||
);
|
||||
const { currentDir, pathInput, selectedPath, selectedJob } = browserState;
|
||||
const updateBrowserState = (patch: Partial<FileBrowserState>) =>
|
||||
setBrowserState((current) => ({ ...current, ...patch }));
|
||||
|
||||
const {
|
||||
data: listing,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useDirectoryListing(currentDir, machineId);
|
||||
const {
|
||||
data: ffprobeData,
|
||||
isLoading: ffprobeLoading,
|
||||
error: ffprobeError,
|
||||
} = useFfprobe(
|
||||
selectedPath ?? "",
|
||||
!!selectedPath && isVideoFile(selectedPath),
|
||||
machineId,
|
||||
);
|
||||
const { data: templates } = useJobTemplates();
|
||||
const runJob = useRunJob(machineId);
|
||||
|
||||
const navigate = (path: string) => {
|
||||
updateBrowserState({
|
||||
currentDir: path,
|
||||
pathInput: path,
|
||||
selectedPath: null,
|
||||
});
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const handleRowClick = (row: DisplayRow) => {
|
||||
if (row.type === "dir" || row.type === "up") {
|
||||
navigate(row.path);
|
||||
return;
|
||||
}
|
||||
updateBrowserState({
|
||||
selectedPath: row.path,
|
||||
currentDir,
|
||||
pathInput: row.path,
|
||||
});
|
||||
};
|
||||
|
||||
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">
|
||||
<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 ? (
|
||||
<Alert>
|
||||
<AlertDescription>Loading ffprobe data...</AlertDescription>
|
||||
</Alert>
|
||||
) : ffprobeData ? (
|
||||
<FfprobeDetails
|
||||
path={selectedPath}
|
||||
data={ffprobeData as FfprobeData}
|
||||
/>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No ffprobe data available.</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Select a video file to view ffprobe details.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Select a file in Browser to view ffprobe details.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</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>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Select a file in Browser to run jobs.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</SectionCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
import { usePersistentState } from "../../hooks/usePersistentState";
|
||||
import type { MediaItem, ServiceInstance } from "../../types";
|
||||
import { useCounts, useLibraries } from "../../hooks/useDashboard";
|
||||
import { useServiceInstances } from "../../hooks/useServices";
|
||||
|
||||
// --- Format helpers (lifted verbatim from Media.tsx) ---
|
||||
|
||||
@@ -182,6 +183,7 @@ function BuildProgress({ value }: { value: number | null }) {
|
||||
|
||||
export function MediaTab({ instance }: { instance: ServiceInstance }) {
|
||||
const navigate = useNavigate();
|
||||
const { data: sshServices = [] } = useServiceInstances("ssh_tasks");
|
||||
const isSmall = usePrefersSmallScreen();
|
||||
const serviceId = instance.id;
|
||||
|
||||
@@ -256,7 +258,14 @@ export function MediaTab({ instance }: { instance: ServiceInstance }) {
|
||||
}, [mediaState.columnVisibility, isSmall]);
|
||||
|
||||
const handleRowClick = (row: MediaItem) => {
|
||||
navigate(`/files?path=${encodeURIComponent(row.path)}`);
|
||||
// Navigate to the ssh_tasks service page with the path query param.
|
||||
// If an ssh_tasks instance exists, open its Files tab; otherwise land
|
||||
// on the ssh_tasks type page (empty state / ServiceTypePage resolver).
|
||||
const sshInstance = sshServices.find((s) => s.enabled);
|
||||
const base = sshInstance
|
||||
? `/services/ssh_tasks/${sshInstance.id}`
|
||||
: "/services/ssh_tasks";
|
||||
navigate(`${base}?path=${encodeURIComponent(row.path)}`);
|
||||
};
|
||||
|
||||
const total = queryResult?.total ?? 0;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { ActionsTab } from "../ActionsTab";
|
||||
import type { ServiceInstance } from "../../../types";
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "ssh-1",
|
||||
service_type: "ssh_tasks",
|
||||
name: "Storage Server",
|
||||
config: {},
|
||||
secrets_set: {},
|
||||
enabled: true,
|
||||
created_at: 1_700_000_000,
|
||||
updated_at: 1_700_000_000,
|
||||
};
|
||||
|
||||
vi.mock("../../../hooks/useSettings", () => ({
|
||||
useTasks: () => ({
|
||||
data: [
|
||||
{
|
||||
id: "t1",
|
||||
name: "Disk usage",
|
||||
task_type: "shell",
|
||||
content: "df -h",
|
||||
enabled: true,
|
||||
default_service_id: "",
|
||||
notes: "",
|
||||
},
|
||||
],
|
||||
}),
|
||||
useTaskRuns: () => ({ data: { items: [] } }),
|
||||
useSaveTask: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useDeleteTask: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useRunTask: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
function renderTab() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<ActionsTab instance={instance} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ActionsTab", () => {
|
||||
it("renders the saved-actions rail and task detail", () => {
|
||||
renderTab();
|
||||
expect(screen.getByText("Saved actions")).toBeInTheDocument();
|
||||
expect(screen.getByText("Disk usage")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the Add action button", () => {
|
||||
renderTab();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add action" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { FilesTab } from "../FilesTab";
|
||||
import type { ServiceInstance } from "../../../types";
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "ssh-1",
|
||||
service_type: "ssh_tasks",
|
||||
name: "Storage Server",
|
||||
config: {},
|
||||
secrets_set: {},
|
||||
enabled: true,
|
||||
created_at: 1_700_000_000,
|
||||
updated_at: 1_700_000_000,
|
||||
};
|
||||
|
||||
vi.mock("../../../hooks/useFiles", () => ({
|
||||
useDirectoryListing: () => ({
|
||||
data: {
|
||||
count: 2,
|
||||
entries: [
|
||||
{ name: "movies", type: "d", size: 0, mtime: 1700000000 },
|
||||
{ name: "video.mkv", type: "f", size: 1024, mtime: 1700000000 },
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useFfprobe: () => ({ data: undefined, isLoading: false, error: null }),
|
||||
useJobTemplates: () => ({ data: [] }),
|
||||
useRunJob: () => ({ mutate: vi.fn(), isPending: false, data: undefined }),
|
||||
}));
|
||||
|
||||
vi.mock("../../../hooks/usePersistentState", () => ({
|
||||
usePersistentState: vi.fn((_key: string, initial: () => unknown) => [
|
||||
initial(),
|
||||
vi.fn(),
|
||||
]),
|
||||
}));
|
||||
|
||||
function renderTab(path = "/services/ssh_tasks/ssh-1") {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<FilesTab instance={instance} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("FilesTab", () => {
|
||||
it("renders the directory listing with instance-scoped hooks", () => {
|
||||
renderTab();
|
||||
expect(screen.getByText("movies")).toBeInTheDocument();
|
||||
expect(screen.getByText("video.mkv")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the path bar and browser section", () => {
|
||||
renderTab();
|
||||
expect(screen.getByText("Browser")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Remote path")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,10 @@ vi.mock("../../../hooks/useDashboard", () => ({
|
||||
useLibraries: () => ({ data: [{ id: "lib1" }] }),
|
||||
}));
|
||||
|
||||
vi.mock("../../../hooks/useServices", () => ({
|
||||
useServiceInstances: () => ({ data: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("../../../hooks/usePersistentState", () => ({
|
||||
usePersistentState: () => [
|
||||
{
|
||||
|
||||
@@ -7,9 +7,7 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { ServiceInstance } from "../../types";
|
||||
import {
|
||||
ActionsTab,
|
||||
AlertsTab,
|
||||
FilesTab,
|
||||
JobsTab,
|
||||
LinksTab,
|
||||
MessagingTab,
|
||||
@@ -19,6 +17,8 @@ import {
|
||||
} from "./stubs";
|
||||
import { MediaTab } from "./MediaTab";
|
||||
import { RequestsTab } from "./RequestsTab";
|
||||
import { FilesTab } from "./FilesTab";
|
||||
import { ActionsTab } from "./ActionsTab";
|
||||
|
||||
export type ServiceTabComponent = ComponentType<{ instance: ServiceInstance }>;
|
||||
|
||||
|
||||
@@ -28,14 +28,6 @@ export function OverviewTab({ instance }: { instance: ServiceInstance }) {
|
||||
return <Stub label="Service overview" instance={instance} />;
|
||||
}
|
||||
|
||||
export function FilesTab({ instance }: { instance: ServiceInstance }) {
|
||||
return <Stub label="Files" instance={instance} />;
|
||||
}
|
||||
|
||||
export function ActionsTab({ instance }: { instance: ServiceInstance }) {
|
||||
return <Stub label="Actions" instance={instance} />;
|
||||
}
|
||||
|
||||
export function JobsTab({ instance }: { instance: ServiceInstance }) {
|
||||
return <Stub label="Backup jobs" instance={instance} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user