feat: schedule editor on the workflow page
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user