Add manual scheduled task entry
Lets users log tasks the Linux agent can't discover on its own (e.g.
Docker-based backup jobs) directly in the UI. Tasks now carry an
origin ('agent' | 'manual') so the agent's report-sync logic only
ever creates/updates/stale-marks agent-sourced rows, leaving manual
entries untouched; the API rejects edits/deletes of agent-sourced
tasks to keep that boundary enforced server-side too.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
ALTER TABLE `scheduled_tasks` ADD `origin` text DEFAULT 'agent' NOT NULL;
|
||||
@@ -0,0 +1,220 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "32e3ac8c-c245-4350-9446-7fc5bb66e2b6",
|
||||
"prevId": "81d34268-977d-459b-b4a1-8d62b2d81a56",
|
||||
"tables": {
|
||||
"scheduled_tasks": {
|
||||
"name": "scheduled_tasks",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"server_id": {
|
||||
"name": "server_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"schedule_type": {
|
||||
"name": "schedule_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"origin": {
|
||||
"name": "origin",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'agent'"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"command": {
|
||||
"name": "command",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"schedule_expression": {
|
||||
"name": "schedule_expression",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source": {
|
||||
"name": "source",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"next_run_at": {
|
||||
"name": "next_run_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"raw_metadata": {
|
||||
"name": "raw_metadata",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_stale": {
|
||||
"name": "is_stale",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"first_seen_at": {
|
||||
"name": "first_seen_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
},
|
||||
"last_seen_at": {
|
||||
"name": "last_seen_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"scheduled_tasks_server_id_servers_id_fk": {
|
||||
"name": "scheduled_tasks_server_id_servers_id_fk",
|
||||
"tableFrom": "scheduled_tasks",
|
||||
"tableTo": "servers",
|
||||
"columnsFrom": [
|
||||
"server_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"servers": {
|
||||
"name": "servers",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"hostname": {
|
||||
"name": "hostname",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"os_type": {
|
||||
"name": "os_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'linux'"
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"api_token_hash": {
|
||||
"name": "api_token_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"api_token_prefix": {
|
||||
"name": "api_token_prefix",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
},
|
||||
"last_seen_at": {
|
||||
"name": "last_seen_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@
|
||||
"when": 1783628241015,
|
||||
"tag": "0000_aspiring_blink",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1783634598855,
|
||||
"tag": "0001_amazing_greymalkin",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -20,7 +20,8 @@ export const scheduledTasks = sqliteTable("scheduled_tasks", {
|
||||
serverId: integer("server_id")
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
scheduleType: text("schedule_type").notNull(), // 'cron' | 'systemd_timer'
|
||||
scheduleType: text("schedule_type").notNull(), // 'cron' | 'systemd_timer' | 'docker' | 'manual'
|
||||
origin: text("origin").notNull().default("agent"), // 'agent' | 'manual' — manual rows are never touched by agent sync
|
||||
name: text("name").notNull(),
|
||||
command: text("command"),
|
||||
scheduleExpression: text("schedule_expression"),
|
||||
|
||||
+102
-16
@@ -1,5 +1,6 @@
|
||||
import { Router } from "express";
|
||||
import { and, eq, like, or } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { scheduledTasks, servers } from "../db/schema.js";
|
||||
import { requireAuth } from "../auth/middleware.js";
|
||||
@@ -7,6 +8,23 @@ import { requireAuth } from "../auth/middleware.js";
|
||||
export const tasksRouter = Router();
|
||||
tasksRouter.use(requireAuth);
|
||||
|
||||
const TASK_COLUMNS = {
|
||||
id: scheduledTasks.id,
|
||||
serverId: scheduledTasks.serverId,
|
||||
serverName: servers.name,
|
||||
scheduleType: scheduledTasks.scheduleType,
|
||||
origin: scheduledTasks.origin,
|
||||
name: scheduledTasks.name,
|
||||
command: scheduledTasks.command,
|
||||
scheduleExpression: scheduledTasks.scheduleExpression,
|
||||
source: scheduledTasks.source,
|
||||
enabled: scheduledTasks.enabled,
|
||||
nextRunAt: scheduledTasks.nextRunAt,
|
||||
isStale: scheduledTasks.isStale,
|
||||
firstSeenAt: scheduledTasks.firstSeenAt,
|
||||
lastSeenAt: scheduledTasks.lastSeenAt,
|
||||
};
|
||||
|
||||
tasksRouter.get("/", async (req, res) => {
|
||||
const serverId = req.query.serverId ? Number(req.query.serverId) : undefined;
|
||||
const scheduleType = typeof req.query.scheduleType === "string" ? req.query.scheduleType : undefined;
|
||||
@@ -17,7 +35,7 @@ tasksRouter.get("/", async (req, res) => {
|
||||
if (serverId && Number.isInteger(serverId)) {
|
||||
conditions.push(eq(scheduledTasks.serverId, serverId));
|
||||
}
|
||||
if (scheduleType === "cron" || scheduleType === "systemd_timer") {
|
||||
if (scheduleType) {
|
||||
conditions.push(eq(scheduledTasks.scheduleType, scheduleType));
|
||||
}
|
||||
if (!includeStale) {
|
||||
@@ -29,21 +47,7 @@ tasksRouter.get("/", async (req, res) => {
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: scheduledTasks.id,
|
||||
serverId: scheduledTasks.serverId,
|
||||
serverName: servers.name,
|
||||
scheduleType: scheduledTasks.scheduleType,
|
||||
name: scheduledTasks.name,
|
||||
command: scheduledTasks.command,
|
||||
scheduleExpression: scheduledTasks.scheduleExpression,
|
||||
source: scheduledTasks.source,
|
||||
enabled: scheduledTasks.enabled,
|
||||
nextRunAt: scheduledTasks.nextRunAt,
|
||||
isStale: scheduledTasks.isStale,
|
||||
firstSeenAt: scheduledTasks.firstSeenAt,
|
||||
lastSeenAt: scheduledTasks.lastSeenAt,
|
||||
})
|
||||
.select(TASK_COLUMNS)
|
||||
.from(scheduledTasks)
|
||||
.innerJoin(servers, eq(scheduledTasks.serverId, servers.id))
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
@@ -51,3 +55,85 @@ tasksRouter.get("/", async (req, res) => {
|
||||
|
||||
res.json({ tasks: rows });
|
||||
});
|
||||
|
||||
const manualTaskSchema = z.object({
|
||||
serverId: z.number().int(),
|
||||
scheduleType: z.enum(["cron", "systemd_timer", "docker", "manual"]),
|
||||
name: z.string().min(1).max(200),
|
||||
command: z.string().max(1000).optional(),
|
||||
scheduleExpression: z.string().max(200).optional(),
|
||||
nextRunAt: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
tasksRouter.post("/", async (req, res) => {
|
||||
const parsed = manualTaskSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
const server = await db.query.servers.findFirst({ where: eq(servers.id, parsed.data.serverId) });
|
||||
if (!server) {
|
||||
return res.status(400).json({ error: "unknown_server" });
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const [created] = await db
|
||||
.insert(scheduledTasks)
|
||||
.values({
|
||||
serverId: parsed.data.serverId,
|
||||
scheduleType: parsed.data.scheduleType,
|
||||
origin: "manual",
|
||||
name: parsed.data.name,
|
||||
command: parsed.data.command,
|
||||
scheduleExpression: parsed.data.scheduleExpression,
|
||||
nextRunAt: parsed.data.nextRunAt,
|
||||
enabled: parsed.data.enabled ?? true,
|
||||
firstSeenAt: now,
|
||||
lastSeenAt: now,
|
||||
})
|
||||
.returning({ id: scheduledTasks.id });
|
||||
|
||||
const [task] = await db.select(TASK_COLUMNS).from(scheduledTasks).innerJoin(servers, eq(scheduledTasks.serverId, servers.id)).where(eq(scheduledTasks.id, created.id));
|
||||
res.status(201).json({ task });
|
||||
});
|
||||
|
||||
const manualTaskUpdateSchema = manualTaskSchema.omit({ serverId: true }).partial();
|
||||
|
||||
tasksRouter.patch("/:id", async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
|
||||
|
||||
const parsed = manualTaskUpdateSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
const existing = await db.query.scheduledTasks.findFirst({ where: eq(scheduledTasks.id, id) });
|
||||
if (!existing) return res.status(404).json({ error: "not_found" });
|
||||
if (existing.origin !== "manual") {
|
||||
return res.status(403).json({ error: "not_editable", message: "Only manually entered tasks can be edited." });
|
||||
}
|
||||
|
||||
await db
|
||||
.update(scheduledTasks)
|
||||
.set({ ...parsed.data, lastSeenAt: new Date().toISOString() })
|
||||
.where(eq(scheduledTasks.id, id));
|
||||
|
||||
const [task] = await db.select(TASK_COLUMNS).from(scheduledTasks).innerJoin(servers, eq(scheduledTasks.serverId, servers.id)).where(eq(scheduledTasks.id, id));
|
||||
res.json({ task });
|
||||
});
|
||||
|
||||
tasksRouter.delete("/:id", async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
|
||||
|
||||
const existing = await db.query.scheduledTasks.findFirst({ where: eq(scheduledTasks.id, id) });
|
||||
if (!existing) return res.status(404).json({ error: "not_found" });
|
||||
if (existing.origin !== "manual") {
|
||||
return res.status(403).json({ error: "not_editable", message: "Only manually entered tasks can be deleted." });
|
||||
}
|
||||
|
||||
await db.delete(scheduledTasks).where(eq(scheduledTasks.id, id));
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function syncServerTasks(serverId: number, report: AgentReport) {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const existing = await db.query.scheduledTasks.findMany({
|
||||
where: eq(scheduledTasks.serverId, serverId),
|
||||
where: and(eq(scheduledTasks.serverId, serverId), eq(scheduledTasks.origin, "agent")),
|
||||
});
|
||||
|
||||
const seenIds: number[] = [];
|
||||
@@ -56,6 +56,7 @@ export async function syncServerTasks(serverId: number, report: AgentReport) {
|
||||
.values({
|
||||
serverId,
|
||||
scheduleType: task.scheduleType,
|
||||
origin: "agent",
|
||||
name: task.name,
|
||||
command: task.command,
|
||||
scheduleExpression: task.scheduleExpression,
|
||||
@@ -71,15 +72,17 @@ export async function syncServerTasks(serverId: number, report: AgentReport) {
|
||||
}
|
||||
}
|
||||
|
||||
// Anything belonging to this server that wasn't in this report is now stale,
|
||||
// rather than deleted, so a bad/partial agent run doesn't wipe history.
|
||||
// Anything agent-sourced for this server that wasn't in this report is now stale,
|
||||
// rather than deleted, so a bad/partial agent run doesn't wipe history. Manually
|
||||
// entered tasks (origin = 'manual') are never touched by agent sync.
|
||||
const agentScope = and(eq(scheduledTasks.serverId, serverId), eq(scheduledTasks.origin, "agent"));
|
||||
if (seenIds.length > 0) {
|
||||
await db
|
||||
.update(scheduledTasks)
|
||||
.set({ isStale: true })
|
||||
.where(and(eq(scheduledTasks.serverId, serverId), notInArray(scheduledTasks.id, seenIds)));
|
||||
.where(and(agentScope, notInArray(scheduledTasks.id, seenIds)));
|
||||
} else {
|
||||
await db.update(scheduledTasks).set({ isStale: true }).where(eq(scheduledTasks.serverId, serverId));
|
||||
await db.update(scheduledTasks).set({ isStale: true }).where(agentScope);
|
||||
}
|
||||
|
||||
await db
|
||||
|
||||
Reference in New Issue
Block a user