Add bot schedules (routines), open Activity, add Help and a Desktop computer view
Bots create, list, update and delete schedules (cron_jobs, Bun.cron in UTC); a per-minute cron queues due runs as tasks. Routines, Activity (super admins) and Help get their own rail aside. Help covers familiar problems, starting with signing in to Google through Cookie-Editor over the new Desktop (noVNC) view. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import computerViewUrl from "./computer-view-url";
|
||||
|
||||
describe("computerViewUrl", () => {
|
||||
test("serves a mode under the computer's address", () => {
|
||||
expect(computerViewUrl({ computer_id: 3, mode: "terminal" })).toBe("/computers/3/terminal");
|
||||
});
|
||||
|
||||
test("points the desktop's VNC socket back through the same address", () => {
|
||||
expect(computerViewUrl({ computer_id: 3, mode: "desktop" })).toBe(
|
||||
"/computers/3/vnc/vnc.html?autoconnect=true&resize=scale&reconnect=true&path=computers/3/websockify",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -11,10 +11,11 @@ type Params = {
|
||||
|
||||
/**
|
||||
* The app address of one of a computer's modes, e.g.
|
||||
* `/computers/3/terminal`.
|
||||
* `/computers/3/terminal`, with `{prefix}` in its path filled in.
|
||||
*/
|
||||
export default function computerViewUrl({ computer_id, mode }: Params) {
|
||||
const base = `${ComputerProxyPrefix}/${computer_id}/`;
|
||||
const path = ComputerViewModes.find((m) => m.value == mode)?.path || "";
|
||||
|
||||
return `${ComputerProxyPrefix}/${computer_id}/${path}`;
|
||||
return `${base}${path.replace("{prefix}", base.replace(/^\//, ""))}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import describeCronJob from "./describe-cron-job";
|
||||
|
||||
describe("describeCronJob", () => {
|
||||
test("describes a running schedule", () => {
|
||||
expect(
|
||||
describeCronJob({
|
||||
cron_job: {
|
||||
id: 3,
|
||||
title: "Morning digest",
|
||||
schedule: "0 9 * * MON-FRI",
|
||||
enabled: 1,
|
||||
next_run_at: Date.UTC(2026, 9, 20, 9),
|
||||
run_count: 1,
|
||||
workspace_name: "Launch",
|
||||
computer_id: 2,
|
||||
},
|
||||
}),
|
||||
).toBe(
|
||||
`#3 "Morning digest", \`0 9 * * MON-FRI\` (UTC), in the workspace Launch, on computer 2, next run 2026-10-20T09:00:00.000Z, 1 run so far`,
|
||||
);
|
||||
});
|
||||
|
||||
test("describes a paused one-off with an error", () => {
|
||||
expect(
|
||||
describeCronJob({
|
||||
cron_job: {
|
||||
id: 4,
|
||||
title: "Remind me",
|
||||
schedule: "30 14 21 10 *",
|
||||
enabled: 0,
|
||||
run_count: 0,
|
||||
max_runs: 1,
|
||||
last_error: "This bot has no access to that computer.",
|
||||
},
|
||||
}),
|
||||
).toBe(
|
||||
`#4 "Remind me", \`30 14 21 10 *\` (UTC), in your own chat, paused, 0 of 1 runs so far, last error: This bot has no access to that computer.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
cron_job: CronJobWithNames;
|
||||
};
|
||||
|
||||
/**
|
||||
* One schedule as a bot reads it: id, title, expression, where it runs,
|
||||
* its state and next run (ISO, UTC), and the last error if any.
|
||||
*/
|
||||
export default function describeCronJob({ cron_job }: Params) {
|
||||
const runs = Number(cron_job.run_count || 0);
|
||||
const parts = [
|
||||
`#${cron_job.id} "${cron_job.title}"`,
|
||||
`\`${cron_job.schedule}\` (UTC)`,
|
||||
cron_job.workspace_name ? `in the workspace ${cron_job.workspace_name}` : `in your own chat`,
|
||||
cron_job.computer_id ? `on computer ${cron_job.computer_id}` : "",
|
||||
cron_job.enabled
|
||||
? cron_job.next_run_at
|
||||
? `next run ${new Date(cron_job.next_run_at).toISOString()}`
|
||||
: "no next run"
|
||||
: "paused",
|
||||
`${runs}${cron_job.max_runs ? ` of ${cron_job.max_runs}` : ""} run${runs == 1 ? "" : "s"} so far`,
|
||||
cron_job.last_error ? `last error: ${cron_job.last_error}` : "",
|
||||
];
|
||||
|
||||
return parts.filter(Boolean).join(", ");
|
||||
}
|
||||
@@ -6,6 +6,9 @@ describe("isAdminSectionPath", () => {
|
||||
expect(isAdminSectionPath({ pathname: "/admin/bots" })).toBe(true);
|
||||
expect(isAdminSectionPath({ pathname: "/admin/bots/3" })).toBe(true);
|
||||
expect(isAdminSectionPath({ pathname: "/admin/workspaces/2/bots/1" })).toBe(true);
|
||||
expect(isAdminSectionPath({ pathname: "/admin/routines" })).toBe(true);
|
||||
expect(isAdminSectionPath({ pathname: "/admin/logs" })).toBe(true);
|
||||
expect(isAdminSectionPath({ pathname: "/admin/help" })).toBe(true);
|
||||
expect(isAdminSectionPath({ pathname: "/admin/settings/change-password" })).toBe(
|
||||
true,
|
||||
);
|
||||
@@ -14,7 +17,7 @@ describe("isAdminSectionPath", () => {
|
||||
test("rejects the admin root, closed sections and lookalikes", () => {
|
||||
expect(isAdminSectionPath({ pathname: "/admin" })).toBe(false);
|
||||
expect(isAdminSectionPath({ pathname: "/admin/people" })).toBe(false);
|
||||
expect(isAdminSectionPath({ pathname: "/admin/logs" })).toBe(false);
|
||||
expect(isAdminSectionPath({ pathname: "/admin/backups" })).toBe(false);
|
||||
expect(isAdminSectionPath({ pathname: "/admin/botsx" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import nextCronRun from "./next-cron-run";
|
||||
|
||||
const from = Date.UTC(2026, 9, 19, 8, 30);
|
||||
|
||||
describe("nextCronRun", () => {
|
||||
test("finds the next run in UTC", () => {
|
||||
expect(nextCronRun({ schedule: "0 9 * * *", from })).toBe(Date.UTC(2026, 9, 19, 9, 0));
|
||||
expect(nextCronRun({ schedule: "@daily", from })).toBe(Date.UTC(2026, 9, 20, 0, 0));
|
||||
});
|
||||
|
||||
test("is null when the schedule never runs", () => {
|
||||
expect(nextCronRun({ schedule: "0 0 30 2 *", from })).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects invalid expressions", () => {
|
||||
expect(() => nextCronRun({ schedule: "every morning", from })).toThrow("isn't a valid schedule");
|
||||
});
|
||||
|
||||
test("rejects schedules that run too often when asked", () => {
|
||||
expect(() => nextCronRun({ schedule: "* * * * *", from, check_interval: true })).toThrow("runs too often");
|
||||
expect(nextCronRun({ schedule: "* * * * *", from })).toBe(Date.UTC(2026, 9, 19, 8, 31));
|
||||
expect(nextCronRun({ schedule: "*/15 * * * *", from, check_interval: true })).toBe(Date.UTC(2026, 9, 19, 8, 45));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import AppError from "./app-error";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* A Bun.cron expression: 5 fields or a nickname like `@daily`, in UTC.
|
||||
*/
|
||||
schedule: string;
|
||||
/**
|
||||
* Unix ms to look from (the next run is after it). Defaults to now.
|
||||
*/
|
||||
from?: number;
|
||||
/**
|
||||
* Refuse schedules that run more often than
|
||||
* `AppData.CronJobMinIntervalMs`. Set when a schedule is saved.
|
||||
*/
|
||||
check_interval?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The next run of a schedule after `from`, in unix ms, or null when it
|
||||
* never runs again. Throws an `AppError` for an invalid expression (or,
|
||||
* with `check_interval`, one that runs too often).
|
||||
*/
|
||||
export default function nextCronRun({ schedule, from = Date.now(), check_interval }: Params): number | null {
|
||||
const expression = schedule.trim() as Bun.CronWithAutocomplete;
|
||||
let next: Date | null;
|
||||
|
||||
try {
|
||||
next = Bun.cron.parse(expression, from);
|
||||
} catch {
|
||||
throw new AppError(
|
||||
`"${schedule}" isn't a valid schedule. Use 5 cron fields (minute hour day month weekday, e.g. "0 9 * * MON-FRI") or a nickname like @daily.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!next) return null;
|
||||
|
||||
if (check_interval) {
|
||||
const after = Bun.cron.parse(expression, next);
|
||||
|
||||
if (after && after.getTime() - next.getTime() < AppData.CronJobMinIntervalMs) {
|
||||
throw new AppError(
|
||||
`"${schedule}" runs too often. Leave at least ${Math.round(AppData.CronJobMinIntervalMs / 60000)} minutes between runs.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return next.getTime();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import timeUntil from "./time-until";
|
||||
|
||||
const now = Date.UTC(2026, 9, 19, 8, 30);
|
||||
|
||||
describe("timeUntil", () => {
|
||||
test("counts down in the largest whole unit", () => {
|
||||
expect(timeUntil({ at: now + 30 * 1000, now })).toBe("now");
|
||||
expect(timeUntil({ at: now - 5000, now })).toBe("now");
|
||||
expect(timeUntil({ at: now + 5 * 60 * 1000, now })).toBe("in 5m");
|
||||
expect(timeUntil({ at: now + 3 * 60 * 60 * 1000, now })).toBe("in 3h");
|
||||
expect(timeUntil({ at: now + 2 * 24 * 60 * 60 * 1000, now })).toBe("in 2d");
|
||||
expect(timeUntil({ at: now + 21 * 24 * 60 * 60 * 1000, now })).toBe("in 3w");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
const MINUTE = 60 * 1000;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
const WEEK = 7 * DAY;
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* Unix timestamp in milliseconds.
|
||||
*/
|
||||
at: number;
|
||||
now?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The future side of `relativeTime`: `now`, `in 5m`, `in 3h`, `in 2d`,
|
||||
* `in 3w`.
|
||||
*/
|
||||
export default function timeUntil({ at, now = Date.now() }: Params): string {
|
||||
const diff = at - now;
|
||||
|
||||
if (diff < MINUTE) return "now";
|
||||
if (diff < HOUR) return `in ${Math.floor(diff / MINUTE)}m`;
|
||||
if (diff < DAY) return `in ${Math.floor(diff / HOUR)}h`;
|
||||
if (diff < WEEK) return `in ${Math.floor(diff / DAY)}d`;
|
||||
|
||||
return `in ${Math.floor(diff / WEEK)}w`;
|
||||
}
|
||||
Reference in New Issue
Block a user