diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 34999b9..8c6cb59 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -20,7 +20,7 @@ export const scheduledTasks = sqliteTable("scheduled_tasks", { serverId: integer("server_id") .notNull() .references(() => servers.id, { onDelete: "cascade" }), - scheduleType: text("schedule_type").notNull(), // 'cron' | 'systemd_timer' | 'docker' | 'backup' | 'update' | 'manual' + scheduleType: text("schedule_type").notNull(), // 'cron' | 'systemd_timer' | 'docker' | 'backup' | 'update' | 'n8n_workflow' | 'manual' origin: text("origin").notNull().default("agent"), // 'agent' | 'manual' — manual rows are never touched by agent sync name: text("name").notNull(), command: text("command"), diff --git a/server/src/routes/tasks.ts b/server/src/routes/tasks.ts index 4e3bc29..f7c4f5b 100644 --- a/server/src/routes/tasks.ts +++ b/server/src/routes/tasks.ts @@ -58,7 +58,7 @@ tasksRouter.get("/", async (req, res) => { const manualTaskSchema = z.object({ serverId: z.number().int(), - scheduleType: z.enum(["cron", "systemd_timer", "docker", "backup", "update", "manual"]), + scheduleType: z.enum(["cron", "systemd_timer", "docker", "backup", "update", "n8n_workflow", "manual"]), name: z.string().min(1).max(200), command: z.string().max(1000).optional(), scheduleExpression: z.string().max(200).optional(), diff --git a/web/src/App.tsx b/web/src/App.tsx index 27b2b7a..7244ad8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4,6 +4,7 @@ import { api, type CurrentUser, UnauthorizedError } from "./api/client"; import Login from "./pages/Login"; import Dashboard from "./pages/Dashboard"; import ServersAdmin from "./pages/ServersAdmin"; +import Privacy from "./pages/Privacy"; export default function App() { const [user, setUser] = useState(undefined); @@ -47,8 +48,18 @@ export default function App() { } /> } /> + } /> + ); } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 6ba3bb0..554e94f 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -9,7 +9,14 @@ export interface ServerRecord { lastSeenAt: string | null; } -export type ScheduleType = "cron" | "systemd_timer" | "docker" | "backup" | "update" | "manual"; +export type ScheduleType = + | "cron" + | "systemd_timer" + | "docker" + | "backup" + | "update" + | "n8n_workflow" + | "manual"; export interface TaskRecord { id: number; diff --git a/web/src/components/FilterBar.tsx b/web/src/components/FilterBar.tsx index b2ebf4e..74cfe3f 100644 --- a/web/src/components/FilterBar.tsx +++ b/web/src/components/FilterBar.tsx @@ -39,6 +39,7 @@ export default function FilterBar({ servers, filters, onChange }: Props) { + = { docker: "Docker jobs", backup: "Backups", update: "Updates", + n8n_workflow: "n8n workflows", manual: "Other", }; diff --git a/web/src/components/TaskForm.tsx b/web/src/components/TaskForm.tsx index 0c82d70..4626a09 100644 --- a/web/src/components/TaskForm.tsx +++ b/web/src/components/TaskForm.tsx @@ -7,6 +7,7 @@ const SCHEDULE_TYPE_OPTIONS: { value: ScheduleType; label: string }[] = [ { 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" }, ]; diff --git a/web/src/components/TaskTable.tsx b/web/src/components/TaskTable.tsx index 21484df..db831fa 100644 --- a/web/src/components/TaskTable.tsx +++ b/web/src/components/TaskTable.tsx @@ -1,6 +1,7 @@ import cronstrue from "cronstrue"; import { CronExpressionParser } from "cron-parser"; import type { TaskRecord } from "../api/client"; +import { formatDateTime } from "../utils/date"; function describeSchedule(task: TaskRecord): string { if (!task.scheduleExpression) return "—"; @@ -77,7 +78,7 @@ export default function TaskTable({ tasks, onEdit, onDelete }: Props) { } > {nextRun.estimated ? "~" : ""} - {nextRun.date.toLocaleString()} + {formatDateTime(nextRun.date)} ) : ( "—" diff --git a/web/src/pages/Privacy.tsx b/web/src/pages/Privacy.tsx new file mode 100644 index 0000000..64c55af --- /dev/null +++ b/web/src/pages/Privacy.tsx @@ -0,0 +1,52 @@ +export default function Privacy() { + return ( +
+

Data & Privacy

+

+ This is a self-hosted tool with no accounts, tracking, or analytics of its own. Everything + below is stored on this server and nowhere else. +

+ +

Who you are

+

+ Sign-in is handled entirely by your Authentik instance. This app has no password or user + database of its own — it only receives your name, email, and account ID from Authentik at + login, and keeps that in your session for as long as you're signed in. +

+ +

What's stored

+ +

None of this is shared with any third party or leaves this server.

+ +

How long it's kept

+

+ Servers and tasks are kept until you delete them from the Servers page or + the Dashboard. A task that stops being reported by its agent is marked "stale" rather than + deleted, so a temporary agent failure doesn't erase history — you can remove it manually at + any time. Your session expires automatically after 7 days of being issued, or immediately + when you sign out. +

+ +

Removing your data

+

+ Deleting a server also deletes every task recorded under it. Signing out ends your session + immediately. Since there's no separate user account to close, that's the entirety of what + can be removed. +

+
+ ); +} diff --git a/web/src/pages/ServersAdmin.tsx b/web/src/pages/ServersAdmin.tsx index efa5514..2b46dba 100644 --- a/web/src/pages/ServersAdmin.tsx +++ b/web/src/pages/ServersAdmin.tsx @@ -1,6 +1,7 @@ import { useEffect, useState, type FormEvent } from "react"; import { api, type ServerRecord } from "../api/client"; import CopyButton from "../components/CopyButton"; +import { formatDateTime } from "../utils/date"; 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`; @@ -144,7 +145,7 @@ export default function ServersAdmin() { {s.name} {s.hostname ?? "—"} {s.apiTokenPrefix}… - {s.lastSeenAt ? new Date(s.lastSeenAt).toLocaleString() : "never"} + {s.lastSeenAt ? formatDateTime(new Date(s.lastSeenAt)) : "never"}