diff --git a/web/components/workflows/EditWorkflowModal.tsx b/web/components/workflows/EditWorkflowModal.tsx index 9a8443a..b2683bd 100644 --- a/web/components/workflows/EditWorkflowModal.tsx +++ b/web/components/workflows/EditWorkflowModal.tsx @@ -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(workflow.target_server_ids); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() }); + const router = useRouter(); + const [name, setName] = useState(workflow.name); + const [targets, setTargets] = useState(workflow.target_server_ids); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(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 ( - -
- {error &&
{error}
} -
- - setName(e.target.value)} /> -
-
- -
- {servers?.map((s) => { - const on = targets.includes(s.server_id); - return ( - - ); - })} - {servers && servers.length === 0 &&

No servers registered.

} -
-
-
- -
- - -
-
-
-
- ); + return ( + +
+ {error &&
{error}
} +
+ + setName(e.target.value)} /> +
+
+ +
+ {servers?.map((s) => { + const on = targets.includes(s.server_id); + return ( + + ); + })} + {servers && servers.length === 0 &&

No servers registered.

} +
+
+
+ +
+ + +
+
+ +
+
+ ); } diff --git a/web/components/workflows/ScheduleCard.tsx b/web/components/workflows/ScheduleCard.tsx new file mode 100644 index 0000000..47f17be --- /dev/null +++ b/web/components/workflows/ScheduleCard.tsx @@ -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(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 ( +
+
+

Schedule

+ {enabled ? "Active" : "Off"} +
+ +
+ + +
+ {PRESETS.map((p) => ( + + ))} +
+ +
+ + + +
+ +
+

Next three runs

+ {preview ? ( +
    + {preview.occurrences.map((o) => ( +
  • {new Date(o).toLocaleString()}
  • + ))} +
+ ) : ( +

That expression is not valid.

+ )} +
+ + {workflow.last_skipped && ( +

+ 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} +

+ )} + + {error &&

{error}

} + +
+ +
+
+
+ ); +}