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:
Developer
2026-06-23 13:55:13 +00:00
parent d7ad933b2a
commit 50eb76a10d
14 changed files with 371 additions and 410 deletions
+43 -36
View File
@@ -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}
+40 -28
View File
@@ -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",
});
});
});