Files
manage/frontend/src/pages/service-tabs/ActionsTab.tsx
T
Developer 01527ae4f0 Rebase services-as-hub-ia onto mobile-responsive-parity
Combine both branches into a single coherent branch:
- Full mobile responsive parity (useIsMobile, MobileCardRow, SheetForm,
  .mobile-touch-target, mobile cards, SheetForm forms, 44px targets,
  dirty-state confirm, TablePagination, refetchIntervalInBackground).
- Full services-as-hub IA (data-driven nav, service-page tab skeleton,
  new service types, Authentik directory + messaging, named dashboards,
  legacy routes 404, Observability split, Jellyseerr absorbed).

Enhancement: service tabs now use mobile-parity primitives:
- MediaTab: MobileCardRow below md (title/size/HDR/library/year) +
  TablePagination; DataTable at md+ (desktop branch preserved).
- FilesTab: MobileCardRow below md (name/type/size/modified) +
  handleRowClick; DataTable at md+.
- ServicePage: SheetForm branch below md (open-on-mount, sticky header
  + save bar, cancel navigates back to /services, dirty-state guard).
- Dashboard: single-column + section anchors below md (from mobile-parity)
  + empty-state CTA (from services-hub).
- App.tsx: useIsMobile() replaces inline matchMedia (from mobile-parity)
  + data-driven useNavItems (from services-hub).
- Backup tables (BackupAlerts/Jobs/Runs) already have MobileCardRow from
  mobile-parity; JobsTab inherits mobile behavior through its sub-components.

Conflict resolutions:
- Backend: entirely from services-hub (mobile didn't touch it).
- Deleted pages (Media/FileBrowser/Actions/Users/UsersPage/Applications/
  ObservabilityPage/BackupsPage + hooks/useUsers + tests): kept deleted
  (services-hub deleted them; content moved into service tabs).
- New service-tabs/*: from services-hub, enhanced with mobile patterns.
- App.tsx: services-hub's data-driven nav + mobile-parity's useIsMobile.
- Dashboard.tsx: merged (services-hub CTA + mobile-parity sections/anchors).
- ServicePage.tsx: services-hub's tab skeleton + mobile-parity's SheetForm.
- Primitives (useIsMobile/mobile-card/sheet-form/etc.): from mobile-parity.

117 frontend tests pass (mobile-parity's 122 - 5 deleted page tests +
services-hub's new tab/dashboard tests); 271 backend tests pass; lint/
build green both sides.
2026-06-26 21:08:51 +00:00

457 lines
12 KiB
TypeScript

/**
* 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>
);
}