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:
Developer
2026-06-26 19:27:16 +00:00
parent dd48214987
commit b2e1acd257
8 changed files with 1384 additions and 11 deletions
@@ -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>
);
}
+10 -1
View File
@@ -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: () => [
{
+2 -2
View File
@@ -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} />;
}