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:
bobbanandClaude Sonnet 5 committed 2026-09-14 23:16:58 +02:00
1 parent ac3feb935d
commit 9712d611a6
17 files changed
+1616 -1

No files matched your search

+71
View File
@@ -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" }),
},
};