feat: schedule editor on the workflow page

This commit is contained in:
2026-08-04 14:13:31 +01:00
parent 439bc2ed7d
commit 484b620867
2 changed files with 221 additions and 61 deletions
+83 -61
View File
@@ -5,73 +5,95 @@ import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { api, Workflow } from "@/lib/api";
import { Button, Modal } from "@/components/ui";
import { ScheduleCard } from "./ScheduleCard";
const inputClass =
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-signal focus:outline-none focus:ring-1 focus:ring-signal";
const inputClass = "w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-signal focus:outline-none focus:ring-1 focus:ring-signal";
export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open: boolean; workflow: Workflow; onSaved: (w: Workflow) => void; onClose: () => void }) {
const router = useRouter();
const [name, setName] = useState(workflow.name);
const [targets, setTargets] = useState<string[]>(workflow.target_server_ids);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
const router = useRouter();
const [name, setName] = useState(workflow.name);
const [targets, setTargets] = useState<string[]>(workflow.target_server_ids);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
useEffect(() => {
if (open) {
setName(workflow.name);
setTargets(workflow.target_server_ids);
}
}, [open, workflow]);
useEffect(() => {
if (open) {
setName(workflow.name);
setTargets(workflow.target_server_ids);
}
}, [open, workflow]);
const toggle = (id: string) => setTargets((t) => (t.includes(id) ? t.filter((x) => x !== id) : [...t, id]));
const toggle = (id: string) => setTargets((t) => (t.includes(id) ? t.filter((x) => x !== id) : [...t, id]));
const save = async () => {
setBusy(true); setError(null);
try {
const updated = await api.updateWorkflow(workflow.workflow_id, { ...workflow, name, target_server_ids: targets });
onSaved(updated); onClose();
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
};
const save = async () => {
setBusy(true);
setError(null);
try {
const updated = await api.updateWorkflow(workflow.workflow_id, { ...workflow, name, target_server_ids: targets });
onSaved(updated);
onClose();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
};
const del = async () => {
if (!window.confirm("Delete this workflow? This cannot be undone.")) return;
setBusy(true); setError(null);
try { await api.deleteWorkflow(workflow.workflow_id); router.push("/workflows"); }
catch (e) { setError((e as Error).message); setBusy(false); }
};
const del = async () => {
if (!window.confirm("Delete this workflow? This cannot be undone.")) return;
setBusy(true);
setError(null);
try {
await api.deleteWorkflow(workflow.workflow_id);
router.push("/workflows");
} catch (e) {
setError((e as Error).message);
setBusy(false);
}
};
return (
<Modal open={open} onClose={onClose} title="Edit workflow">
<div className="space-y-4">
{error && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Name</label>
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Target servers</label>
<div className="flex flex-wrap gap-2">
{servers?.map((s) => {
const on = targets.includes(s.server_id);
return (
<label key={s.server_id} className={`flex cursor-pointer items-center gap-2 rounded-lg border px-2 py-1 text-sm ${on ? "border-signal bg-signal/10 text-text-primary" : "border-border text-text-secondary"}`}>
<input type="checkbox" className="accent-signal" checked={on} onChange={() => toggle(s.server_id)} />
{s.hostname}
</label>
);
})}
{servers && servers.length === 0 && <p className="text-xs text-text-secondary">No servers registered.</p>}
</div>
</div>
<div className="flex items-center justify-between pt-2">
<Button variant="danger" onClick={del} loading={busy}>Delete workflow</Button>
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={save} loading={busy} disabled={!name.trim()}>Save</Button>
</div>
</div>
</div>
</Modal>
);
return (
<Modal open={open} onClose={onClose} title="Edit workflow">
<div className="space-y-4">
{error && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Name</label>
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Target servers</label>
<div className="flex flex-wrap gap-2">
{servers?.map((s) => {
const on = targets.includes(s.server_id);
return (
<label
key={s.server_id}
className={`flex cursor-pointer items-center gap-2 rounded-lg border px-2 py-1 text-sm ${on ? "border-signal bg-signal/10 text-text-primary" : "border-border text-text-secondary"}`}
>
<input type="checkbox" className="accent-signal" checked={on} onChange={() => toggle(s.server_id)} />
{s.hostname}
</label>
);
})}
{servers && servers.length === 0 && <p className="text-xs text-text-secondary">No servers registered.</p>}
</div>
</div>
<div className="flex items-center justify-between pt-2">
<Button variant="danger" onClick={del} loading={busy}>
Delete workflow
</Button>
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" onClick={save} loading={busy} disabled={!name.trim()}>
Save
</Button>
</div>
</div>
<ScheduleCard workflow={workflow} />
</div>
</Modal>
);
}
+138
View File
@@ -0,0 +1,138 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, Workflow } from "@/lib/api";
import { Button } from "@/components/ui";
/*
* Presets write cron underneath rather than being their own storage format:
* one representation, and the raw field is always the truth. The next three
* occurrences come from the server so the browser cannot disagree with the
* scheduler about what an expression means.
*/
const PRESETS: { label: string; cron: string }[] = [
{ label: "Hourly", cron: "0 * * * *" },
{ label: "Nightly, 02:00", cron: "0 2 * * *" },
{ label: "Weekly, Sun 02:00", cron: "0 2 * * 0" },
{ label: "Monthly, 1st 02:00", cron: "0 2 1 * *" },
];
const ZONES = ["UTC", "Europe/London", "Europe/Berlin", "America/New_York", "America/Los_Angeles", "Asia/Singapore", "Australia/Sydney"];
export function ScheduleCard({ workflow }: { workflow: Workflow }) {
const queryClient = useQueryClient();
const [enabled, setEnabled] = useState(workflow.schedule?.enabled ?? false);
const [cron, setCron] = useState(workflow.schedule?.cron ?? "0 2 * * 0");
const [tz, setTz] = useState(workflow.schedule?.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC");
const [error, setError] = useState<string | null>(null);
const { data: preview } = useQuery({
queryKey: ["schedule-preview", workflow.workflow_id, cron, tz],
queryFn: () => api.previewSchedule(workflow.workflow_id, cron, tz),
retry: false,
});
const { mutate: save, isPending } = useMutation({
mutationFn: () => api.setWorkflowSchedule(workflow.workflow_id, { enabled, cron, tz }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["workflows"] });
setError(null);
},
onError: (e: Error) => setError(e.message),
});
return (
<div className="rounded-lg border border-border bg-surface">
<div className="flex items-baseline justify-between gap-3 border-b border-border-soft px-5 py-3.5">
<h2 className="text-[15px] font-semibold text-text-primary">Schedule</h2>
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">{enabled ? "Active" : "Off"}</span>
</div>
<div className="flex flex-col gap-4 p-5">
<label className="flex items-start gap-3">
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} className="mt-0.5 h-4 w-4 accent-accent" />
<span>
<span className="block text-sm text-text-primary">Run on a schedule</span>
<span className="mt-0.5 block text-xs text-text-tertiary">A scheduled run is skipped, not queued, while a previous run is still going.</span>
</span>
</label>
<div className="flex flex-wrap gap-2">
{PRESETS.map((p) => (
<button
key={p.cron}
type="button"
onClick={() => setCron(p.cron)}
className={`rounded-lg border px-3 py-1.5 text-xs transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
cron === p.cron ? "border-accent bg-accent/10 text-accent" : "border-border bg-surface-2 text-text-secondary hover:border-accent/40"
}`}
>
{p.label}
</button>
))}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-text-secondary">Cron expression</span>
<input
value={cron}
onChange={(e) => setCron(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 font-mono text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
<span className="mt-1.5 block font-mono text-[11px] text-text-tertiary">minute hour day month weekday</span>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-text-secondary">Timezone</span>
<select
value={tz}
onChange={(e) => setTz(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none"
>
{[...new Set([tz, ...ZONES])].map((z) => (
<option key={z} value={z}>
{z}
</option>
))}
</select>
</label>
</div>
<div className="rounded-lg bg-well px-4 py-3">
<p className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">Next three runs</p>
{preview ? (
<ul className="mt-1.5 flex flex-col gap-0.5 font-mono text-[11.5px] text-text-secondary">
{preview.occurrences.map((o) => (
<li key={o}>{new Date(o).toLocaleString()}</li>
))}
</ul>
) : (
<p className="mt-1.5 font-mono text-[11.5px] text-danger">That expression is not valid.</p>
)}
</div>
{workflow.last_skipped && (
<p className="rounded-lg border border-warning/30 bg-warning/10 px-4 py-3 text-xs text-warning">
Skipped {new Date(workflow.last_skipped.due).toLocaleString()} {" "}
{workflow.last_skipped.reason === "already_running"
? "previous run still active"
: workflow.last_skipped.reason === "missed"
? "the control plane was not running at the time"
: workflow.last_skipped.reason}
</p>
)}
{error && <p className="text-sm text-danger">{error}</p>}
<div>
<Button variant="primary" loading={isPending} onClick={() => save()}>
Save schedule
</Button>
</div>
</div>
</div>
);
}