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
|
||||
|
||||
+19
-1
@@ -9,11 +9,14 @@ export interface ServerRecord {
|
||||
lastSeenAt: string | null;
|
||||
}
|
||||
|
||||
export type ScheduleType = "cron" | "systemd_timer" | "docker" | "manual";
|
||||
|
||||
export interface TaskRecord {
|
||||
id: number;
|
||||
serverId: number;
|
||||
serverName: string;
|
||||
scheduleType: "cron" | "systemd_timer";
|
||||
scheduleType: ScheduleType;
|
||||
origin: "agent" | "manual";
|
||||
name: string;
|
||||
command: string | null;
|
||||
scheduleExpression: string | null;
|
||||
@@ -25,6 +28,16 @@ export interface TaskRecord {
|
||||
lastSeenAt: string;
|
||||
}
|
||||
|
||||
export interface ManualTaskInput {
|
||||
serverId: number;
|
||||
scheduleType: ScheduleType;
|
||||
name: string;
|
||||
command?: string;
|
||||
scheduleExpression?: string;
|
||||
nextRunAt?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface CurrentUser {
|
||||
sub: string;
|
||||
email?: string;
|
||||
@@ -77,5 +90,10 @@ export const api = {
|
||||
const query = qs.toString();
|
||||
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="cron">Cron</option>
|
||||
<option value="systemd_timer">systemd timer</option>
|
||||
<option value="docker">Docker</option>
|
||||
<option value="manual">Other / manual</option>
|
||||
</select>
|
||||
<input
|
||||
type="search"
|
||||
|
||||
@@ -4,9 +4,18 @@ import TaskTable from "./TaskTable";
|
||||
const SCHEDULE_TYPE_LABELS: Record<string, string> = {
|
||||
cron: "Cron jobs",
|
||||
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[]>();
|
||||
for (const task of tasks) {
|
||||
const list = byType.get(task.scheduleType) ?? [];
|
||||
@@ -20,7 +29,7 @@ export default function ServerGroup({ serverName, tasks }: { serverName: string;
|
||||
{[...byType.entries()].map(([scheduleType, groupTasks]) => (
|
||||
<div key={scheduleType} className="schedule-type-group">
|
||||
<h3>{SCHEDULE_TYPE_LABELS[scheduleType] ?? scheduleType}</h3>
|
||||
<TaskTable tasks={groupTasks} />
|
||||
<TaskTable tasks={groupTasks} onEdit={onEdit} onDelete={onDelete} />
|
||||
</div>
|
||||
))}
|
||||
</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;
|
||||
}
|
||||
|
||||
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 (
|
||||
<table className="task-table">
|
||||
<thead>
|
||||
@@ -23,6 +29,7 @@ export default function TaskTable({ tasks }: { tasks: TaskRecord[] }) {
|
||||
<th>Schedule</th>
|
||||
<th>Next run</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -43,6 +50,19 @@ export default function TaskTable({ tasks }: { tasks: TaskRecord[] }) {
|
||||
) : (
|
||||
<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>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
+81
-13
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api, type ServerRecord, type TaskRecord } from "../api/client";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { api, type ManualTaskInput, type ServerRecord, type TaskRecord } from "../api/client";
|
||||
import FilterBar, { type Filters } from "../components/FilterBar";
|
||||
import ServerGroup from "../components/ServerGroup";
|
||||
import TaskForm from "../components/TaskForm";
|
||||
|
||||
export default function Dashboard() {
|
||||
const [servers, setServers] = useState<ServerRecord[]>([]);
|
||||
@@ -14,28 +15,37 @@ export default function Dashboard() {
|
||||
search: "",
|
||||
includeStale: false,
|
||||
});
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingTask, setEditingTask] = useState<TaskRecord | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.servers
|
||||
const loadServers = useCallback(() => {
|
||||
return api.servers
|
||||
.list()
|
||||
.then((res) => setServers(res.servers))
|
||||
.catch((err) => setError(String(err)));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const handle = setTimeout(() => {
|
||||
api.tasks
|
||||
const loadTasks = useCallback(() => {
|
||||
return api.tasks
|
||||
.list(filters)
|
||||
.then((res) => {
|
||||
setTasks(res.tasks);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err) => setError(String(err)))
|
||||
.finally(() => setLoading(false));
|
||||
.catch((err) => setError(String(err)));
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
loadServers();
|
||||
}, [loadServers]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const handle = setTimeout(() => {
|
||||
loadTasks().finally(() => setLoading(false));
|
||||
}, 200);
|
||||
return () => clearTimeout(handle);
|
||||
}, [filters]);
|
||||
}, [loadTasks]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const byServer = new Map<number, { serverId: number; serverName: string; tasks: TaskRecord[] }>();
|
||||
@@ -51,18 +61,76 @@ export default function Dashboard() {
|
||||
return [...byServer.values()];
|
||||
}, [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 (
|
||||
<div className="dashboard">
|
||||
<div className="dashboard-toolbar">
|
||||
<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>}
|
||||
{loading && <div className="loading-inline">Loading tasks…</div>}
|
||||
{!loading && groups.length === 0 && (
|
||||
<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>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -146,6 +146,75 @@ main {
|
||||
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 {
|
||||
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