diff --git a/frontend/src/pages/service-tabs/ActionsTab.tsx b/frontend/src/pages/service-tabs/ActionsTab.tsx
new file mode 100644
index 0000000..a9e7cf4
--- /dev/null
+++ b/frontend/src/pages/service-tabs/ActionsTab.tsx
@@ -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 (
+
+
+ {children}
+ {helperText ? (
+
{helperText}
+ ) : null}
+
+ );
+}
+
+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 (
+
+
+
+ {task.id ? "Edit action" : "New action"}
+
+
{task.task_type}
+
{task.enabled ? "enabled" : "disabled"}
+
+
+
+ onChange({ ...task, name: e.target.value })}
+ />
+
+
+
+
+
+
+
+
+
+ onChange({ ...task, notes: e.target.value })}
+ />
+
+
+
+
+
+ );
+}
+
+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 (
+
+ );
+}
+
+export function ActionsTab({ instance }: { instance: ServiceInstance }) {
+ const { data: tasks = [] } = useTasks();
+ const saveTask = useSaveTask();
+ const deleteTask = useDeleteTask();
+ const runTask = useRunTask();
+ const [tab, setTab] = useState("new");
+ const [draft, setDraft] = useState(emptyTask());
+ const [draftBaseline, setDraftBaseline] = useState(
+ 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 (
+
+ {saveTask.error && (
+
+ {String(saveTask.error)}
+
+ )}
+ {deleteTask.error && (
+
+ {String(deleteTask.error)}
+
+ )}
+ {runTask.error && (
+
+ {String(runTask.error)}
+
+ )}
+
+
+
openEdit(emptyTask())}
+ >
+ Add action
+
+ }
+ >
+ setTab(value)}
+ orientation="vertical"
+ className="w-full"
+ >
+
+ {tasks.map((task) => (
+
+
setTab(task.id)}
+ onDoubleClick={() => openEdit(initialFromTask(task))}
+ >
+ {task.name}
+
+
+ openEdit(initialFromTask(task))}
+ />
+
+
+ ))}
+
+
+
+
+
+ {selectedTask ? (
+
+
+
+
+ }
+ >
+
+
Recent runs
+ {selectedRuns.data?.items?.length ? (
+
+ {selectedRuns.data.items.map((run) => (
+
+
+
+
{run.status}
+
+ {new Date(run.created_at * 1000).toLocaleString()}
+
+
+ {run.stdout_tail && (
+
+
+ stdout
+
+
+ {run.stdout_tail}
+
+
+ )}
+ {run.stderr_tail && (
+
+
+ stderr
+
+
+ {run.stderr_tail}
+
+
+ )}
+ {run.error && (
+
+ {run.error}
+
+ )}
+
+
+ ))}
+
+ ) : (
+
+ No runs yet.
+
+ )}
+
+ ) : (
+
+ {tasks[0] && (
+
+ )}
+
+ )}
+
+
+
+ setEditOpen(false)}
+ onChange={setDraft}
+ onSave={saveDraft}
+ onDelete={
+ draft.id ? () => deleteTask.mutate(String(draft.id)) : undefined
+ }
+ />
+
+ );
+}
diff --git a/frontend/src/pages/service-tabs/FilesTab.tsx b/frontend/src/pages/service-tabs/FilesTab.tsx
new file mode 100644
index 0000000..d99758d
--- /dev/null
+++ b/frontend/src/pages/service-tabs/FilesTab.tsx
@@ -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;
+}
+
+interface FfprobeFormat {
+ filename?: string;
+ format_name?: string;
+ format_long_name?: string;
+ duration?: string | number;
+ size?: string | number;
+ bit_rate?: string | number;
+ tags?: Record;
+}
+
+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[] = [
+ {
+ accessorKey: "type",
+ header: () => "Type",
+ cell: ({ row }) => (
+ {row.original.type}
+ ),
+ },
+ {
+ accessorKey: "name",
+ header: () => "Name",
+ cell: ({ row }) => {row.original.name},
+ },
+ {
+ 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 {children};
+}
+
+function StreamBlock({ children }: { children: React.ReactNode }) {
+ return {children}
;
+}
+
+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 (
+
+
+
ffprobe details
+
{path}
+
+
+
+ Container / format
+
+
+
+ Format:{" "}
+ {fieldLabel("format", format.format_name)}
+
+
+ Long name:{" "}
+ {fieldLabel("format_long_name", format.format_long_name)}
+
+
+ Duration:{" "}
+ {humanDuration(format.duration)}
+
+
+
+
+ Size:{" "}
+ {humanBytes(format.size)}
+
+
+ Bitrate:{" "}
+ {humanRate(format.bit_rate)}
+
+
+ Filename:{" "}
+ {fieldLabel("filename", format.filename)}
+
+
+
+
+
+
+
+ Streams
+ {videoStreams.length > 0 && (
+
+
Video streams
+
+ {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 (
+
+
+ #{stream.index ?? index}
+
+ {stream.codec_type ?? "video"}
+
+
+ {stream.codec_name ?? "unknown codec"}
+
+ {stream.codec_long_name && (
+
+ {stream.codec_long_name}
+
+ )}
+ {stream.profile && (
+
+ {stream.profile}
+
+ )}
+ {stream.bit_rate && (
+
+ {humanRate(stream.bit_rate)}
+
+ )}
+ {stream.duration && (
+
+ {humanDuration(stream.duration)}
+
+ )}
+ {stream.width && stream.height && (
+ {`${stream.width}×${stream.height}`}
+ )}
+ {stream.pix_fmt && (
+
+ {stream.pix_fmt}
+
+ )}
+ {stream.display_aspect_ratio && (
+ {`DAR ${stream.display_aspect_ratio}`}
+ )}
+ {stream.sample_aspect_ratio && (
+ {`SAR ${stream.sample_aspect_ratio}`}
+ )}
+ {stream.level !== undefined &&
+ stream.level !== null && (
+ {`L${stream.level}`}
+ )}
+ {stream.field_order &&
+ stream.field_order !== "unknown" && (
+
+ {stream.field_order}
+
+ )}
+ {(stream.color_range ||
+ stream.color_space ||
+ stream.color_transfer ||
+ stream.color_primaries) && (
+
+ {[
+ stream.color_range,
+ stream.color_space,
+ stream.color_transfer,
+ stream.color_primaries,
+ ]
+ .filter(Boolean)
+ .join(" / ")}
+
+ )}
+
+
+ {stream.tags?.language
+ ? `Language: ${stream.tags.language}. `
+ : ""}
+ {stream.tags?.title
+ ? `Title: ${stream.tags.title}.`
+ : ""}
+
+
+ );
+ })}
+
+
+ )}
+ {audioStreams.length > 0 && (
+
+
Audio streams
+
+ {audioStreams.map((stream, index) => (
+
+
+ #{stream.index ?? index}
+
+ {stream.codec_type ?? "audio"}
+
+
+ {stream.codec_name ?? "unknown codec"}
+
+ {stream.channels && (
+ {`${stream.channels} ch`}
+ )}
+ {stream.sample_rate && (
+ {`${stream.sample_rate} Hz`}
+ )}
+ {stream.bit_rate && (
+
+ {humanRate(stream.bit_rate)}
+
+ )}
+ {stream.duration && (
+
+ {humanDuration(stream.duration)}
+
+ )}
+
+
+ {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}.` : ""}
+
+
+ ))}
+
+
+ )}
+ {subtitleStreams.length > 0 && (
+
+
+ Subtitle streams
+
+
+ {subtitleStreams.map((stream, index) => (
+
+
+ #{stream.index ?? index}
+
+ {stream.codec_type ?? "subtitle"}
+
+
+ {stream.codec_name ?? "unknown codec"}
+
+ {stream.tags?.language && (
+
+ {stream.tags.language}
+
+ )}
+ {stream.tags?.title && (
+
+ {stream.tags.title}
+
+ )}
+
+
+ ))}
+
+
+ )}
+ {streams.length === 0 && (
+
+ No streams found.
+
+ )}
+
+
+ {Object.keys(format.tags ?? {}).length > 0 && (
+
+
+ Tags
+
+ {Object.entries(format.tags ?? {}).map(([key, value]) => (
+ {`${key}: ${value}`}
+ ))}
+
+
+
+ )}
+
+ );
+}
+
+// --- Component ---
+
+export function FilesTab({ instance }: { instance: ServiceInstance }) {
+ const machineId = instance.id;
+ const [searchParams] = useSearchParams();
+ const requestedPath = searchParams.get("path");
+ const [columnVisibility, setColumnVisibility] = useState<
+ Record
+ >({});
+ const [browserState, setBrowserState] = usePersistentState(
+ `${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) =>
+ 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 (
+
+
+
+
+
+
+
+ updateBrowserState({ pathInput: e.target.value })
+ }
+ onKeyDown={handlePathSubmit}
+ />
+
+
+
+
+
+
+
+ {`Current: ${currentDir} `}
+ {selectedPath ? `| Selected: ${selectedPath} ` : ""}
+ {listing ? `| Entries: ${listing.count}` : ""}
+
+ {error && (
+
+ {String(error)}
+
+ )}
+
+ row.id}
+ enableRowSelection
+ rowSelection={rowSelection}
+ onRowSelectionChange={handleSelectionChange}
+ onRowClick={handleRowClick}
+ enableColumnVisibilityToggle
+ columnVisibility={columnVisibility}
+ onColumnVisibilityChange={setColumnVisibility}
+ emptyMessage={
+ isLoading ? "Loading directory..." : "This directory is empty."
+ }
+ />
+
+
+
+
+
+ {selectedPath ? (
+ isVideoFile(selectedPath) ? (
+ ffprobeError ? (
+
+ {String(ffprobeError)}
+
+ ) : ffprobeLoading && !ffprobeData ? (
+
+ Loading ffprobe data...
+
+ ) : ffprobeData ? (
+
+ ) : (
+
+ No ffprobe data available.
+
+ )
+ ) : (
+
+
+ Select a video file to view ffprobe details.
+
+
+ )
+ ) : (
+
+
+ Select a file in Browser to view ffprobe details.
+
+
+ )}
+
+
+
+ {selectedPath && templates && templates.length > 0 ? (
+
+
+
+
+
+
+
+
+ {selectedTemplate && (
+
+ {selectedTemplate.description}
+
+ )}
+
+
+ {runJob.data && (
+
+ {`Exit: ${runJob.data.exit_status}`}
+ {"\n"}
+ {runJob.data.stdout}
+ {runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
+
+ )}
+
+ ) : (
+
+
+ Select a file in Browser to run jobs.
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/pages/service-tabs/MediaTab.tsx b/frontend/src/pages/service-tabs/MediaTab.tsx
index ad0ae5d..5307dc6 100644
--- a/frontend/src/pages/service-tabs/MediaTab.tsx
+++ b/frontend/src/pages/service-tabs/MediaTab.tsx
@@ -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;
diff --git a/frontend/src/pages/service-tabs/__tests__/ActionsTab.test.tsx b/frontend/src/pages/service-tabs/__tests__/ActionsTab.test.tsx
new file mode 100644
index 0000000..2760ddf
--- /dev/null
+++ b/frontend/src/pages/service-tabs/__tests__/ActionsTab.test.tsx
@@ -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(
+
+
+ ,
+ );
+}
+
+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();
+ });
+});
diff --git a/frontend/src/pages/service-tabs/__tests__/FilesTab.test.tsx b/frontend/src/pages/service-tabs/__tests__/FilesTab.test.tsx
new file mode 100644
index 0000000..f5b20e9
--- /dev/null
+++ b/frontend/src/pages/service-tabs/__tests__/FilesTab.test.tsx
@@ -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(
+
+
+ ,
+ );
+}
+
+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();
+ });
+});
diff --git a/frontend/src/pages/service-tabs/__tests__/MediaTab.test.tsx b/frontend/src/pages/service-tabs/__tests__/MediaTab.test.tsx
index 370cc34..9af0d2d 100644
--- a/frontend/src/pages/service-tabs/__tests__/MediaTab.test.tsx
+++ b/frontend/src/pages/service-tabs/__tests__/MediaTab.test.tsx
@@ -32,6 +32,10 @@ vi.mock("../../../hooks/useDashboard", () => ({
useLibraries: () => ({ data: [{ id: "lib1" }] }),
}));
+vi.mock("../../../hooks/useServices", () => ({
+ useServiceInstances: () => ({ data: [] }),
+}));
+
vi.mock("../../../hooks/usePersistentState", () => ({
usePersistentState: () => [
{
diff --git a/frontend/src/pages/service-tabs/index.ts b/frontend/src/pages/service-tabs/index.ts
index ff38e63..3b35b01 100644
--- a/frontend/src/pages/service-tabs/index.ts
+++ b/frontend/src/pages/service-tabs/index.ts
@@ -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 }>;
diff --git a/frontend/src/pages/service-tabs/stubs.tsx b/frontend/src/pages/service-tabs/stubs.tsx
index ae509a1..d16fe35 100644
--- a/frontend/src/pages/service-tabs/stubs.tsx
+++ b/frontend/src/pages/service-tabs/stubs.tsx
@@ -28,14 +28,6 @@ export function OverviewTab({ instance }: { instance: ServiceInstance }) {
return ;
}
-export function FilesTab({ instance }: { instance: ServiceInstance }) {
- return ;
-}
-
-export function ActionsTab({ instance }: { instance: ServiceInstance }) {
- return ;
-}
-
export function JobsTab({ instance }: { instance: ServiceInstance }) {
return ;
}