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,
|
"when": 1783628241015,
|
||||||
"tag": "0000_aspiring_blink",
|
"tag": "0000_aspiring_blink",
|
||||||
"breakpoints": true
|
"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")
|
serverId: integer("server_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => servers.id, { onDelete: "cascade" }),
|
.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(),
|
name: text("name").notNull(),
|
||||||
command: text("command"),
|
command: text("command"),
|
||||||
scheduleExpression: text("schedule_expression"),
|
scheduleExpression: text("schedule_expression"),
|
||||||
|
|||||||
+102
-16
@@ -1,5 +1,6 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { and, eq, like, or } from "drizzle-orm";
|
import { and, eq, like, or } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { scheduledTasks, servers } from "../db/schema.js";
|
import { scheduledTasks, servers } from "../db/schema.js";
|
||||||
import { requireAuth } from "../auth/middleware.js";
|
import { requireAuth } from "../auth/middleware.js";
|
||||||
@@ -7,6 +8,23 @@ import { requireAuth } from "../auth/middleware.js";
|
|||||||
export const tasksRouter = Router();
|
export const tasksRouter = Router();
|
||||||
tasksRouter.use(requireAuth);
|
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) => {
|
tasksRouter.get("/", async (req, res) => {
|
||||||
const serverId = req.query.serverId ? Number(req.query.serverId) : undefined;
|
const serverId = req.query.serverId ? Number(req.query.serverId) : undefined;
|
||||||
const scheduleType = typeof req.query.scheduleType === "string" ? req.query.scheduleType : 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)) {
|
if (serverId && Number.isInteger(serverId)) {
|
||||||
conditions.push(eq(scheduledTasks.serverId, serverId));
|
conditions.push(eq(scheduledTasks.serverId, serverId));
|
||||||
}
|
}
|
||||||
if (scheduleType === "cron" || scheduleType === "systemd_timer") {
|
if (scheduleType) {
|
||||||
conditions.push(eq(scheduledTasks.scheduleType, scheduleType));
|
conditions.push(eq(scheduledTasks.scheduleType, scheduleType));
|
||||||
}
|
}
|
||||||
if (!includeStale) {
|
if (!includeStale) {
|
||||||
@@ -29,21 +47,7 @@ tasksRouter.get("/", async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select(TASK_COLUMNS)
|
||||||
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,
|
|
||||||
})
|
|
||||||
.from(scheduledTasks)
|
.from(scheduledTasks)
|
||||||
.innerJoin(servers, eq(scheduledTasks.serverId, servers.id))
|
.innerJoin(servers, eq(scheduledTasks.serverId, servers.id))
|
||||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||||
@@ -51,3 +55,85 @@ tasksRouter.get("/", async (req, res) => {
|
|||||||
|
|
||||||
res.json({ tasks: rows });
|
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 now = new Date().toISOString();
|
||||||
|
|
||||||
const existing = await db.query.scheduledTasks.findMany({
|
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[] = [];
|
const seenIds: number[] = [];
|
||||||
@@ -56,6 +56,7 @@ export async function syncServerTasks(serverId: number, report: AgentReport) {
|
|||||||
.values({
|
.values({
|
||||||
serverId,
|
serverId,
|
||||||
scheduleType: task.scheduleType,
|
scheduleType: task.scheduleType,
|
||||||
|
origin: "agent",
|
||||||
name: task.name,
|
name: task.name,
|
||||||
command: task.command,
|
command: task.command,
|
||||||
scheduleExpression: task.scheduleExpression,
|
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,
|
// 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.
|
// 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) {
|
if (seenIds.length > 0) {
|
||||||
await db
|
await db
|
||||||
.update(scheduledTasks)
|
.update(scheduledTasks)
|
||||||
.set({ isStale: true })
|
.set({ isStale: true })
|
||||||
.where(and(eq(scheduledTasks.serverId, serverId), notInArray(scheduledTasks.id, seenIds)));
|
.where(and(agentScope, notInArray(scheduledTasks.id, seenIds)));
|
||||||
} else {
|
} else {
|
||||||
await db.update(scheduledTasks).set({ isStale: true }).where(eq(scheduledTasks.serverId, serverId));
|
await db.update(scheduledTasks).set({ isStale: true }).where(agentScope);
|
||||||
}
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
|
|||||||
+19
-1
@@ -9,11 +9,14 @@ export interface ServerRecord {
|
|||||||
lastSeenAt: string | null;
|
lastSeenAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ScheduleType = "cron" | "systemd_timer" | "docker" | "manual";
|
||||||
|
|
||||||
export interface TaskRecord {
|
export interface TaskRecord {
|
||||||
id: number;
|
id: number;
|
||||||
serverId: number;
|
serverId: number;
|
||||||
serverName: string;
|
serverName: string;
|
||||||
scheduleType: "cron" | "systemd_timer";
|
scheduleType: ScheduleType;
|
||||||
|
origin: "agent" | "manual";
|
||||||
name: string;
|
name: string;
|
||||||
command: string | null;
|
command: string | null;
|
||||||
scheduleExpression: string | null;
|
scheduleExpression: string | null;
|
||||||
@@ -25,6 +28,16 @@ export interface TaskRecord {
|
|||||||
lastSeenAt: string;
|
lastSeenAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ManualTaskInput {
|
||||||
|
serverId: number;
|
||||||
|
scheduleType: ScheduleType;
|
||||||
|
name: string;
|
||||||
|
command?: string;
|
||||||
|
scheduleExpression?: string;
|
||||||
|
nextRunAt?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CurrentUser {
|
export interface CurrentUser {
|
||||||
sub: string;
|
sub: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
@@ -77,5 +90,10 @@ export const api = {
|
|||||||
const query = qs.toString();
|
const query = qs.toString();
|
||||||
return request<{ tasks: TaskRecord[] }>(`/api/tasks${query ? `?${query}` : ""}`);
|
return request<{ tasks: TaskRecord[] }>(`/api/tasks${query ? `?${query}` : ""}`);
|
||||||
},
|
},
|
||||||
|
create: (data: ManualTaskInput) =>
|
||||||
|
request<{ task: TaskRecord }>("/api/tasks", { method: "POST", body: JSON.stringify(data) }),
|
||||||
|
update: (id: number, data: Partial<Omit<ManualTaskInput, "serverId">>) =>
|
||||||
|
request<{ task: TaskRecord }>(`/api/tasks/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||||
|
remove: (id: number) => request<void>(`/api/tasks/${id}`, { method: "DELETE" }),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export default function FilterBar({ servers, filters, onChange }: Props) {
|
|||||||
<option value="">All schedule types</option>
|
<option value="">All schedule types</option>
|
||||||
<option value="cron">Cron</option>
|
<option value="cron">Cron</option>
|
||||||
<option value="systemd_timer">systemd timer</option>
|
<option value="systemd_timer">systemd timer</option>
|
||||||
|
<option value="docker">Docker</option>
|
||||||
|
<option value="manual">Other / manual</option>
|
||||||
</select>
|
</select>
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
|
|||||||
@@ -4,9 +4,18 @@ import TaskTable from "./TaskTable";
|
|||||||
const SCHEDULE_TYPE_LABELS: Record<string, string> = {
|
const SCHEDULE_TYPE_LABELS: Record<string, string> = {
|
||||||
cron: "Cron jobs",
|
cron: "Cron jobs",
|
||||||
systemd_timer: "systemd timers",
|
systemd_timer: "systemd timers",
|
||||||
|
docker: "Docker jobs",
|
||||||
|
manual: "Other",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ServerGroup({ serverName, tasks }: { serverName: string; tasks: TaskRecord[] }) {
|
interface Props {
|
||||||
|
serverName: string;
|
||||||
|
tasks: TaskRecord[];
|
||||||
|
onEdit?: (task: TaskRecord) => void;
|
||||||
|
onDelete?: (task: TaskRecord) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ServerGroup({ serverName, tasks, onEdit, onDelete }: Props) {
|
||||||
const byType = new Map<string, TaskRecord[]>();
|
const byType = new Map<string, TaskRecord[]>();
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
const list = byType.get(task.scheduleType) ?? [];
|
const list = byType.get(task.scheduleType) ?? [];
|
||||||
@@ -20,7 +29,7 @@ export default function ServerGroup({ serverName, tasks }: { serverName: string;
|
|||||||
{[...byType.entries()].map(([scheduleType, groupTasks]) => (
|
{[...byType.entries()].map(([scheduleType, groupTasks]) => (
|
||||||
<div key={scheduleType} className="schedule-type-group">
|
<div key={scheduleType} className="schedule-type-group">
|
||||||
<h3>{SCHEDULE_TYPE_LABELS[scheduleType] ?? scheduleType}</h3>
|
<h3>{SCHEDULE_TYPE_LABELS[scheduleType] ?? scheduleType}</h3>
|
||||||
<TaskTable tasks={groupTasks} />
|
<TaskTable tasks={groupTasks} onEdit={onEdit} onDelete={onDelete} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import type { ManualTaskInput, ScheduleType, ServerRecord, TaskRecord } from "../api/client";
|
||||||
|
|
||||||
|
const SCHEDULE_TYPE_OPTIONS: { value: ScheduleType; label: string }[] = [
|
||||||
|
{ value: "docker", label: "Docker" },
|
||||||
|
{ value: "cron", label: "Cron" },
|
||||||
|
{ value: "systemd_timer", label: "systemd timer" },
|
||||||
|
{ value: "manual", label: "Other / manual" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
servers: ServerRecord[];
|
||||||
|
task: TaskRecord | null;
|
||||||
|
defaultServerId?: number;
|
||||||
|
onSubmit: (data: ManualTaskInput) => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TaskForm({ servers, task, defaultServerId, onSubmit, onCancel }: Props) {
|
||||||
|
const [serverId, setServerId] = useState<number | "">(task?.serverId ?? defaultServerId ?? "");
|
||||||
|
const [scheduleType, setScheduleType] = useState<ScheduleType>(task?.scheduleType ?? "docker");
|
||||||
|
const [name, setName] = useState(task?.name ?? "");
|
||||||
|
const [command, setCommand] = useState(task?.command ?? "");
|
||||||
|
const [scheduleExpression, setScheduleExpression] = useState(task?.scheduleExpression ?? "");
|
||||||
|
const [enabled, setEnabled] = useState(task?.enabled ?? true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!serverId) {
|
||||||
|
setError("Choose a server.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await onSubmit({
|
||||||
|
serverId,
|
||||||
|
scheduleType,
|
||||||
|
name,
|
||||||
|
command: command || undefined,
|
||||||
|
scheduleExpression: scheduleExpression || undefined,
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err));
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="task-form" onSubmit={handleSubmit}>
|
||||||
|
<h3>{task ? "Edit task" : "Add a task manually"}</h3>
|
||||||
|
<div className="task-form-grid">
|
||||||
|
<label>
|
||||||
|
Server
|
||||||
|
<select
|
||||||
|
value={serverId}
|
||||||
|
onChange={(e) => setServerId(e.target.value ? Number(e.target.value) : "")}
|
||||||
|
disabled={Boolean(task)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Choose a server…</option>
|
||||||
|
{servers.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Schedule type
|
||||||
|
<select value={scheduleType} onChange={(e) => setScheduleType(e.target.value as ScheduleType)}>
|
||||||
|
{SCHEDULE_TYPE_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="span-2">
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
placeholder="e.g. postgres-backup"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="span-2">
|
||||||
|
Command / description
|
||||||
|
<input
|
||||||
|
placeholder="e.g. docker exec pg-backup /backup.sh"
|
||||||
|
value={command}
|
||||||
|
onChange={(e) => setCommand(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="span-2">
|
||||||
|
Schedule
|
||||||
|
<input
|
||||||
|
placeholder="e.g. daily at 02:00, or 0 2 * * *"
|
||||||
|
value={scheduleExpression}
|
||||||
|
onChange={(e) => setScheduleExpression(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="checkbox-label span-2">
|
||||||
|
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
<div className="task-form-actions">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||||
|
{task ? "Save changes" : "Add task"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn" onClick={onCancel} disabled={submitting}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,13 @@ function describeSchedule(task: TaskRecord): string {
|
|||||||
return task.scheduleExpression;
|
return task.scheduleExpression;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TaskTable({ tasks }: { tasks: TaskRecord[] }) {
|
interface Props {
|
||||||
|
tasks: TaskRecord[];
|
||||||
|
onEdit?: (task: TaskRecord) => void;
|
||||||
|
onDelete?: (task: TaskRecord) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TaskTable({ tasks, onEdit, onDelete }: Props) {
|
||||||
return (
|
return (
|
||||||
<table className="task-table">
|
<table className="task-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -23,6 +29,7 @@ export default function TaskTable({ tasks }: { tasks: TaskRecord[] }) {
|
|||||||
<th>Schedule</th>
|
<th>Schedule</th>
|
||||||
<th>Next run</th>
|
<th>Next run</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -43,6 +50,19 @@ export default function TaskTable({ tasks }: { tasks: TaskRecord[] }) {
|
|||||||
) : (
|
) : (
|
||||||
<span className="badge badge-disabled">Disabled</span>
|
<span className="badge badge-disabled">Disabled</span>
|
||||||
)}
|
)}
|
||||||
|
{task.origin === "manual" && <span className="badge badge-manual">Manual</span>}
|
||||||
|
</td>
|
||||||
|
<td className="actions">
|
||||||
|
{task.origin === "manual" && (
|
||||||
|
<>
|
||||||
|
<button className="btn btn-small" onClick={() => onEdit?.(task)}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-small btn-danger" onClick={() => onDelete?.(task)}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+81
-13
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { api, type ServerRecord, type TaskRecord } from "../api/client";
|
import { api, type ManualTaskInput, type ServerRecord, type TaskRecord } from "../api/client";
|
||||||
import FilterBar, { type Filters } from "../components/FilterBar";
|
import FilterBar, { type Filters } from "../components/FilterBar";
|
||||||
import ServerGroup from "../components/ServerGroup";
|
import ServerGroup from "../components/ServerGroup";
|
||||||
|
import TaskForm from "../components/TaskForm";
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [servers, setServers] = useState<ServerRecord[]>([]);
|
const [servers, setServers] = useState<ServerRecord[]>([]);
|
||||||
@@ -14,28 +15,37 @@ export default function Dashboard() {
|
|||||||
search: "",
|
search: "",
|
||||||
includeStale: false,
|
includeStale: false,
|
||||||
});
|
});
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [editingTask, setEditingTask] = useState<TaskRecord | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const loadServers = useCallback(() => {
|
||||||
api.servers
|
return api.servers
|
||||||
.list()
|
.list()
|
||||||
.then((res) => setServers(res.servers))
|
.then((res) => setServers(res.servers))
|
||||||
.catch((err) => setError(String(err)));
|
.catch((err) => setError(String(err)));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const loadTasks = useCallback(() => {
|
||||||
setLoading(true);
|
return api.tasks
|
||||||
const handle = setTimeout(() => {
|
|
||||||
api.tasks
|
|
||||||
.list(filters)
|
.list(filters)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setTasks(res.tasks);
|
setTasks(res.tasks);
|
||||||
setError(null);
|
setError(null);
|
||||||
})
|
})
|
||||||
.catch((err) => setError(String(err)))
|
.catch((err) => setError(String(err)));
|
||||||
.finally(() => setLoading(false));
|
}, [filters]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadServers();
|
||||||
|
}, [loadServers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
const handle = setTimeout(() => {
|
||||||
|
loadTasks().finally(() => setLoading(false));
|
||||||
}, 200);
|
}, 200);
|
||||||
return () => clearTimeout(handle);
|
return () => clearTimeout(handle);
|
||||||
}, [filters]);
|
}, [loadTasks]);
|
||||||
|
|
||||||
const groups = useMemo(() => {
|
const groups = useMemo(() => {
|
||||||
const byServer = new Map<number, { serverId: number; serverName: string; tasks: TaskRecord[] }>();
|
const byServer = new Map<number, { serverId: number; serverName: string; tasks: TaskRecord[] }>();
|
||||||
@@ -51,18 +61,76 @@ export default function Dashboard() {
|
|||||||
return [...byServer.values()];
|
return [...byServer.values()];
|
||||||
}, [tasks]);
|
}, [tasks]);
|
||||||
|
|
||||||
|
function openAddForm() {
|
||||||
|
setEditingTask(null);
|
||||||
|
setFormOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditForm(task: TaskRecord) {
|
||||||
|
setEditingTask(task);
|
||||||
|
setFormOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeForm() {
|
||||||
|
setFormOpen(false);
|
||||||
|
setEditingTask(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFormSubmit(data: ManualTaskInput) {
|
||||||
|
if (editingTask) {
|
||||||
|
await api.tasks.update(editingTask.id, data);
|
||||||
|
} else {
|
||||||
|
await api.tasks.create(data);
|
||||||
|
}
|
||||||
|
closeForm();
|
||||||
|
await loadTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(task: TaskRecord) {
|
||||||
|
if (!confirm(`Delete "${task.name}"?`)) return;
|
||||||
|
try {
|
||||||
|
await api.tasks.remove(task.id);
|
||||||
|
await loadTasks();
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard">
|
<div className="dashboard">
|
||||||
|
<div className="dashboard-toolbar">
|
||||||
<FilterBar servers={servers} filters={filters} onChange={setFilters} />
|
<FilterBar servers={servers} filters={filters} onChange={setFilters} />
|
||||||
|
<button className="btn btn-primary" onClick={openAddForm} disabled={servers.length === 0}>
|
||||||
|
Add task
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formOpen && (
|
||||||
|
<TaskForm
|
||||||
|
servers={servers}
|
||||||
|
task={editingTask}
|
||||||
|
defaultServerId={filters.serverId}
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
onCancel={closeForm}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && <div className="error-banner">{error}</div>}
|
{error && <div className="error-banner">{error}</div>}
|
||||||
{loading && <div className="loading-inline">Loading tasks…</div>}
|
{loading && <div className="loading-inline">Loading tasks…</div>}
|
||||||
{!loading && groups.length === 0 && (
|
{!loading && groups.length === 0 && (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
No scheduled tasks found yet. Add a server under "Servers" and install the agent to start reporting.
|
No scheduled tasks found yet. Install the agent on a server to auto-report cron/systemd tasks, or use
|
||||||
|
"Add task" above for things like Docker-based backup jobs.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
<ServerGroup key={group.serverId} serverName={group.serverName} tasks={group.tasks} />
|
<ServerGroup
|
||||||
|
key={group.serverId}
|
||||||
|
serverName={group.serverName}
|
||||||
|
tasks={group.tasks}
|
||||||
|
onEdit={openEditForm}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -146,6 +146,75 @@ main {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-toolbar .filter-bar {
|
||||||
|
margin-bottom: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-grid label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-grid label.span-2 {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-grid input,
|
||||||
|
.task-form-grid select {
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-grid .checkbox-label {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-manual {
|
||||||
|
background: var(--disabled-bg);
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
.server-group {
|
.server-group {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/filterbar.tsx","./src/components/servergroup.tsx","./src/components/tasktable.tsx","./src/pages/dashboard.tsx","./src/pages/login.tsx","./src/pages/serversadmin.tsx"],"version":"5.9.3"}
|
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/filterbar.tsx","./src/components/servergroup.tsx","./src/components/taskform.tsx","./src/components/tasktable.tsx","./src/pages/dashboard.tsx","./src/pages/login.tsx","./src/pages/serversadmin.tsx"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user