Add Servers & Tasks module ported from Schedule Task Manager
Ports cron/systemd task tracking across Debian/Raspbian servers, including the Linux push agent (install/report/uninstall scripts, rebranded from "schedule-task-manager-agent" to "homelab-manager-agent") and the manual-task-entry flow for things an agent can't see (Docker jobs, backups). The schema (servers/scheduled_tasks tables) was already in place from the foundation pass, so this is mostly a straight port of the original's services/routes. Deliberate change from the original: server/token management (which mints agent credentials) is now admin-only rather than open to any logged-in user, and manual task CRUD is gated to operator+ — consistent with how Secrets, IPAM, and DNS already split "configure credentials" from "everyday edits" across roles. All mutations are audit-logged. - server/src/services/tokens.ts, taskSync.ts: ported near-verbatim (agent token hashing, the agent-sync-marks-missing-as-stale-not-deleted logic). - server/src/routes/servers.ts, tasks.ts, agentReport.ts: same contract as the original (agent auth is a per-server bearer token, independent of the session-based requireAuth used everywhere else). - web: a single Servers & Tasks page (filter bar, task table grouped by server/schedule type, manual task form, and an admin-only server management panel with token reveal + copyable install/uninstall commands), replacing the original's two separate pages/apps. Verified: full build passes; a scripted HTTP test against a running server covers unauthenticated access, role gating at each tier (admin-only server mgmt, operator+ task mgmt), agent bearer-token auth (valid/invalid/rotated), manual-vs-agent task edit protection, stale-marking on re-sync, and cascade delete — 22/22 checks passing. Real agent installation on an actual Debian/Raspbian host still needs to be tried on the user's network. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ac3feb935d
commit
9712d611a6
17 files changed
+1616
-1
No files matched your search
+2
-1
@@ -8,6 +8,7 @@ import AuditLog from "./pages/AuditLog";
|
||||
import Secrets from "./pages/Secrets";
|
||||
import Ipam from "./pages/Ipam";
|
||||
import Dns from "./pages/Dns";
|
||||
import ServersTasks from "./pages/ServersTasks";
|
||||
import ComingSoon from "./pages/ComingSoon";
|
||||
import AppShell from "./layout/AppShell";
|
||||
|
||||
@@ -57,7 +58,7 @@ export default function App() {
|
||||
<AppShell user={user}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard user={user} />} />
|
||||
<Route path="/servers" element={<ComingSoon title="Servers & Tasks" />} />
|
||||
<Route path="/servers" element={<ServersTasks user={user} />} />
|
||||
<Route path="/dns" element={<Dns user={user} />} />
|
||||
<Route path="/ipam" element={<Ipam user={user} />} />
|
||||
<Route path="/secrets" element={<Secrets user={user} />} />
|
||||
|
||||
@@ -120,6 +120,46 @@ export interface DnsRecordInput {
|
||||
proxied?: boolean;
|
||||
}
|
||||
|
||||
export interface ServerRecord {
|
||||
id: number;
|
||||
name: string;
|
||||
hostname: string | null;
|
||||
osType: string;
|
||||
description: string | null;
|
||||
apiTokenPrefix: string;
|
||||
createdAt: string;
|
||||
lastSeenAt: string | null;
|
||||
}
|
||||
|
||||
export type ScheduleType = "cron" | "systemd_timer" | "docker" | "backup" | "update" | "n8n_workflow" | "manual";
|
||||
|
||||
export interface TaskRecord {
|
||||
id: number;
|
||||
serverId: number;
|
||||
serverName: string;
|
||||
scheduleType: ScheduleType;
|
||||
origin: "agent" | "manual";
|
||||
name: string;
|
||||
command: string | null;
|
||||
scheduleExpression: string | null;
|
||||
source: string | null;
|
||||
enabled: boolean;
|
||||
nextRunAt: string | null;
|
||||
isStale: boolean;
|
||||
firstSeenAt: string;
|
||||
lastSeenAt: string;
|
||||
}
|
||||
|
||||
export interface ManualTaskInput {
|
||||
serverId: number;
|
||||
scheduleType: ScheduleType;
|
||||
name: string;
|
||||
command?: string;
|
||||
scheduleExpression?: string;
|
||||
nextRunAt?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends Error {}
|
||||
export class ForbiddenError extends Error {}
|
||||
|
||||
@@ -227,4 +267,35 @@ export const api = {
|
||||
),
|
||||
},
|
||||
},
|
||||
servers: {
|
||||
list: () => request<{ servers: ServerRecord[] }>("/api/servers"),
|
||||
create: (data: { name: string; hostname?: string; description?: string }) =>
|
||||
request<{ server: ServerRecord; token: string }>("/api/servers", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
rotateToken: (id: number) =>
|
||||
request<{ server: ServerRecord; token: string }>(`/api/servers/${id}/rotate-token`, {
|
||||
method: "POST",
|
||||
}),
|
||||
remove: (id: number) => request<void>(`/api/servers/${id}`, { method: "DELETE" }),
|
||||
},
|
||||
tasks: {
|
||||
list: (
|
||||
params: { serverId?: number; scheduleType?: string; search?: string; includeStale?: boolean } = {},
|
||||
) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.serverId) qs.set("serverId", String(params.serverId));
|
||||
if (params.scheduleType) qs.set("scheduleType", params.scheduleType);
|
||||
if (params.search) qs.set("search", params.search);
|
||||
if (params.includeStale) qs.set("includeStale", "true");
|
||||
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" }),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useState } from "react";
|
||||
|
||||
export default function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// Clipboard API unavailable (e.g. insecure context) — nothing more we can do.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" className="btn btn-sm btn-outline-secondary" onClick={handleCopy}>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import cronstrue from "cronstrue";
|
||||
import { CronExpressionParser } from "cron-parser";
|
||||
import {
|
||||
api,
|
||||
type CurrentUser,
|
||||
type ManualTaskInput,
|
||||
type ScheduleType,
|
||||
type ServerRecord,
|
||||
type TaskRecord,
|
||||
} from "../api/client";
|
||||
import CopyButton from "../components/CopyButton";
|
||||
import { formatDateTime } from "../utils/date";
|
||||
|
||||
const SCHEDULE_TYPE_LABELS: Record<string, string> = {
|
||||
cron: "Cron jobs",
|
||||
systemd_timer: "systemd timers",
|
||||
docker: "Docker jobs",
|
||||
backup: "Backups",
|
||||
update: "Updates",
|
||||
n8n_workflow: "n8n workflows",
|
||||
manual: "Other",
|
||||
};
|
||||
|
||||
const SCHEDULE_TYPE_OPTIONS: { value: ScheduleType; label: string }[] = [
|
||||
{ value: "docker", label: "Docker" },
|
||||
{ value: "cron", label: "Cron" },
|
||||
{ value: "systemd_timer", label: "systemd timer" },
|
||||
{ value: "backup", label: "Backup" },
|
||||
{ value: "update", label: "Update" },
|
||||
{ value: "n8n_workflow", label: "n8n workflow" },
|
||||
{ value: "manual", label: "Other / manual" },
|
||||
];
|
||||
|
||||
function installCommand(token: string): string {
|
||||
return `curl -fsSL ${window.location.origin}/agent/linux/install.sh | sudo API_URL=${window.location.origin} API_TOKEN=${token} bash`;
|
||||
}
|
||||
|
||||
function uninstallCommand(): string {
|
||||
return `curl -fsSL ${window.location.origin}/agent/linux/uninstall.sh | sudo bash`;
|
||||
}
|
||||
|
||||
function describeSchedule(task: TaskRecord): string {
|
||||
if (!task.scheduleExpression) return "—";
|
||||
if (task.scheduleType === "cron") {
|
||||
try {
|
||||
return cronstrue.toString(task.scheduleExpression, { verbose: false });
|
||||
} catch {
|
||||
return task.scheduleExpression;
|
||||
}
|
||||
}
|
||||
return task.scheduleExpression;
|
||||
}
|
||||
|
||||
interface NextRun {
|
||||
date: Date;
|
||||
estimated: boolean;
|
||||
}
|
||||
|
||||
function computeNextRun(task: TaskRecord): NextRun | null {
|
||||
if (task.nextRunAt) return { date: new Date(task.nextRunAt), estimated: false };
|
||||
if (task.scheduleType !== "cron" || !task.scheduleExpression || task.isStale || !task.enabled) return null;
|
||||
try {
|
||||
const date = CronExpressionParser.parse(task.scheduleExpression).next().toDate();
|
||||
return { date, estimated: true };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const emptyTaskForm = {
|
||||
scheduleType: "docker" as ScheduleType,
|
||||
name: "",
|
||||
command: "",
|
||||
scheduleExpression: "",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
export default function ServersTasks({ user }: { user: CurrentUser }) {
|
||||
const isAdmin = user.role === "admin";
|
||||
const canEditTasks = user.role === "admin" || user.role === "operator";
|
||||
|
||||
const [servers, setServers] = useState<ServerRecord[]>([]);
|
||||
const [tasks, setTasks] = useState<TaskRecord[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [managingServers, setManagingServers] = useState(false);
|
||||
|
||||
const [filters, setFilters] = useState({
|
||||
serverId: undefined as number | undefined,
|
||||
scheduleType: undefined as string | undefined,
|
||||
search: "",
|
||||
includeStale: false,
|
||||
});
|
||||
|
||||
const [taskForm, setTaskForm] = useState<typeof emptyTaskForm | null>(null);
|
||||
const [editingTask, setEditingTask] = useState<TaskRecord | null>(null);
|
||||
const [newTaskServerId, setNewTaskServerId] = useState<number | undefined>(undefined);
|
||||
const [savingTask, setSavingTask] = useState(false);
|
||||
|
||||
const [serverName, setServerName] = useState("");
|
||||
const [serverHostname, setServerHostname] = useState("");
|
||||
const [serverDescription, setServerDescription] = useState("");
|
||||
const [newToken, setNewToken] = useState<{ server: ServerRecord; token: string } | null>(null);
|
||||
const [uninstallFor, setUninstallFor] = useState<ServerRecord | null>(null);
|
||||
|
||||
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))
|
||||
.catch((err) => setError(String(err)));
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
loadServers();
|
||||
}, [loadServers]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(loadTasks, 200);
|
||||
return () => clearTimeout(handle);
|
||||
}, [loadTasks]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const byServer = new Map<number, { serverId: number; serverName: string; tasks: TaskRecord[] }>();
|
||||
for (const task of tasks ?? []) {
|
||||
const entry = byServer.get(task.serverId) ?? { serverId: task.serverId, serverName: task.serverName, tasks: [] };
|
||||
entry.tasks.push(task);
|
||||
byServer.set(task.serverId, entry);
|
||||
}
|
||||
return [...byServer.values()];
|
||||
}, [tasks]);
|
||||
|
||||
function openAddTask() {
|
||||
setEditingTask(null);
|
||||
setNewTaskServerId(filters.serverId ?? servers[0]?.id);
|
||||
setTaskForm({ ...emptyTaskForm });
|
||||
}
|
||||
|
||||
function openEditTask(task: TaskRecord) {
|
||||
setEditingTask(task);
|
||||
setTaskForm({
|
||||
scheduleType: task.scheduleType,
|
||||
name: task.name,
|
||||
command: task.command ?? "",
|
||||
scheduleExpression: task.scheduleExpression ?? "",
|
||||
enabled: task.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
async function submitTask(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!taskForm) return;
|
||||
setSavingTask(true);
|
||||
setError(null);
|
||||
try {
|
||||
const serverId = editingTask?.serverId ?? newTaskServerId;
|
||||
if (!serverId) {
|
||||
setError("Choose a server first.");
|
||||
return;
|
||||
}
|
||||
const data: ManualTaskInput = {
|
||||
serverId,
|
||||
scheduleType: taskForm.scheduleType,
|
||||
name: taskForm.name,
|
||||
command: taskForm.command || undefined,
|
||||
scheduleExpression: taskForm.scheduleExpression || undefined,
|
||||
enabled: taskForm.enabled,
|
||||
};
|
||||
if (editingTask) {
|
||||
await api.tasks.update(editingTask.id, data);
|
||||
} else {
|
||||
await api.tasks.create(data);
|
||||
}
|
||||
setTaskForm(null);
|
||||
setEditingTask(null);
|
||||
await loadTasks();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSavingTask(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTask(task: TaskRecord) {
|
||||
if (!confirm(`Delete "${task.name}"?`)) return;
|
||||
try {
|
||||
await api.tasks.remove(task.id);
|
||||
await loadTasks();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function createServer(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api.servers.create({
|
||||
name: serverName,
|
||||
hostname: serverHostname || undefined,
|
||||
description: serverDescription || undefined,
|
||||
});
|
||||
setNewToken(result);
|
||||
setServerName("");
|
||||
setServerHostname("");
|
||||
setServerDescription("");
|
||||
await loadServers();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateToken(id: number) {
|
||||
try {
|
||||
const result = await api.servers.rotateToken(id);
|
||||
setNewToken(result);
|
||||
await loadServers();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteServer(id: number) {
|
||||
if (!confirm("Remove this server and all its tracked tasks?")) return;
|
||||
try {
|
||||
await api.servers.remove(id);
|
||||
await loadServers();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="d-flex align-items-center mb-3">
|
||||
<h2 className="page-title mb-0">Servers & Tasks</h2>
|
||||
{isAdmin && (
|
||||
<button className="btn btn-outline-secondary ms-auto" onClick={() => setManagingServers((v) => !v)}>
|
||||
{managingServers ? "Back to tasks" : "Manage servers"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
|
||||
{managingServers ? (
|
||||
<>
|
||||
<div className="card mb-3">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Add a server</h3>
|
||||
</div>
|
||||
<form onSubmit={createServer}>
|
||||
<div className="card-body row g-3">
|
||||
<div className="col-md-4">
|
||||
<label className="form-label">Server name</label>
|
||||
<input
|
||||
className="form-control"
|
||||
required
|
||||
placeholder="e.g. nas01"
|
||||
value={serverName}
|
||||
onChange={(e) => setServerName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<label className="form-label">Hostname</label>
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder="optional"
|
||||
value={serverHostname}
|
||||
onChange={(e) => setServerHostname(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<label className="form-label">Description</label>
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder="optional"
|
||||
value={serverDescription}
|
||||
onChange={(e) => setServerDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-footer">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Add server
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{newToken && (
|
||||
<div className="card mb-3">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Agent token for {newToken.server.name}</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<p className="text-secondary">This token is only shown once — copy it now.</p>
|
||||
<div className="input-group mb-3">
|
||||
<code className="form-control">{newToken.token}</code>
|
||||
<CopyButton text={newToken.token} />
|
||||
</div>
|
||||
<p className="text-secondary">
|
||||
Install the agent on the server (run as root — put sudo right after the pipe, not before curl):
|
||||
</p>
|
||||
<div className="input-group">
|
||||
<pre className="form-control text-wrap mb-0">{installCommand(newToken.token)}</pre>
|
||||
<CopyButton text={installCommand(newToken.token)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-footer">
|
||||
<button className="btn" onClick={() => setNewToken(null)}>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uninstallFor && (
|
||||
<div className="card mb-3">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Uninstall agent from {uninstallFor.name}</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<p className="text-secondary">Run this on the server as root to stop and remove the agent (its entry here is kept):</p>
|
||||
<div className="input-group">
|
||||
<pre className="form-control text-wrap mb-0">{uninstallCommand()}</pre>
|
||||
<CopyButton text={uninstallCommand()} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-footer">
|
||||
<button className="btn" onClick={() => setUninstallFor(null)}>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Hostname</th>
|
||||
<th>Token</th>
|
||||
<th>Last seen</th>
|
||||
<th className="w-1">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{servers.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td>{s.name}</td>
|
||||
<td>{s.hostname ?? "—"}</td>
|
||||
<td className="text-secondary">{s.apiTokenPrefix}…</td>
|
||||
<td>{s.lastSeenAt ? formatDateTime(new Date(s.lastSeenAt)) : "never"}</td>
|
||||
<td>
|
||||
<div className="btn-list flex-nowrap">
|
||||
<button className="btn btn-sm" onClick={() => rotateToken(s.id)}>
|
||||
Rotate token
|
||||
</button>
|
||||
<button className="btn btn-sm" onClick={() => setUninstallFor(s)}>
|
||||
Uninstall
|
||||
</button>
|
||||
<button className="btn btn-sm btn-outline-danger" onClick={() => deleteServer(s.id)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{servers.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-secondary text-center">
|
||||
No servers registered yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="card mb-3">
|
||||
<div className="card-body">
|
||||
<div className="row g-2 align-items-end">
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">Server</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={filters.serverId ?? ""}
|
||||
onChange={(e) => setFilters({ ...filters, serverId: e.target.value ? Number(e.target.value) : undefined })}
|
||||
>
|
||||
<option value="">All servers</option>
|
||||
{servers.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">Schedule type</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={filters.scheduleType ?? ""}
|
||||
onChange={(e) => setFilters({ ...filters, scheduleType: e.target.value || undefined })}
|
||||
>
|
||||
<option value="">All schedule types</option>
|
||||
{SCHEDULE_TYPE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">Search</label>
|
||||
<input
|
||||
type="search"
|
||||
className="form-control"
|
||||
placeholder="Name or command…"
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-3 d-flex align-items-center gap-3">
|
||||
<label className="form-check mb-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
checked={filters.includeStale}
|
||||
onChange={(e) => setFilters({ ...filters, includeStale: e.target.checked })}
|
||||
/>
|
||||
<span className="form-check-label">Show stale/missing</span>
|
||||
</label>
|
||||
{canEditTasks && (
|
||||
<button className="btn btn-primary ms-auto" onClick={openAddTask} disabled={servers.length === 0}>
|
||||
Add task
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{taskForm && (
|
||||
<div className="card mb-3">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">{editingTask ? "Edit task" : "Add a task manually"}</h3>
|
||||
</div>
|
||||
<form onSubmit={submitTask}>
|
||||
<div className="card-body row g-3">
|
||||
{!editingTask && (
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">Server</label>
|
||||
<select
|
||||
className="form-select"
|
||||
required
|
||||
value={newTaskServerId ?? ""}
|
||||
onChange={(e) => setNewTaskServerId(e.target.value ? Number(e.target.value) : undefined)}
|
||||
>
|
||||
<option value="">Choose a server…</option>
|
||||
{servers.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">Schedule type</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={taskForm.scheduleType}
|
||||
onChange={(e) => setTaskForm({ ...taskForm, scheduleType: e.target.value as ScheduleType })}
|
||||
>
|
||||
{SCHEDULE_TYPE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label className="form-label">Name</label>
|
||||
<input
|
||||
className="form-control"
|
||||
required
|
||||
placeholder="e.g. postgres-backup"
|
||||
value={taskForm.name}
|
||||
onChange={(e) => setTaskForm({ ...taskForm, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label className="form-label">Command / description</label>
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder="e.g. docker exec pg-backup /backup.sh"
|
||||
value={taskForm.command}
|
||||
onChange={(e) => setTaskForm({ ...taskForm, command: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label className="form-label">Schedule</label>
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder="e.g. daily at 02:00, or 0 2 * * *"
|
||||
value={taskForm.scheduleExpression}
|
||||
onChange={(e) => setTaskForm({ ...taskForm, scheduleExpression: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
checked={taskForm.enabled}
|
||||
onChange={(e) => setTaskForm({ ...taskForm, enabled: e.target.checked })}
|
||||
/>
|
||||
<span className="form-check-label">Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-footer d-flex gap-2">
|
||||
<button type="submit" className="btn btn-primary" disabled={savingTask}>
|
||||
{editingTask ? "Save changes" : "Add task"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
setTaskForm(null);
|
||||
setEditingTask(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tasks && groups.length === 0 && (
|
||||
<div className="card">
|
||||
<div className="card-body text-secondary">
|
||||
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) => {
|
||||
const byType = new Map<string, TaskRecord[]>();
|
||||
for (const task of group.tasks) {
|
||||
const list = byType.get(task.scheduleType) ?? [];
|
||||
list.push(task);
|
||||
byType.set(task.scheduleType, list);
|
||||
}
|
||||
return (
|
||||
<div className="card mb-3" key={group.serverId}>
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">{group.serverName}</h3>
|
||||
</div>
|
||||
{[...byType.entries()].map(([scheduleType, groupTasks]) => (
|
||||
<div key={scheduleType}>
|
||||
<div className="card-body py-2 bg-secondary-lt">
|
||||
<strong>{SCHEDULE_TYPE_LABELS[scheduleType] ?? scheduleType}</strong>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Command</th>
|
||||
<th>Schedule</th>
|
||||
<th>Next run</th>
|
||||
<th>Status</th>
|
||||
{canEditTasks && <th className="w-1">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groupTasks.map((task) => {
|
||||
const nextRun = computeNextRun(task);
|
||||
return (
|
||||
<tr key={task.id}>
|
||||
<td>{task.name}</td>
|
||||
<td className="text-secondary">{task.command ?? "—"}</td>
|
||||
<td>
|
||||
<div>{describeSchedule(task)}</div>
|
||||
{task.scheduleExpression && (
|
||||
<span className="text-secondary small">{task.scheduleExpression}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{nextRun ? (
|
||||
<span
|
||||
title={
|
||||
nextRun.estimated
|
||||
? "Estimated from the cron expression in your browser's timezone — the server may run in a different timezone"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{nextRun.estimated ? "~" : ""}
|
||||
{formatDateTime(nextRun.date)}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{task.isStale ? (
|
||||
<span className="badge bg-red-lt text-red me-1">Missing</span>
|
||||
) : task.enabled ? (
|
||||
<span className="badge bg-green-lt text-green me-1">Enabled</span>
|
||||
) : (
|
||||
<span className="badge bg-secondary-lt text-secondary me-1">Disabled</span>
|
||||
)}
|
||||
{task.origin === "manual" && <span className="badge bg-blue-lt">Manual</span>}
|
||||
</td>
|
||||
{canEditTasks && (
|
||||
<td>
|
||||
{task.origin === "manual" && (
|
||||
<div className="btn-list flex-nowrap">
|
||||
<button className="btn btn-sm" onClick={() => openEditTask(task)}>
|
||||
Edit
|
||||
</button>
|
||||
<button className="btn btn-sm btn-outline-danger" onClick={() => deleteTask(task)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Formats a date as "YYYY-MM-DD HH:mm:ss" on a 24-hour clock. */
|
||||
export function formatDateTime(date: Date): string {
|
||||
return date.toLocaleString("sv-SE", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user