From 5269499520210ff6b9fedde11be06a3e23c795c2 Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Fri, 10 Jul 2026 00:12:34 +0200 Subject: [PATCH] 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. --- server/drizzle/0001_amazing_greymalkin.sql | 1 + server/drizzle/meta/0001_snapshot.json | 220 +++++++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/db/schema.ts | 3 +- server/src/routes/tasks.ts | 118 +++++++++-- server/src/services/taskSync.ts | 13 +- web/src/api/client.ts | 20 +- web/src/components/FilterBar.tsx | 2 + web/src/components/ServerGroup.tsx | 13 +- web/src/components/TaskForm.tsx | 124 ++++++++++++ web/src/components/TaskTable.tsx | 22 ++- web/src/pages/Dashboard.tsx | 100 ++++++++-- web/src/styles.css | 69 +++++++ web/tsconfig.tsbuildinfo | 2 +- 14 files changed, 671 insertions(+), 43 deletions(-) create mode 100644 server/drizzle/0001_amazing_greymalkin.sql create mode 100644 server/drizzle/meta/0001_snapshot.json create mode 100644 web/src/components/TaskForm.tsx diff --git a/server/drizzle/0001_amazing_greymalkin.sql b/server/drizzle/0001_amazing_greymalkin.sql new file mode 100644 index 0000000..28a61b8 --- /dev/null +++ b/server/drizzle/0001_amazing_greymalkin.sql @@ -0,0 +1 @@ +ALTER TABLE `scheduled_tasks` ADD `origin` text DEFAULT 'agent' NOT NULL; \ No newline at end of file diff --git a/server/drizzle/meta/0001_snapshot.json b/server/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..a630af9 --- /dev/null +++ b/server/drizzle/meta/0001_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 5c694ae..7f35389 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1783628241015, "tag": "0000_aspiring_blink", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1783634598855, + "tag": "0001_amazing_greymalkin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index c03090c..4be0239 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -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"), diff --git a/server/src/routes/tasks.ts b/server/src/routes/tasks.ts index 2ed54cc..bdac524 100644 --- a/server/src/routes/tasks.ts +++ b/server/src/routes/tasks.ts @@ -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(); +}); diff --git a/server/src/services/taskSync.ts b/server/src/services/taskSync.ts index 92e9851..bb5442b 100644 --- a/server/src/services/taskSync.ts +++ b/server/src/services/taskSync.ts @@ -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 diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c48ee5d..d73769a 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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>) => + request<{ task: TaskRecord }>(`/api/tasks/${id}`, { method: "PATCH", body: JSON.stringify(data) }), + remove: (id: number) => request(`/api/tasks/${id}`, { method: "DELETE" }), }, }; diff --git a/web/src/components/FilterBar.tsx b/web/src/components/FilterBar.tsx index e2c1269..55d4944 100644 --- a/web/src/components/FilterBar.tsx +++ b/web/src/components/FilterBar.tsx @@ -36,6 +36,8 @@ export default function FilterBar({ servers, filters, onChange }: Props) { + + = { 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(); 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]) => (

{SCHEDULE_TYPE_LABELS[scheduleType] ?? scheduleType}

- +
))} diff --git a/web/src/components/TaskForm.tsx b/web/src/components/TaskForm.tsx new file mode 100644 index 0000000..ae059f4 --- /dev/null +++ b/web/src/components/TaskForm.tsx @@ -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; + onCancel: () => void; +} + +export default function TaskForm({ servers, task, defaultServerId, onSubmit, onCancel }: Props) { + const [serverId, setServerId] = useState(task?.serverId ?? defaultServerId ?? ""); + const [scheduleType, setScheduleType] = useState(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(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 ( +
+

{task ? "Edit task" : "Add a task manually"}

+
+ + + + + + +
+ {error &&
{error}
} +
+ + +
+
+ ); +} diff --git a/web/src/components/TaskTable.tsx b/web/src/components/TaskTable.tsx index b90a48d..bd7499c 100644 --- a/web/src/components/TaskTable.tsx +++ b/web/src/components/TaskTable.tsx @@ -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 ( @@ -23,6 +29,7 @@ export default function TaskTable({ tasks }: { tasks: TaskRecord[] }) { + @@ -43,6 +50,19 @@ export default function TaskTable({ tasks }: { tasks: TaskRecord[] }) { ) : ( Disabled )} + {task.origin === "manual" && Manual} + + ))} diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 0ad8bdb..9d0300a 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -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([]); @@ -14,28 +15,37 @@ export default function Dashboard() { search: "", includeStale: false, }); + const [formOpen, setFormOpen] = useState(false); + const [editingTask, setEditingTask] = useState(null); - useEffect(() => { - api.servers + const loadServers = useCallback(() => { + return api.servers .list() .then((res) => setServers(res.servers)) .catch((err) => setError(String(err))); }, []); + const loadTasks = useCallback(() => { + return api.tasks + .list(filters) + .then((res) => { + setTasks(res.tasks); + setError(null); + }) + .catch((err) => setError(String(err))); + }, [filters]); + + useEffect(() => { + loadServers(); + }, [loadServers]); + useEffect(() => { setLoading(true); const handle = setTimeout(() => { - api.tasks - .list(filters) - .then((res) => { - setTasks(res.tasks); - setError(null); - }) - .catch((err) => setError(String(err))) - .finally(() => setLoading(false)); + loadTasks().finally(() => setLoading(false)); }, 200); return () => clearTimeout(handle); - }, [filters]); + }, [loadTasks]); const groups = useMemo(() => { const byServer = new Map(); @@ -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 (
- +
+ + +
+ + {formOpen && ( + + )} + {error &&
{error}
} {loading &&
Loading tasks…
} {!loading && groups.length === 0 && (
- 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.
)} {groups.map((group) => ( - + ))}
); diff --git a/web/src/styles.css b/web/src/styles.css index a965f23..ba50cf6 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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; } diff --git a/web/tsconfig.tsbuildinfo b/web/tsconfig.tsbuildinfo index fb8a0d2..c43b573 100644 --- a/web/tsconfig.tsbuildinfo +++ b/web/tsconfig.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file
Schedule Next run Status
+ {task.origin === "manual" && ( + <> + + + + )}