Add backup/update schedule types, copy buttons, cron next-run estimate; update dependencies

- New "backup" and "update" schedule types for manually tracked tasks,
  plumbed through the form, filters, group labels, and API validation.
- Copy buttons next to the agent token and install/uninstall commands
  on the Servers page, so they don't need to be selected by hand.
- Cron jobs now show an estimated "next run" (via cron-parser),
  computed client-side and clearly marked with "~" plus a tooltip,
  since the agent has no way to compute this for cron (unlike systemd
  timers, which report a real value from systemctl).
- Dependency updates: fixes a high-severity SQL injection advisory in
  drizzle-orm (0.38.4 -> 0.45.2, plus drizzle-kit 0.31.10), a qs
  vulnerability pinned past body-parser's own range via an npm
  "overrides" entry, a leftover vulnerable esbuild pulled in
  transitively by drizzle-kit (also via override), and cron-parser
  4 -> 5 (v4 is unmaintained; updated its call site for the new
  CronExpressionParser API). `npm audit` is now clean. Re-verified the
  DB layer end-to-end (migrations, CRUD, agent sync, stale-marking)
  and the cron estimate against the new cron-parser API after the
  upgrade.
This commit is contained in:
bobban committed 2026-09-02 23:51:21 +02:00
1 parent 329dcab936
commit 0f961e7377
15 files changed
+541 -1819

No files matched your search

