feat(tasks): unify saved tasks on ssh_tasks services
- Add shared task_runner.run_saved_task helper used by routers/tasks.py and widgets/sources.py SshTaskWidgetSource. - Saved tasks now target ssh_tasks service instances via default_service_id; the legacy default_machine_id and saved_task_runs are removed. - Actions page lists ssh_tasks services for default and run-time selection. - Update types, API client, hooks, tests, docs, and changelog. Backend tests: 222 passed. Frontend lint/build/test: clean (71 passed).
This commit is contained in:
@@ -245,19 +245,19 @@ export const saveTask = (task: SavedTaskInput) =>
|
||||
});
|
||||
export const deleteTask = (taskId: string) =>
|
||||
del<{ status: string }>(`/api/tasks/${encodeURIComponent(taskId)}`);
|
||||
export const runTask = (taskId: string, machineId?: string) =>
|
||||
export const runTask = (taskId: string, serviceId?: string) =>
|
||||
post<{
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
machine_id: string;
|
||||
machine_name: string;
|
||||
service_id: string;
|
||||
service_name: string;
|
||||
task_type: string;
|
||||
exit_status: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}>(
|
||||
machineId
|
||||
? `/api/tasks/run?machine_id=${encodeURIComponent(machineId)}`
|
||||
serviceId
|
||||
? `/api/tasks/run?service_id=${encodeURIComponent(serviceId)}`
|
||||
: "/api/tasks/run",
|
||||
{ task_id: taskId },
|
||||
);
|
||||
|
||||
@@ -113,11 +113,11 @@ export function useRunTask() {
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
taskId,
|
||||
machineId,
|
||||
serviceId,
|
||||
}: {
|
||||
taskId: string;
|
||||
machineId?: string;
|
||||
}) => runTask(taskId, machineId),
|
||||
serviceId?: string;
|
||||
}) => runTask(taskId, serviceId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { MonitoringMachine, SavedTask, SavedTaskInput } from "../types";
|
||||
import type { SavedTask, SavedTaskInput, ServiceInstance } from "../types";
|
||||
import {
|
||||
useDeleteTask,
|
||||
useMonitoringSettings,
|
||||
useRunTask,
|
||||
useSaveTask,
|
||||
useTaskRuns,
|
||||
useTasks,
|
||||
} from "../hooks/useSettings";
|
||||
import { useServiceInstances } from "../hooks/useServices";
|
||||
import { DialogFooter } from "../components/DialogFooter";
|
||||
import { HoverEditButton } from "../components/HoverEditButton";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
@@ -75,7 +75,7 @@ function emptyTask(): SavedTaskInput {
|
||||
task_type: "shell",
|
||||
content: "",
|
||||
enabled: true,
|
||||
default_machine_id: "",
|
||||
default_service_id: "",
|
||||
notes: "",
|
||||
};
|
||||
}
|
||||
@@ -87,7 +87,7 @@ function sameTask(a: SavedTaskInput, b: SavedTaskInput) {
|
||||
a.task_type === b.task_type &&
|
||||
a.content === b.content &&
|
||||
a.enabled === b.enabled &&
|
||||
a.default_machine_id === b.default_machine_id &&
|
||||
a.default_service_id === b.default_service_id &&
|
||||
a.notes === b.notes
|
||||
);
|
||||
}
|
||||
@@ -99,22 +99,22 @@ function initialFromTask(task: SavedTask): SavedTaskInput {
|
||||
task_type: task.task_type,
|
||||
content: task.content,
|
||||
enabled: task.enabled,
|
||||
default_machine_id: task.default_machine_id,
|
||||
default_service_id: task.default_service_id,
|
||||
notes: task.notes,
|
||||
};
|
||||
}
|
||||
|
||||
function TaskEditor({
|
||||
task,
|
||||
machines,
|
||||
services,
|
||||
onChange,
|
||||
}: {
|
||||
task: SavedTaskInput;
|
||||
machines: MonitoringMachine[];
|
||||
services: ServiceInstance[];
|
||||
onChange: (task: SavedTaskInput) => void;
|
||||
}) {
|
||||
const selectedMachine = machines.find(
|
||||
(machine) => machine.id === task.default_machine_id,
|
||||
const selectedService = services.find(
|
||||
(service) => service.id === task.default_service_id,
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -124,8 +124,8 @@ function TaskEditor({
|
||||
</p>
|
||||
<Badge variant="outline">{task.task_type}</Badge>
|
||||
<Badge variant="outline">{task.enabled ? "enabled" : "disabled"}</Badge>
|
||||
{selectedMachine && (
|
||||
<Badge variant="outline">{`default: ${selectedMachine.name}`}</Badge>
|
||||
{selectedService && (
|
||||
<Badge variant="outline">{`default: ${selectedService.name}`}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -160,13 +160,13 @@ function TaskEditor({
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="min-w-[220px] flex-1">
|
||||
<FormField label="Default machine">
|
||||
<FormField label="Default SSH task service">
|
||||
<Select
|
||||
value={task.default_machine_id || NONE}
|
||||
value={task.default_service_id || NONE}
|
||||
onValueChange={(value) =>
|
||||
onChange({
|
||||
...task,
|
||||
default_machine_id: value === NONE ? "" : value,
|
||||
default_service_id: value === NONE ? "" : value,
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -175,9 +175,9 @@ function TaskEditor({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>None</SelectItem>
|
||||
{machines.map((machine) => (
|
||||
<SelectItem key={machine.id} value={machine.id}>
|
||||
{machine.name}
|
||||
{services.map((service) => (
|
||||
<SelectItem key={service.id} value={service.id}>
|
||||
{service.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -216,7 +216,7 @@ function TaskDialog({
|
||||
open,
|
||||
task,
|
||||
baseline,
|
||||
machines,
|
||||
services,
|
||||
onClose,
|
||||
onChange,
|
||||
onSave,
|
||||
@@ -225,7 +225,7 @@ function TaskDialog({
|
||||
open: boolean;
|
||||
task: SavedTaskInput;
|
||||
baseline: SavedTaskInput;
|
||||
machines: MonitoringMachine[];
|
||||
services: ServiceInstance[];
|
||||
onClose: () => void;
|
||||
onChange: (task: SavedTaskInput) => void;
|
||||
onSave: () => void;
|
||||
@@ -254,9 +254,10 @@ function TaskDialog({
|
||||
<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 the selected SSH task service instance.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<TaskEditor task={task} machines={machines} onChange={onChange} />
|
||||
<TaskEditor task={task} services={services} onChange={onChange} />
|
||||
<DialogFooter
|
||||
onCancel={requestClose}
|
||||
cancelLabel="Cancel"
|
||||
@@ -277,7 +278,7 @@ function TaskDialog({
|
||||
}
|
||||
|
||||
export function Actions() {
|
||||
const { data: machines = [] } = useMonitoringSettings();
|
||||
const { data: sshServices = [] } = useServiceInstances("ssh_tasks");
|
||||
const { data: tasks = [] } = useTasks();
|
||||
const saveTask = useSaveTask();
|
||||
const deleteTask = useDeleteTask();
|
||||
@@ -287,7 +288,7 @@ export function Actions() {
|
||||
const [draftBaseline, setDraftBaseline] = useState<SavedTaskInput>(
|
||||
emptyTask(),
|
||||
);
|
||||
const [runMachineId, setRunMachineId] = useState("");
|
||||
const [runServiceId, setRunServiceId] = useState("");
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
||||
const selectedTask = useMemo(
|
||||
@@ -306,7 +307,7 @@ export function Actions() {
|
||||
const initial = emptyTask();
|
||||
setDraft(initial);
|
||||
setDraftBaseline(initial);
|
||||
setRunMachineId(machines[0]?.id || "");
|
||||
setRunServiceId(sshServices[0]?.id || "");
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
@@ -320,7 +321,7 @@ export function Actions() {
|
||||
task_type: saved.task_type,
|
||||
content: saved.content,
|
||||
enabled: saved.enabled,
|
||||
default_machine_id: saved.default_machine_id,
|
||||
default_service_id: saved.default_service_id,
|
||||
notes: saved.notes,
|
||||
};
|
||||
setDraft(nextDraft);
|
||||
@@ -418,11 +419,11 @@ export function Actions() {
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
disabled={runTask.isPending || !runMachineId}
|
||||
disabled={runTask.isPending || !runServiceId}
|
||||
onClick={async () => {
|
||||
await runTask.mutateAsync({
|
||||
taskId: editingTask.id,
|
||||
machineId: runMachineId,
|
||||
serviceId: runServiceId,
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -432,18 +433,25 @@ export function Actions() {
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FormField label="Run on machine">
|
||||
<FormField
|
||||
label="Run on SSH task service"
|
||||
htmlFor="run-service-id"
|
||||
>
|
||||
<Select
|
||||
value={runMachineId}
|
||||
onValueChange={(value) => setRunMachineId(value)}
|
||||
value={runServiceId}
|
||||
onValueChange={(value) => setRunServiceId(value)}
|
||||
>
|
||||
<SelectTrigger className="min-w-[240px]" size="sm">
|
||||
<SelectValue placeholder="Select machine" />
|
||||
<SelectTrigger
|
||||
id="run-service-id"
|
||||
className="min-w-[240px]"
|
||||
size="sm"
|
||||
>
|
||||
<SelectValue placeholder="Select service" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{machines.map((machine) => (
|
||||
<SelectItem key={machine.id} value={machine.id}>
|
||||
{machine.name}
|
||||
{sshServices.map((service) => (
|
||||
<SelectItem key={service.id} value={service.id}>
|
||||
{service.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -462,7 +470,6 @@ export function Actions() {
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{run.status}</Badge>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{run.machine_name} ·{" "}
|
||||
{new Date(run.created_at * 1000).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
@@ -529,7 +536,7 @@ export function Actions() {
|
||||
open={editOpen}
|
||||
task={draft}
|
||||
baseline={draftBaseline}
|
||||
machines={machines}
|
||||
services={sshServices}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onChange={setDraft}
|
||||
onSave={saveDraft}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Actions } from "../Actions";
|
||||
import type { MonitoringMachine, SavedTask } from "../../types";
|
||||
import type { SavedTask, ServiceInstance } from "../../types";
|
||||
|
||||
const saveTaskMutate = vi.fn().mockResolvedValue({
|
||||
id: "t1",
|
||||
@@ -10,17 +10,16 @@ const saveTaskMutate = vi.fn().mockResolvedValue({
|
||||
task_type: "shell",
|
||||
content: "",
|
||||
enabled: true,
|
||||
default_machine_id: "",
|
||||
default_service_id: "",
|
||||
notes: "",
|
||||
});
|
||||
const deleteTaskMutate = vi.fn();
|
||||
const runTaskMutate = vi.fn().mockResolvedValue({});
|
||||
|
||||
let machines: MonitoringMachine[] = [];
|
||||
let sshServices: ServiceInstance[] = [];
|
||||
let tasks: SavedTask[] = [];
|
||||
|
||||
vi.mock("../../hooks/useSettings", () => ({
|
||||
useMonitoringSettings: () => ({ data: machines }),
|
||||
useTasks: () => ({ data: tasks }),
|
||||
useSaveTask: () => ({ mutateAsync: saveTaskMutate, isPending: false }),
|
||||
useDeleteTask: () => ({ mutate: deleteTaskMutate }),
|
||||
@@ -28,29 +27,22 @@ vi.mock("../../hooks/useSettings", () => ({
|
||||
useTaskRuns: () => ({ data: { items: [] } }),
|
||||
}));
|
||||
|
||||
function machine(
|
||||
overrides: Partial<MonitoringMachine> = {},
|
||||
): MonitoringMachine {
|
||||
vi.mock("../../hooks/useServices", () => ({
|
||||
useServiceInstances: () => ({ data: sshServices }),
|
||||
}));
|
||||
|
||||
function sshService(overrides: Partial<ServiceInstance> = {}): ServiceInstance {
|
||||
return {
|
||||
id: "m1",
|
||||
name: "This machine",
|
||||
mode: "local",
|
||||
id: "s1",
|
||||
service_type: "ssh_tasks",
|
||||
name: "Box",
|
||||
config: { host: "box", username: "u" },
|
||||
secrets_set: {},
|
||||
enabled: true,
|
||||
services: ["monitoring", "files"],
|
||||
host: "",
|
||||
port: 22,
|
||||
username: "",
|
||||
key_directory: "",
|
||||
key_name: "",
|
||||
ssh_key_id: "",
|
||||
ssh_private_key_set: false,
|
||||
ssh_private_key_passphrase_set: false,
|
||||
password_set: false,
|
||||
media_root: "",
|
||||
path_prefix: "",
|
||||
notes: "",
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
...overrides,
|
||||
} as MonitoringMachine;
|
||||
} as ServiceInstance;
|
||||
}
|
||||
|
||||
function task(overrides: Partial<SavedTask> = {}): SavedTask {
|
||||
@@ -60,7 +52,7 @@ function task(overrides: Partial<SavedTask> = {}): SavedTask {
|
||||
task_type: "shell",
|
||||
content: "systemctl restart foo",
|
||||
enabled: true,
|
||||
default_machine_id: "",
|
||||
default_service_id: "",
|
||||
notes: "",
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
@@ -72,7 +64,7 @@ beforeEach(() => {
|
||||
saveTaskMutate.mockClear();
|
||||
deleteTaskMutate.mockClear();
|
||||
runTaskMutate.mockClear();
|
||||
machines = [];
|
||||
sshServices = [];
|
||||
tasks = [];
|
||||
});
|
||||
|
||||
@@ -97,10 +89,11 @@ describe("Actions", () => {
|
||||
const saved = saveTaskMutate.mock.calls[0][0];
|
||||
expect(saved.name).toBe("Restart svc");
|
||||
expect(saved.task_type).toBe("shell");
|
||||
expect(saved.default_service_id).toBe("");
|
||||
});
|
||||
|
||||
it("disables the Run button until a run machine is selected", async () => {
|
||||
machines = [machine()];
|
||||
it("disables the Run button until a run service is selected", async () => {
|
||||
sshServices = [sshService()];
|
||||
tasks = [task()];
|
||||
render(<Actions />);
|
||||
|
||||
@@ -110,4 +103,23 @@ describe("Actions", () => {
|
||||
const runButton = screen.getByRole("button", { name: "Run action" });
|
||||
expect(runButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("runs a task on the selected SSH task service", async () => {
|
||||
sshServices = [sshService()];
|
||||
tasks = [task()];
|
||||
render(<Actions />);
|
||||
|
||||
await userEvent.click(screen.getByRole("tab", { name: "Restart svc" }));
|
||||
await userEvent.click(
|
||||
screen.getByRole("combobox", { name: "Run on SSH task service" }),
|
||||
);
|
||||
await userEvent.click(screen.getByRole("option", { name: "Box" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Run action" }));
|
||||
|
||||
expect(runTaskMutate).toHaveBeenCalledTimes(1);
|
||||
expect(runTaskMutate).toHaveBeenCalledWith({
|
||||
taskId: "t1",
|
||||
serviceId: "s1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,3 +23,26 @@ globalThis.ResizeObserver =
|
||||
// Radix popper also probes `requestAnimationFrame`; jsdom provides it, but some
|
||||
// primitives defer layout reads through rAF that never flush in jsdom. Keep the
|
||||
// default rAF; this guard is intentionally minimal.
|
||||
|
||||
// Radix Select uses pointer capture APIs that jsdom does not implement.
|
||||
// Stub them on HTMLElement so opening/closing selects in tests does not throw.
|
||||
if (typeof window !== "undefined" && window.HTMLElement) {
|
||||
const proto = window.HTMLElement.prototype;
|
||||
if (!proto.hasPointerCapture) {
|
||||
proto.hasPointerCapture = () => false;
|
||||
}
|
||||
if (!proto.setPointerCapture) {
|
||||
proto.setPointerCapture = () => {};
|
||||
}
|
||||
if (!proto.releasePointerCapture) {
|
||||
proto.releasePointerCapture = () => {};
|
||||
}
|
||||
}
|
||||
|
||||
// Radix Select also calls scrollIntoView on items when opening; jsdom lacks it.
|
||||
if (typeof window !== "undefined" && window.Element) {
|
||||
const proto = window.Element.prototype;
|
||||
if (!proto.scrollIntoView) {
|
||||
proto.scrollIntoView = () => {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ export interface SavedTask {
|
||||
task_type: "shell" | "python";
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
default_machine_id: string;
|
||||
default_service_id: string;
|
||||
notes: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
@@ -138,21 +138,18 @@ export interface SavedTaskInput {
|
||||
task_type: "shell" | "python";
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
default_machine_id: string;
|
||||
default_service_id: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface SavedTaskRun {
|
||||
id: string;
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
machine_id: string;
|
||||
machine_name: string;
|
||||
task_type: "shell" | "python";
|
||||
status: string;
|
||||
service_id: string;
|
||||
status: "success" | "failure" | "error" | "timeout" | string;
|
||||
exit_status: number | null;
|
||||
created_at: number;
|
||||
duration_ms: number;
|
||||
request_id: string;
|
||||
stdout_tail: string;
|
||||
stderr_tail: string;
|
||||
error: string;
|
||||
@@ -198,44 +195,6 @@ export interface MonitoringMachineInput {
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface SavedTask {
|
||||
id: string;
|
||||
name: string;
|
||||
task_type: "shell" | "python";
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
default_machine_id: string;
|
||||
notes: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface SavedTaskInput {
|
||||
id?: string | null;
|
||||
name: string;
|
||||
task_type: "shell" | "python";
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
default_machine_id: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface SavedTaskRun {
|
||||
id: string;
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
machine_id: string;
|
||||
machine_name: string;
|
||||
task_type: "shell" | "python";
|
||||
status: string;
|
||||
created_at: number;
|
||||
duration_ms: number;
|
||||
request_id: string;
|
||||
stdout_tail: string;
|
||||
stderr_tail: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface ResetLocalDatabaseInput {
|
||||
confirm_phrase: string;
|
||||
acknowledge_settings_loss: boolean;
|
||||
|
||||
Reference in New Issue
Block a user