"use client"; import { Suspense, useEffect, useState } from "react"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { ArrowLeft, CheckCircle2, ExternalLink, Github, KeyRound, Lock, Play, Plus, Trash2, Webhook, XCircle, } 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 { CredentialForm, CredentialRow, } from "@/components/credentials/credential-form"; import { api, type Agent, type AuthStatus, type Environment, type FlowSummary, type PublicCredential, type Run, type ServerConfig, } from "@/lib/api"; import { formatDate } from "@/lib/utils"; export default function AgentDetailPage() { return ( Loading…}> ); } function AgentDetail() { const router = useRouter(); const params = useSearchParams(); const id = params.get("id") ?? ""; const [agent, setAgent] = useState(null); const [config, setConfig] = useState(null); const [environments, setEnvironments] = useState([]); const [pipelines, setPipelines] = useState([]); const [runs, setRuns] = useState([]); const [error, setError] = useState(null); const [busy, setBusy] = useState(null); // which action is in flight const [showEnvPicker, setShowEnvPicker] = useState(false); async function load() { if (!id) return; try { const [a, cfg, allEnvs, allPipes] = await Promise.all([ api.getAgent(id), api.getConfig().catch(() => null), api.listEnvironments().catch(() => []), api.listFlows().catch(() => []), ]); setAgent(a); setConfig(cfg); setEnvironments(allEnvs); setPipelines(allPipes); // Recent runs across the pipelines of every followed env. const followed = (a.environments ?? []) .map((n) => allEnvs.find((e) => e.name === n)) .filter((e): e is Environment => Boolean(e)); const pipelineIds = new Set(); followed.forEach((e) => (e.pipelineIds ?? []).forEach((pid) => pipelineIds.add(pid))); if (pipelineIds.size) { const lists = await Promise.all( [...pipelineIds].map((pid) => api.listRuns({ pipelineId: pid, limit: 5 }).catch(() => []) ) ); const merged = lists.flat(); merged.sort((x, y) => (y.startedAt || "").localeCompare(x.startedAt || "")); setRuns(merged.slice(0, 10)); } else { setRuns([]); } setError(null); } catch (e) { setError(e instanceof Error ? e.message : "load failed"); } } useEffect(() => { load(); // Reload recent runs whenever any run is dispatched (manual / agent / // GitHub push). Cheap — `load()` is one round-trip. const es = new EventSource(api.runsStreamURL()); es.onmessage = () => load(); es.onerror = () => {}; return () => es.close(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [id]); async function withBusy(label: string, fn: () => Promise) { setBusy(label); setError(null); try { return await fn(); } catch (e) { setError(e instanceof Error ? e.message : `${label} failed`); } finally { setBusy(null); } } async function onTrigger() { await withBusy("trigger", async () => { const res = await api.triggerAgent(id); const ids = res.executionIds ?? []; if (ids.length === 1) { router.push(`/executions/view/?id=${encodeURIComponent(ids[0])}`); return; } if (ids.length > 1) { router.push(`/executions/multi/?ids=${ids.map(encodeURIComponent).join(",")}`); return; } // Nothing dispatched — surface failures so the user sees why. const fails = res.failures ?? []; if (fails.length === 0) { throw new Error("trigger returned no executions and no failure detail"); } throw new Error( fails .map((f) => { const where = [f.environment, f.pipelineId].filter(Boolean).join("/"); return `${where || "?"}: ${f.reason}${f.error ? " — " + f.error : ""}`; }) .join("; ") ); }); } async function onTestAuth() { await withBusy("test-auth", async () => { await api.testAgentAuth(id); await load(); }); } async function onInstallWebhook() { await withBusy("webhook-install", async () => { await api.installAgentWebhook(id); await load(); }); } async function onUninstallWebhook() { if (!confirm("Uninstall the GitHub webhook for this agent?")) return; await withBusy("webhook-uninstall", async () => { await api.uninstallAgentWebhook(id); await load(); }); } async function onFollowEnv(envName: string) { await withBusy("follow-env", async () => { await api.agentFollowEnv(id, envName); setShowEnvPicker(false); await load(); }); } async function onUnfollowEnv(envName: string) { if (!confirm(`Stop following environment "${envName}"? This agent will no longer dispatch its pipelines.`)) return; await withBusy("unfollow-env", async () => { await api.agentUnfollowEnv(id, envName); await load(); }); } async function onDeleteAgent() { if (!confirm("Delete this agent? Webhook will be removed too.")) return; await withBusy("delete", async () => { await api.deleteAgent(id); router.push("/agents"); }); } if (!id) { return (
Missing id query param.
); } if (!agent) { return (
{error ? (

{error}

) : (

Loading agent…

)}
); } const lastRun = runs[0]; const followedEnvs = (agent.environments ?? []) .map((n) => environments.find((e) => e.name === n)) .filter((e): e is Environment => Boolean(e)); const followableEnvs = environments.filter( (e) => !agent.environments?.includes(e.name) ); const pipelineName = (pid: string) => pipelines.find((p) => p.id === pid)?.name ?? pid; return (

{agent.name}

{agent.id}

{error && ( {error} )} {/* Overview ---------------------------------------------------------- */} Overview
{agent.name}
{lastRun ? (
{formatDate(lastRun.startedAt)}
) : ( No runs yet. )}
{followedEnvs.length === 0 ? ( None followed.{" "} Manage environments . ) : ( {followedEnvs.map((e) => e.name).join(", ")} )}
{agent.environments?.length ? (

Pushes to{" "} {agent.name} route through the pipelines of the followed environments (matched by branch).

) : null} {agent.webhookUrl && (

{agent.webhookUrl}

)}
{/* Repo / webhook -------------------------------------------------- */} Repo
{agent.name}
Token{" "} {agent.hasPat ? ( ******** ) : ( not set )}
Auth: {agent.authCheckedAt && ( · {formatDate(agent.authCheckedAt)} )}
Webhook: {agent.webhookInstalledAt && ( · {formatDate(agent.webhookInstalledAt)} )}
{agent.webhookUrl && (

{agent.webhookUrl}

)} {!config?.webhooksAvailable && !agent.webhookInstalled && (

FLOW_PUBLIC_URL is not configured on the server. Set it (e.g. to a cloudflared tunnel) to install webhooks.

)}
{agent.webhookInstalled ? ( ) : ( )}
{/* Environments -------------------------------------------------- */} Environments followed {followableEnvs.length > 0 ? ( ) : environments.length === 0 ? ( ) : ( )} {followedEnvs.length === 0 ? (

Not following any environment. Follow one to dispatch its pipelines for this agent. Manage envs on the{" "} Environments page .

) : (
    {followedEnvs.map((e) => (
  • {e.name}
    {(e.pipelineIds ?? []).length === 0 ? "no pipelines" : (e.pipelineIds ?? []) .map(pipelineName) .join(" → ")}
  • ))}
)} {showEnvPicker && followableEnvs.length > 0 && (
Follow an environment
    {followableEnvs.map((e) => (
  • ))}
)}
{/* Credentials ------------------------------------------------------ */} { // refetch agent so the credentials list updates const a = await api.getAgent(id); setAgent(a); }} /> {/* Recent runs ------------------------------------------------------ */} Recent runs {runs.length > 0 && ( View all → )} {runs.length === 0 ? (

No runs yet — trigger one from the Overview block above.

) : (
    {runs.map((r) => (
  • {r.id}
    {r.pipelineName || r.pipelineId} ·{" "} {formatDate(r.startedAt)}
  • ))}
)}
); } function Field({ label, children }: { label: string; children: React.ReactNode }) { return (
{label}
{children}
); } function AuthBadge({ status }: { status: AuthStatus }) { if (status === "ok") { return ( auth ok ); } if (status === "failed") { return ( auth failed ); } return auth untested; } function WebhookBadge({ agent }: { agent: Agent }) { if (agent.webhookInstalled) { return ( webhook installed ); } return ( no webhook ); } function RunStatus({ status }: { status: string }) { const s = status.toLowerCase(); if (s === "success" || s === "completed") { return ( {status} ); } if (s === "failed" || s === "error") { return ( {status} ); } return {status}; } // 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; }; function CredentialsSection({ agentId, credentials, onChanged }: CredentialsSectionProps) { const [adding, setAdding] = useState(false); const [editingName, setEditingName] = useState(null); const [error, setError] = useState(null); const [globals, setGlobals] = useState([]); // 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 (
Credentials Cloud creds available to nodes for this agent. Globals defined on the Credentials page are inherited; add an override here to specialize a credential for this agent only.
{error && (

{error}

)} {!adding && credentials.length === 0 && inherited.length === 0 && (

No credentials available. Add a global one on the{" "} Credentials page{" "} or an agent-specific override here.

)} {credentials.map((c) => (
{editingName === c.name ? ( setEditingName(null)} onSubmit={async (body) => { await api.updateCredential(agentId, c.name, body); setEditingName(null); await onChanged(); }} onError={setError} /> ) : ( setEditingName(c.name)} onDelete={() => handleDelete(c.name)} /> )}
))} {inherited.map((c) => (
{ /* edit globals on the global page */ }} onDelete={() => { /* deletes go through global page */ }} />
))} {adding && (
setAdding(false)} onSubmit={async (body) => { await api.createCredential(agentId, body); setAdding(false); await onChanged(); }} onError={setError} />
)}
); }