swarm-ui: a schedules page, one card per scheduled prompt
/schedules lists the controller's scheduled prompts as cards: name as the title, the agents it fires for, the message, its cadence and next fire. Edit turns a card into its own form; pause, resume and remove act in place, and + schedule opens an empty form. The form sends only what changed, so a rename never moves the fire times, and warns when the start or the repeat does. Filters reuse the shared card filter bar. Adds a TextAreaField to the ui kit, and an upcoming mode on RelativeTime so a next-fire time reads "in 5m". Needs the controller's /api/schedules routes.
This commit is contained in:
parent
7f597f5c43
commit
63ee6d12be
14 changed files with 931 additions and 3 deletions
|
|
@ -14,6 +14,7 @@ domain, covers host-level detail for one hive.
|
||||||
| `/agents/<name>/terminal` | one agent's live terminal |
|
| `/agents/<name>/terminal` | one agent's live terminal |
|
||||||
| `/agents/<name>/subagents/<subagent>/terminal` | one of that agent's subagents' live terminal |
|
| `/agents/<name>/subagents/<subagent>/terminal` | one of that agent's subagents' live terminal |
|
||||||
| `/jobs` | the controller's job graph — where agent creation and credential mints show progress |
|
| `/jobs` | the controller's job graph — where agent creation and credential mints show progress |
|
||||||
|
| `/schedules` | the scheduled prompts: add, edit, pause, resume and remove |
|
||||||
| `/issues` | a cross-repo issue report |
|
| `/issues` | a cross-repo issue report |
|
||||||
|
|
||||||
Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md).
|
Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md).
|
||||||
|
|
@ -32,6 +33,26 @@ agent spawns shows up once it writes output.
|
||||||
A preview shows rows published while it's open. A subagent terminal has no
|
A preview shows rows published while it's open. A subagent terminal has no
|
||||||
turn-state badges.
|
turn-state badges.
|
||||||
|
|
||||||
|
### Scheduled prompts
|
||||||
|
|
||||||
|
`/schedules` has one card per scheduled prompt. The **name** is the card's
|
||||||
|
title, with the **agents** it fires for, the message, how often it repeats and
|
||||||
|
when it fires next. **edit** turns the card into its own form; **pause** and
|
||||||
|
**resume** stop and restart it; **remove** deletes it after a confirmation.
|
||||||
|
**+ schedule** opens an empty form above the list, and the filters narrow the
|
||||||
|
list by name, agent or state (`active`, `paused`, or `done` for a one-shot that
|
||||||
|
has fired).
|
||||||
|
|
||||||
|
A schedule repeats **every** some number of seconds, minutes, hours or days, or
|
||||||
|
fires **once**. Its start time is the moment a one-shot fires, and the origin of
|
||||||
|
the fire times of a repeating one. The form previews the next three fire times.
|
||||||
|
Changing the start or the repeat moves them; editing only the name, message or
|
||||||
|
agents does not, and the form warns when an edit would.
|
||||||
|
|
||||||
|
Only an operator can change a schedule here or with `swarmctl schedule`; an
|
||||||
|
agent has no way to add one. The page needs the controller's `/api/schedules`
|
||||||
|
routes.
|
||||||
|
|
||||||
### Sending a message to an agent
|
### Sending a message to an agent
|
||||||
|
|
||||||
Under an agent's terminal, in the preview and in its own tab, is a **message**
|
Under an agent's terminal, in the preview and in its own tab, is a **message**
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { ComponentsPage } from "./pages/ComponentsPage.js";
|
||||||
import { JobsPage } from "./pages/JobsPage.js";
|
import { JobsPage } from "./pages/JobsPage.js";
|
||||||
import { HivesPage } from "./pages/HivesPage.js";
|
import { HivesPage } from "./pages/HivesPage.js";
|
||||||
import { IssueReportPage } from "./pages/IssueReportPage.js";
|
import { IssueReportPage } from "./pages/IssueReportPage.js";
|
||||||
|
import { SchedulesPage } from "./pages/schedules/SchedulesPage.js";
|
||||||
import { Panel } from "./ui/panel/Panel.js";
|
import { Panel } from "./ui/panel/Panel.js";
|
||||||
|
|
||||||
function NotFound() {
|
function NotFound() {
|
||||||
|
|
@ -30,6 +31,7 @@ export function App() {
|
||||||
component={AgentTerminalPage}
|
component={AgentTerminalPage}
|
||||||
/>
|
/>
|
||||||
<Route path="/jobs" component={JobsPage} />
|
<Route path="/jobs" component={JobsPage} />
|
||||||
|
<Route path="/schedules" component={SchedulesPage} />
|
||||||
<Route path="/issues" component={IssueReportPage} />
|
<Route path="/issues" component={IssueReportPage} />
|
||||||
<Route path="/components" component={ComponentsPage} />
|
<Route path="/components" component={ComponentsPage} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
import { SplitView } from "../ui/split-view/SplitView.js";
|
import { SplitView } from "../ui/split-view/SplitView.js";
|
||||||
import { Table, type TableColumn } from "../ui/table/Table.js";
|
import { Table, type TableColumn } from "../ui/table/Table.js";
|
||||||
import { TextField } from "../ui/text-field/TextField.js";
|
import { TextField } from "../ui/text-field/TextField.js";
|
||||||
|
import { TextAreaField } from "../ui/text-area-field/TextAreaField.js";
|
||||||
import {
|
import {
|
||||||
SelectField,
|
SelectField,
|
||||||
type SelectOption,
|
type SelectOption,
|
||||||
|
|
@ -114,6 +115,18 @@ const MODEL_OPTIONS: DropdownOption[] = [
|
||||||
{ value: "opus", label: "opus", description: "powerful" },
|
{ value: "opus", label: "opus", description: "powerful" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function TextAreaFieldSample() {
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
return (
|
||||||
|
<TextAreaField
|
||||||
|
id="sample-text-area-field"
|
||||||
|
label="message"
|
||||||
|
value={value}
|
||||||
|
onInput={setValue}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Controlled samples need their own state to actually type/select into —
|
// Controlled samples need their own state to actually type/select into —
|
||||||
// module-level consts can't do that, hence these two small wrappers
|
// module-level consts can't do that, hence these two small wrappers
|
||||||
// rather than inline JSX in the page body below.
|
// rather than inline JSX in the page body below.
|
||||||
|
|
@ -465,6 +478,12 @@ export function ComponentsPage() {
|
||||||
</Sample>
|
</Sample>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<Section title="TextAreaField">
|
||||||
|
<Sample label="editable">
|
||||||
|
<TextAreaFieldSample />
|
||||||
|
</Sample>
|
||||||
|
</Section>
|
||||||
|
|
||||||
<Section title="SelectField">
|
<Section title="SelectField">
|
||||||
<Sample label="editable">
|
<Sample label="editable">
|
||||||
<SelectFieldSample />
|
<SelectFieldSample />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
/* <ScheduleCard> content layout — the bordered container is `Card`'s. */
|
||||||
|
.ui-schedule-card {
|
||||||
|
gap: 0.5em;
|
||||||
|
}
|
||||||
|
.ui-schedule-card-head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5em;
|
||||||
|
}
|
||||||
|
.ui-schedule-card-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1em;
|
||||||
|
/* Takes the leftover width so the badges stay on the title's line when it
|
||||||
|
is short and wrap under it when it is long. */
|
||||||
|
margin-right: auto;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.ui-schedule-card-agents,
|
||||||
|
.ui-schedule-card-next {
|
||||||
|
color: var(--muted);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
/* The prompt text keeps its own line breaks; long unbroken tokens wrap
|
||||||
|
rather than widening the card. */
|
||||||
|
.ui-schedule-card-body {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.ui-schedule-card-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5em;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
// <ScheduleCard> — one scheduled prompt as a card: its name as the title,
|
||||||
|
// "agents: foo, bar" beneath, then the message, the cadence and when it
|
||||||
|
// fires next. Edit, pause/resume and remove sit at the foot; edit swaps the
|
||||||
|
// card for `ScheduleForm` (the page owns that swap).
|
||||||
|
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
|
||||||
|
import { Button } from "../../ui/button/Button.js";
|
||||||
|
import { Card } from "../../ui/card/Card.js";
|
||||||
|
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
|
||||||
|
import {
|
||||||
|
fmtInterval,
|
||||||
|
fmtLocal,
|
||||||
|
scheduleState,
|
||||||
|
type Schedule,
|
||||||
|
type ScheduleState,
|
||||||
|
} from "./ScheduleTypes.js";
|
||||||
|
import "./ScheduleCard.css";
|
||||||
|
|
||||||
|
const STATE_TONE: Record<ScheduleState, BadgeTone> = {
|
||||||
|
active: "positive",
|
||||||
|
paused: "warning",
|
||||||
|
done: "neutral",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ScheduleCard({
|
||||||
|
schedule,
|
||||||
|
pending,
|
||||||
|
onEdit,
|
||||||
|
onTogglePaused,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
schedule: Schedule;
|
||||||
|
/** A pause/resume or remove for this schedule is in flight. */
|
||||||
|
pending: boolean;
|
||||||
|
onEdit: () => void;
|
||||||
|
onTogglePaused: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const state = scheduleState(schedule);
|
||||||
|
return (
|
||||||
|
<Card class="ui-schedule-card">
|
||||||
|
<div class="ui-schedule-card-head">
|
||||||
|
<h3 class="ui-schedule-card-name">{schedule.name}</h3>
|
||||||
|
<Badge tone={STATE_TONE[state]} value={state} />
|
||||||
|
<Badge
|
||||||
|
value={
|
||||||
|
schedule.interval_seconds === null
|
||||||
|
? "once"
|
||||||
|
: `every ${fmtInterval(schedule.interval_seconds)}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="ui-schedule-card-agents">
|
||||||
|
agents: {schedule.agents.join(", ")}
|
||||||
|
</div>
|
||||||
|
<p class="ui-schedule-card-body">{schedule.body}</p>
|
||||||
|
<div class="ui-schedule-card-next">
|
||||||
|
{schedule.next_fire_unix !== null ? (
|
||||||
|
<>
|
||||||
|
next fire:{" "}
|
||||||
|
<span title={fmtLocal(schedule.next_fire_unix)}>
|
||||||
|
<RelativeTime epochMs={schedule.next_fire_unix * 1000} upcoming />
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : state === "paused" ? (
|
||||||
|
"paused, will not fire"
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
fired{" "}
|
||||||
|
<span title={fmtLocal(schedule.anchor_unix)}>
|
||||||
|
<RelativeTime epochMs={schedule.anchor_unix * 1000} />
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div class="ui-schedule-card-actions">
|
||||||
|
<Button onClick={onEdit} disabled={pending}>
|
||||||
|
edit
|
||||||
|
</Button>
|
||||||
|
{state !== "done" ? (
|
||||||
|
<Button onClick={onTogglePaused} disabled={pending}>
|
||||||
|
{schedule.paused ? "resume" : "pause"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button onClick={onRemove} disabled={pending}>
|
||||||
|
remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
/* The form fills its card; fields stack with the page's usual gap. The
|
||||||
|
cadence controls sit in one wrapping row so "repeats / every / unit /
|
||||||
|
starts at" read as one sentence on a wide card and stack on a narrow one. */
|
||||||
|
.ui-schedule-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.9em;
|
||||||
|
}
|
||||||
|
.ui-schedule-form-agents {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3em;
|
||||||
|
}
|
||||||
|
/* The picker's own list is a scrolling column sized for a filter popover; in
|
||||||
|
a form the agents read better as a wrapping row, all visible at once. */
|
||||||
|
.ui-schedule-form-agents .ui-multiselect-filter-options {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.3em 1.2em;
|
||||||
|
max-height: none;
|
||||||
|
overflow-y: visible;
|
||||||
|
}
|
||||||
|
.ui-schedule-form-cadence {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75em;
|
||||||
|
}
|
||||||
|
.ui-schedule-form-cadence > .ui-form-field {
|
||||||
|
width: auto;
|
||||||
|
flex: 1 1 9em;
|
||||||
|
}
|
||||||
|
.ui-schedule-form-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.ui-schedule-form-warn {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--yellow);
|
||||||
|
}
|
||||||
|
.ui-schedule-form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5em;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
294
frontend/packages/swarm-ui/src/pages/schedules/ScheduleForm.tsx
Normal file
294
frontend/packages/swarm-ui/src/pages/schedules/ScheduleForm.tsx
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
// <ScheduleForm> — create or edit one scheduled prompt, rendered as a card
|
||||||
|
// so an edited schedule's card turns into its own form in place (mara: "the
|
||||||
|
// edit button changes the card to be the edit form"). With `schedule` it
|
||||||
|
// edits and sends a PATCH carrying only what changed; without, it POSTs a
|
||||||
|
// new one.
|
||||||
|
//
|
||||||
|
// Cadence is either "once" or "every N unit", plus a start time: the fire
|
||||||
|
// time of a one-shot, the origin of the fire grid of a repeating schedule.
|
||||||
|
// Changing the start or the interval moves that grid; the name, message and
|
||||||
|
// agents never do, so the form says which of the two an edit is.
|
||||||
|
import { useState } from "preact/hooks";
|
||||||
|
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||||
|
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||||
|
import { Button } from "../../ui/button/Button.js";
|
||||||
|
import { Card } from "../../ui/card/Card.js";
|
||||||
|
import {
|
||||||
|
MultiselectFilterOptions,
|
||||||
|
type MultiselectFilterState,
|
||||||
|
} from "../../ui/multiselect-filter/MultiselectFilter.js";
|
||||||
|
import { SelectField } from "../../ui/select-field/SelectField.js";
|
||||||
|
import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js";
|
||||||
|
import { TextField } from "../../ui/text-field/TextField.js";
|
||||||
|
import {
|
||||||
|
UNIT_SECONDS,
|
||||||
|
fmtLocal,
|
||||||
|
fromLocalInput,
|
||||||
|
splitInterval,
|
||||||
|
toLocalInput,
|
||||||
|
upcomingFires,
|
||||||
|
type IntervalUnit,
|
||||||
|
type Schedule,
|
||||||
|
} from "./ScheduleTypes.js";
|
||||||
|
import "./ScheduleForm.css";
|
||||||
|
|
||||||
|
const UNIT_OPTIONS = [
|
||||||
|
{ value: "s", label: "seconds" },
|
||||||
|
{ value: "m", label: "minutes" },
|
||||||
|
{ value: "h", label: "hours" },
|
||||||
|
{ value: "d", label: "days" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PREVIEW_COUNT = 3;
|
||||||
|
|
||||||
|
// The current time rounded up to the next whole minute: what a new schedule
|
||||||
|
// starts at unless the operator picks another time. Rounded because the
|
||||||
|
// time input has minute resolution.
|
||||||
|
function nextMinuteUnix(): number {
|
||||||
|
return Math.ceil(Date.now() / 60_000) * 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScheduleForm({
|
||||||
|
schedule,
|
||||||
|
agentOptions,
|
||||||
|
onSaved,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
/** The schedule being edited; absent for a new one. */
|
||||||
|
schedule?: Schedule;
|
||||||
|
/** Every agent a schedule can target. */
|
||||||
|
agentOptions: string[];
|
||||||
|
onSaved: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const initialInterval =
|
||||||
|
schedule?.interval_seconds != null
|
||||||
|
? splitInterval(schedule.interval_seconds)
|
||||||
|
: { amount: 1, unit: "h" as IntervalUnit };
|
||||||
|
const initialStart = toLocalInput(schedule?.anchor_unix ?? nextMinuteUnix());
|
||||||
|
|
||||||
|
const [name, setName] = useState(schedule?.name ?? "");
|
||||||
|
const [body, setBody] = useState(schedule?.body ?? "");
|
||||||
|
const [agents, setAgents] = useState<string[]>(schedule?.agents ?? []);
|
||||||
|
const [repeat, setRepeat] = useState(
|
||||||
|
schedule ? schedule.interval_seconds !== null : true,
|
||||||
|
);
|
||||||
|
const [amount, setAmount] = useState(String(initialInterval.amount));
|
||||||
|
const [unit, setUnit] = useState<IntervalUnit>(initialInterval.unit);
|
||||||
|
const [start, setStart] = useState(initialStart);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||||
|
|
||||||
|
const idPrefix = `schedule-${schedule?.id ?? "new"}`;
|
||||||
|
const amountNum = Number(amount);
|
||||||
|
const intervalSeconds =
|
||||||
|
repeat && Number.isInteger(amountNum) && amountNum > 0
|
||||||
|
? amountNum * UNIT_SECONDS[unit]
|
||||||
|
: null;
|
||||||
|
const startUnix = fromLocalInput(start);
|
||||||
|
const cadenceValid = startUnix !== null && (!repeat || intervalSeconds !== null);
|
||||||
|
|
||||||
|
const picked: MultiselectFilterState = { values: agents, negate: false };
|
||||||
|
const toggleAgent = (a: string) =>
|
||||||
|
setAgents((cur) =>
|
||||||
|
cur.includes(a) ? cur.filter((x) => x !== a) : [...cur, a],
|
||||||
|
);
|
||||||
|
|
||||||
|
// The grid moves when the start or the repeat interval differs from what
|
||||||
|
// the schedule has now; only meaningful when editing. The start compares
|
||||||
|
// as the text the input shows, not as seconds: the input has minute
|
||||||
|
// resolution, so a schedule created off a whole minute would otherwise
|
||||||
|
// always look edited.
|
||||||
|
const startChanged = start !== initialStart;
|
||||||
|
const cadenceChanged =
|
||||||
|
schedule !== undefined &&
|
||||||
|
(startChanged ||
|
||||||
|
(repeat ? intervalSeconds : null) !== schedule.interval_seconds);
|
||||||
|
|
||||||
|
const fires =
|
||||||
|
cadenceValid && startUnix !== null
|
||||||
|
? upcomingFires(
|
||||||
|
startUnix,
|
||||||
|
repeat ? intervalSeconds : null,
|
||||||
|
Math.floor(Date.now() / 1000),
|
||||||
|
PREVIEW_COUNT,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
async function submit(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!cadenceValid || startUnix === null) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const cadence = repeat ? intervalSeconds : null;
|
||||||
|
const patch = schedule
|
||||||
|
? patchBody(schedule, {
|
||||||
|
name,
|
||||||
|
body,
|
||||||
|
agents,
|
||||||
|
startUnix: startChanged ? startUnix : null,
|
||||||
|
cadence,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
// Nothing differs: there is nothing to send.
|
||||||
|
if (patch && Object.keys(patch).length === 0) {
|
||||||
|
onSaved();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const r = schedule
|
||||||
|
? await fetch(`/api/schedules/${encodeURIComponent(schedule.id)}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
})
|
||||||
|
: await fetch("/api/schedules", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
agents,
|
||||||
|
body,
|
||||||
|
anchor_unix: startUnix,
|
||||||
|
interval_seconds: cadence,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
setError(await readApiError(r));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSaved();
|
||||||
|
} catch (err) {
|
||||||
|
setError({ detail: String(err) });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card class="ui-schedule-form-card">
|
||||||
|
<form class="ui-schedule-form" onSubmit={submit}>
|
||||||
|
<TextField
|
||||||
|
id={`${idPrefix}-name`}
|
||||||
|
label="name"
|
||||||
|
value={name}
|
||||||
|
required
|
||||||
|
onInput={setName}
|
||||||
|
/>
|
||||||
|
<div class="ui-schedule-form-agents">
|
||||||
|
<span class="ui-form-field-label">agents</span>
|
||||||
|
{agentOptions.length === 0 ? (
|
||||||
|
<span class="ui-schedule-form-hint">no agents to pick from</span>
|
||||||
|
) : (
|
||||||
|
<MultiselectFilterOptions
|
||||||
|
options={agentOptions}
|
||||||
|
filter={picked}
|
||||||
|
onToggle={toggleAgent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<TextAreaField
|
||||||
|
id={`${idPrefix}-body`}
|
||||||
|
label="message"
|
||||||
|
value={body}
|
||||||
|
required
|
||||||
|
onInput={setBody}
|
||||||
|
/>
|
||||||
|
<div class="ui-schedule-form-cadence">
|
||||||
|
<SelectField
|
||||||
|
id={`${idPrefix}-repeat`}
|
||||||
|
label="repeats"
|
||||||
|
value={repeat ? "every" : "once"}
|
||||||
|
onChange={(v) => setRepeat(v === "every")}
|
||||||
|
options={[
|
||||||
|
{ value: "every", label: "every…" },
|
||||||
|
{ value: "once", label: "once" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{repeat ? (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
id={`${idPrefix}-amount`}
|
||||||
|
label="every"
|
||||||
|
type="number"
|
||||||
|
value={amount}
|
||||||
|
required
|
||||||
|
onInput={setAmount}
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
id={`${idPrefix}-unit`}
|
||||||
|
label="unit"
|
||||||
|
value={unit}
|
||||||
|
onChange={(v) => setUnit(v as IntervalUnit)}
|
||||||
|
options={UNIT_OPTIONS}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<TextField
|
||||||
|
id={`${idPrefix}-start`}
|
||||||
|
label={repeat ? "starts at" : "fires at"}
|
||||||
|
type="datetime-local"
|
||||||
|
value={start}
|
||||||
|
required
|
||||||
|
onInput={setStart}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{fires.length > 0 ? (
|
||||||
|
<p class="ui-schedule-form-hint">
|
||||||
|
{repeat ? "next fires: " : "fires: "}
|
||||||
|
{fires.map(fmtLocal).join(" · ")}
|
||||||
|
</p>
|
||||||
|
) : cadenceValid ? (
|
||||||
|
<p class="ui-schedule-form-hint">that time has already passed</p>
|
||||||
|
) : null}
|
||||||
|
{cadenceChanged ? (
|
||||||
|
<p class="ui-schedule-form-warn">
|
||||||
|
Changing the start or the repeat moves when this schedule fires.
|
||||||
|
Editing only the name, message or agents does not.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{error ? (
|
||||||
|
<ApiErrorPanel context="failed to save the schedule" problem={error} />
|
||||||
|
) : null}
|
||||||
|
<div class="ui-schedule-form-actions">
|
||||||
|
<Button onClick={onCancel}>cancel</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={saving || !cadenceValid || agents.length === 0}
|
||||||
|
>
|
||||||
|
{saving ? "saving…" : schedule ? "save" : "create"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A PATCH body holding only the fields that differ from `schedule`, so an
|
||||||
|
// edit never rewrites what the operator did not touch (and a body-only edit
|
||||||
|
// cannot move the fire grid by resending the anchor).
|
||||||
|
function patchBody(
|
||||||
|
schedule: Schedule,
|
||||||
|
next: {
|
||||||
|
name: string;
|
||||||
|
body: string;
|
||||||
|
agents: string[];
|
||||||
|
/** The new start, or null when the operator left it alone. */
|
||||||
|
startUnix: number | null;
|
||||||
|
cadence: number | null;
|
||||||
|
},
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const patch: Record<string, unknown> = {};
|
||||||
|
if (next.name !== schedule.name) patch.name = next.name;
|
||||||
|
if (next.body !== schedule.body) patch.body = next.body;
|
||||||
|
if (next.startUnix !== null) patch.anchor_unix = next.startUnix;
|
||||||
|
if (next.cadence !== schedule.interval_seconds) {
|
||||||
|
patch.interval_seconds = next.cadence;
|
||||||
|
}
|
||||||
|
const add = next.agents.filter((a) => !schedule.agents.includes(a));
|
||||||
|
const remove = schedule.agents.filter((a) => !next.agents.includes(a));
|
||||||
|
if (add.length > 0) patch.add_agents = add;
|
||||||
|
if (remove.length > 0) patch.remove_agents = remove;
|
||||||
|
return patch;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
// Wire type and the small pure helpers the schedules page shares between
|
||||||
|
// its card, its form and the page itself.
|
||||||
|
|
||||||
|
// One scheduled prompt as swarm-controller's `GET /api/schedules` sends it.
|
||||||
|
// The controller computes `next_fire_unix`: null while paused, and for a
|
||||||
|
// one-shot whose time has passed. `interval_seconds` is always present,
|
||||||
|
// null for a one-shot.
|
||||||
|
export interface Schedule {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
agents: string[];
|
||||||
|
body: string;
|
||||||
|
anchor_unix: number;
|
||||||
|
interval_seconds: number | null;
|
||||||
|
paused: boolean;
|
||||||
|
next_fire_unix: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScheduleState = "active" | "paused" | "done";
|
||||||
|
|
||||||
|
// A one-shot that has fired reads "done" rather than "active": nothing is
|
||||||
|
// left to wait for, but the card stays until the operator removes it.
|
||||||
|
export function scheduleState(s: Schedule): ScheduleState {
|
||||||
|
if (s.paused) return "paused";
|
||||||
|
if (s.interval_seconds === null && s.next_fire_unix === null) return "done";
|
||||||
|
return "active";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IntervalUnit = "s" | "m" | "h" | "d";
|
||||||
|
|
||||||
|
export const UNIT_SECONDS: Record<IntervalUnit, number> = {
|
||||||
|
s: 1,
|
||||||
|
m: 60,
|
||||||
|
h: 3600,
|
||||||
|
d: 86400,
|
||||||
|
};
|
||||||
|
|
||||||
|
// "1d 2h", "15m", "90s": the non-zero parts, largest first.
|
||||||
|
export function fmtInterval(seconds: number): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
let rest = seconds;
|
||||||
|
for (const unit of ["d", "h", "m", "s"] as const) {
|
||||||
|
const n = Math.floor(rest / UNIT_SECONDS[unit]);
|
||||||
|
if (n > 0) parts.push(`${n}${unit}`);
|
||||||
|
rest -= n * UNIT_SECONDS[unit];
|
||||||
|
}
|
||||||
|
return parts.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Splits an interval into the form's amount + unit: the largest unit that
|
||||||
|
// divides it evenly, so "86400" reopens as "1 d" and "5400" as "90 m".
|
||||||
|
export function splitInterval(seconds: number): {
|
||||||
|
amount: number;
|
||||||
|
unit: IntervalUnit;
|
||||||
|
} {
|
||||||
|
for (const unit of ["d", "h", "m"] as const) {
|
||||||
|
if (seconds % UNIT_SECONDS[unit] === 0) {
|
||||||
|
return { amount: seconds / UNIT_SECONDS[unit], unit };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { amount: seconds, unit: "s" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next `count` fire times after `nowUnix` on the grid `anchor + k *
|
||||||
|
// interval`; a one-shot has at most one. Only a preview for the form: the
|
||||||
|
// controller's own `next_fire_unix` is what a saved schedule shows.
|
||||||
|
export function upcomingFires(
|
||||||
|
anchorUnix: number,
|
||||||
|
intervalSeconds: number | null,
|
||||||
|
nowUnix: number,
|
||||||
|
count: number,
|
||||||
|
): number[] {
|
||||||
|
if (intervalSeconds === null) return anchorUnix > nowUnix ? [anchorUnix] : [];
|
||||||
|
const first = Math.max(0, Math.ceil((nowUnix - anchorUnix) / intervalSeconds));
|
||||||
|
return Array.from(
|
||||||
|
{ length: count },
|
||||||
|
(_, i) => anchorUnix + (first + i) * intervalSeconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `<input type="datetime-local">` reads and writes local wall-clock time
|
||||||
|
// with no zone, so converting either way goes through `Date`'s local fields.
|
||||||
|
export function toLocalInput(unix: number): string {
|
||||||
|
const d = new Date(unix * 1000);
|
||||||
|
const p = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromLocalInput(value: string): number | null {
|
||||||
|
const ms = new Date(value).getTime();
|
||||||
|
return Number.isNaN(ms) ? null : Math.floor(ms / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtLocal(unix: number): string {
|
||||||
|
return new Date(unix * 1000).toLocaleString(undefined, {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short",
|
||||||
|
});
|
||||||
|
}
|
||||||
252
frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.tsx
Normal file
252
frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.tsx
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
// <SchedulesPage> — the swarm's scheduled prompts, one card per schedule.
|
||||||
|
// Backed by swarm-controller's `GET/POST /api/schedules` and
|
||||||
|
// `PATCH/DELETE /api/schedules/{id}`; the controller is the only writer of
|
||||||
|
// the schedule store and computes each schedule's `next_fire_unix`, so
|
||||||
|
// nothing here derives a fire time except the form's preview. Operator-only:
|
||||||
|
// the click is the approval, there is no approval queue behind it.
|
||||||
|
//
|
||||||
|
// Filtering reuses `FilterableView`'s card bar (name text, agent and state
|
||||||
|
// multiselects). A card being edited keeps its form state across the
|
||||||
|
// periodic refetch because `FilterableView` keys cards by schedule id and
|
||||||
|
// the open edit is held here by id, not by position.
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
|
||||||
|
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||||
|
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||||
|
import { Button } from "../../ui/button/Button.js";
|
||||||
|
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
|
||||||
|
import { FilterableView } from "../../ui/filterable-view/FilterableView.js";
|
||||||
|
import { Panel } from "../../ui/panel/Panel.js";
|
||||||
|
import {
|
||||||
|
RefreshIntervalPicker,
|
||||||
|
useRefreshInterval,
|
||||||
|
type RefreshIntervalMs,
|
||||||
|
} from "../../ui/refresh-interval/RefreshInterval.js";
|
||||||
|
import type { TableColumn } from "../../ui/table/Table.js";
|
||||||
|
import type { AgentRow } from "../agents/AgentTypes.js";
|
||||||
|
import { ScheduleCard } from "./ScheduleCard.js";
|
||||||
|
import { ScheduleForm } from "./ScheduleForm.js";
|
||||||
|
import { scheduleState, type Schedule } from "./ScheduleTypes.js";
|
||||||
|
|
||||||
|
// A refetch never touches an open form's own state (it lives in the form),
|
||||||
|
// so the usual 30s default is safe here too.
|
||||||
|
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
|
||||||
|
|
||||||
|
const COLUMNS: TableColumn<Schedule>[] = [
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
header: "name",
|
||||||
|
render: (s) => s.name,
|
||||||
|
sortBy: (s) => s.name,
|
||||||
|
filterValue: (s) => s.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "agents",
|
||||||
|
header: "agents",
|
||||||
|
render: (s) => s.agents.join(", "),
|
||||||
|
filterValues: (s) => s.agents,
|
||||||
|
filterMode: "multiselect",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "state",
|
||||||
|
header: "state",
|
||||||
|
render: (s) => scheduleState(s),
|
||||||
|
sortBy: (s) => scheduleState(s),
|
||||||
|
filterValues: (s) => [scheduleState(s)],
|
||||||
|
filterMode: "multiselect",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// The agent names a schedule can target, from the same roster the agents
|
||||||
|
// page lists. A failed load leaves the picker empty rather than blocking
|
||||||
|
// the page: the cards themselves do not need it.
|
||||||
|
async function loadAgentNames(): Promise<string[]> {
|
||||||
|
const r = await fetch("/api/agents/status");
|
||||||
|
if (!r.ok) return [];
|
||||||
|
const rows = (await r.json()) as AgentRow[];
|
||||||
|
return rows.map((a) => a.name).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SchedulesPage() {
|
||||||
|
const [intervalMs, setIntervalMs] =
|
||||||
|
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
||||||
|
const [schedules, setSchedules] = useState<Schedule[] | null>(null);
|
||||||
|
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||||
|
const [agentNames, setAgentNames] = useState<string[]>([]);
|
||||||
|
// The schedule whose card is currently its edit form, and whether the
|
||||||
|
// "new schedule" form is open above the list.
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [pendingIds, setPendingIds] = useState<Set<string>>(new Set());
|
||||||
|
const [removeTarget, setRemoveTarget] = useState<Schedule | null>(null);
|
||||||
|
const [actionError, setActionError] = useState<ProblemDetails | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/schedules");
|
||||||
|
if (!r.ok) {
|
||||||
|
setError(await readApiError(r));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSchedules((await r.json()) as Schedule[]);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError({ detail: String(err) });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useRefreshInterval(intervalMs, load);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAgentNames().then(setAgentNames, () => setAgentNames([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function markPending(id: string, on: boolean) {
|
||||||
|
setPendingIds((cur) => {
|
||||||
|
const next = new Set(cur);
|
||||||
|
if (on) next.add(id);
|
||||||
|
else next.delete(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// One mutation on one schedule: pending while in flight, the error kept
|
||||||
|
// for the banner, a refetch on success so the card shows the controller's
|
||||||
|
// own answer rather than a guessed one.
|
||||||
|
async function act(id: string, request: () => Promise<Response>) {
|
||||||
|
markPending(id, true);
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
const r = await request();
|
||||||
|
if (!r.ok) {
|
||||||
|
setActionError(await readApiError(r));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setActionError({ detail: String(err) });
|
||||||
|
} finally {
|
||||||
|
markPending(id, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePaused(s: Schedule) {
|
||||||
|
return act(s.id, () =>
|
||||||
|
fetch(`/api/schedules/${encodeURIComponent(s.id)}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ paused: !s.paused }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRemove() {
|
||||||
|
const target = removeTarget;
|
||||||
|
if (!target) return;
|
||||||
|
setRemoveTarget(null);
|
||||||
|
await act(target.id, () =>
|
||||||
|
fetch(`/api/schedules/${encodeURIComponent(target.id)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A schedule may still target an agent that has since left the roster;
|
||||||
|
// its own agents stay pickable so an edit can keep or drop them.
|
||||||
|
const optionsFor = useMemo(
|
||||||
|
() => (s?: Schedule) =>
|
||||||
|
[...new Set([...agentNames, ...(s?.agents ?? [])])].sort(),
|
||||||
|
[agentNames],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function saved() {
|
||||||
|
setCreating(false);
|
||||||
|
setEditingId(null);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel
|
||||||
|
title="schedules"
|
||||||
|
icon="⏰"
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => setCreating(true)}
|
||||||
|
disabled={creating}
|
||||||
|
>
|
||||||
|
+ schedule
|
||||||
|
</Button>
|
||||||
|
<RefreshIntervalPicker
|
||||||
|
id="schedules-refresh"
|
||||||
|
value={intervalMs}
|
||||||
|
onChange={setIntervalMs}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{error ? (
|
||||||
|
<ApiErrorPanel
|
||||||
|
context="failed to load the schedules"
|
||||||
|
problem={error}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{actionError ? (
|
||||||
|
<ApiErrorPanel
|
||||||
|
context="the schedule was not changed"
|
||||||
|
problem={actionError}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{creating ? (
|
||||||
|
<ScheduleForm
|
||||||
|
agentOptions={optionsFor()}
|
||||||
|
onSaved={saved}
|
||||||
|
onCancel={() => setCreating(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{!error && schedules === null ? <p>loading…</p> : null}
|
||||||
|
{schedules ? (
|
||||||
|
<FilterableView
|
||||||
|
columns={COLUMNS}
|
||||||
|
rows={schedules}
|
||||||
|
rowKey={(s) => s.id}
|
||||||
|
storageKey="swarm-ui:schedules:table-filters"
|
||||||
|
view="cards"
|
||||||
|
emptyMessage="no schedules yet — add one with + schedule"
|
||||||
|
renderCard={(s) =>
|
||||||
|
editingId === s.id ? (
|
||||||
|
<ScheduleForm
|
||||||
|
schedule={s}
|
||||||
|
agentOptions={optionsFor(s)}
|
||||||
|
onSaved={saved}
|
||||||
|
onCancel={() => setEditingId(null)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ScheduleCard
|
||||||
|
schedule={s}
|
||||||
|
pending={pendingIds.has(s.id)}
|
||||||
|
onEdit={() => setEditingId(s.id)}
|
||||||
|
onTogglePaused={() => togglePaused(s)}
|
||||||
|
onRemove={() => setRemoveTarget(s)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={removeTarget !== null}
|
||||||
|
label="remove schedule"
|
||||||
|
confirmLabel="remove"
|
||||||
|
onCancel={() => setRemoveTarget(null)}
|
||||||
|
onConfirm={confirmRemove}
|
||||||
|
>
|
||||||
|
{removeTarget ? (
|
||||||
|
<p>
|
||||||
|
Remove <strong>{removeTarget.name}</strong>? It stops firing for{" "}
|
||||||
|
{removeTarget.agents.join(", ")} and cannot be brought back.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</ConfirmDialog>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -49,6 +49,7 @@ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
|
||||||
{ href: "/", label: "hives", accent: "var(--purple)" },
|
{ href: "/", label: "hives", accent: "var(--purple)" },
|
||||||
{ href: "/agents", label: "agents", accent: "var(--green)" },
|
{ href: "/agents", label: "agents", accent: "var(--green)" },
|
||||||
{ href: "/jobs", label: "jobs", accent: "var(--pink)" },
|
{ href: "/jobs", label: "jobs", accent: "var(--pink)" },
|
||||||
|
{ href: "/schedules", label: "schedules", accent: "var(--cyan)" },
|
||||||
{ href: "/issues", label: "issues", accent: "var(--yellow)" },
|
{ href: "/issues", label: "issues", accent: "var(--yellow)" },
|
||||||
{ href: "/components", label: "components", accent: "var(--blue)" },
|
{ href: "/components", label: "components", accent: "var(--blue)" },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,24 @@
|
||||||
// package's matrix-rain effect — and resyncs immediately on becoming
|
// package's matrix-rain effect — and resyncs immediately on becoming
|
||||||
// visible again instead of waiting out the rest of a stale interval.
|
// visible again instead of waiting out the rest of a stale interval.
|
||||||
import { useEffect, useState } from "preact/hooks";
|
import { useEffect, useState } from "preact/hooks";
|
||||||
import { fmtAgo } from "../../util.js";
|
import { fmtAgo, fmtUntil } from "../../util.js";
|
||||||
|
|
||||||
// Cheap at the handful of rows/chips this renders for today, and coarse
|
// Cheap at the handful of rows/chips this renders for today, and coarse
|
||||||
// enough to be correct without visibly ticking every frame — the same
|
// enough to be correct without visibly ticking every frame — the same
|
||||||
// choice a wall clock makes.
|
// choice a wall clock makes.
|
||||||
const TICK_MS = 1000;
|
const TICK_MS = 1000;
|
||||||
|
|
||||||
export function RelativeTime({ epochMs }: { epochMs: number }) {
|
// `upcoming`: the instant is expected to be ahead (a schedule's next fire),
|
||||||
|
// so one still ahead reads "in 5m" instead of clamping to "0s ago". Off by
|
||||||
|
// default, so a caller showing an age never sees a skewed clock turn it
|
||||||
|
// into a countdown.
|
||||||
|
export function RelativeTime({
|
||||||
|
epochMs,
|
||||||
|
upcoming = false,
|
||||||
|
}: {
|
||||||
|
epochMs: number;
|
||||||
|
upcoming?: boolean;
|
||||||
|
}) {
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -43,5 +53,8 @@ export function RelativeTime({ epochMs }: { epochMs: number }) {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <>{fmtAgo((now - epochMs) / 1000)}</>;
|
const ageSeconds = (now - epochMs) / 1000;
|
||||||
|
return (
|
||||||
|
<>{upcoming && ageSeconds < 0 ? fmtUntil(-ageSeconds) : fmtAgo(ageSeconds)}</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
/* `min-height` from `.ui-form-control` is a single-line floor; a textarea's
|
||||||
|
own `rows` sets its height, so only the resize axis needs saying here. */
|
||||||
|
.ui-text-area {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
// <TextAreaField> — `TextField`'s multi-line sibling, for free text that
|
||||||
|
// runs past one line (a scheduled prompt's body is the motivating caller).
|
||||||
|
// Same label chrome and `.ui-form-control` look as the rest of the kit;
|
||||||
|
// the box only grows vertically, since a horizontal drag would break out
|
||||||
|
// of whatever column the page gave the field.
|
||||||
|
import { FormField } from "../form-field/FormField.js";
|
||||||
|
import "./TextAreaField.css";
|
||||||
|
|
||||||
|
export function TextAreaField({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onInput,
|
||||||
|
rows = 4,
|
||||||
|
required,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onInput: (value: string) => void;
|
||||||
|
rows?: number;
|
||||||
|
required?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<FormField label={label} htmlFor={id}>
|
||||||
|
<textarea
|
||||||
|
id={id}
|
||||||
|
class="ui-form-control ui-text-area"
|
||||||
|
value={value}
|
||||||
|
rows={rows}
|
||||||
|
required={required}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onInput={(e) => onInput((e.target as HTMLTextAreaElement).value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,17 @@ const RTF = new Intl.RelativeTimeFormat("en", {
|
||||||
style: "narrow",
|
style: "narrow",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The same coarsening for an instant still ahead ("in 5m"). A separate
|
||||||
|
// function rather than a negative `fmtAgo`: that one clamps to zero so a
|
||||||
|
// clock a little ahead of the server never reads as "-3s ago".
|
||||||
|
export function fmtUntil(secondsAhead: number): string {
|
||||||
|
const ahead = Math.max(0, Math.floor(secondsAhead));
|
||||||
|
if (ahead < 60) return RTF.format(ahead, "second");
|
||||||
|
if (ahead < 3600) return RTF.format(Math.floor(ahead / 60), "minute");
|
||||||
|
if (ahead < 86400) return RTF.format(Math.floor(ahead / 3600), "hour");
|
||||||
|
return RTF.format(Math.floor(ahead / 86400), "day");
|
||||||
|
}
|
||||||
|
|
||||||
export function fmtAgo(ageSeconds: number): string {
|
export function fmtAgo(ageSeconds: number): string {
|
||||||
const age = Math.max(0, Math.floor(ageSeconds));
|
const age = Math.max(0, Math.floor(ageSeconds));
|
||||||
if (age < 60) return RTF.format(-age, "second");
|
if (age < 60) return RTF.format(-age, "second");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue