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:
@@ -14,7 +14,7 @@ export default function ComposerAttachments({ pending, onRemove }: Props) {
|
||||
if (!pending.length) return null;
|
||||
|
||||
return (
|
||||
<Row className="w-full max-w-[760px] mx-auto gap-2! px-6 pb-3">
|
||||
<Row className="w-full max-w-container mx-auto gap-2! px-6 pb-3">
|
||||
{pending.map((item) => (
|
||||
<ComposerAttachmentChip
|
||||
key={item.key}
|
||||
|
||||
@@ -2,7 +2,10 @@ import { useState, type KeyboardEvent } from "react";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Textarea from "@moduletrace/twui/form/Textarea";
|
||||
import type { BotMessageWithAttachments, BotWithLastMessage } from "@/src/types";
|
||||
import type {
|
||||
BotMessageWithAttachments,
|
||||
BotWithLastMessage,
|
||||
} from "@/src/types";
|
||||
import sendBotMessage from "../(functions)/send-bot-message";
|
||||
import useComposerAttachments from "../(hooks)/use-composer-attachments";
|
||||
import ComposerAttachButton from "./composer-attach-button";
|
||||
@@ -30,7 +33,9 @@ export default function Composer({ bot, workspace_id, onSent }: Props) {
|
||||
const files = useComposerAttachments();
|
||||
|
||||
const can_send =
|
||||
Boolean(text.trim() || files.media_ids.length) && !sending && !files.uploading;
|
||||
Boolean(text.trim() || files.media_ids.length) &&
|
||||
!sending &&
|
||||
!files.uploading;
|
||||
|
||||
async function send() {
|
||||
if (!can_send || !bot.id) return;
|
||||
@@ -57,7 +62,9 @@ export default function Composer({ bot, workspace_id, onSent }: Props) {
|
||||
onSent({ message: res.singleRes });
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) {
|
||||
function handleKeyDown(
|
||||
e: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>,
|
||||
) {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
@@ -77,10 +84,16 @@ export default function Composer({ bot, workspace_id, onSent }: Props) {
|
||||
files.add({ files: [...e.dataTransfer.files] });
|
||||
}}
|
||||
>
|
||||
<ComposerAttachments pending={files.pending} onRemove={files.remove} />
|
||||
<ComposerAttachments
|
||||
pending={files.pending}
|
||||
onRemove={files.remove}
|
||||
/>
|
||||
<Stack className="shrink-0 gap-0! items-stretch border-0 border-t border-solid border-hairline">
|
||||
<Row className="w-full max-w-[760px] mx-auto h-[88px] gap-4! flex-nowrap items-center px-6">
|
||||
<ComposerAttachButton disabled={files.full} onFiles={files.add} />
|
||||
<Row className="w-full max-w-container mx-auto h-[88px] gap-4! flex-nowrap items-center px-6">
|
||||
<ComposerAttachButton
|
||||
disabled={files.full}
|
||||
onFiles={files.add}
|
||||
/>
|
||||
<Textarea
|
||||
name="message"
|
||||
aria-label={`Message ${bot.name}`}
|
||||
@@ -106,7 +119,11 @@ export default function Composer({ bot, workspace_id, onSent }: Props) {
|
||||
"p-0! outline-0! border-0! bg-transparent! dark:bg-transparent! rounded-none",
|
||||
}}
|
||||
/>
|
||||
<ComposerSendButton disabled={!can_send} loading={sending || files.uploading} onClick={send} />
|
||||
<ComposerSendButton
|
||||
disabled={!can_send}
|
||||
loading={sending || files.uploading}
|
||||
onClick={send}
|
||||
/>
|
||||
</Row>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { BotMessageWithAttachments, BotWithLastMessage } from "@/src/types";
|
||||
import type {
|
||||
BotMessageWithAttachments,
|
||||
BotWithLastMessage,
|
||||
} from "@/src/types";
|
||||
import groupMessages from "../(utils)/group-messages";
|
||||
import BotThinkingRow from "./bot-thinking-row";
|
||||
import MessageGroupBlock from "./message-group-block";
|
||||
@@ -28,10 +31,14 @@ export default function MessageThread({ bot, messages, progress }: Props) {
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-label={`Conversation with ${bot.name}`}
|
||||
className="w-full max-w-[760px] mx-auto gap-4! items-stretch px-6 pt-6 pb-6"
|
||||
className="w-full max-w-container mx-auto gap-4! items-stretch px-6 pt-6 pb-6"
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<MessageGroupBlock key={group.key} bot={bot} group={group} />
|
||||
<MessageGroupBlock
|
||||
key={group.key}
|
||||
bot={bot}
|
||||
group={group}
|
||||
/>
|
||||
))}
|
||||
<BotThinkingRow bot={bot} progress={progress} />
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { HelpSignInTopicId } from "@/src/dict/help-dict";
|
||||
|
||||
export type HelpTopic = {
|
||||
/**
|
||||
* The anchor: `/admin/help#<id>` opens the page at it.
|
||||
*/
|
||||
id: string;
|
||||
title: string;
|
||||
/**
|
||||
* One line under the title: when this is the topic you need.
|
||||
*/
|
||||
summary: string;
|
||||
/**
|
||||
* The answer, in markdown.
|
||||
*/
|
||||
body: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The help page: familiar problems and how to get past them, the most
|
||||
* common first.
|
||||
*/
|
||||
export const HelpTopics: HelpTopic[] = [
|
||||
{
|
||||
id: HelpSignInTopicId,
|
||||
title: "Google won't let me sign in on a computer",
|
||||
summary: `"This browser or app may not be secure", "Couldn't sign you in", or a sign-in that loops. Google and some other sites do this.`,
|
||||
body: `Your bots drive the browser on their computers, and Google (like some banks and social sites) refuses sign-ins from browsers it can tell are automated. Typing the password yourself doesn't help: the browser itself is turned away.
|
||||
|
||||
The way around it is to sign in on your own browser, where Google trusts you, and carry that session over to the computer with the **Cookie-Editor** extension.
|
||||
|
||||
**Before you start:** the cookies you copy *are* your signed-in session. Anyone who has them is signed in as you, without a password. Never paste them into a chat with a bot, don't keep them in a file, and clear them when you're done (step 9).
|
||||
|
||||
### On your own browser
|
||||
|
||||
1. In Chrome (or Edge, Brave, any Chromium browser), install [Cookie-Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) from the Chrome Web Store.
|
||||
2. Sign in to the site with the account your bot should use. For Google, go to [myaccount.google.com](https://myaccount.google.com) and sign in.
|
||||
3. Still on that site, click the extensions icon (the puzzle piece) in the toolbar, then **Cookie-Editor**. Click **Export**, then **JSON**. The cookies are now on your clipboard.
|
||||
|
||||
### On the bot's computer
|
||||
|
||||
4. Open the computer: in the bot's chat, click the computer icon in the header and open the computer, or click **Take over** if the bot asked you to. Switch to **Desktop**. This is the only view that shows the browser's toolbar, where extensions live.
|
||||
5. The first time only: in the computer's Chrome, open the [Cookie-Editor page](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm), click **Add to Chrome**, then **Add extension**. Pin it from the puzzle piece so it's always one click away. It stays installed: the computer keeps its browser between restarts.
|
||||
6. In the computer's Chrome, go to the same site (for Google, [myaccount.google.com](https://myaccount.google.com)). It's fine that it shows the sign-in page.
|
||||
7. Put the cookies on the computer's clipboard. Desktop has a small handle on the left edge: open it, click the **Clipboard** icon, and paste into the box (Ctrl+V, or Cmd+V on a Mac). What's in that box is the computer's clipboard.
|
||||
8. Click **Cookie-Editor** in the computer's toolbar, then **Import**. Click into the box, paste (Ctrl+V), and click **Import**. Reload the page (F5): you're signed in.
|
||||
9. Clean up: open the **Clipboard** panel again and clear it.
|
||||
|
||||
Then tell the bot you're signed in, or click **Hand back** if it asked you to take over, and it carries on.
|
||||
|
||||
### If it doesn't work
|
||||
|
||||
- **The paste in step 7 or 8 is empty.** Use the computer's code editor instead: switch to **Code**, create a file such as \`cookies.json\`, and paste into it (that paste always works). In the computer's Chrome, open \`file:///home/gem/cookies.json\`, press Ctrl+A then Ctrl+C, and import as in step 8. **Delete the file afterwards.**
|
||||
- **You're signed in, then signed out again soon after.** Google sometimes ends a session it sees used from a new place. Sign out and back in on your own browser, export again, and import straight away. Using the same account and keeping the bot's work regular helps the session last.
|
||||
- **Cookie-Editor's icon doesn't show.** You're in the **Browser** view, which shows pages only. Switch to **Desktop**.
|
||||
- **Another site still says no.** Make sure you exported while on that site (Cookie-Editor exports the cookies of the site you're on), and that you opened the same site on the computer before importing.`,
|
||||
},
|
||||
{
|
||||
id: "take-over",
|
||||
title: "A bot asks me to take over",
|
||||
summary: "A message with a Take over button, and the bot shows it needs you.",
|
||||
body: `Some things only you can do on a computer: sign in, solve a captcha, type a code sent to your phone, approve a prompt. The bot stops there and asks you to take over.
|
||||
|
||||
1. Click **Take over** in its message. The computer opens in the view the bot picked (browser, terminal, code or desktop).
|
||||
2. Do what it asked. You're using the same computer the bot works on.
|
||||
3. Click **Hand back to** the bot. It gets a message that you're done and carries on.
|
||||
|
||||
A task that hit the wall stops and reports instead of waiting, so after you hand back the bot picks the work up again from the chat.`,
|
||||
},
|
||||
{
|
||||
id: "computer-not-ready",
|
||||
title: "A computer is stuck on Creating, or isn't ready",
|
||||
summary: "Tasks fail with \"isn't ready\", or the computer never leaves Creating.",
|
||||
body: `A new computer downloads its image and starts up, which can take a few minutes the first time. It stays **Creating** until its health check passes (its sandbox and browser both answer).
|
||||
|
||||
- **Still Creating after several minutes, or Failed:** open **Settings, Computers** and look at its state. A super admin can **Retry** it there; the error shows on the row.
|
||||
- **Unhealthy or stopped:** the computer's container is down or struggling. Give it more memory or CPUs from **Edit** (it restarts, its files are kept), or retry it.
|
||||
- **Everything fails at once:** the app needs Docker on the machine it runs on. Check that Docker is running.
|
||||
|
||||
A computer's home folder lives in the app's data folder on the machine, so restarting or recreating its container never loses its files.`,
|
||||
},
|
||||
{
|
||||
id: "bot-asleep",
|
||||
title: "A bot is asleep, or says no model is available",
|
||||
summary: "Its face is asleep and replies wait, or tasks fail naming the models.",
|
||||
body: `Bots think with AI models. By default they use free models, which are shared and rate limited: when every model a bot can use is resting, it falls asleep and its work waits until the first one comes back. Nothing is lost.
|
||||
|
||||
To make this rarer, a super admin can open **Settings, Models**:
|
||||
|
||||
- Add a provider with your own API key (OpenRouter, OpenAI, Anthropic, Gemini and others), so paid models are there when free ones rest.
|
||||
- Pick a model for the text harness instead of automatic, and keep **fallback** on so the automatic picks still step in.
|
||||
|
||||
A bot's own models (the model icon in its chat header) win over the defaults, and a workspace's win over a bot's.`,
|
||||
},
|
||||
{
|
||||
id: "routine-time",
|
||||
title: "A routine runs at the wrong time",
|
||||
summary: "It runs hours early or late.",
|
||||
body: `Routines run on UTC, not your local time. A routine set for \`0 9 * * *\` runs at 09:00 UTC.
|
||||
|
||||
Tell the bot your time zone once ("I'm in Lagos", "I'm on New York time") and it remembers it and converts for you. To fix one yourself, open **Routines**, click **Edit**, and change the schedule to the UTC hour you want. Routines don't follow daylight saving changes, so ones near a clock change may need a nudge twice a year.`,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,23 @@
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { MessageMarkdownComponents } from "@/src/pages/admin/bots/[bot_id]/(data)/message-markdown-components";
|
||||
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
|
||||
import type { HelpTopic } from "../(data)/help-topics";
|
||||
|
||||
/**
|
||||
* One help topic: the problem and when you'd see it on the left, the
|
||||
* answer (markdown, rendered like a bot message) on the right. Its id is
|
||||
* the anchor bots and other pages link to.
|
||||
*/
|
||||
export default function HelpTopicGroup({ id, title, summary, body }: HelpTopic) {
|
||||
return (
|
||||
<SettingsGroup id={id} title={title} description={summary}>
|
||||
<Stack className="gap-3! items-stretch min-w-0">
|
||||
<Markdown remarkPlugins={[remarkGfm]} components={MessageMarkdownComponents}>
|
||||
{body}
|
||||
</Markdown>
|
||||
</Stack>
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { HelpTopics } from "../(data)/help-topics";
|
||||
import HelpTopicGroup from "../(partials)/help-topic-group";
|
||||
|
||||
/**
|
||||
* Every help topic, one block each.
|
||||
*/
|
||||
export default function HelpTopicsSection() {
|
||||
return (
|
||||
<>
|
||||
{HelpTopics.map((topic) => (
|
||||
<HelpTopicGroup key={topic.id} {...topic} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import SettingsShell from "@/src/pages/admin/settings/(partials)/settings-shell";
|
||||
import HelpTopicsSection from "./(sections)/help-topics-section";
|
||||
|
||||
export default function AdminHelpPage() {
|
||||
return (
|
||||
<SettingsShell title="Help" description="Familiar problems, and how to get past them.">
|
||||
<HelpTopicsSection />
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
|
||||
export const meta: BunextPageModuleMetaFn = async () => {
|
||||
return {
|
||||
title: `Help | ${SiteData["SiteName"]}`,
|
||||
description: `Familiar problems on ${SiteData["SiteName"]}, and how to get past them.`,
|
||||
};
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
import AdminHero from "@/src/components/general/admin-hero";
|
||||
|
||||
export default function Hero() {
|
||||
return (
|
||||
<AdminHero
|
||||
title="Logs"
|
||||
description="System activity and audit trail - login, WebSocket, cron, and error events"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
import Section from "@moduletrace/twui/layout/Section";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import LogsList from "../(partials)/logs-list";
|
||||
|
||||
export default function LogsListSection() {
|
||||
return (
|
||||
<Section>
|
||||
<Stack className="w-full items-stretch">
|
||||
<LogsList />
|
||||
</Stack>
|
||||
</Section>
|
||||
<Stack className="w-full items-stretch py-8">
|
||||
<LogsList />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
|
||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Activity (the audit log): super admins only, everyone else lands on the
|
||||
* bots.
|
||||
*/
|
||||
export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
|
||||
const user = await grabSuperAdminUser({ req });
|
||||
|
||||
if (!user?.id) {
|
||||
return { redirect: { destination: AdminHomePath } };
|
||||
}
|
||||
|
||||
return { props: {} };
|
||||
};
|
||||
@@ -1,20 +1,23 @@
|
||||
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import Hero from "./(sections)/hero";
|
||||
import SettingsShell from "@/src/pages/admin/settings/(partials)/settings-shell";
|
||||
import LogsListSection from "./(sections)/logs-list-section";
|
||||
|
||||
export default function LogsPage() {
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
<SettingsShell
|
||||
title="Activity"
|
||||
description="Sign-ins, scheduled runs, cron and errors, newest first."
|
||||
wide
|
||||
>
|
||||
<LogsListSection />
|
||||
</>
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
|
||||
export const meta: BunextPageModuleMetaFn = async (params) => {
|
||||
export const meta: BunextPageModuleMetaFn = async () => {
|
||||
return {
|
||||
title: `Logs | ${SiteData["SiteName"]}`,
|
||||
title: `Activity | ${SiteData["SiteName"]}`,
|
||||
description: "System activity and audit logs",
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
import RoutineForm from "./routine-form";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Open while set.
|
||||
*/
|
||||
cron_job?: CronJobWithNames;
|
||||
onClose: () => void;
|
||||
onSaved: (params: { cron_job: CronJobWithNames }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Edits a routine in a modal: its name, schedule and brief.
|
||||
*/
|
||||
export default function RoutineEditModal({ cron_job, onClose, onSaved }: Props) {
|
||||
return (
|
||||
<AddModalShell
|
||||
open={Boolean(cron_job)}
|
||||
setOpen={(open) => !open && onClose()}
|
||||
title={`Edit ${cron_job?.title || "the routine"}`}
|
||||
description={`${cron_job?.bot_name || "The bot"} gets the brief as a task at every run.`}
|
||||
>
|
||||
{cron_job ? <RoutineForm key={cron_job.id} cron_job={cron_job} onCancel={onClose} onSaved={onSaved} /> : null}
|
||||
</AddModalShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState } from "react";
|
||||
import Form from "@moduletrace/twui/form/Form";
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Textarea from "@moduletrace/twui/form/Textarea";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
|
||||
type Props = {
|
||||
cron_job: CronJobWithNames;
|
||||
onCancel: () => void;
|
||||
onSaved: (params: { cron_job: CronJobWithNames }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A routine's name, schedule (a cron expression in UTC) and brief.
|
||||
*/
|
||||
export default function RoutineForm({ cron_job, onCancel, onSaved }: Props) {
|
||||
const [title, setTitle] = useState(cron_job.title || "");
|
||||
const [schedule, setSchedule] = useState(cron_job.schedule || "");
|
||||
const [instructions, setInstructions] = useState(cron_job.instructions || "");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const submit = async () => {
|
||||
if (loading) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = await postAdminApi<CronJobWithNames>({
|
||||
path: "routines/update-routine",
|
||||
body: { cron_job_id: cron_job.id, title, schedule, instructions },
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res.success || !res.singleRes) {
|
||||
setError(res.msg || "Couldn't save the routine.");
|
||||
return;
|
||||
}
|
||||
|
||||
onSaved({ cron_job: res.singleRes });
|
||||
};
|
||||
|
||||
return (
|
||||
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
|
||||
<Input
|
||||
id="routine-title"
|
||||
title="Name"
|
||||
label="Name"
|
||||
showLabel
|
||||
autoComplete="off"
|
||||
defaultValue={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
id="routine-schedule"
|
||||
title="Schedule"
|
||||
label="Schedule"
|
||||
showLabel
|
||||
autoComplete="off"
|
||||
placeholder="0 9 * * MON-FRI"
|
||||
defaultValue={schedule}
|
||||
info="Cron, in UTC: minute hour day month weekday, or @hourly, @daily, @weekly."
|
||||
onChange={(e) => setSchedule(e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
id="routine-instructions"
|
||||
title="Brief"
|
||||
label="Brief"
|
||||
showLabel
|
||||
rows={6}
|
||||
defaultValue={instructions}
|
||||
info="What the bot does at every run, and how it knows it's done."
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
/>
|
||||
<AddFormActions submit_label="Save" loading={loading} error={error} onCancel={onCancel} />
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
import SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row";
|
||||
import SettingsRowAction from "@/src/pages/admin/settings/(partials)/settings-row-action";
|
||||
import routineSubtitle from "../(utils)/routine-subtitle";
|
||||
|
||||
type Props = {
|
||||
cron_job: CronJobWithNames;
|
||||
busy?: boolean;
|
||||
onToggle: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One routine: its bot's avatar, its name (Paused or Done when it is),
|
||||
* whose it is and when it runs, then Chat, Pause or Resume, Edit, Delete.
|
||||
*/
|
||||
export default function RoutineRow({ cron_job, busy, onToggle, onEdit, onDelete }: Props) {
|
||||
const title = cron_job.title || `Routine ${cron_job.id}`;
|
||||
const done = Boolean(cron_job.max_runs && Number(cron_job.run_count || 0) >= cron_job.max_runs);
|
||||
const chat_url = cron_job.workspace_id
|
||||
? `/admin/workspaces/${cron_job.workspace_id}/bots/${cron_job.bot_id}`
|
||||
: `/admin/bots/${cron_job.bot_id}`;
|
||||
|
||||
return (
|
||||
<SettingsListRow
|
||||
avatar={
|
||||
<BotAvatar
|
||||
image_media_id={cron_job.bot_image_media_id}
|
||||
hue={(cron_job.bot_kind == "coordinator" ? null : cron_job.bot_hue || null) as BotHue | null}
|
||||
face={(cron_job.bot_face || "idle") as BotFace}
|
||||
/>
|
||||
}
|
||||
title={title}
|
||||
badge={cron_job.enabled ? undefined : done ? "Done" : "Paused"}
|
||||
subtitle={routineSubtitle({ cron_job })}
|
||||
>
|
||||
<SettingsRowAction title={`Chat with ${cron_job.bot_name || "the bot"}`} label="Chat" href={chat_url} />
|
||||
<SettingsRowAction
|
||||
title={cron_job.enabled ? `Pause ${title}` : `Resume ${title}`}
|
||||
label={cron_job.enabled ? "Pause" : "Resume"}
|
||||
disabled={busy}
|
||||
onClick={onToggle}
|
||||
/>
|
||||
<SettingsRowAction title={`Edit ${title}`} label="Edit" disabled={busy} onClick={onEdit} />
|
||||
<SettingsRowAction title={`Delete ${title}`} label="Delete" disabled={busy} onClick={onDelete} />
|
||||
</SettingsListRow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
import useSettingsList from "@/src/pages/admin/settings/(hooks)/use-settings-list";
|
||||
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
|
||||
import SettingsList from "@/src/pages/admin/settings/(partials)/settings-list";
|
||||
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import RoutineEditModal from "../(partials)/routine-edit-modal";
|
||||
import RoutineRow from "../(partials)/routine-row";
|
||||
|
||||
/**
|
||||
* Every routine the user's bots have: chat with the bot, pause or resume
|
||||
* one, edit it, delete it. Bots create them when asked in chat.
|
||||
*/
|
||||
export default function RoutinesSection() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const { items, setItems, busy, status, setStatus, remove } = useSettingsList({
|
||||
items: pageProps?.cron_jobs || [],
|
||||
});
|
||||
const [editing, setEditing] = useState<CronJobWithNames>();
|
||||
|
||||
const replace = ({ cron_job }: { cron_job: CronJobWithNames }) =>
|
||||
setItems((prev) => prev.map((item) => (item.id == cron_job.id ? cron_job : item)));
|
||||
|
||||
const toggle = async ({ cron_job }: { cron_job: CronJobWithNames }) => {
|
||||
setStatus(undefined);
|
||||
|
||||
const res = await postAdminApi<CronJobWithNames>({
|
||||
path: "routines/update-routine",
|
||||
body: { cron_job_id: cron_job.id, enabled: !cron_job.enabled },
|
||||
});
|
||||
|
||||
if (!res.success || !res.singleRes) {
|
||||
setStatus({ error: true, msg: res.msg || "That didn't work." });
|
||||
return;
|
||||
}
|
||||
|
||||
replace({ cron_job: res.singleRes });
|
||||
setStatus({ success: true, msg: res.msg });
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="Your routines"
|
||||
description={`Ask any bot to set one up, e.g. "Every weekday at 9, sum up my inbox." Each run is a task, and its result lands in that bot's chat.`}
|
||||
>
|
||||
<SettingsList is_empty={!items.length} empty="No routines yet.">
|
||||
{items.map((cron_job) => (
|
||||
<RoutineRow
|
||||
key={cron_job.id}
|
||||
cron_job={cron_job}
|
||||
busy={busy}
|
||||
onToggle={() => toggle({ cron_job })}
|
||||
onEdit={() => setEditing(cron_job)}
|
||||
onDelete={() =>
|
||||
remove({
|
||||
id: cron_job.id,
|
||||
path: "routines/delete-routine",
|
||||
body: { cron_job_id: cron_job.id },
|
||||
confirm: `Delete the routine "${cron_job.title}"? A run already started carries on.`,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SettingsList>
|
||||
<SettingsStatus status={status} />
|
||||
<RoutineEditModal
|
||||
cron_job={editing}
|
||||
onClose={() => setEditing(undefined)}
|
||||
onSaved={({ cron_job }) => {
|
||||
replace({ cron_job });
|
||||
setEditing(undefined);
|
||||
setStatus({ success: true, msg: "Saved." });
|
||||
}}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import routineSubtitle from "./routine-subtitle";
|
||||
|
||||
const now = Date.UTC(2026, 9, 19, 8, 30);
|
||||
|
||||
describe("routineSubtitle", () => {
|
||||
test("names the bot, workspace, schedule and next run", () => {
|
||||
expect(
|
||||
routineSubtitle({
|
||||
cron_job: {
|
||||
bot_name: "Scout",
|
||||
workspace_name: "Launch",
|
||||
schedule: "0 9 * * *",
|
||||
enabled: 1,
|
||||
next_run_at: now + 30 * 60 * 1000,
|
||||
},
|
||||
now,
|
||||
}),
|
||||
).toBe("Scout · Launch · 0 9 * * * UTC · next in 30m");
|
||||
});
|
||||
|
||||
test("shows the last error instead, and no next run when paused", () => {
|
||||
expect(
|
||||
routineSubtitle({
|
||||
cron_job: { bot_name: "Scout", schedule: "@daily", enabled: 0, next_run_at: now, last_error: "Skipped a run." },
|
||||
now,
|
||||
}),
|
||||
).toBe("Scout · @daily UTC · Skipped a run.");
|
||||
expect(routineSubtitle({ cron_job: { bot_name: "Scout", schedule: "@daily", enabled: 0, next_run_at: now }, now })).toBe(
|
||||
"Scout · @daily UTC",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
import timeUntil from "@/src/utils/time-until";
|
||||
|
||||
type Params = {
|
||||
cron_job: CronJobWithNames;
|
||||
now?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The line under a routine's name: whose it is, where it runs, its
|
||||
* schedule, then when it runs next or why it last couldn't.
|
||||
*/
|
||||
export default function routineSubtitle({ cron_job, now = Date.now() }: Params) {
|
||||
const next =
|
||||
cron_job.enabled && cron_job.next_run_at ? `next ${timeUntil({ at: cron_job.next_run_at, now })}` : "";
|
||||
|
||||
return [
|
||||
cron_job.bot_name || "A bot",
|
||||
cron_job.workspace_name || "",
|
||||
`${cron_job.schedule} UTC`,
|
||||
cron_job.last_error || next,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabCronJobs from "@/src/functions/backend/db/cron-jobs/grab-cron-jobs";
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* The routines page: every schedule the user's bots have.
|
||||
*/
|
||||
export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
|
||||
const { user } = await userAuth({ req });
|
||||
|
||||
if (!user?.id) {
|
||||
return { redirect: { destination: "/auth/login" } };
|
||||
}
|
||||
|
||||
return { props: { cron_jobs: await grabCronJobs({ user_id: user.id }) } };
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import SettingsShell from "@/src/pages/admin/settings/(partials)/settings-shell";
|
||||
import RoutinesSection from "./(sections)/routines-section";
|
||||
|
||||
export default function AdminRoutinesPage() {
|
||||
return (
|
||||
<SettingsShell title="Routines" description="Work your bots do on a schedule, on their own.">
|
||||
<RoutinesSection />
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
|
||||
export const meta: BunextPageModuleMetaFn = async () => {
|
||||
return {
|
||||
title: `Routines | ${SiteData["SiteName"]}`,
|
||||
description: "Work your bots do on a schedule.",
|
||||
};
|
||||
};
|
||||
@@ -7,15 +7,19 @@ type Props = {
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
children: ReactNode;
|
||||
/**
|
||||
* An anchor, so a link can open the page at this block.
|
||||
*/
|
||||
id?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* One block of settings: what it is on the left, the controls on the right.
|
||||
* Stacks below 768px. Blocks are separated by hairlines, never cards.
|
||||
*/
|
||||
export default function SettingsGroup({ title, description, children }: Props) {
|
||||
export default function SettingsGroup({ title, description, children, id }: Props) {
|
||||
return (
|
||||
<Stack className="w-full gap-4! md:grid md:grid-cols-[200px_1fr] md:gap-10! py-8 border-0 border-b border-solid border-hairline last:border-b-0">
|
||||
<Stack id={id} className="w-full scroll-mt-4 gap-4! md:grid md:grid-cols-[200px_1fr] md:gap-10! py-8 border-0 border-b border-solid border-hairline last:border-b-0">
|
||||
<Stack className="gap-1! min-w-0">
|
||||
<H2 className="text-sm! leading-5! tracking-normal! font-bold text-ink mb-0!">
|
||||
{title}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import SettingsPaneHeader from "./settings-pane-header";
|
||||
|
||||
@@ -6,18 +7,32 @@ type Props = {
|
||||
title: string;
|
||||
description: string;
|
||||
children: ReactNode;
|
||||
/**
|
||||
* A wider column, for tables (activity).
|
||||
*/
|
||||
wide?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The settings pane beside the rail: a 64px header like the conversation
|
||||
* header, then one readable column that scrolls on its own.
|
||||
*/
|
||||
export default function SettingsShell({ title, description, children }: Props) {
|
||||
export default function SettingsShell({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
wide,
|
||||
}: Props) {
|
||||
return (
|
||||
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
|
||||
<SettingsPaneHeader title={title} description={description} />
|
||||
<Stack className="grow min-h-0 overflow-y-auto gap-0! items-stretch">
|
||||
<Stack className="w-full max-w-[760px] mx-auto gap-0! items-stretch px-6 lg:px-10 pb-16">
|
||||
<Stack
|
||||
className={twMerge(
|
||||
"w-full max-w-container mx-auto gap-0! items-stretch px-6 lg:px-10 pb-16",
|
||||
wide && "max-w-[1200px]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import deleteCronJob from "@/src/functions/backend/db/cron-jobs/delete-cron-job";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import { CronJobIdRequestSchema } from "@/src/schemas/cron-job-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Deletes one of the session user's routines (a bot's schedule).
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({ req, body }) => {
|
||||
try {
|
||||
if (req.method != "POST") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const { user, user_types } = await userAuth({ req });
|
||||
|
||||
if (!user?.logged_in_status || !user.id) {
|
||||
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||
}
|
||||
|
||||
if (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const { cron_job_id } = parseWithSchema({ schema: CronJobIdRequestSchema, data: body });
|
||||
|
||||
const cron_job = await deleteCronJob({ user_id: user.id, cron_job_id });
|
||||
|
||||
await logEvent({
|
||||
title: "Routine deleted",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} deleted the routine "${cron_job.title}" of ${cron_job.bot_name}.`,
|
||||
metadata: { cron_job_id },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return { success: true, msg: `${cron_job.title || "The routine"} is deleted.` };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import updateCronJob from "@/src/functions/backend/db/cron-jobs/update-cron-job";
|
||||
import { UpdateCronJobRequestSchema } from "@/src/schemas/cron-job-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Changes one of the session user's routines (a bot's schedule): its
|
||||
* name, brief or schedule, or pauses and resumes it. Returns the routine.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<CronJobWithNames>> = async ({ req, body }) => {
|
||||
try {
|
||||
if (req.method != "POST") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const { user, user_types } = await userAuth({ req });
|
||||
|
||||
if (!user?.logged_in_status || !user.id) {
|
||||
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||
}
|
||||
|
||||
if (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const { cron_job_id, ...data } = parseWithSchema({ schema: UpdateCronJobRequestSchema, data: body });
|
||||
|
||||
const cron_job = await updateCronJob({ user_id: user.id, cron_job_id, data });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
singleRes: cron_job,
|
||||
msg:
|
||||
data.enabled === undefined
|
||||
? `Saved.`
|
||||
: data.enabled
|
||||
? `${cron_job.title} is back on.`
|
||||
: `${cron_job.title} is paused.`,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user