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:
1 parent
0f961e7377
commit
8d5729822d
13 files changed
+141
-6
No files matched your search
@@ -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"),
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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 & Privacy</NavLink>
|
||||||
|
<span className="footer-credit">
|
||||||
|
By{" "}
|
||||||
|
<a href="https://bobbantech.com" target="_blank" rel="noopener noreferrer">
|
||||||
|
bobbantech
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
"—"
|
"—"
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
export default function Privacy() {
|
||||||
|
return (
|
||||||
|
<div className="privacy-page">
|
||||||
|
<h1>Data & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
{"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"}
|
||||||
Reference in new issue
Block a user