feat: Implement AI Gateway page with feature tiles and descriptions

feat: Update AppSidebar to include new Environments and Credentials sections

feat: Enhance node form with branch selection for triggers and deployment

feat: Create CredentialForm and CredentialRow components for managing credentials

feat: Add API endpoints for credential management

fix: Update node catalog defaults for deploy and promote nodes
This commit is contained in:
patel-lyzr
2026-05-13 22:15:08 +05:30
parent 22a0fc694d
commit 1766bdf497
32 changed files with 3982 additions and 256 deletions
+303 -85
View File
@@ -1,36 +1,102 @@
"use client";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { ArrowLeft, Eye, EyeOff, KeyRound, Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
ArrowRight,
Eye,
EyeOff,
Github,
Gitlab,
KeyRound,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/lib/api";
// ─── Templates (no-op for now) ──────────────────────────────────────────────
// Display-only cards; clicking pre-fills the URL bar with a known starter
// repo. Wiring real template cloning is a later story.
type Template = {
badge: string;
title: string;
description: string;
// repoUrl is the URL we pre-fill on click. `null` means the template
// isn't wired yet — the card renders disabled with a SOON pill.
repoUrl: string | null;
};
const TEMPLATES: Template[] = [
{
badge: "LANGGRAPH",
title: "LangGraph quickstart",
description: "Stateful agent graph with tool calls + memory.",
repoUrl: "https://github.com/patel-lyzr/langraph-agent",
},
{
badge: "CREWAI",
title: "CrewAI starter",
description: "Multi-agent crew with role-based collaboration.",
repoUrl: null,
},
{
badge: "LANGCHAIN",
title: "LangChain agent",
description: "Classic ReAct agent with retrievers and tools.",
repoUrl: null,
},
];
export default function NewAgentPage() {
const router = useRouter();
const [repoUrl, setRepoUrl] = useState("");
const [pat, setPat] = useState("");
const [showPat, setShowPat] = useState(false);
const [showPatField, setShowPatField] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const urlInputRef = useRef<HTMLInputElement | null>(null);
const patInputRef = useRef<HTMLInputElement | null>(null);
// Focus the URL bar after either provider/template card click so the user
// immediately has a clear next action.
useEffect(() => {
if (showPatField) {
// Give the input a beat to render before focusing.
requestAnimationFrame(() => patInputRef.current?.focus());
}
}, [showPatField]);
function pickTemplate(t: Template) {
if (!t.repoUrl) return;
setRepoUrl(t.repoUrl);
setShowPatField(true);
requestAnimationFrame(() => urlInputRef.current?.focus());
}
function continueWithGitHub() {
setShowPatField(true);
if (!repoUrl.trim()) {
requestAnimationFrame(() => urlInputRef.current?.focus());
} else {
requestAnimationFrame(() => patInputRef.current?.focus());
}
}
async function onSave() {
setSaving(true);
setError(null);
try {
const url = repoUrl.trim();
if (!url) throw new Error("Repository URL is required");
await api.createAgent({ repoUrl: url, pat: pat.trim() || undefined });
await api.createAgent({
repoUrl: url,
pat: pat.trim() || undefined,
});
router.push("/agents");
} catch (e) {
setError(e instanceof Error ? e.message : "save failed");
@@ -40,95 +106,247 @@ export default function NewAgentPage() {
}
return (
<div className="mx-auto w-full max-w-2xl space-y-6 p-6">
<Button variant="ghost" size="sm" asChild>
<Link href="/agents">
<ArrowLeft />
Back
</Link>
</Button>
<div className="mx-auto w-full max-w-5xl space-y-8 p-6">
<div>
<h1 className="text-3xl font-semibold tracking-tight">Add agent</h1>
<h1 className="text-3xl font-semibold tracking-tight">
Register an agent
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Link a git repository that contains your agent code. The PAT is
stored encrypted server-side and used only for git operations.
One agent = one repo + one PAT + the pipelines that ship it.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Repository</CardTitle>
<CardDescription>
HTTPS or SSH URL public repos can leave the PAT blank.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="repoUrl">Git URL</Label>
<Input
id="repoUrl"
value={repoUrl}
onChange={(e) => setRepoUrl(e.target.value)}
placeholder="https://github.com/org/agent-repo.git"
spellCheck={false}
autoComplete="off"
/>
<p className="text-[11px] text-muted-foreground">
Name is auto-derived from the repo path (e.g.{" "}
<code className="font-mono">org/repo</code>).
</p>
</div>
{/* URL bar -------------------------------------------------------- */}
<div className="flex items-center gap-3 rounded-lg border bg-muted/20 p-3">
<Sparkles className="ml-1 size-4 shrink-0 text-muted-foreground" />
<Input
ref={urlInputRef}
value={repoUrl}
onChange={(e) => setRepoUrl(e.target.value)}
placeholder="Paste a GitHub repo URL — or pick a template below"
spellCheck={false}
autoComplete="off"
className="h-10 border-0 bg-transparent text-sm focus-visible:ring-0"
/>
<Button
onClick={continueWithGitHub}
disabled={!repoUrl.trim() && !showPatField}
className="shrink-0"
>
Continue
<ArrowRight />
</Button>
</div>
{/* Two-column: provider + templates ------------------------------- */}
<div className="grid gap-4 md:grid-cols-2">
{/* Import Git Repository ---------------------------------------- */}
<div className="rounded-lg border bg-card p-5">
<div className="mb-1 text-base font-semibold">
Import Git Repository
</div>
<p className="mb-5 text-xs text-muted-foreground">
Pick a provider. We use a PAT to install a webhook on your repo so
push/PR events trigger pipelines.
</p>
<div className="space-y-2">
<Label htmlFor="pat" className="flex items-center gap-1">
<KeyRound className="size-3.5" />
Personal access token (optional)
</Label>
<div className="relative">
<button
type="button"
onClick={continueWithGitHub}
className="flex w-full items-center justify-center gap-2 rounded-md border bg-foreground px-4 py-2.5 text-sm font-medium text-background transition-opacity hover:opacity-90"
>
<Github className="size-4" />
Continue with GitHub
</button>
<ProviderDisabled icon={Gitlab} label="Continue with GitLab" />
<ProviderDisabled
icon={BitbucketIcon}
label="Continue with Bitbucket"
/>
</div>
<p className="mt-4 text-center text-[11px] text-muted-foreground">
Only GitHub is wired up in v0. GitLab and Bitbucket are next.
</p>
</div>
{/* Clone Template ----------------------------------------------- */}
<div className="rounded-lg border bg-card p-5">
<div className="mb-1 flex items-baseline justify-between">
<div className="text-base font-semibold">Clone Template</div>
<span className="text-[11px] text-muted-foreground">
Framework starters
</span>
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
{TEMPLATES.map((t) => {
const disabled = !t.repoUrl;
return (
<button
key={t.badge}
type="button"
onClick={() => pickTemplate(t)}
disabled={disabled}
className={
"text-left rounded-md border bg-muted/20 p-3 transition-colors " +
(disabled
? "cursor-not-allowed opacity-60"
: "hover:bg-muted/40")
}
>
<div className="mb-1.5 flex items-center gap-1.5">
<span className="inline-block rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium tracking-wider text-muted-foreground">
{t.badge}
</span>
{disabled && (
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium tracking-wider text-muted-foreground">
SOON
</span>
)}
</div>
<div className="text-sm font-medium">{t.title}</div>
<div className="mt-0.5 line-clamp-2 text-[11px] text-muted-foreground">
{t.description}
</div>
</button>
);
})}
</div>
<div className="mt-4 text-[11px] text-muted-foreground">
More framework starters coming soon. Want one added? Open an issue
on the Langship repo.
</div>
</div>
</div>
{/* PAT entry + Save (revealed after a provider/template is chosen) - */}
{showPatField && (
<div className="rounded-lg border bg-card p-5">
<div className="mb-1 text-base font-semibold">Connect repo</div>
<p className="mb-5 text-xs text-muted-foreground">
HTTPS URL above; PAT below. Public repos can leave the PAT blank.
</p>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="repoUrl">Git URL</Label>
<Input
id="pat"
type={showPat ? "text" : "password"}
value={pat}
onChange={(e) => setPat(e.target.value)}
placeholder="ghp_… or glpat_…"
id="repoUrl"
value={repoUrl}
onChange={(e) => setRepoUrl(e.target.value)}
placeholder="https://github.com/org/agent-repo.git"
spellCheck={false}
autoComplete="new-password"
className="pr-10 font-mono text-xs"
autoComplete="off"
/>
<button
type="button"
onClick={() => setShowPat((v) => !v)}
className="absolute inset-y-0 right-2 flex items-center text-muted-foreground hover:text-foreground"
aria-label={showPat ? "Hide token" : "Show token"}
>
{showPat ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>
<p className="text-[11px] text-muted-foreground">
Agent name is auto-derived from the repo path (e.g.{" "}
<code className="font-mono">org/repo</code>).
</p>
</div>
<div className="space-y-2">
<Label htmlFor="pat" className="flex items-center gap-1">
<KeyRound className="size-3.5" />
Personal access token (optional)
</Label>
<div className="relative">
<Input
id="pat"
ref={patInputRef}
type={showPat ? "text" : "password"}
value={pat}
onChange={(e) => setPat(e.target.value)}
placeholder="ghp_… or glpat_…"
spellCheck={false}
autoComplete="new-password"
className="pr-10 font-mono text-xs"
/>
<button
type="button"
onClick={() => setShowPat((v) => !v)}
className="absolute inset-y-0 right-2 flex items-center text-muted-foreground hover:text-foreground"
aria-label={showPat ? "Hide token" : "Show token"}
>
{showPat ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</button>
</div>
<p className="text-[11px] text-muted-foreground">
Required for private repos. Scope:{" "}
<code className="font-mono">repo</code> read access is enough.
Stored encrypted server-side; never returned by the API after
save.
</p>
</div>
{error && (
<p className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
{error}
</p>
)}
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setShowPatField(false)}>
Cancel
</Button>
<Button onClick={onSave} disabled={saving || !repoUrl.trim()}>
{saving ? "Saving…" : "Add agent"}
</Button>
</div>
<p className="text-[11px] text-muted-foreground">
Required for private repos. Scope:{" "}
<code className="font-mono">repo</code> read access is enough.
Never returned by the API after save.
After saving, open the agent to attach pipelines and (if you&rsquo;ll
use the Deploy node) override credentials.
</p>
</div>
</div>
)}
{error && (
<p className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
{error}
</p>
)}
<div className="flex justify-end gap-2">
<Button variant="ghost" asChild>
<Link href="/agents">Cancel</Link>
</Button>
<Button onClick={onSave} disabled={saving || !repoUrl.trim()}>
<Save />
{saving ? "Saving…" : "Add agent"}
</Button>
</div>
</CardContent>
</Card>
{/* Empty agent --------------------------------------------------- */}
<div className="flex items-center justify-between rounded-lg border border-dashed bg-card/40 p-4">
<div>
<div className="text-sm font-medium">Empty agent</div>
<p className="text-[11px] text-muted-foreground">
Skip git for now and configure the connection later. Coming soon
for now an agent must be registered with a repo + PAT.
</p>
</div>
<Button variant="outline" disabled>
Coming soon
</Button>
</div>
</div>
);
}
function ProviderDisabled({
icon: Icon,
label,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
}) {
return (
<div className="flex w-full items-center justify-center gap-2 rounded-md border bg-muted/30 px-4 py-2.5 text-sm font-medium text-muted-foreground">
<Icon className="size-4" />
{label}
<span className="ml-2 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium tracking-wider">
SOON
</span>
</div>
);
}
// Bitbucket isn't in lucide-react. Tiny inline SVG to match the visual.
function BitbucketIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
className={className}
fill="currentColor"
aria-hidden="true"
>
<path d="M2 4l2.5 14h15L22 4H2zm10.85 9.7h-1.7l-.55-3.4h2.8l-.55 3.4z" />
</svg>
);
}
+147
View File
@@ -9,6 +9,7 @@ import {
ExternalLink,
Github,
KeyRound,
Lock,
Play,
Plus,
Trash2,
@@ -25,11 +26,16 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
CredentialForm,
CredentialRow,
} from "@/components/credentials/credential-form";
import {
api,
type Agent,
type AuthStatus,
type FlowSummary,
type PublicCredential,
type Run,
type ServerConfig,
} from "@/lib/api";
@@ -512,6 +518,17 @@ function AgentDetail() {
</Card>
</div>
{/* Credentials ------------------------------------------------------ */}
<CredentialsSection
agentId={id}
credentials={agent.credentials ?? []}
onChanged={async () => {
// refetch agent so the credentials list updates
const a = await api.getAgent(id);
setAgent(a);
}}
/>
{/* Recent runs ------------------------------------------------------ */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
@@ -629,3 +646,133 @@ function RunStatus({ status }: { status: string }) {
// Avoid unused-import lint when the symbol is referenced only by type.
void KeyRound;
void ExternalLink;
// ─── Credentials ────────────────────────────────────────────────────────────
type CredentialsSectionProps = {
agentId: string;
credentials: PublicCredential[];
onChanged: () => void | Promise<void>;
};
function CredentialsSection({ agentId, credentials, onChanged }: CredentialsSectionProps) {
const [adding, setAdding] = useState(false);
const [editingName, setEditingName] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [globals, setGlobals] = useState<PublicCredential[]>([]);
// Pull globals once so we can show inherited rows alongside the
// per-agent overrides. Refreshed when `onChanged` re-fetches the agent
// (cheap — credentials list is small).
useEffect(() => {
let cancelled = false;
api.listGlobalCredentials().then((g) => {
if (!cancelled) setGlobals(g);
}).catch(() => {});
return () => { cancelled = true; };
}, [credentials]);
// Globals shadowed by an agent override: hide them from the inherited
// list; the override row is the source of truth.
const overrideNames = new Set(credentials.map((c) => c.name.toLowerCase()));
const inherited = globals.filter((g) => !overrideNames.has(g.name.toLowerCase()));
async function handleDelete(name: string) {
if (!confirm(`Delete agent override "${name}"? The pipeline will fall back to the global credential of the same name (if any).`)) return;
try {
await api.deleteCredential(agentId, name);
await onChanged();
} catch (e) {
setError(e instanceof Error ? e.message : "delete failed");
}
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
<div>
<CardTitle className="flex items-center gap-2">
<Lock className="size-4" />
Credentials
</CardTitle>
<CardDescription>
Cloud creds available to nodes for this agent. Globals defined on
the <Link href="/credentials" className="underline">Credentials page</Link> are
inherited; add an override here to specialize a credential for
this agent only.
</CardDescription>
</div>
<Button size="sm" onClick={() => { setAdding(true); setEditingName(null); }}>
<Plus />
Add override
</Button>
</CardHeader>
<CardContent className="space-y-3">
{error && (
<p className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
{error}
</p>
)}
{!adding && credentials.length === 0 && inherited.length === 0 && (
<p className="text-sm text-muted-foreground">
No credentials available. Add a global one on the{" "}
<Link href="/credentials" className="underline">Credentials page</Link>{" "}
or an agent-specific override here.
</p>
)}
{credentials.map((c) => (
<div key={c.id} className="rounded-md border bg-muted/20 p-3">
{editingName === c.name ? (
<CredentialForm
initial={c}
onCancel={() => setEditingName(null)}
onSubmit={async (body) => {
await api.updateCredential(agentId, c.name, body);
setEditingName(null);
await onChanged();
}}
onError={setError}
/>
) : (
<CredentialRow
cred={c}
scopeLabel="agent override"
onEdit={() => setEditingName(c.name)}
onDelete={() => handleDelete(c.name)}
/>
)}
</div>
))}
{inherited.map((c) => (
<div key={`g-${c.id}`} className="rounded-md border border-dashed bg-muted/10 p-3 opacity-90">
<CredentialRow
cred={c}
scopeLabel="inherited (global)"
onEdit={() => { /* edit globals on the global page */ }}
onDelete={() => { /* deletes go through global page */ }}
/>
</div>
))}
{adding && (
<div className="rounded-md border bg-muted/20 p-3">
<CredentialForm
initial={null}
onCancel={() => setAdding(false)}
onSubmit={async (body) => {
await api.createCredential(agentId, body);
setAdding(false);
await onChanged();
}}
onError={setError}
/>
</div>
)}
</CardContent>
</Card>
);
}
+236 -6
View File
@@ -1,10 +1,240 @@
import { EmptySection } from "@/components/empty-section";
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import {
ArrowRight,
Check,
Pause,
RefreshCw,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import { api, type ExecutionStatus, type Run } from "@/lib/api";
import { formatDate } from "@/lib/utils";
interface PendingApproval {
awakeable_id: string;
node?: string;
context?: { reason?: string; node?: string; [k: string]: unknown };
}
interface PendingItem {
run: Run;
approval: PendingApproval;
}
// /approvals lists every run that is currently parked on a HITL Approval
// node. The orchestrator stores `pending_approval` as Restate KV and
// surfaces it via GetExecution; we fan out per "running" run, keep only
// the ones that have a pending_approval set, and let the user resolve
// them in one click.
export default function ApprovalsInboxPage() {
const [items, setItems] = useState<PendingItem[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const loadRef = useRef(0);
const load = useCallback(async () => {
const tag = ++loadRef.current;
try {
const runs = await api.listRuns({ limit: 50 });
const candidates = runs.filter((r) => /running|pending|paused|waiting/i.test(r.status));
const checked = await Promise.all(
candidates.map(async (r) => {
try {
const s = (await api.getExecution(r.id)) as ExecutionStatus & {
pending_approval?: PendingApproval;
};
const pa = s.pending_approval;
if (pa && pa.awakeable_id) {
return { run: r, approval: pa } as PendingItem;
}
} catch {
/* ignore — run may have advanced */
}
return null;
})
);
// Only commit if a newer load hasn't started.
if (tag !== loadRef.current) return;
setItems(checked.filter(Boolean) as PendingItem[]);
setError(null);
} catch (e) {
if (tag !== loadRef.current) return;
setError(e instanceof Error ? e.message : "load failed");
}
}, []);
useEffect(() => {
load();
// 6s — same reasoning as executions/view: avoid racing the workflow
// SDK's shared-handler with parallel reads.
const t = setInterval(load, 6000);
// Also reload when ANY new run is created (covers the case where a
// freshly-triggered run instantly parks on an Approval node).
const es = new EventSource(api.runsStreamURL());
es.onmessage = () => load();
es.onerror = () => {};
return () => {
clearInterval(t);
es.close();
};
}, [load]);
async function decide(it: PendingItem, approved: boolean, reason: string) {
setBusyId(it.run.id);
try {
await api.resumeExecution(it.run.id, {
awakeable_id: it.approval.awakeable_id,
data: { approved, reason },
});
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "resume failed");
} finally {
setBusyId(null);
}
}
export default function ApprovalsPage() {
return (
<EmptySection
title="Approvals"
description="An inbox for pending Approval-node decisions across runs. The wiring exists (Restate awakeables + the Resume panel on each run page); this page will surface them in one place."
/>
<div className="space-y-6 p-6">
<div className="flex items-end justify-between">
<div>
<h1 className="text-3xl font-semibold tracking-tight">Approvals</h1>
<p className="mt-1 text-sm text-muted-foreground">
Runs paused on a HITL Approval node, awaiting a decision.
</p>
</div>
<Button variant="outline" size="sm" onClick={load}>
<RefreshCw />
Refresh
</Button>
</div>
{error && (
<Card className="border-destructive/40">
<CardContent className="pt-6 text-sm text-destructive">{error}</CardContent>
</Card>
)}
{items === null ? (
<p className="text-sm text-muted-foreground">Loading</p>
) : items.length === 0 ? (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
<Pause className="size-5 text-muted-foreground" />
<div className="text-sm font-medium">No approvals waiting</div>
<p className="max-w-sm text-sm text-muted-foreground">
When a pipeline hits a Wait-for-approval node it&rsquo;ll show up here
for a one-click decision.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{items.map((it) => (
<ApprovalCard
key={it.run.id}
item={it}
busy={busyId === it.run.id}
onDecide={decide}
/>
))}
</div>
)}
</div>
);
}
function ApprovalCard({
item,
busy,
onDecide,
}: {
item: PendingItem;
busy: boolean;
onDecide: (it: PendingItem, approved: boolean, reason: string) => void;
}) {
const [reason, setReason] = useState("");
const reasonHint =
typeof item.approval.context?.reason === "string"
? (item.approval.context.reason as string)
: undefined;
const nodeName =
item.approval.node ||
(typeof item.approval.context?.node === "string"
? (item.approval.context.node as string)
: "Approval");
return (
<Card className="border-amber-500/40">
<CardHeader className="border-b border-amber-500/20 bg-amber-500/5 py-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<CardTitle className="flex items-center gap-2 text-base">
<Pause className="size-4 text-amber-600 dark:text-amber-400" />
{item.run.pipelineName || "Pipeline"}
<Badge variant="warning">{nodeName}</Badge>
</CardTitle>
<CardDescription className="mt-1 truncate font-mono text-[10px]">
{item.run.id}
</CardDescription>
</div>
<Button size="sm" variant="ghost" asChild>
<Link href={`/executions/view/?id=${encodeURIComponent(item.run.id)}`}>
Open run <ArrowRight className="size-3.5" />
</Link>
</Button>
</div>
{reasonHint && (
<p className="mt-1 text-sm text-muted-foreground">{reasonHint}</p>
)}
<div className="mt-1 text-xs text-muted-foreground">
paused since {formatDate(item.run.startedAt)}
</div>
</CardHeader>
<CardContent className="space-y-3 pt-3">
<div className="space-y-1.5">
<Textarea
rows={2}
value={reason}
onChange={(e) => setReason(e.target.value)}
spellCheck
placeholder="Decision note (optional, recorded in audit)"
className="text-xs"
/>
</div>
<div className="flex gap-2">
<Button
onClick={() => onDecide(item, true, reason)}
disabled={busy}
className="flex-1 bg-emerald-600 hover:bg-emerald-700 text-white"
>
<Check className="size-4" />
{busy ? "Sending…" : "Approve"}
</Button>
<Button
onClick={() => onDecide(item, false, reason)}
disabled={busy}
variant="destructive"
className="flex-1"
>
<X className="size-4" />
Reject
</Button>
</div>
</CardContent>
</Card>
);
}
+137
View File
@@ -0,0 +1,137 @@
"use client";
import { useEffect, useState } from "react";
import { Lock, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
CredentialForm,
CredentialRow,
} from "@/components/credentials/credential-form";
import { api, type PublicCredential } from "@/lib/api";
export default function CredentialsPage() {
const [creds, setCreds] = useState<PublicCredential[] | null>(null);
const [adding, setAdding] = useState(false);
const [editingName, setEditingName] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function refresh() {
setError(null);
try {
const list = await api.listGlobalCredentials();
setCreds(list);
} catch (e) {
setError(e instanceof Error ? e.message : "load failed");
}
}
useEffect(() => {
refresh();
}, []);
async function handleDelete(name: string) {
if (!confirm(`Delete credential "${name}"? Pipelines that reference it will fail until replaced (unless an agent override exists).`)) return;
try {
await api.deleteGlobalCredential(name);
await refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "delete failed");
}
}
return (
<div className="mx-auto w-full max-w-4xl space-y-6 p-6">
<div>
<h1 className="text-3xl font-semibold tracking-tight">Credentials</h1>
<p className="mt-1 text-sm text-muted-foreground">
Org-wide credentials referenced by Deploy and other nodes. Available
to every agent. Per-agent overrides live on the agent page.
</p>
</div>
{error && (
<p className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
{error}
</p>
)}
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
<div>
<CardTitle className="flex items-center gap-2">
<Lock className="size-4" />
Stored credentials
</CardTitle>
<CardDescription>
Secret values (GCP service-account JSON, kv values) are AES-GCM
encrypted at rest with <code>FLOW_SECRET_KEY</code>. The API
never returns them list shows only metadata + key names.
</CardDescription>
</div>
<Button size="sm" onClick={() => { setAdding(true); setEditingName(null); }}>
<Plus />
Add credential
</Button>
</CardHeader>
<CardContent className="space-y-3">
{creds === null && (
<p className="text-sm text-muted-foreground">Loading</p>
)}
{!adding && creds?.length === 0 && (
<p className="text-sm text-muted-foreground">
No credentials yet. Add one (e.g. <code className="font-mono">aws</code>)
and reference it by name from a Deploy node.
</p>
)}
{creds?.map((c) => (
<div key={c.id} className="rounded-md border bg-muted/20 p-3">
{editingName === c.name ? (
<CredentialForm
initial={c}
onCancel={() => setEditingName(null)}
onSubmit={async (body) => {
await api.updateGlobalCredential(c.name, body);
setEditingName(null);
await refresh();
}}
onError={setError}
/>
) : (
<CredentialRow
cred={c}
scopeLabel="global"
onEdit={() => setEditingName(c.name)}
onDelete={() => handleDelete(c.name)}
/>
)}
</div>
))}
{adding && (
<div className="rounded-md border bg-muted/20 p-3">
<CredentialForm
initial={null}
onCancel={() => setAdding(false)}
onSubmit={async (body) => {
await api.createGlobalCredential(body);
setAdding(false);
await refresh();
}}
onError={setError}
/>
</div>
)}
</CardContent>
</Card>
</div>
);
}
+117 -5
View File
@@ -1,10 +1,122 @@
import { EmptySection } from "@/components/empty-section";
"use client";
import { Layers, Lock, ScanFace, ShieldCheck } from "lucide-react";
// Static preview of the Environments concept. Not wired to storage yet —
// this page is the contract we show clients before the runtime work
// lands. When the storage / routing actually exists, replace the three
// demo tiles with live env records from the API.
const ENVS: { name: string; description: string; accent: string }[] = [
{
name: "DEV",
description:
"Auto-deploy on every push, smoke evals only, no approval gates.",
accent: "border-foreground/60",
},
{
name: "STAGING",
description:
"Full eval suite, optional approval, canary or progressive rollout.",
accent: "border-amber-400/60",
},
{
name: "PROD",
description:
"Strict policy gates, human approval, audit log, SLO-backed rollback.",
accent: "border-rose-400/60",
},
];
export default function EnvironmentsPage() {
return (
<EmptySection
title="Environments"
description="Per-environment config (dev / staging / prod, runtime targets, secrets bindings) for agent deployments. Hooks into the Promote node."
/>
<div className="mx-auto w-full max-w-6xl space-y-6 p-6">
<div>
<div className="mb-1 flex items-center gap-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
<Layers className="size-3.5" />
Environments
</div>
<h1 className="text-3xl font-semibold tracking-tight">Environments</h1>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
Each environment owns its runtime target, credentials, secrets,
scaling, and approval policy. Pipelines reference envs by name;
promotion moves an artifact from one env&rsquo;s pipeline to the
next.
</p>
</div>
<div className="rounded-lg border bg-card/40 p-5">
{/* Three env tiles */}
<div className="grid gap-4 md:grid-cols-3">
{ENVS.map((e) => (
<div
key={e.name}
className={`rounded-lg border-2 ${e.accent} bg-background/40 p-5`}
>
<div className="mb-2 flex items-start justify-between">
<span className="font-mono text-sm font-semibold tracking-wider">
{e.name}
</span>
<span className="text-[11px] text-muted-foreground">tier</span>
</div>
<p className="text-xs text-muted-foreground">{e.description}</p>
</div>
))}
</div>
{/* Concept rows */}
<div className="mt-6 grid gap-5 border-t pt-5 md:grid-cols-3">
<ConceptRow
icon={ScanFace}
title="Runtime target"
body="K8s cluster, Bedrock AgentCore account, or Vertex Agent Engine project. Different per env."
/>
<ConceptRow
icon={Lock}
title="Credentials"
body="Cloud creds + registry auth, sealed at rest. Resolved by pipelines at run time."
/>
<ConceptRow
icon={ShieldCheck}
title="Approval policy"
body="Who can approve, by what method (UI / Slack / auto-policy / quorum), with timeout & escalation."
/>
</div>
{/* Footer */}
<div className="mt-6 flex items-center justify-end border-t pt-5">
<button
type="button"
disabled
className="rounded-md border bg-foreground/95 px-3 py-1.5 text-xs font-medium text-background opacity-90 disabled:cursor-not-allowed"
title="Coming soon"
>
+ New environment
</button>
</div>
</div>
</div>
);
}
function ConceptRow({
icon: Icon,
title,
body,
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
body: string;
}) {
return (
<div className="flex items-start gap-3">
<div className="grid size-9 shrink-0 place-items-center rounded-md border bg-muted/30">
<Icon className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<div className="text-sm font-medium">{title}</div>
<p className="mt-0.5 text-xs text-muted-foreground">{body}</p>
</div>
</div>
);
}
+142 -34
View File
@@ -3,7 +3,7 @@
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { ChevronDown, ChevronRight, RefreshCw, Send } from "lucide-react";
import { Check, ChevronDown, ChevronRight, Pause, RefreshCw, Send, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -90,6 +90,7 @@ function ExecutionView() {
const [awakeable, setAwakeable] = useState("");
const [data, setData] = useState(`{"approved": true}`);
const [resuming, setResuming] = useState(false);
const [approvalReason, setApprovalReason] = useState("");
// Initial load
useEffect(() => {
@@ -197,6 +198,26 @@ function ExecutionView() {
return () => clearInterval(t);
}, [id, streamConnected]);
// Poll the orchestrator REST endpoint independently of the SSE feed so
// we pick up `pending_approval` as soon as the Approval node parks the
// workflow. SSE only carries the engine's lifecycle events; pending-
// approval state is set by the executor as Restate KV and surfaced via
// GetPendingApproval. Don't poll once we've reached terminal state.
// 4s interval matches Restate's recommended minimum to avoid the
// shared-handler racing the workflow's own goroutine.
useEffect(() => {
if (!id) return;
const isTerm = (s: string | undefined) =>
["success", "completed", "failed", "error", "partial_error"].includes(
(s || "").toLowerCase()
);
if (isTerm(status?.status)) return;
const t = setInterval(() => {
api.getExecution(id).then(setStatus).catch(() => {});
}, 4000);
return () => clearInterval(t);
}, [id, status?.status]);
// Hydrate canvas node statuses from the terminal payload whenever we
// have status + pipeline def. This handles the "joined after the run
// finished" case — without it, the canvas stays at PENDING forever
@@ -233,9 +254,44 @@ function ExecutionView() {
}
}
// approveOrReject is the one-click path: reads the awakeable id from
// pending_approval (no manual copy) and resolves with the standard
// {approved: bool, reason} body the ApprovalExecutor unwraps.
async function approveOrReject(approved: boolean, reason?: string) {
const pending = (status as { pending_approval?: { awakeable_id?: string } } | null)
?.pending_approval;
if (!pending?.awakeable_id) {
setError("no pending approval on this run");
return;
}
setResuming(true);
setError(null);
try {
await api.resumeExecution(id, {
awakeable_id: pending.awakeable_id,
data: { approved, reason },
});
const s = await api.getExecution(id).catch(() => null);
if (s) setStatus(s);
} catch (e) {
setError(e instanceof Error ? e.message : "resume failed");
} finally {
setResuming(false);
}
}
const pendingApproval = useMemo(() => {
const pa = (status as { pending_approval?: { node?: string; awakeable_id?: string; context?: Record<string, unknown> } } | null)
?.pending_approval;
return pa && pa.awakeable_id ? pa : null;
}, [status]);
const overallStatus = useMemo(
() => (status?.status as string) || run?.status || "running",
[status, run]
() =>
pendingApproval
? "paused"
: (status?.status as string) || run?.status || "running",
[status, run, pendingApproval]
);
const isTerminal = ["success", "completed", "failed", "error", "partial_error"]
.includes(overallStatus.toLowerCase());
@@ -348,45 +404,87 @@ function ExecutionView() {
</CardContent>
</Card>
{/* Resume overlay if paused */}
{(overallStatus === "paused" || overallStatus === "waiting") && (
<Card className="fixed bottom-6 right-6 z-30 w-80 shadow-xl">
<CardHeader>
<CardTitle>Resume</CardTitle>
{/* Pending approval overlay surfaces the awakeable, exposes
one-click Approve / Reject so the user never has to copy IDs. */}
{pendingApproval && (
<Card className="fixed bottom-6 right-6 z-30 w-96 border-amber-500/50 shadow-xl">
<CardHeader className="space-y-1 border-b border-amber-500/20 bg-amber-500/5">
<div className="flex items-center justify-between gap-2">
<CardTitle className="flex items-center gap-2 text-amber-700 dark:text-amber-400">
<Pause className="size-4" />
Approval needed
</CardTitle>
<Badge variant="warning">{pendingApproval.node}</Badge>
</div>
<CardDescription>
Resolve a Restate awakeable to continue.
{pendingReason(pendingApproval) ??
"This run is waiting for a human decision."}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<CardContent className="space-y-3 pt-3">
<div className="space-y-1.5">
<Label htmlFor="awakeable">Awakeable ID</Label>
<Input
id="awakeable"
value={awakeable}
onChange={(e) => setAwakeable(e.target.value)}
placeholder="awk_…"
className="font-mono text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="data">Resolution data (JSON)</Label>
<Label htmlFor="approval-reason">
Reason (optional, recorded in audit)
</Label>
<Textarea
id="data"
rows={4}
value={data}
onChange={(e) => setData(e.target.value)}
spellCheck={false}
id="approval-reason"
rows={2}
value={approvalReason}
onChange={(e) => setApprovalReason(e.target.value)}
spellCheck
placeholder="LGTM — image scan clean, deploy to dev"
className="text-xs"
/>
</div>
<Button
className="w-full"
onClick={onResume}
disabled={resuming || !awakeable}
>
<Send />
{resuming ? "Sending…" : "Resume"}
</Button>
<div className="grid grid-cols-2 gap-2">
<Button
onClick={() => approveOrReject(true, approvalReason)}
disabled={resuming}
className="bg-emerald-600 hover:bg-emerald-700 text-white"
>
<Check className="size-4" />
{resuming ? "Sending…" : "Approve"}
</Button>
<Button
onClick={() => approveOrReject(false, approvalReason)}
disabled={resuming}
variant="destructive"
>
<X className="size-4" />
Reject
</Button>
</div>
<details className="rounded-md border bg-muted/20 p-2">
<summary className="cursor-pointer text-[10px] uppercase tracking-wider text-muted-foreground">
Manual resolve (raw awakeable)
</summary>
<div className="mt-2 space-y-2">
<div className="font-mono text-[10px] break-all text-muted-foreground">
{pendingApproval.awakeable_id}
</div>
<Textarea
rows={3}
value={data}
onChange={(e) => setData(e.target.value)}
spellCheck={false}
className="text-[10px] font-mono"
placeholder='{"approved": true, "reason": "…"}'
/>
<Button
size="sm"
variant="outline"
className="w-full"
disabled={resuming}
onClick={() => {
setAwakeable(pendingApproval.awakeable_id ?? "");
onResume();
}}
>
<Send className="size-3.5" />
Send raw
</Button>
</div>
</details>
</CardContent>
</Card>
)}
@@ -1033,3 +1131,13 @@ function ScanResults({
</div>
);
}
// pendingReason returns a human-readable label for a pending approval. The
// ApprovalExecutor stores its `reason` parameter under `context.reason` so
// it round-trips through the workflow journal — pull it back out here.
function pendingReason(pa: {
context?: Record<string, unknown>;
}): string | undefined {
const r = pa.context?.reason;
return typeof r === "string" && r.trim() ? r : undefined;
}
+83 -5
View File
@@ -1,10 +1,88 @@
import { EmptySection } from "@/components/empty-section";
"use client";
import { Activity, ArrowLeftRight, Coins, Gauge, Radio } from "lucide-react";
// Static preview of the AI Gateway concept. No real proxy yet — this
// page is the contract we show clients while the gateway is being
// built. When the runtime exists, replace the four feature tiles with
// live config + metrics.
export default function GatewayPage() {
return (
<EmptySection
title="Gateway"
description="Public ingress for deployed agents — routes, auth, rate limits, OpenTelemetry export. Replaces the per-runtime gateway (Bedrock AgentCore endpoints, Vertex Reasoning Engine routes, K8s Ingress) with a uniform layer."
/>
<div className="mx-auto w-full max-w-6xl space-y-6 p-6">
<div>
<div className="mb-1 flex items-center gap-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
<Radio className="size-3.5" />
Gateway
</div>
<h1 className="text-3xl font-semibold tracking-tight">AI Gateway</h1>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
A proxy in front of your agents&rsquo; LLM calls. Enforce
per-agent budgets, log every prompt and completion for replay,
rate-limit by tenant, and fall back across providers without
redeploying.
</p>
</div>
<div className="rounded-lg border bg-card/40 p-5">
<div className="grid gap-4 md:grid-cols-2">
<FeatureTile
icon={Coins}
title="Budgets & cost caps"
body="Per-agent and per-environment spend ceilings. Hard cap or alert-only; the gateway short-circuits requests that would breach the budget."
/>
<FeatureTile
icon={Activity}
title="Prompt + completion logs"
body="Every call captured for replay and incident review. Plumbs into the Operations pillar's trace store."
/>
<FeatureTile
icon={Gauge}
title="Rate limits & quotas"
body="Throttle per tenant, per agent, or per route. Lives in the gateway, not the app — same policy across runtimes."
/>
<FeatureTile
icon={ArrowLeftRight}
title="Provider fallback"
body="Try OpenAI → Anthropic → Bedrock on failure. Routing is config, not code, so swaps don't need a redeploy."
/>
</div>
<div className="mt-6 flex items-center justify-end border-t pt-5">
<button
type="button"
disabled
className="rounded-md border bg-foreground/95 px-3 py-1.5 text-xs font-medium text-background opacity-90 disabled:cursor-not-allowed"
title="Coming soon"
>
+ New gateway
</button>
</div>
</div>
</div>
);
}
function FeatureTile({
icon: Icon,
title,
body,
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
body: string;
}) {
return (
<div className="rounded-lg border bg-background/40 p-5">
<div className="flex items-start gap-3">
<div className="grid size-9 shrink-0 place-items-center rounded-md border bg-muted/30">
<Icon className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<div className="text-sm font-medium">{title}</div>
<p className="mt-1 text-xs text-muted-foreground">{body}</p>
</div>
</div>
</div>
);
}