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:
Generated
+387
-1777
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,10 @@
|
|||||||
"server",
|
"server",
|
||||||
"web"
|
"web"
|
||||||
],
|
],
|
||||||
|
"overrides": {
|
||||||
|
"qs": "^6.16.0",
|
||||||
|
"esbuild": "^0.25.0"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:server": "npm run dev --workspace server",
|
"dev:server": "npm run dev --workspace server",
|
||||||
"dev:web": "npm run dev --workspace web",
|
"dev:web": "npm run dev --workspace web",
|
||||||
|
|||||||
+2
-2
@@ -12,7 +12,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@libsql/client": "^0.14.0",
|
"@libsql/client": "^0.14.0",
|
||||||
"drizzle-orm": "^0.38.4",
|
"drizzle-orm": "^0.45.2",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
"openid-client": "^6.1.7",
|
"openid-client": "^6.1.7",
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"@types/express-session": "^1.18.1",
|
"@types/express-session": "^1.18.1",
|
||||||
"@types/node": "^22.10.5",
|
"@types/node": "^22.10.5",
|
||||||
"@types/session-file-store": "^1.2.5",
|
"@types/session-file-store": "^1.2.5",
|
||||||
"drizzle-kit": "^0.30.2",
|
"drizzle-kit": "^0.31.10",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' | 'manual'
|
scheduleType: text("schedule_type").notNull(), // 'cron' | 'systemd_timer' | 'docker' | 'backup' | 'update' | '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", "manual"]),
|
scheduleType: z.enum(["cron", "systemd_timer", "docker", "backup", "update", "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(),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"cron-parser": "^5.10.0",
|
||||||
"cronstrue": "^2.53.0",
|
"cronstrue": "^2.53.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export interface ServerRecord {
|
|||||||
lastSeenAt: string | null;
|
lastSeenAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScheduleType = "cron" | "systemd_timer" | "docker" | "manual";
|
export type ScheduleType = "cron" | "systemd_timer" | "docker" | "backup" | "update" | "manual";
|
||||||
|
|
||||||
export interface TaskRecord {
|
export interface TaskRecord {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -37,6 +37,8 @@ export default function FilterBar({ servers, filters, onChange }: Props) {
|
|||||||
<option value="cron">Cron</option>
|
<option value="cron">Cron</option>
|
||||||
<option value="systemd_timer">systemd timer</option>
|
<option value="systemd_timer">systemd timer</option>
|
||||||
<option value="docker">Docker</option>
|
<option value="docker">Docker</option>
|
||||||
|
<option value="backup">Backup</option>
|
||||||
|
<option value="update">Update</option>
|
||||||
<option value="manual">Other / manual</option>
|
<option value="manual">Other / manual</option>
|
||||||
</select>
|
</select>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ const SCHEDULE_TYPE_LABELS: Record<string, string> = {
|
|||||||
cron: "Cron jobs",
|
cron: "Cron jobs",
|
||||||
systemd_timer: "systemd timers",
|
systemd_timer: "systemd timers",
|
||||||
docker: "Docker jobs",
|
docker: "Docker jobs",
|
||||||
|
backup: "Backups",
|
||||||
|
update: "Updates",
|
||||||
manual: "Other",
|
manual: "Other",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ const SCHEDULE_TYPE_OPTIONS: { value: ScheduleType; label: string }[] = [
|
|||||||
{ value: "docker", label: "Docker" },
|
{ value: "docker", label: "Docker" },
|
||||||
{ value: "cron", label: "Cron" },
|
{ value: "cron", label: "Cron" },
|
||||||
{ value: "systemd_timer", label: "systemd timer" },
|
{ value: "systemd_timer", label: "systemd timer" },
|
||||||
|
{ value: "backup", label: "Backup" },
|
||||||
|
{ value: "update", label: "Update" },
|
||||||
{ value: "manual", label: "Other / manual" },
|
{ value: "manual", label: "Other / manual" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import cronstrue from "cronstrue";
|
import cronstrue from "cronstrue";
|
||||||
|
import { CronExpressionParser } from "cron-parser";
|
||||||
import type { TaskRecord } from "../api/client";
|
import type { TaskRecord } from "../api/client";
|
||||||
|
|
||||||
function describeSchedule(task: TaskRecord): string {
|
function describeSchedule(task: TaskRecord): string {
|
||||||
@@ -13,6 +14,27 @@ function describeSchedule(task: TaskRecord): string {
|
|||||||
return task.scheduleExpression;
|
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 {
|
interface Props {
|
||||||
tasks: TaskRecord[];
|
tasks: TaskRecord[];
|
||||||
onEdit?: (task: TaskRecord) => void;
|
onEdit?: (task: TaskRecord) => void;
|
||||||
@@ -33,39 +55,59 @@ export default function TaskTable({ tasks, onEdit, onDelete }: Props) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{tasks.map((task) => (
|
{tasks.map((task) => {
|
||||||
<tr key={task.id} className={task.isStale ? "stale" : undefined}>
|
const nextRun = computeNextRun(task);
|
||||||
<td>{task.name}</td>
|
return (
|
||||||
<td className="mono">{task.command ?? "—"}</td>
|
<tr key={task.id} className={task.isStale ? "stale" : undefined}>
|
||||||
<td>
|
<td>{task.name}</td>
|
||||||
<div className="schedule-human">{describeSchedule(task)}</div>
|
<td className="mono">{task.command ?? "—"}</td>
|
||||||
{task.scheduleExpression && <span className="mono schedule-raw">{task.scheduleExpression}</span>}
|
<td>
|
||||||
</td>
|
<div className="schedule-human">{describeSchedule(task)}</div>
|
||||||
<td>{task.nextRunAt ? new Date(task.nextRunAt).toLocaleString() : "—"}</td>
|
{task.scheduleExpression && (
|
||||||
<td>
|
<span className="mono schedule-raw">{task.scheduleExpression}</span>
|
||||||
{task.isStale ? (
|
)}
|
||||||
<span className="badge badge-stale">Missing</span>
|
</td>
|
||||||
) : task.enabled ? (
|
<td>
|
||||||
<span className="badge badge-ok">Enabled</span>
|
{nextRun ? (
|
||||||
) : (
|
<span
|
||||||
<span className="badge badge-disabled">Disabled</span>
|
title={
|
||||||
)}
|
nextRun.estimated
|
||||||
{task.origin === "manual" && <span className="badge badge-manual">Manual</span>}
|
? "Estimated from the cron expression in your browser's timezone — the server may run in a different timezone"
|
||||||
</td>
|
: undefined
|
||||||
<td className="actions">
|
}
|
||||||
{task.origin === "manual" && (
|
>
|
||||||
<>
|
{nextRun.estimated ? "~" : ""}
|
||||||
<button className="btn btn-small" onClick={() => onEdit?.(task)}>
|
{nextRun.date.toLocaleString()}
|
||||||
Edit
|
</span>
|
||||||
</button>
|
) : (
|
||||||
<button className="btn btn-small btn-danger" onClick={() => onDelete?.(task)}>
|
"—"
|
||||||
Delete
|
)}
|
||||||
</button>
|
</td>
|
||||||
</>
|
<td>
|
||||||
)}
|
{task.isStale ? (
|
||||||
</td>
|
<span className="badge badge-stale">Missing</span>
|
||||||
</tr>
|
) : 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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
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";
|
||||||
|
|
||||||
|
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() {
|
export default function ServersAdmin() {
|
||||||
const [servers, setServers] = useState<ServerRecord[]>([]);
|
const [servers, setServers] = useState<ServerRecord[]>([]);
|
||||||
@@ -88,9 +97,15 @@ export default function ServersAdmin() {
|
|||||||
<section className="token-reveal">
|
<section className="token-reveal">
|
||||||
<h3>Agent token for {newToken.server.name}</h3>
|
<h3>Agent token for {newToken.server.name}</h3>
|
||||||
<p>This token is only shown once — copy it now.</p>
|
<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>
|
<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">
|
<button onClick={() => setNewToken(null)} className="btn">
|
||||||
Dismiss
|
Dismiss
|
||||||
</button>
|
</button>
|
||||||
@@ -101,7 +116,10 @@ export default function ServersAdmin() {
|
|||||||
<section className="token-reveal uninstall-reveal">
|
<section className="token-reveal uninstall-reveal">
|
||||||
<h3>Uninstall agent from {uninstallFor.name}</h3>
|
<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>
|
<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">
|
<button onClick={() => setUninstallFor(null)} className="btn">
|
||||||
Dismiss
|
Dismiss
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -398,6 +398,26 @@ tr.stale {
|
|||||||
font-size: 0.8rem;
|
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 {
|
.servers-admin section {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"}
|
||||||
Reference in New Issue
Block a user