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

+11
View File
@@ -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<CurrentUser | null | undefined>(undefined);
@@ -47,8 +48,18 @@ export default function App() {
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/servers" element={<ServersAdmin />} />
<Route path="/privacy" element={<Privacy />} />
</Routes>
</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>
);
}
+8 -1
View File
@@ -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;
+1
View File
@@ -39,6 +39,7 @@ export default function FilterBar({ servers, filters, onChange }: Props) {
<option value="docker">Docker</option>
<option value="backup">Backup</option>
<option value="update">Update</option>
<option value="n8n_workflow">n8n workflow</option>
<option value="manual">Other / manual</option>
</select>
<input
+1
View File
@@ -7,6 +7,7 @@ const SCHEDULE_TYPE_LABELS: Record<string, string> = {
docker: "Docker jobs",
backup: "Backups",
update: "Updates",
n8n_workflow: "n8n workflows",
manual: "Other",
};
+1
View File
@@ -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" },
];
+2 -1
View File
@@ -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)}
</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 { 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() {
<td>{s.name}</td>
<td>{s.hostname ?? "—"}</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">
<button onClick={() => handleRotate(s.id)} className="btn btn-small">
Rotate token
+48
View File
@@ -121,6 +121,54 @@ main {
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 {
display: flex;
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"}