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:
2026-07-10 00:12:34 +02:00
parent fac491134d
commit 5269499520
14 changed files with 671 additions and 43 deletions
+19 -1
View File
@@ -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" }),
},
};
+2
View File
@@ -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"
+11 -2
View File
@@ -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>
+124
View File
@@ -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>
);
}
+21 -1
View File
@@ -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>
))}
+84 -16
View File
@@ -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)));
}, []);
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<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">
<FilterBar servers={servers} filters={filters} onChange={setFilters} />
<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>
);
+69
View File
@@ -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
View File
@@ -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"}