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:
+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>
|
||||
))}
|
||||
|
||||
+84
-16
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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