+1
View File
@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"cron-parser": "^5.10.0",
"cronstrue": "^2.53.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+1 -1
View File
@@ -9,7 +9,7 @@ export interface ServerRecord {
lastSeenAt: string | null;
}
export type ScheduleType = "cron" | "systemd_timer" | "docker" | "manual";
export type ScheduleType = "cron" | "systemd_timer" | "docker" | "backup" | "update" | "manual";
export interface TaskRecord {
id: number;
+21
View File
@@ -0,0 +1,21 @@
import { useState } from "react";
export default function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
async function handleCopy() {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard API unavailable (e.g. insecure context) — nothing more we can do.
}
}
return (
<button type="button" className="btn btn-small copy-btn" onClick={handleCopy}>
{copied ? "Copied!" : "Copy"}
</button>
);
}
+2
View File
@@ -37,6 +37,8 @@ export default function FilterBar({ servers, filters, onChange }: Props) {
<option value="cron">Cron</option>
<option value="systemd_timer">systemd timer</option>
<option value="docker">Docker</option>
<option value="backup">Backup</option>
<option value="update">Update</option>
<option value="manual">Other / manual</option>
</select>
<input
+2
View File
@@ -5,6 +5,8 @@ const SCHEDULE_TYPE_LABELS: Record<string, string> = {
cron: "Cron jobs",
systemd_timer: "systemd timers",
docker: "Docker jobs",
backup: "Backups",
update: "Updates",
manual: "Other",
};
+2
View File
@@ -5,6 +5,8 @@ const SCHEDULE_TYPE_OPTIONS: { value: ScheduleType; label: string }[] = [
{ value: "docker", label: "Docker" },
{ value: "cron", label: "Cron" },
{ value: "systemd_timer", label: "systemd timer" },
{ value: "backup", label: "Backup" },
{ value: "update", label: "Update" },
{ value: "manual", label: "Other / manual" },
];
+75 -33
View File
@@ -1,4 +1,5 @@
import cronstrue from "cronstrue";
import { CronExpressionParser } from "cron-parser";
import type { TaskRecord } from "../api/client";
function describeSchedule(task: TaskRecord): string {
@@ -13,6 +14,27 @@ function describeSchedule(task: TaskRecord): string {
return task.scheduleExpression;
}
interface NextRun {
date: Date;
/** true when computed client-side from the cron expression rather than reported by the agent */
estimated: boolean;
}
function computeNextRun(task: TaskRecord): NextRun | null {
if (task.nextRunAt) {
return { date: new Date(task.nextRunAt), estimated: false };
}
if (task.scheduleType !== "cron" || !task.scheduleExpression || task.isStale || !task.enabled) {
return null;
}
try {
const date = CronExpressionParser.parse(task.scheduleExpression).next().toDate();
return { date, estimated: true };
} catch {
return null;
}
}
interface Props {
tasks: TaskRecord[];
onEdit?: (task: TaskRecord) => void;
@@ -33,39 +55,59 @@ export default function TaskTable({ tasks, onEdit, onDelete }: Props) {
</tr>
</thead>
<tbody>
{tasks.map((task) => (
<tr key={task.id} className={task.isStale ? "stale" : undefined}>
<td>{task.name}</td>
<td className="mono">{task.command ?? "—"}</td>
<td>
<div className="schedule-human">{describeSchedule(task)}</div>
{task.scheduleExpression && <span className="mono schedule-raw">{task.scheduleExpression}</span>}
</td>
<td>{task.nextRunAt ? new Date(task.nextRunAt).toLocaleString() : "—"}</td>
<td>
{task.isStale ? (
<span className="badge badge-stale">Missing</span>
) : task.enabled ? (
<span className="badge badge-ok">Enabled</span>
) : (
<span className="badge badge-disabled">Disabled</span>
)}
{task.origin === "manual" && <span className="badge badge-manual">Manual</span>}
</td>
<td className="actions">
{task.origin === "manual" && (
<>
<button className="btn btn-small" onClick={() => onEdit?.(task)}>
Edit
</button>
<button className="btn btn-small btn-danger" onClick={() => onDelete?.(task)}>
Delete
</button>
</>
)}
</td>
</tr>
))}
{tasks.map((task) => {
const nextRun = computeNextRun(task);
return (
<tr key={task.id} className={task.isStale ? "stale" : undefined}>
<td>{task.name}</td>
<td className="mono">{task.command ?? "—"}</td>
<td>
<div className="schedule-human">{describeSchedule(task)}</div>
{task.scheduleExpression && (
<span className="mono schedule-raw">{task.scheduleExpression}</span>
)}
</td>
<td>
{nextRun ? (
<span
title={
nextRun.estimated
? "Estimated from the cron expression in your browser's timezone — the server may run in a different timezone"
: undefined
}
>
{nextRun.estimated ? "~" : ""}
{nextRun.date.toLocaleString()}
</span>
) : (
"—"
)}
</td>
<td>
{task.isStale ? (
<span className="badge badge-stale">Missing</span>
) : task.enabled ? (
<span className="badge badge-ok">Enabled</span>
) : (
<span className="badge badge-disabled">Disabled</span>
)}
{task.origin === "manual" && <span className="badge badge-manual">Manual</span>}
</td>
<td className="actions">
{task.origin === "manual" && (
<>
<button className="btn btn-small" onClick={() => onEdit?.(task)}>
Edit
</button>
<button className="btn btn-small btn-danger" onClick={() => onDelete?.(task)}>
Delete
</button>
</>
)}
</td>
</tr>
);
})}
</tbody>
</table>
);
+21 -3
View File
@@ -1,5 +1,14 @@
import { useEffect, useState, type FormEvent } from "react";
import { api, type ServerRecord } from "../api/client";
import CopyButton from "../components/CopyButton";
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`;
}
function uninstallCommand(): string {
return `curl -fsSL ${window.location.origin}/agent/linux/uninstall.sh | sudo bash`;
}
export default function ServersAdmin() {
const [servers, setServers] = useState<ServerRecord[]>([]);
@@ -88,9 +97,15 @@ export default function ServersAdmin() {
<section className="token-reveal">
<h3>Agent token for {newToken.server.name}</h3>
<p>This token is only shown once — copy it now.</p>
<code className="token-value">{newToken.token}</code>
<div className="copyable-row">
<code className="token-value">{newToken.token}</code>
<CopyButton text={newToken.token} />
</div>
<p>Install the agent on the server (run as root — put sudo right after the pipe, not before curl):</p>
<pre className="install-command">{`curl -fsSL ${window.location.origin}/agent/linux/install.sh | sudo API_URL=${window.location.origin} API_TOKEN=${newToken.token} bash`}</pre>
<div className="copyable-row">
<pre className="install-command">{installCommand(newToken.token)}</pre>
<CopyButton text={installCommand(newToken.token)} />
</div>
<button onClick={() => setNewToken(null)} className="btn">
Dismiss
</button>
@@ -101,7 +116,10 @@ export default function ServersAdmin() {
<section className="token-reveal uninstall-reveal">
<h3>Uninstall agent from {uninstallFor.name}</h3>
<p>Run this on the server as root to stop and remove the agent (its entry here is kept):</p>
<pre className="install-command">{`curl -fsSL ${window.location.origin}/agent/linux/uninstall.sh | sudo bash`}</pre>
<div className="copyable-row">
<pre className="install-command">{uninstallCommand()}</pre>
<CopyButton text={uninstallCommand()} />
</div>
<button onClick={() => setUninstallFor(null)} className="btn">
Dismiss
</button>
+20
View File
@@ -398,6 +398,26 @@ tr.stale {
font-size: 0.8rem;
}
.copyable-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
margin: 0.5rem 0;
}
.copyable-row .token-value,
.copyable-row .install-command {
flex: 1;
min-width: 0;
margin: 0;
}
.copy-btn {
flex-shrink: 0;
margin-right: 0;
margin-top: 0.1rem;
}
.servers-admin section {
margin-bottom: 2rem;
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./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/serversadmin.tsx"],"version":"5.9.3"}