mirror of
https://github.com/open-gitagent/langship.sh.git
synced 2026-08-03 07:21:04 +02:00
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:
+303
-85
@@ -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’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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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’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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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’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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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’ 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user