Add n8n workflow schedule type, Swedish date format, and Data & Privacy page

- New "n8n workflow" schedule type for manually tracked tasks, plumbed
  through the form, filters, group labels, and API validation, same as
  the earlier backup/update types.
- Dates (cron next-run, server last-seen) now render in Swedish
  standard format (YYYY-MM-DD, 24-hour clock) via a shared formatter
  instead of the browser's default locale.
- New Data & Privacy page describing what's stored, where, how long,
  and how to remove it, linked from a footer shown on every page
  alongside a "By bobbantech" credit linking to bobbantech.com.
This commit is contained in:
bobban committed 2026-09-04 20:27:05 +02:00
1 parent 0f961e7377
commit 8d5729822d
13 files changed
+141 -6

No files matched your search

+1 -1
View File
@@ -20,7 +20,7 @@ export const scheduledTasks = sqliteTable("scheduled_tasks", {
serverId: integer("server_id") serverId: integer("server_id")
.notNull() .notNull()
.references(() => servers.id, { onDelete: "cascade" }), .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 origin: text("origin").notNull().default("agent"), // 'agent' | 'manual' — manual rows are never touched by agent sync
name: text("name").notNull(), name: text("name").notNull(),
command: text("command"), command: text("command"),
+1 -1
View File
@@ -58,7 +58,7 @@ tasksRouter.get("/", async (req, res) => {
const manualTaskSchema = z.object({ const manualTaskSchema = z.object({
serverId: z.number().int(), 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), name: z.string().min(1).max(200),
command: z.string().max(1000).optional(), command: z.string().max(1000).optional(),
scheduleExpression: z.string().max(200).optional(), scheduleExpression: z.string().max(200).optional(),
+11
View File
@@ -4,6 +4,7 @@ import { api, type CurrentUser, UnauthorizedError } from "./api/client";
import Login from "./pages/Login"; import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard"; import Dashboard from "./pages/Dashboard";
import ServersAdmin from "./pages/ServersAdmin"; import ServersAdmin from "./pages/ServersAdmin";
import Privacy from "./pages/Privacy";
export default function App() { export default function App() {
const [user, setUser] = useState<CurrentUser | null | undefined>(undefined); const [user, setUser] = useState<CurrentUser | null | undefined>(undefined);
@@ -47,8 +48,18 @@ export default function App() {
<Routes> <Routes>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/servers" element={<ServersAdmin />} /> <Route path="/servers" element={<ServersAdmin />} />
<Route path="/privacy" element={<Privacy />} />
</Routes> </Routes>
</main> </main>
<footer className="app-footer">
<NavLink to="/privacy">Data &amp; Privacy</NavLink>
<span className="footer-credit">
By{" "}
<a href="https://bobbantech.com" target="_blank" rel="noopener noreferrer">
bobbantech
</a>
</span>
</footer>
</div> </div>
); );
} }
+8 -1
View File
@@ -9,7 +9,14 @@ export interface ServerRecord {
lastSeenAt: string | null; 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 { export interface TaskRecord {
id: number; id: number;
+1
View File
@@ -39,6 +39,7 @@ export default function FilterBar({ servers, filters, onChange }: Props) {
<option value="docker">Docker</option> <option value="docker">Docker</option>
<option value="backup">Backup</option> <option value="backup">Backup</option>
<option value="update">Update</option> <option value="update">Update</option>
<option value="n8n_workflow">n8n workflow</option>
<option value="manual">Other / manual</option> <option value="manual">Other / manual</option>
</select> </select>
<input <input
+1
View File
@@ -7,6 +7,7 @@ const SCHEDULE_TYPE_LABELS: Record<string, string> = {
docker: "Docker jobs", docker: "Docker jobs",
backup: "Backups", backup: "Backups",
update: "Updates", update: "Updates",
n8n_workflow: "n8n workflows",
manual: "Other", manual: "Other",
}; };
+1
View File
@@ -7,6 +7,7 @@ const SCHEDULE_TYPE_OPTIONS: { value: ScheduleType; label: string }[] = [
{ value: "systemd_timer", label: "systemd timer" }, { value: "systemd_timer", label: "systemd timer" },
{ value: "backup", label: "Backup" }, { value: "backup", label: "Backup" },
{ value: "update", label: "Update" }, { value: "update", label: "Update" },
{ value: "n8n_workflow", label: "n8n workflow" },
{ value: "manual", label: "Other / manual" }, { value: "manual", label: "Other / manual" },
]; ];
+2 -1
View File
@@ -1,6 +1,7 @@
import cronstrue from "cronstrue"; import cronstrue from "cronstrue";
import { CronExpressionParser } from "cron-parser"; import { CronExpressionParser } from "cron-parser";
import type { TaskRecord } from "../api/client"; import type { TaskRecord } from "../api/client";
import { formatDateTime } from "../utils/date";
function describeSchedule(task: TaskRecord): string { function describeSchedule(task: TaskRecord): string {
if (!task.scheduleExpression) return "—"; if (!task.scheduleExpression) return "—";
@@ -77,7 +78,7 @@ export default function TaskTable({ tasks, onEdit, onDelete }: Props) {
} }
> >
{nextRun.estimated ? "~" : ""} {nextRun.estimated ? "~" : ""}
{nextRun.date.toLocaleString()} {formatDateTime(nextRun.date)}
</span> </span>
) : ( ) : (
"—" "—"
+52
View File
@@ -0,0 +1,52 @@
export default function Privacy() {
return (
<div className="privacy-page">
<h1>Data &amp; Privacy</h1>
<p>
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.
</p>
<h2>Who you are</h2>
<p>
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.
</p>
<h2>What's stored</h2>
<ul>
<li>
<strong>Servers</strong> — name, hostname, and description you enter, plus a hashed copy
of each server's agent API token (the raw token is shown once at creation and never
stored).
</li>
<li>
<strong>Scheduled tasks</strong> — name, command, and schedule for each cron job, systemd
timer, or manually added task, and which server it belongs to.
</li>
<li>
<strong>Your session</strong> — the name, email, and account ID Authentik gives us, held
only in a session file, separate from the database above.
</li>
</ul>
<p>None of this is shared with any third party or leaves this server.</p>
<h2>How long it's kept</h2>
<p>
Servers and tasks are kept until you delete them from the <strong>Servers</strong> 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.
</p>
<h2>Removing your data</h2>
<p>
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.
</p>
</div>
);
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useState, type FormEvent } from "react"; import { useEffect, useState, type FormEvent } from "react";
import { api, type ServerRecord } from "../api/client"; import { api, type ServerRecord } from "../api/client";
import CopyButton from "../components/CopyButton"; import CopyButton from "../components/CopyButton";
import { formatDateTime } from "../utils/date";
function installCommand(token: string): string { 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`; 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() {
<td>{s.name}</td> <td>{s.name}</td>
<td>{s.hostname ?? "—"}</td> <td>{s.hostname ?? "—"}</td>
<td className="mono">{s.apiTokenPrefix}…</td> <td className="mono">{s.apiTokenPrefix}…</td>
<td>{s.lastSeenAt ? new Date(s.lastSeenAt).toLocaleString() : "never"}</td> <td>{s.lastSeenAt ? formatDateTime(new Date(s.lastSeenAt)) : "never"}</td>
<td className="actions"> <td className="actions">
<button onClick={() => handleRotate(s.id)} className="btn btn-small"> <button onClick={() => handleRotate(s.id)} className="btn btn-small">
Rotate token Rotate token
+48
View File
@@ -121,6 +121,54 @@ main {
padding: 1.5rem; padding: 1.5rem;
} }
.app-footer {
max-width: 1100px;
margin: 0 auto;
padding: 1rem 1.5rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.app-footer a {
color: var(--text-muted);
font-size: 0.85rem;
text-decoration: none;
}
.app-footer a:hover {
color: var(--accent);
}
.footer-credit {
color: var(--text-muted);
font-size: 0.85rem;
}
.privacy-page {
max-width: 640px;
}
.privacy-page h1 {
margin-top: 0;
}
.privacy-page h2 {
font-size: 1rem;
margin-top: 1.75rem;
margin-bottom: 0.5rem;
}
.privacy-page p,
.privacy-page li {
color: var(--text-muted);
line-height: 1.5;
}
.privacy-page ul {
padding-left: 1.25rem;
}
.filter-bar { .filter-bar {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
+12
View File
@@ -0,0 +1,12 @@
/** Formats a date as Swedish-standard "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,
});
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/copybutton.tsx","./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"} {"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/copybutton.tsx","./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/privacy.tsx","./src/pages/serversadmin.tsx","./src/utils/date.ts"],"version":"5.9.3"}