loops UI: real list + canvas + 4-step wizard with webhook secrets card
Seventh and final commit of the Research + Loops arc. Replaces the
placeholder Loops surface from commit 5 with the full working UI.
New:
- lib/api/loops.ts — thin TypeScript client for every /api/loops
endpoint (list, get, create, patch, delete, run-now, enable, disable).
- dashboard/LoopsWizard.tsx — 4-step modal:
1. title + description
2. task template + topology graph JSON (defaults to empty graph; a
visual builder is future work)
3. triggers — any combination of cron / on-completion / webhook,
with the cron pattern input revealed inline when cron is on
4. repeat policy — infinite or fixed-iterations
When the webhook trigger is enabled, submit lands on a fifth SECRETS
screen showing webhook_token + signing_key exactly once (never shown
again by the backend), a copy-to-clipboard row for each, and the
X-Loop-Signature: sha256=<HMAC-SHA256(key, body)> usage snippet.
- dashboard/LoopsList.tsx — replaces the stub. Loop cards with an
enabled/disabled dot + a next-fire chip. Re-fetches on refreshKey.
- dashboard/LoopsCanvas.tsx — replaces the stub. Selected loop detail:
header (title, enabled state, next-fire), triggers card, repeat
policy, task template preview, topology graph JSON, and an action row
(Run now / Enable-or-Disable / Delete with confirm).
Wired into Dashboard.tsx via a `loopsSel` / `loopsRefresh` state pair
matching the Research pattern. onDeleted clears the selection and bumps
the refresh key so the list drops the deleted card and the canvas
returns to its placeholder.
Closes the arc: all 5 tiers active, all state machines driveable from
the UI, and the loop scheduler + cron + webhook plumbing wired end-to-
end. Future work per the roadmap: visual topology builder, iteration
timeline in the canvas (via GET /api/topology-runs?loop_id=X), and the
"until" repeat policy UI.
This commit is contained in:
@@ -207,6 +207,9 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
|||||||
// list + canvas re-fetch after start/submit/publish etc.
|
// list + canvas re-fetch after start/submit/publish etc.
|
||||||
const [researchSel, setResearchSel] = useState<string | null>(null);
|
const [researchSel, setResearchSel] = useState<string | null>(null);
|
||||||
const [researchRefresh, setResearchRefresh] = useState(0);
|
const [researchRefresh, setResearchRefresh] = useState(0);
|
||||||
|
// Loops tier: same pattern.
|
||||||
|
const [loopsSel, setLoopsSel] = useState<string | null>(null);
|
||||||
|
const [loopsRefresh, setLoopsRefresh] = useState(0);
|
||||||
// Infrastructure tier: which nav view (local / cloud) + the connect-host wizard.
|
// Infrastructure tier: which nav view (local / cloud) + the connect-host wizard.
|
||||||
const [infraSel, setInfraSel] = useState<string | null>("local");
|
const [infraSel, setInfraSel] = useState<string | null>("local");
|
||||||
const [infraConnectOpen, setInfraConnectOpen] = useState(false);
|
const [infraConnectOpen, setInfraConnectOpen] = useState(false);
|
||||||
@@ -614,7 +617,15 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : isLoops ? (
|
) : isLoops ? (
|
||||||
<LoopsList />
|
<LoopsList
|
||||||
|
selectedId={loopsSel}
|
||||||
|
onSelect={setLoopsSel}
|
||||||
|
refreshKey={loopsRefresh}
|
||||||
|
onCreated={(id) => {
|
||||||
|
setLoopsSel(id);
|
||||||
|
setLoopsRefresh((n) => n + 1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : isInfra ? (
|
) : isInfra ? (
|
||||||
<InfraNav view={infraSel ?? "local"} onSelect={setInfraSel} onConnectHost={() => setInfraConnectOpen(true)} />
|
<InfraNav view={infraSel ?? "local"} onSelect={setInfraSel} onConnectHost={() => setInfraConnectOpen(true)} />
|
||||||
) : (
|
) : (
|
||||||
@@ -650,7 +661,15 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
|||||||
onChanged={() => setResearchRefresh((n) => n + 1)}
|
onChanged={() => setResearchRefresh((n) => n + 1)}
|
||||||
/>
|
/>
|
||||||
) : isLoops ? (
|
) : isLoops ? (
|
||||||
<LoopsCanvas />
|
<LoopsCanvas
|
||||||
|
selectedId={loopsSel}
|
||||||
|
refreshKey={loopsRefresh}
|
||||||
|
onChanged={() => setLoopsRefresh((n) => n + 1)}
|
||||||
|
onDeleted={() => {
|
||||||
|
setLoopsSel(null);
|
||||||
|
setLoopsRefresh((n) => n + 1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : isWorld ? (
|
) : isWorld ? (
|
||||||
<>
|
<>
|
||||||
{/* Graph stage — condensed by the right slide-out's width. */}
|
{/* Graph stage — condensed by the right slide-out's width. */}
|
||||||
|
|||||||
@@ -1,13 +1,320 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// Canvas for the Loops tier — stub. The real canvas (topology preview +
|
// Canvas for the Loops tier — selected loop detail: header, triggers card,
|
||||||
// trigger config + iteration timeline + webhook signing card) arrives with
|
// repeat policy, task template, topology graph JSON, and action row
|
||||||
// the loops-frontend commit.
|
// (Run now / Enable / Disable / Delete).
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
deleteLoop,
|
||||||
|
disableLoop,
|
||||||
|
enableLoop,
|
||||||
|
getLoop,
|
||||||
|
runLoopNow,
|
||||||
|
type Loop,
|
||||||
|
} from "@/lib/api/loops";
|
||||||
|
|
||||||
const mono =
|
const mono =
|
||||||
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
||||||
|
|
||||||
export function LoopsCanvas() {
|
export function LoopsCanvas({
|
||||||
|
selectedId,
|
||||||
|
refreshKey,
|
||||||
|
onChanged,
|
||||||
|
onDeleted,
|
||||||
|
}: {
|
||||||
|
selectedId: string | null;
|
||||||
|
refreshKey: number;
|
||||||
|
onChanged: () => void;
|
||||||
|
onDeleted: () => void;
|
||||||
|
}) {
|
||||||
|
const [loop, setLoop] = useState<Loop | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [acting, setActing] = useState(false);
|
||||||
|
const [flash, setFlash] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const load = async () => {
|
||||||
|
if (!selectedId) {
|
||||||
|
if (alive) setLoop(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const l = await getLoop(selectedId);
|
||||||
|
if (alive) setLoop(l);
|
||||||
|
} catch (e) {
|
||||||
|
if (alive) setError(e instanceof Error ? e.message : "load failed");
|
||||||
|
} finally {
|
||||||
|
if (alive) setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [selectedId, refreshKey]);
|
||||||
|
|
||||||
|
if (!selectedId) return <Placeholder />;
|
||||||
|
if (loading && !loop) return <PlaceholderText>Loading loop…</PlaceholderText>;
|
||||||
|
if (error) return <PlaceholderText color="#ff8a7a">{error}</PlaceholderText>;
|
||||||
|
if (!loop) return <Placeholder />;
|
||||||
|
|
||||||
|
async function run<T>(fn: () => Promise<T>, successMsg?: string) {
|
||||||
|
setActing(true);
|
||||||
|
setError(null);
|
||||||
|
setFlash(null);
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
if (successMsg) setFlash(successMsg);
|
||||||
|
onChanged();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "action failed");
|
||||||
|
} finally {
|
||||||
|
setActing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggers = loop.triggers ?? {};
|
||||||
|
const enabledTriggers = [
|
||||||
|
triggers.cron ? `cron: ${triggers.cron}` : null,
|
||||||
|
triggers.on_completion ? "on completion" : null,
|
||||||
|
triggers.webhook_enabled ? "webhook" : null,
|
||||||
|
].filter(Boolean) as string[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
overflow: "auto",
|
||||||
|
background:
|
||||||
|
"radial-gradient(120% 90% at 55% 38%, #0e0e13 0%, #08080a 70%)",
|
||||||
|
padding: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ maxWidth: 820, margin: "0 auto", display: "flex", flexDirection: "column", gap: 24 }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 11,
|
||||||
|
color: "#8a8a92",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: loop.enabled ? "#5fd08a" : "#5a5a62",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ color: loop.enabled ? "#5fd08a" : "#5a5a62", fontWeight: 700 }}>
|
||||||
|
{loop.enabled ? "ENABLED" : "DISABLED"}
|
||||||
|
</span>
|
||||||
|
{loop.next_fire_at && (
|
||||||
|
<>
|
||||||
|
<span style={{ opacity: 0.5 }}>·</span>
|
||||||
|
<span>next {new Date(loop.next_fire_at).toLocaleString()}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: 30,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#f3f3f5",
|
||||||
|
letterSpacing: "-.02em",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loop.title}
|
||||||
|
</h1>
|
||||||
|
{loop.description && (
|
||||||
|
<p style={{ fontFamily: mono, fontSize: 13, color: "#b5b5bd", margin: 0, lineHeight: 1.6 }}>
|
||||||
|
{loop.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Triggers + Repeat side by side */}
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
|
||||||
|
<Section header="Triggers">
|
||||||
|
{enabledTriggers.length === 0 ? (
|
||||||
|
<p style={hintStyle}>No triggers configured — loop can only be run manually.</p>
|
||||||
|
) : (
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
listStyle: "none",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{enabledTriggers.map((t) => (
|
||||||
|
<li
|
||||||
|
key={t}
|
||||||
|
style={{
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: "#eaeaee",
|
||||||
|
padding: "6px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
background: "rgba(255,255,255,.05)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
<Section header="Repeat">
|
||||||
|
<div style={{ fontFamily: mono, fontSize: 12.5, color: "#eaeaee" }}>
|
||||||
|
{loop.repeat_policy?.kind === "iters"
|
||||||
|
? `${(loop.repeat_policy as { kind: "iters"; n: number }).n} iterations`
|
||||||
|
: loop.repeat_policy?.kind === "until"
|
||||||
|
? "until"
|
||||||
|
: "infinite"}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Task template */}
|
||||||
|
<Section header="Task template">
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
padding: 14,
|
||||||
|
borderRadius: 10,
|
||||||
|
background: "#101014",
|
||||||
|
border: "1px solid rgba(255,255,255,.06)",
|
||||||
|
color: "#eaeaee",
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 12.5,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loop.task_template}
|
||||||
|
</pre>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Graph */}
|
||||||
|
<Section header="Topology (graph JSON)">
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
padding: 14,
|
||||||
|
borderRadius: 10,
|
||||||
|
background: "#101014",
|
||||||
|
border: "1px solid rgba(255,255,255,.06)",
|
||||||
|
color: "#eaeaee",
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 11.5,
|
||||||
|
lineHeight: 1.55,
|
||||||
|
maxHeight: 240,
|
||||||
|
overflow: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{JSON.stringify(loop.graph, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => run(() => runLoopNow(loop.id), "Run enqueued")}
|
||||||
|
disabled={acting}
|
||||||
|
style={primaryBtn}
|
||||||
|
>
|
||||||
|
Run now
|
||||||
|
</button>
|
||||||
|
{loop.enabled ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => run(() => disableLoop(loop.id))}
|
||||||
|
disabled={acting}
|
||||||
|
style={secondaryBtn}
|
||||||
|
>
|
||||||
|
Disable
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => run(() => enableLoop(loop.id))}
|
||||||
|
disabled={acting}
|
||||||
|
style={secondaryBtn}
|
||||||
|
>
|
||||||
|
Enable
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Delete loop "${loop.title}"?`)) {
|
||||||
|
run(async () => {
|
||||||
|
await deleteLoop(loop.id);
|
||||||
|
onDeleted();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={acting}
|
||||||
|
style={dangerBtn}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
{flash && (
|
||||||
|
<span style={{ fontFamily: mono, fontSize: 12, color: "#5fd08a" }}>{flash}</span>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<span style={{ fontFamily: mono, fontSize: 12, color: "#ff8a7a" }}>{error}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
header,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
header: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 10,
|
||||||
|
letterSpacing: ".12em",
|
||||||
|
color: "#5a5a62",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{header}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Placeholder() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -21,57 +328,74 @@ export function LoopsCanvas() {
|
|||||||
padding: 48,
|
padding: 48,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div style={{ maxWidth: 460, textAlign: "center" }}>
|
||||||
style={{
|
<div style={{ fontSize: 22, fontWeight: 700, color: "#f3f3f5", marginBottom: 12 }}>Loops</div>
|
||||||
maxWidth: 460,
|
<div style={{ fontFamily: mono, fontSize: 12, color: "#8a8a92", lineHeight: 1.6 }}>
|
||||||
textAlign: "center",
|
Pick a loop on the left, or hit the + button in the sidebar to launch
|
||||||
display: "flex",
|
the wizard.
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 20,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Orbit-arrows: the loops motif from the rail */}
|
|
||||||
<svg width="48" height="48" viewBox="0 0 24 24" aria-hidden>
|
|
||||||
<path
|
|
||||||
d="M20 12 A8 8 0 1 1 12 4"
|
|
||||||
stroke="#ff6f61"
|
|
||||||
strokeWidth="1.7"
|
|
||||||
fill="none"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M13 3 L20 4 L18.5 10"
|
|
||||||
stroke="#ff6f61"
|
|
||||||
strokeWidth="1.7"
|
|
||||||
fill="none"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
<circle cx="12" cy="12" r="2.2" fill="currentColor" opacity=".6" />
|
|
||||||
</svg>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "#f3f3f5",
|
|
||||||
letterSpacing: "-.01em",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Loops
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: mono,
|
|
||||||
fontSize: 12,
|
|
||||||
color: "#8a8a92",
|
|
||||||
lineHeight: 1.6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Pick a loop on the left to see its topology, trigger config, and
|
|
||||||
iteration timeline. Full canvas ships next commit.
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PlaceholderText({
|
||||||
|
children,
|
||||||
|
color = "#8a8a92",
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
color?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 12,
|
||||||
|
color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hintStyle: React.CSSProperties = {
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#8a8a92",
|
||||||
|
};
|
||||||
|
const primaryBtn: React.CSSProperties = {
|
||||||
|
padding: "10px 18px",
|
||||||
|
borderRadius: 9,
|
||||||
|
border: 0,
|
||||||
|
background: "linear-gradient(135deg,#ff8a7a,#ff5f57)",
|
||||||
|
color: "#2a0d0a",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: "pointer",
|
||||||
|
};
|
||||||
|
const secondaryBtn: React.CSSProperties = {
|
||||||
|
padding: "10px 18px",
|
||||||
|
borderRadius: 9,
|
||||||
|
border: "1px solid rgba(255,255,255,.14)",
|
||||||
|
background: "transparent",
|
||||||
|
color: "#cfcfd5",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
};
|
||||||
|
const dangerBtn: React.CSSProperties = {
|
||||||
|
padding: "10px 18px",
|
||||||
|
borderRadius: 9,
|
||||||
|
border: "1px solid rgba(255,111,97,.35)",
|
||||||
|
background: "transparent",
|
||||||
|
color: "#ff8a7a",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,13 +1,51 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// Sidebar for the Loops tier — stub. The real list (loop cards + status +
|
// Sidebar for the Loops tier — real loop cards + `+` opens the wizard.
|
||||||
// next-fire chip + `+` opens the loop wizard) arrives in the loops-frontend
|
|
||||||
// commit.
|
import { useEffect, useState } from "react";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
|
||||||
|
import { listLoops, type Loop } from "@/lib/api/loops";
|
||||||
|
|
||||||
|
import { LoopsWizard } from "./LoopsWizard";
|
||||||
|
|
||||||
const mono =
|
const mono =
|
||||||
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
||||||
|
|
||||||
export function LoopsList() {
|
export function LoopsList({
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
refreshKey,
|
||||||
|
onCreated,
|
||||||
|
}: {
|
||||||
|
selectedId: string | null;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
refreshKey: number;
|
||||||
|
onCreated: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const [loops, setLoops] = useState<Loop[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const rows = await listLoops();
|
||||||
|
if (alive) setLoops(rows);
|
||||||
|
} catch {
|
||||||
|
if (alive) setLoops([]);
|
||||||
|
} finally {
|
||||||
|
if (alive) setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [refreshKey]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@@ -29,7 +67,7 @@ export function LoopsList() {
|
|||||||
marginBottom: 6,
|
marginBottom: 6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
0 LOOPS
|
{loops.length} LOOP{loops.length === 1 ? "" : "S"}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -42,25 +80,129 @@ export function LoopsList() {
|
|||||||
Loops
|
Loops
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<button
|
||||||
<div
|
type="button"
|
||||||
|
onClick={() => setWizardOpen(true)}
|
||||||
|
title="New loop"
|
||||||
|
aria-label="New loop"
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: "none",
|
||||||
minHeight: 0,
|
width: 34,
|
||||||
display: "flex",
|
height: 34,
|
||||||
|
borderRadius: 9,
|
||||||
|
border: "1px dashed rgba(255,111,97,.4)",
|
||||||
|
background: "rgba(255,111,97,.06)",
|
||||||
|
color: "#ff6f61",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "inline-flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
padding: "40px 16px",
|
}}
|
||||||
|
>
|
||||||
|
<Plus aria-hidden size={17} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "8px" }}>
|
||||||
|
{loading && loops.length === 0 ? (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
fontFamily: mono,
|
fontFamily: mono,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: "#5a5a62",
|
color: "#5a5a62",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Loading…
|
||||||
|
</p>
|
||||||
|
) : loops.length === 0 ? (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 11,
|
||||||
|
color: "#5a5a62",
|
||||||
|
textAlign: "center",
|
||||||
|
padding: 24,
|
||||||
lineHeight: 1.6,
|
lineHeight: 1.6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Loop cards land in the next commit. Recurring topology executions —
|
No loops yet. Hit the + button to launch the wizard.
|
||||||
cron, on-completion, or webhook-triggered.
|
</p>
|
||||||
|
) : (
|
||||||
|
loops.map((l) => {
|
||||||
|
const on = l.id === selectedId;
|
||||||
|
const dot = l.enabled ? "#5fd08a" : "#5a5a62";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={l.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(l.id)}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
padding: "10px 12px",
|
||||||
|
marginBottom: 4,
|
||||||
|
borderRadius: 9,
|
||||||
|
border: `1px solid ${on ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.06)"}`,
|
||||||
|
background: on ? "rgba(255,111,97,.08)" : "#101014",
|
||||||
|
color: "#eaeaee",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13.5,
|
||||||
|
fontWeight: 600,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{l.title}
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 10,
|
||||||
|
color: "#8a8a92",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: dot,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ color: dot }}>{l.enabled ? "enabled" : "disabled"}</span>
|
||||||
|
<span style={{ opacity: 0.5 }}>·</span>
|
||||||
|
<span>
|
||||||
|
{l.next_fire_at
|
||||||
|
? `next ${new Date(l.next_fire_at).toLocaleString()}`
|
||||||
|
: "no schedule"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{wizardOpen && (
|
||||||
|
<LoopsWizard
|
||||||
|
onClose={() => setWizardOpen(false)}
|
||||||
|
onCreated={(id) => {
|
||||||
|
setWizardOpen(false);
|
||||||
|
onCreated(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,480 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// 4-step loop wizard: identity → task/topology → triggers → repeat policy.
|
||||||
|
// The topology field is a JSON textarea for v1 (defaults to an empty graph);
|
||||||
|
// a proper visual builder is future work. On submit, if the webhook trigger
|
||||||
|
// was enabled the response returns webhook_token + signing_key ONCE — the
|
||||||
|
// wizard shows them in a copy-friendly card before dismissing.
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createLoop,
|
||||||
|
type LoopCreated,
|
||||||
|
type LoopRepeatPolicy,
|
||||||
|
} from "@/lib/api/loops";
|
||||||
|
|
||||||
|
const mono =
|
||||||
|
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
||||||
|
|
||||||
|
export function LoopsWizard({
|
||||||
|
onClose,
|
||||||
|
onCreated,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const [step, setStep] = useState<1 | 2 | 3 | 4 | 5>(1);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [task, setTask] = useState("");
|
||||||
|
const [graphText, setGraphText] = useState(
|
||||||
|
JSON.stringify({ nodes: [], edges: [] }, null, 2),
|
||||||
|
);
|
||||||
|
const [cronEnabled, setCronEnabled] = useState(false);
|
||||||
|
const [cron, setCron] = useState("0 */6 * * *");
|
||||||
|
const [onCompletion, setOnCompletion] = useState(false);
|
||||||
|
const [webhookEnabled, setWebhookEnabled] = useState(false);
|
||||||
|
const [repeatKind, setRepeatKind] = useState<"infinite" | "iters">("infinite");
|
||||||
|
const [iters, setIters] = useState(10);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
const [created, setCreated] = useState<LoopCreated | null>(null);
|
||||||
|
|
||||||
|
const canNext =
|
||||||
|
(step === 1 && title.trim().length > 0) ||
|
||||||
|
(step === 2 && task.trim().length > 0 && isValidJson(graphText)) ||
|
||||||
|
(step === 3 && (cronEnabled || onCompletion || webhookEnabled)) ||
|
||||||
|
step === 4;
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
setSubmitError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
let graph: unknown;
|
||||||
|
try {
|
||||||
|
graph = JSON.parse(graphText);
|
||||||
|
} catch {
|
||||||
|
setSubmitError("Graph is not valid JSON");
|
||||||
|
setSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const repeat_policy: LoopRepeatPolicy =
|
||||||
|
repeatKind === "infinite"
|
||||||
|
? { kind: "infinite" }
|
||||||
|
: { kind: "iters", n: Math.max(1, Math.floor(iters)) };
|
||||||
|
const out = await createLoop({
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
graph,
|
||||||
|
task_template: task.trim(),
|
||||||
|
triggers: {
|
||||||
|
...(cronEnabled ? { cron: cron.trim() } : {}),
|
||||||
|
...(onCompletion ? { on_completion: true } : {}),
|
||||||
|
...(webhookEnabled ? { webhook_enabled: true } : {}),
|
||||||
|
},
|
||||||
|
repeat_policy,
|
||||||
|
});
|
||||||
|
setCreated(out);
|
||||||
|
// If no webhook material, dismiss immediately.
|
||||||
|
if (!out.webhook_token) {
|
||||||
|
onCreated(out.id);
|
||||||
|
} else {
|
||||||
|
setStep(5);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setSubmitError(e instanceof Error ? e.message : "create failed");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0,0,0,.55)",
|
||||||
|
zIndex: 200,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 640,
|
||||||
|
maxHeight: "90vh",
|
||||||
|
background: "#0d0d10",
|
||||||
|
border: "1px solid rgba(255,255,255,.1)",
|
||||||
|
borderRadius: 14,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "14px 18px",
|
||||||
|
borderBottom: "1px solid rgba(255,255,255,.06)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontFamily: mono, fontSize: 10, color: "#5a5a62" }}>
|
||||||
|
{step <= 4 ? `STEP ${step} / 4` : "SECRETS"}
|
||||||
|
</span>
|
||||||
|
<span style={{ flex: 1, fontSize: 16, fontWeight: 700, color: "#f3f3f5" }}>
|
||||||
|
{step <= 4 ? "New loop" : "Copy the webhook secret"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
style={closeBtn}
|
||||||
|
>
|
||||||
|
<X aria-hidden size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 20 }}>
|
||||||
|
{step === 1 && (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
|
<label style={labelStyle} htmlFor="loop-title">Title</label>
|
||||||
|
<input
|
||||||
|
id="loop-title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Nightly regression scan"
|
||||||
|
style={fieldStyle}
|
||||||
|
/>
|
||||||
|
<label style={labelStyle} htmlFor="loop-desc">Description</label>
|
||||||
|
<textarea
|
||||||
|
id="loop-desc"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
style={fieldStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
|
<label style={labelStyle} htmlFor="loop-task">Task template</label>
|
||||||
|
<textarea
|
||||||
|
id="loop-task"
|
||||||
|
value={task}
|
||||||
|
onChange={(e) => setTask(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
placeholder="What should the loop's agents do each iteration?"
|
||||||
|
style={fieldStyle}
|
||||||
|
/>
|
||||||
|
<label style={labelStyle} htmlFor="loop-graph">Topology graph (JSON)</label>
|
||||||
|
<textarea
|
||||||
|
id="loop-graph"
|
||||||
|
value={graphText}
|
||||||
|
onChange={(e) => setGraphText(e.target.value)}
|
||||||
|
rows={10}
|
||||||
|
style={{ ...fieldStyle, fontFamily: mono, fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<p style={hintStyle}>
|
||||||
|
Uses the same shape as topology_runs.graph. Empty graph
|
||||||
|
(default) works — a visual builder is future work.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 3 && (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||||
|
<span style={labelStyle}>Triggers (pick any combination)</span>
|
||||||
|
<TriggerToggle
|
||||||
|
on={cronEnabled}
|
||||||
|
onToggle={setCronEnabled}
|
||||||
|
label="Cron schedule"
|
||||||
|
hint="Whenever the pattern fires; missed windows fire once and skip the backlog."
|
||||||
|
>
|
||||||
|
{cronEnabled && (
|
||||||
|
<input
|
||||||
|
value={cron}
|
||||||
|
onChange={(e) => setCron(e.target.value)}
|
||||||
|
placeholder="0 */6 * * *"
|
||||||
|
style={{ ...fieldStyle, fontFamily: mono }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TriggerToggle>
|
||||||
|
<TriggerToggle
|
||||||
|
on={onCompletion}
|
||||||
|
onToggle={setOnCompletion}
|
||||||
|
label="On previous completion"
|
||||||
|
hint="Enqueue the next iteration as soon as the previous one emits run_completed."
|
||||||
|
/>
|
||||||
|
<TriggerToggle
|
||||||
|
on={webhookEnabled}
|
||||||
|
onToggle={setWebhookEnabled}
|
||||||
|
label="Webhook"
|
||||||
|
hint="A public /webhooks/loops/:token endpoint. HMAC-SHA256 verified via X-Loop-Signature: sha256=…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 4 && (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||||
|
<span style={labelStyle}>Repeat policy</span>
|
||||||
|
<label style={radioRowStyle(repeatKind === "infinite")}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={repeatKind === "infinite"}
|
||||||
|
onChange={() => setRepeatKind("infinite")}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>Infinite</div>
|
||||||
|
<div style={hintStyle}>Loop until disabled or deleted.</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label style={radioRowStyle(repeatKind === "iters")}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={repeatKind === "iters"}
|
||||||
|
onChange={() => setRepeatKind("iters")}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>Fixed iterations</div>
|
||||||
|
<div style={hintStyle}>
|
||||||
|
Stop after N total iterations. Scheduler disables the loop when reached.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{repeatKind === "iters" && (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={iters}
|
||||||
|
min={1}
|
||||||
|
onChange={(e) => setIters(parseInt(e.target.value, 10) || 1)}
|
||||||
|
style={{ ...fieldStyle, width: 90 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
{submitError && (
|
||||||
|
<p style={{ fontFamily: mono, fontSize: 12, color: "#ff8a7a" }}>
|
||||||
|
{submitError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 5 && created && (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||||
|
<p style={{ fontFamily: mono, fontSize: 12, color: "#ffb44a", lineHeight: 1.6 }}>
|
||||||
|
Save these now — they're never shown again. To rotate, disable
|
||||||
|
the webhook trigger and re-enable it.
|
||||||
|
</p>
|
||||||
|
<SecretRow label="Webhook token" value={created.webhook_token ?? ""} />
|
||||||
|
<SecretRow label="Signing key" value={created.webhook_signing_key ?? ""} />
|
||||||
|
<p style={hintStyle}>
|
||||||
|
POST to <code style={{ color: "#f3f3f5" }}>/webhooks/loops/{created.webhook_token}</code>
|
||||||
|
{" "}with header <code style={{ color: "#f3f3f5" }}>X-Loop-Signature: sha256=<hex></code>
|
||||||
|
{" "}where hex = HMAC-SHA256(signing_key, request_body).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 14,
|
||||||
|
borderTop: "1px solid rgba(255,255,255,.06)",
|
||||||
|
display: "flex",
|
||||||
|
gap: 8,
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step <= 4 ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep((s) => ((s > 1 ? s - 1 : s) as 1 | 2 | 3 | 4 | 5))}
|
||||||
|
disabled={step === 1}
|
||||||
|
style={{ ...secondaryBtn, opacity: step === 1 ? 0.4 : 1 }}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
{step < 4 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep((s) => ((s + 1) as 1 | 2 | 3 | 4 | 5))}
|
||||||
|
disabled={!canNext}
|
||||||
|
style={{ ...primaryBtn, opacity: !canNext ? 0.4 : 1 }}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={submitting}
|
||||||
|
style={{ ...primaryBtn, opacity: submitting ? 0.6 : 1 }}
|
||||||
|
>
|
||||||
|
{submitting ? "Creating…" : "Create loop"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onCreated(created!.id)}
|
||||||
|
style={primaryBtn}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TriggerToggle({
|
||||||
|
on,
|
||||||
|
onToggle,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
on: boolean;
|
||||||
|
onToggle: (v: boolean) => void;
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 10,
|
||||||
|
border: `1px solid ${on ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.1)"}`,
|
||||||
|
background: on ? "rgba(255,111,97,.06)" : "transparent",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label style={{ display: "flex", alignItems: "flex-start", gap: 10, cursor: "pointer" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={on}
|
||||||
|
onChange={(e) => onToggle(e.target.checked)}
|
||||||
|
style={{ marginTop: 3 }}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>{label}</div>
|
||||||
|
<div style={hintStyle}>{hint}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SecretRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
||||||
|
<span style={labelStyle}>{label}</span>
|
||||||
|
<div style={{ display: "flex", gap: 6 }}>
|
||||||
|
<input
|
||||||
|
value={value}
|
||||||
|
readOnly
|
||||||
|
style={{ ...fieldStyle, fontFamily: mono, fontSize: 12, flex: 1 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigator.clipboard?.writeText(value)}
|
||||||
|
style={secondaryBtn}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidJson(s: string) {
|
||||||
|
try {
|
||||||
|
JSON.parse(s);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 11,
|
||||||
|
color: "#b5b5bd",
|
||||||
|
};
|
||||||
|
const hintStyle: React.CSSProperties = {
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 11,
|
||||||
|
color: "#8a8a92",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
};
|
||||||
|
const fieldStyle: React.CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
padding: "10px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid rgba(255,255,255,.12)",
|
||||||
|
background: "#0a0a0c",
|
||||||
|
color: "#f3f3f5",
|
||||||
|
outline: "none",
|
||||||
|
fontSize: 13,
|
||||||
|
};
|
||||||
|
const primaryBtn: React.CSSProperties = {
|
||||||
|
padding: "9px 16px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: 0,
|
||||||
|
background: "linear-gradient(135deg,#ff8a7a,#ff5f57)",
|
||||||
|
color: "#2a0d0a",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: "pointer",
|
||||||
|
};
|
||||||
|
const secondaryBtn: React.CSSProperties = {
|
||||||
|
padding: "9px 16px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid rgba(255,255,255,.14)",
|
||||||
|
background: "transparent",
|
||||||
|
color: "#cfcfd5",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
};
|
||||||
|
const closeBtn: React.CSSProperties = {
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid rgba(255,255,255,.12)",
|
||||||
|
background: "transparent",
|
||||||
|
color: "#cfcfd5",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
};
|
||||||
|
const radioRowStyle = (on: boolean): React.CSSProperties => ({
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
padding: "12px",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: `1px solid ${on ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.1)"}`,
|
||||||
|
background: on ? "rgba(255,111,97,.06)" : "transparent",
|
||||||
|
cursor: "pointer",
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// Loops API client. Uses relative fetch(); the Next.js proxy forwards to
|
||||||
|
// the Rust backend with the auth cookie.
|
||||||
|
|
||||||
|
export interface Loop {
|
||||||
|
id: string;
|
||||||
|
workspace_id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
graph: unknown;
|
||||||
|
task_template: string;
|
||||||
|
triggers: LoopTriggers;
|
||||||
|
repeat_policy: LoopRepeatPolicy;
|
||||||
|
enabled: boolean;
|
||||||
|
next_fire_at: string | null;
|
||||||
|
last_run_id: string | null;
|
||||||
|
webhook_token: string | null;
|
||||||
|
webhook_signing_key: string | null;
|
||||||
|
created_by: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoopTriggers {
|
||||||
|
cron?: string;
|
||||||
|
on_completion?: boolean;
|
||||||
|
webhook_enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoopRepeatPolicy =
|
||||||
|
| { kind: "infinite" }
|
||||||
|
| { kind: "iters"; n: number }
|
||||||
|
| { kind: "until"; event: string; within_iters?: number };
|
||||||
|
|
||||||
|
export interface LoopCreated {
|
||||||
|
id: string;
|
||||||
|
/** Set only when triggers.webhook_enabled === true. Returned ONCE. */
|
||||||
|
webhook_token?: string;
|
||||||
|
webhook_signing_key?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
...init,
|
||||||
|
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`${init?.method ?? "GET"} ${path} → ${res.status}`);
|
||||||
|
if (res.status === 204 || res.status === 205) return undefined as T;
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listLoops = () => api<Loop[]>("/api/loops");
|
||||||
|
export const getLoop = (id: string) => api<Loop>(`/api/loops/${id}`);
|
||||||
|
|
||||||
|
export const createLoop = (body: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
graph: unknown;
|
||||||
|
task_template: string;
|
||||||
|
triggers: LoopTriggers;
|
||||||
|
repeat_policy: LoopRepeatPolicy;
|
||||||
|
}) =>
|
||||||
|
api<LoopCreated>("/api/loops", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const patchLoop = (
|
||||||
|
id: string,
|
||||||
|
body: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
graph: unknown;
|
||||||
|
task_template: string;
|
||||||
|
triggers: LoopTriggers;
|
||||||
|
repeat_policy: LoopRepeatPolicy;
|
||||||
|
},
|
||||||
|
) =>
|
||||||
|
api<void>(`/api/loops/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteLoop = (id: string) =>
|
||||||
|
api<void>(`/api/loops/${id}`, { method: "DELETE" });
|
||||||
|
|
||||||
|
export const runLoopNow = (id: string) =>
|
||||||
|
api<{ run_id: string; iteration: number }>(`/api/loops/${id}/run`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const enableLoop = (id: string) =>
|
||||||
|
api<void>(`/api/loops/${id}/enable`, { method: "POST" });
|
||||||
|
|
||||||
|
export const disableLoop = (id: string) =>
|
||||||
|
api<void>(`/api/loops/${id}/disable`, { method: "POST" });
|
||||||
Reference in New Issue
Block a user