Files
langship.sh/web/components/canvas/node-form.tsx
T
patel-lyzr 6b1a13ecdc feat: implement environment management features
- Added environment creation and editing pages with forms for name and description.
- Integrated environment listing with options to edit and delete environments.
- Updated agent detail page to manage environments followed by agents.
- Enhanced API to support environment operations including listing, creating, updating, and deleting environments.
- Refactored related components and state management for improved clarity and functionality.
2026-05-13 22:15:08 +05:30

1485 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import type { PipelineNode } from "@/lib/pipeline-graph";
interface NodeFormProps {
node: PipelineNode;
onChange: (next: PipelineNode) => void;
}
// Typed forms per node type. Anything we don't know about renders a generic
// JSON view (handled by the inspector — this component returns null in that
// case so the parent shows the JSON fallback).
//
// Each form mutates node.parameters and calls onChange with the updated node.
// We deliberately keep these dumb (no internal state); the inspector owns
// debouncing and persistence.
export function NodeForm({ node, onChange }: NodeFormProps) {
switch (node.type) {
case "flow-nodes-base.trigger":
return <TriggerForm node={node} onChange={onChange} />;
case "flow-nodes-base.build":
return <BuildForm node={node} onChange={onChange} />;
case "flow-nodes-base.test":
return <TestForm node={node} onChange={onChange} />;
case "flow-nodes-base.eval":
return <EvalForm node={node} onChange={onChange} />;
case "flow-nodes-base.policy":
return <PolicyForm node={node} onChange={onChange} />;
case "flow-nodes-base.waitForApproval":
return <ApprovalForm node={node} onChange={onChange} />;
case "flow-nodes-base.sast":
return <SastForm node={node} onChange={onChange} />;
case "flow-nodes-base.imageScan":
return <ImageScanForm node={node} onChange={onChange} />;
case "flow-nodes-base.push":
return <PushForm node={node} onChange={onChange} />;
case "flow-nodes-base.deploy":
return <DeployForm node={node} onChange={onChange} />;
case "flow-nodes-base.promote":
return <PromoteForm node={node} onChange={onChange} />;
case "flow-nodes-base.rollback":
return <RollbackForm node={node} onChange={onChange} />;
default:
return null;
}
}
/** Returns true if we render a typed form for this type (so the JSON
* fallback can be hidden). */
export function hasTypedForm(type: string): boolean {
return [
"flow-nodes-base.trigger",
"flow-nodes-base.build",
"flow-nodes-base.sast",
"flow-nodes-base.imageScan",
"flow-nodes-base.push",
"flow-nodes-base.test",
"flow-nodes-base.eval",
"flow-nodes-base.policy",
"flow-nodes-base.waitForApproval",
"flow-nodes-base.deploy",
"flow-nodes-base.promote",
"flow-nodes-base.rollback",
].includes(type);
}
// --- helpers --------------------------------------------------------------
function setParam<T>(node: PipelineNode, key: string, value: T): PipelineNode {
return {
...node,
parameters: { ...(node.parameters ?? {}), [key]: value },
};
}
function getString(node: PipelineNode, key: string, fallback = ""): string {
const v = node.parameters?.[key];
return typeof v === "string" ? v : fallback;
}
function getNumber(node: PipelineNode, key: string, fallback = 0): number {
const v = node.parameters?.[key];
return typeof v === "number" ? v : fallback;
}
function getStringArray(node: PipelineNode, key: string): string[] {
const v = node.parameters?.[key];
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
}
function getBool(node: PipelineNode, key: string, fallback: boolean): boolean {
const v = node.parameters?.[key];
if (typeof v === "boolean") return v;
return fallback;
}
// --- forms ----------------------------------------------------------------
function TriggerForm({ node, onChange }: NodeFormProps) {
const mode = getString(node, "mode", "manual");
const cron = getString(node, "cron", "0 * * * *");
const fromBranch = getString(node, "fromBranch", "main");
const toBranch = getString(node, "toBranch", "production");
return (
<div className="space-y-3">
<div className="space-y-1.5">
<Label>Mode</Label>
<select
value={mode}
onChange={(e) => onChange(setParam(node, "mode", e.target.value))}
className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm"
>
<option value="manual">Manual</option>
<option value="webhook">Git webhook (push)</option>
<option value="schedule">Scheduled (cron)</option>
</select>
<p className="text-[11px] text-muted-foreground">
How runs are dispatched. Webhook + manual are wired today; schedule
lands once the cron worker exists.
</p>
</div>
{mode === "schedule" && (
<div className="space-y-1.5">
<Label htmlFor="cron">Cron expression</Label>
<Input
id="cron"
value={cron}
onChange={(e) => onChange(setParam(node, "cron", e.target.value))}
placeholder="0 * * * *"
className="font-mono text-xs"
/>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="trig-from">From branch</Label>
<Input
id="trig-from"
value={fromBranch}
onChange={(e) =>
onChange(setParam(node, "fromBranch", e.target.value))
}
placeholder="main"
className="font-mono text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="trig-to">To branch</Label>
<Input
id="trig-to"
value={toBranch}
onChange={(e) =>
onChange(setParam(node, "toBranch", e.target.value))
}
placeholder="production"
className="font-mono text-xs"
/>
</div>
</div>
<p className="text-[11px] text-muted-foreground">
From/To branch travel in the trigger payload Build clones{" "}
<code>fromBranch</code>, Promote opens a PR{" "}
<code>fromBranch toBranch</code>.
</p>
</div>
);
}
function BuildForm({ node, onChange }: NodeFormProps) {
const mode = getString(node, "mode", "docker");
const dockerfile = getString(node, "dockerfile", "Dockerfile");
const ctx = getString(node, "context", ".");
const imageName = getString(node, "imageName", "");
const registry = getString(node, "registry", "registry:5000");
const platform = getString(node, "platform", "linux/amd64");
const buildArgs = getString(node, "buildArgs", "");
const command = getString(
node,
"command",
"docker build -t $AGENT_NAME:$COMMIT_SHA ."
);
const workdir = getString(node, "workdir", ".");
const timeout = getNumber(node, "timeoutSeconds", 600);
return (
<div className="space-y-3">
<div className="space-y-1.5">
<Label>Mode</Label>
<select
value={mode}
onChange={(e) => onChange(setParam(node, "mode", e.target.value))}
className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm"
>
<option value="docker">docker</option>
<option value="shell">shell</option>
</select>
<p className="text-[11px] text-muted-foreground">
<code>docker</code> = real OCI image build &amp; push via BuildKit.{" "}
<code>shell</code> = run any command (escape hatch).
</p>
</div>
{mode === "docker" ? (
<>
<div className="space-y-1.5">
<Label htmlFor="dockerfile">Dockerfile</Label>
<Input
id="dockerfile"
value={dockerfile}
onChange={(e) =>
onChange(setParam(node, "dockerfile", e.target.value))
}
placeholder="Dockerfile"
className="font-mono text-xs"
/>
<p className="text-[11px] text-muted-foreground">
Path relative to the repo root.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="b-ctx">Build context</Label>
<Input
id="b-ctx"
value={ctx}
onChange={(e) => onChange(setParam(node, "context", e.target.value))}
placeholder="."
className="font-mono text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="b-image">Image name (optional)</Label>
<Input
id="b-image"
value={imageName}
onChange={(e) =>
onChange(setParam(node, "imageName", e.target.value))
}
placeholder="my-org/my-agent"
className="font-mono text-xs"
/>
<p className="text-[11px] text-muted-foreground">
Defaults to <code>&lt;owner&gt;/&lt;repo&gt;</code> from the
agent&rsquo;s connection.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="b-reg">Registry</Label>
<Input
id="b-reg"
value={registry}
onChange={(e) =>
onChange(setParam(node, "registry", e.target.value))
}
placeholder="ghcr.io"
className="font-mono text-xs"
/>
<p className="text-[11px] text-muted-foreground">
In docker-compose, <code>registry:5000</code> is the bundled
local registry (host port 5050 for <code>docker pull</code>).
For <code>ghcr.io</code>, the agent&rsquo;s PAT must have{" "}
<code>write:packages</code>.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="b-plat">Target platform</Label>
<Input
id="b-plat"
value={platform}
onChange={(e) =>
onChange(setParam(node, "platform", e.target.value))
}
placeholder="linux/amd64"
className="font-mono text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="b-args">Build args (optional)</Label>
<Input
id="b-args"
value={buildArgs}
onChange={(e) =>
onChange(setParam(node, "buildArgs", e.target.value))
}
placeholder="NODE_ENV=production, FOO=bar"
className="font-mono text-xs"
/>
<p className="text-[11px] text-muted-foreground">
<code>key=value</code>, comma-separated.
</p>
</div>
</>
) : (
<>
<div className="space-y-1.5">
<Label htmlFor="command">Command</Label>
<Textarea
id="command"
rows={3}
value={command}
onChange={(e) => onChange(setParam(node, "command", e.target.value))}
spellCheck={false}
className="font-mono text-xs"
/>
<p className="text-[11px] text-muted-foreground">
Runs in a clone of the agent repo. Available env:{" "}
<code>$AGENT_NAME</code>, <code>$REPO_URL</code>,{" "}
<code>$COMMIT_SHA</code>, <code>$REF</code>.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="workdir">Workdir</Label>
<Input
id="workdir"
value={workdir}
onChange={(e) =>
onChange(setParam(node, "workdir", e.target.value))
}
placeholder="."
className="font-mono text-xs"
/>
</div>
</>
)}
<div className="space-y-1.5">
<Label htmlFor="timeout">Timeout (seconds)</Label>
<Input
id="timeout"
type="number"
min={10}
max={3600}
value={timeout}
onChange={(e) =>
onChange(setParam(node, "timeoutSeconds", Number(e.target.value)))
}
/>
</div>
</div>
);
}
function TestForm({ node, onChange }: NodeFormProps) {
const command = getString(node, "command", "pytest -q");
const workdir = getString(node, "workdir", ".");
const timeout = getNumber(node, "timeoutSeconds", 600);
return (
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="t-cmd">Command</Label>
<Textarea
id="t-cmd"
rows={3}
value={command}
onChange={(e) => onChange(setParam(node, "command", e.target.value))}
spellCheck={false}
className="font-mono text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="t-wd">Workdir</Label>
<Input
id="t-wd"
value={workdir}
onChange={(e) => onChange(setParam(node, "workdir", e.target.value))}
placeholder="."
className="font-mono text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="t-to">Timeout (s)</Label>
<Input
id="t-to"
type="number"
min={10}
max={3600}
value={timeout}
onChange={(e) =>
onChange(setParam(node, "timeoutSeconds", Number(e.target.value)))
}
/>
</div>
</div>
<p className="text-[11px] text-muted-foreground">
Stub today: logs the command and returns success. Wire to a real
executor when the test runner exists.
</p>
</div>
);
}
function EvalForm({ node, onChange }: NodeFormProps) {
const suite = getString(node, "suite", "default");
const metric = getString(node, "metric", "accuracy");
const threshold = getNumber(node, "threshold", 0.8);
return (
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="e-suite">Suite</Label>
<Input
id="e-suite"
value={suite}
onChange={(e) => onChange(setParam(node, "suite", e.target.value))}
placeholder="default"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="e-metric">Metric</Label>
<Input
id="e-metric"
value={metric}
onChange={(e) => onChange(setParam(node, "metric", e.target.value))}
placeholder="accuracy"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="e-threshold">Threshold</Label>
<Input
id="e-threshold"
type="number"
step="0.01"
min={0}
max={1}
value={threshold}
onChange={(e) =>
onChange(setParam(node, "threshold", Number(e.target.value)))
}
/>
</div>
</div>
</div>
);
}
function PolicyForm({ node, onChange }: NodeFormProps) {
const rules = getStringArray(node, "rules");
const mode = getString(node, "mode", "enforce");
const text = rules.join("\n");
function commit(t: string) {
const parsed = t.split("\n").map((s) => s.trim()).filter(Boolean);
onChange(setParam(node, "rules", parsed));
}
return (
<div className="space-y-3">
<div className="space-y-1.5">
<Label>Enforcement</Label>
<select
value={mode}
onChange={(e) => onChange(setParam(node, "mode", e.target.value))}
className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm"
>
<option value="enforce">Enforce fail on violation</option>
<option value="warn">Warn log only</option>
<option value="audit">Audit record, never block</option>
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="p-rules">Rules (one per line)</Label>
<Textarea
id="p-rules"
rows={6}
defaultValue={text}
onBlur={(e) => commit(e.target.value)}
spellCheck={false}
className="font-mono text-xs"
placeholder={"max_monthly_spend_usd:1000\nno_pii_in_outputs"}
/>
</div>
</div>
);
}
function ApprovalForm({ node, onChange }: NodeFormProps) {
const reason = getString(node, "reason", "Manual review");
const reviewers = getStringArray(node, "reviewers");
const text = reviewers.join("\n");
const method = getString(node, "method", "");
const minApprovers = getNumber(node, "minApprovers", 0);
const timeout = getNumber(node, "timeoutSeconds", 0);
function commit(t: string) {
const parsed = t.split("\n").map((s) => s.trim()).filter(Boolean);
onChange(setParam(node, "reviewers", parsed));
}
return (
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="a-reason">Reason</Label>
<Input
id="a-reason"
value={reason}
onChange={(e) => onChange(setParam(node, "reason", e.target.value))}
placeholder="Manual review before deploy"
/>
<p className="text-[11px] text-muted-foreground">
Shown in the Resume panel on the executions page.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="a-reviewers">Reviewers (one per line)</Label>
<Textarea
id="a-reviewers"
rows={4}
defaultValue={text}
onBlur={(e) => commit(e.target.value)}
spellCheck={false}
className="font-mono text-xs"
placeholder="user@example.com"
/>
</div>
<div className="space-y-1.5">
<Label>Method</Label>
<select
value={method}
onChange={(e) => onChange(setParam(node, "method", e.target.value))}
className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm"
>
<option value="">UI (human) default</option>
<option value="ui">UI (human)</option>
<option value="quorum">Quorum (N approvers)</option>
<option value="auto">Auto (don&rsquo;t pause)</option>
</select>
<p className="text-[11px] text-muted-foreground">
<strong>auto</strong> emits straight to the approved output without
pausing. Quorum N&gt;1 enforcement is surfaced to reviewers but not
yet hard-enforced.
</p>
</div>
{method === "quorum" && (
<div className="space-y-1.5">
<Label htmlFor="a-minappr">Min approvers (override)</Label>
<Input
id="a-minappr"
type="number"
value={minApprovers}
onChange={(e) =>
onChange(setParam(node, "minApprovers", Number(e.target.value)))
}
placeholder="(inherit)"
className="font-mono text-xs"
/>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="a-timeout">Timeout seconds (override, 0=inherit)</Label>
<Input
id="a-timeout"
type="number"
value={timeout}
onChange={(e) =>
onChange(setParam(node, "timeoutSeconds", Number(e.target.value)))
}
className="font-mono text-xs"
/>
<p className="text-[11px] text-muted-foreground">
When set, the run is auto-rejected after this long (routed to the
rejected output).
</p>
</div>
</div>
);
}
function SastForm({ node, onChange }: NodeFormProps) {
const tool = getString(node, "tool", "trivy");
const threshold = getString(node, "severityThreshold", "HIGH");
const failOnFinding = getBool(node, "failOnFinding", true);
const timeout = getNumber(node, "timeoutSeconds", 600);
// sonar
const sonarHost = getString(node, "sonarHost", "https://sonarcloud.io");
const organization = getString(node, "organization", "");
const projectKey = getString(node, "projectKey", "");
const sonarToken = getString(node, "sonarToken", "");
const branchName = getString(node, "branchName", "");
// custom
const image = getString(node, "image", "");
const command = getString(node, "command", "");
return (
<div className="space-y-3">
<div className="space-y-1.5">
<Label>Tool</Label>
<select
value={tool}
onChange={(e) => onChange(setParam(node, "tool", e.target.value))}
className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm"
>
<option value="trivy">Trivy vulns + secrets + IaC misconfig</option>
<option value="semgrep">Semgrep code-flow / taint analysis</option>
<option value="gitleaks">Gitleaks leaked secrets</option>
<option value="sonar">SonarCloud code quality + quality gate</option>
<option value="custom">Custom your container, your command</option>
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Fail-at severity</Label>
<select
value={threshold}
onChange={(e) =>
onChange(setParam(node, "severityThreshold", e.target.value))
}
className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm"
>
<option value="LOW">LOW (everything)</option>
<option value="MEDIUM">MEDIUM</option>
<option value="HIGH">HIGH (default)</option>
<option value="CRITICAL">CRITICAL only</option>
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="sast-timeout">Timeout (s)</Label>
<Input
id="sast-timeout"
type="number"
min={30}
max={3600}
value={timeout}
onChange={(e) =>
onChange(setParam(node, "timeoutSeconds", Number(e.target.value)))
}
/>
</div>
</div>
<div className="flex items-center gap-2">
<input
id="sast-fail"
type="checkbox"
checked={failOnFinding}
onChange={(e) =>
onChange(setParam(node, "failOnFinding", e.target.checked))
}
className="size-3.5"
/>
<Label htmlFor="sast-fail" className="text-[11px]">
Fail the run when findings exceed threshold (default on)
</Label>
</div>
{tool === "sonar" && (
<div className="rounded-md border bg-muted/20 p-3 space-y-2">
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
SonarCloud
</div>
<div className="space-y-1.5">
<Label htmlFor="sonar-host">Host</Label>
<Input
id="sonar-host"
value={sonarHost}
onChange={(e) =>
onChange(setParam(node, "sonarHost", e.target.value))
}
placeholder="https://sonarcloud.io"
className="font-mono text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<Label htmlFor="sonar-org">Organization</Label>
<Input
id="sonar-org"
value={organization}
onChange={(e) =>
onChange(setParam(node, "organization", e.target.value))
}
placeholder="my-org"
className="font-mono text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="sonar-key">Project key</Label>
<Input
id="sonar-key"
value={projectKey}
onChange={(e) =>
onChange(setParam(node, "projectKey", e.target.value))
}
placeholder="my-org_my-agent"
className="font-mono text-xs"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="sonar-token">Token</Label>
<Input
id="sonar-token"
type="password"
value={sonarToken}
onChange={(e) =>
onChange(setParam(node, "sonarToken", e.target.value))
}
placeholder="SONAR_TOKEN (User → My Account → Security)"
autoComplete="new-password"
className="font-mono text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="sonar-branch">Branch (optional)</Label>
<Input
id="sonar-branch"
value={branchName}
onChange={(e) =>
onChange(setParam(node, "branchName", e.target.value))
}
placeholder="(uses agent ref by default)"
className="font-mono text-xs"
/>
</div>
<p className="text-[11px] text-muted-foreground">
Quality-gate failure flips the run to <code>failed</code>. We
also link the dashboard URL on the run page.
</p>
</div>
)}
{tool === "custom" && (
<div className="rounded-md border bg-muted/20 p-3 space-y-2">
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Custom scanner
</div>
<div className="space-y-1.5">
<Label htmlFor="cust-img">Container image</Label>
<Input
id="cust-img"
value={image}
onChange={(e) =>
onChange(setParam(node, "image", e.target.value))
}
placeholder="ghcr.io/owner/scanner:latest"
className="font-mono text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="cust-cmd">Command (runs in /src)</Label>
<Textarea
id="cust-cmd"
rows={3}
value={command}
onChange={(e) =>
onChange(setParam(node, "command", e.target.value))
}
spellCheck={false}
placeholder="my-scanner --src /src --json"
className="font-mono text-xs"
/>
</div>
<p className="text-[11px] text-muted-foreground">
Repo is mounted at <code>/src</code> read-only. Non-zero exit
fails the node.
</p>
</div>
)}
{tool !== "sonar" && tool !== "custom" && (
<p className="text-[11px] text-muted-foreground">
Sane defaults no extra config needed. Findings list ends up in{" "}
<code className="font-mono">__sast.findings</code> and per-finding
lines stream into the build log.
</p>
)}
</div>
);
}
function ImageScanForm({ node, onChange }: NodeFormProps) {
const tool = getString(node, "tool", "trivy");
const threshold = getString(node, "severityThreshold", "HIGH");
const failOnFinding = getBool(node, "failOnFinding", true);
const timeout = getNumber(node, "timeoutSeconds", 600);
const insecure = getBool(node, "insecure", true);
const imageRef = getString(node, "imageRef", "");
const registryUsername = getString(node, "registryUsername", "");
const registryPassword = getString(node, "registryPassword", "");
const image = getString(node, "image", "");
const command = getString(node, "command", "");
return (
<div className="space-y-3">
<div className="space-y-1.5">
<Label>Tool</Label>
<select
value={tool}
onChange={(e) => onChange(setParam(node, "tool", e.target.value))}
className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm"
>
<option value="trivy">Trivy CVEs + secrets in the image</option>
<option value="grype">Grype Anchore CVE scanner</option>
<option value="custom">Custom your container, your command</option>
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="is-ref">Image ref (optional)</Label>
<Input
id="is-ref"
value={imageRef}
onChange={(e) => onChange(setParam(node, "imageRef", e.target.value))}
placeholder="registry:5000/owner/agent:sha (defaults to upstream Build)"
className="font-mono text-xs"
/>
<p className="text-[11px] text-muted-foreground">
Leave blank to use upstream{" "}
<code className="font-mono">__build.image</code>.
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Fail-at severity</Label>
<select
value={threshold}
onChange={(e) =>
onChange(setParam(node, "severityThreshold", e.target.value))
}
className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm"
>
<option value="LOW">LOW</option>
<option value="MEDIUM">MEDIUM</option>
<option value="HIGH">HIGH (default)</option>
<option value="CRITICAL">CRITICAL only</option>
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="is-timeout">Timeout (s)</Label>
<Input
id="is-timeout"
type="number"
min={30}
max={3600}
value={timeout}
onChange={(e) =>
onChange(setParam(node, "timeoutSeconds", Number(e.target.value)))
}
/>
</div>
</div>
<div className="flex items-center gap-2">
<input
id="is-fail"
type="checkbox"
checked={failOnFinding}
onChange={(e) =>
onChange(setParam(node, "failOnFinding", e.target.checked))
}
className="size-3.5"
/>
<Label htmlFor="is-fail" className="text-[11px]">
Fail run when findings exceed threshold
</Label>
</div>
<div className="flex items-center gap-2">
<input
id="is-insecure"
type="checkbox"
checked={insecure}
onChange={(e) =>
onChange(setParam(node, "insecure", e.target.checked))
}
className="size-3.5"
/>
<Label htmlFor="is-insecure" className="text-[11px]">
Source registry allows HTTP (default; the local{" "}
<code className="font-mono">registry:5000</code> is plain HTTP)
</Label>
</div>
{tool !== "custom" && (
<div className="rounded-md border bg-muted/20 p-3 space-y-2">
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Registry auth (optional only needed for private sources)
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<Label htmlFor="is-user">Username</Label>
<Input
id="is-user"
value={registryUsername}
onChange={(e) =>
onChange(setParam(node, "registryUsername", e.target.value))
}
placeholder="(empty = anonymous)"
autoComplete="off"
className="font-mono text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="is-pass">Password / token</Label>
<Input
id="is-pass"
type="password"
value={registryPassword}
onChange={(e) =>
onChange(setParam(node, "registryPassword", e.target.value))
}
placeholder="ghp_… or registry password"
autoComplete="new-password"
className="font-mono text-xs"
/>
</div>
</div>
</div>
)}
{tool === "custom" && (
<div className="rounded-md border bg-muted/20 p-3 space-y-2">
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Custom scanner
</div>
<div className="space-y-1.5">
<Label htmlFor="is-cust-img">Container image</Label>
<Input
id="is-cust-img"
value={image}
onChange={(e) => onChange(setParam(node, "image", e.target.value))}
placeholder="ghcr.io/owner/scanner:latest"
className="font-mono text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="is-cust-cmd">
Command (image ref exported as <code>$IMAGE_REF</code>)
</Label>
<Textarea
id="is-cust-cmd"
rows={3}
value={command}
onChange={(e) =>
onChange(setParam(node, "command", e.target.value))
}
spellCheck={false}
placeholder='trivy image --quiet "$IMAGE_REF"'
className="font-mono text-xs"
/>
</div>
</div>
)}
</div>
);
}
type PushTarget = {
name?: string;
registry?: string;
image?: string;
tag?: string;
username?: string;
password?: string;
insecure?: boolean;
};
function PushForm({ node, onChange }: NodeFormProps) {
const srcImage = getString(node, "srcImage", "");
const srcInsecure = getBool(node, "srcInsecure", true);
const rawTargets = (node.parameters?.targets as unknown) as PushTarget[] | undefined;
const targets: PushTarget[] = Array.isArray(rawTargets) ? rawTargets : [];
function setTargets(next: PushTarget[]) {
onChange(setParam(node, "targets", next as unknown as Record<string, unknown>[]));
}
function patch(idx: number, patch: Partial<PushTarget>) {
setTargets(targets.map((t, i) => (i === idx ? { ...t, ...patch } : t)));
}
function addTarget() {
setTargets([
...targets,
{ name: `target-${targets.length + 1}`, registry: "", image: "" },
]);
}
function removeTarget(idx: number) {
setTargets(targets.filter((_, i) => i !== idx));
}
return (
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="push-src">Source image (optional)</Label>
<Input
id="push-src"
value={srcImage}
onChange={(e) => onChange(setParam(node, "srcImage", e.target.value))}
placeholder="registry:5000/owner/agent:sha (defaults to upstream Build)"
className="font-mono text-xs"
/>
<p className="text-[11px] text-muted-foreground">
Leave blank to use the upstream Build node&rsquo;s{" "}
<code className="font-mono">__build.image</code>.
</p>
</div>
<div className="flex items-center gap-2">
<input
id="push-src-insecure"
type="checkbox"
checked={srcInsecure}
onChange={(e) => onChange(setParam(node, "srcInsecure", e.target.checked))}
className="size-3.5"
/>
<Label htmlFor="push-src-insecure" className="text-[11px]">
Source allows HTTP (default the local{" "}
<code className="font-mono">registry:5000</code> is plain HTTP)
</Label>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs uppercase tracking-wider">
Targets ({targets.length})
</Label>
<button
type="button"
onClick={addTarget}
className="rounded-md border bg-background px-2 py-1 text-[11px] hover:bg-accent"
>
+ add target
</button>
</div>
{targets.length === 0 && (
<p className="rounded-md border border-amber-500/40 bg-amber-500/5 p-2 text-[11px] text-amber-700 dark:text-amber-400">
No targets configured Push will fail at run time. Click + add
target.
</p>
)}
{targets.map((t, idx) => (
<TargetRow
key={idx}
idx={idx}
target={t}
onPatch={(p) => patch(idx, p)}
onRemove={() => removeTarget(idx)}
/>
))}
</div>
<p className="text-[11px] text-muted-foreground">
Each target runs in parallel. Image is pulled from the source once and
pushed concurrently no docker daemon needed. For GHCR, password is a
PAT with <code className="font-mono">write:packages</code>.
</p>
</div>
);
}
function TargetRow({
idx,
target,
onPatch,
onRemove,
}: {
idx: number;
target: PushTarget;
onPatch: (p: Partial<PushTarget>) => void;
onRemove: () => void;
}) {
const ref = `${target.registry || "<registry>"}/${target.image || "<image>"}:${target.tag || "<tag>"}`;
return (
<div className="rounded-md border bg-muted/10 p-2 space-y-2">
<div className="flex items-center gap-2">
<Input
value={target.name ?? ""}
onChange={(e) => onPatch({ name: e.target.value })}
placeholder={`target-${idx + 1}`}
className="h-7 max-w-[140px] font-mono text-[11px]"
/>
<span className="flex-1 truncate font-mono text-[10px] text-muted-foreground">
{ref}
</span>
<button
type="button"
onClick={onRemove}
className="rounded-md border bg-background px-2 py-1 text-[11px] text-destructive hover:bg-destructive/10"
aria-label="Remove target"
>
remove
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[10px] uppercase tracking-wider text-muted-foreground">
Registry
</Label>
<Input
value={target.registry ?? ""}
onChange={(e) => onPatch({ registry: e.target.value })}
placeholder="ghcr.io"
className="h-7 font-mono text-[11px]"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px] uppercase tracking-wider text-muted-foreground">
Tag
</Label>
<Input
value={target.tag ?? ""}
onChange={(e) => onPatch({ tag: e.target.value })}
placeholder="(commit sha → latest)"
className="h-7 font-mono text-[11px]"
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-[10px] uppercase tracking-wider text-muted-foreground">
Image
</Label>
<Input
value={target.image ?? ""}
onChange={(e) => onPatch({ image: e.target.value })}
placeholder="org/agent"
className="h-7 font-mono text-[11px]"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[10px] uppercase tracking-wider text-muted-foreground">
Username
</Label>
<Input
value={target.username ?? ""}
onChange={(e) => onPatch({ username: e.target.value })}
placeholder="(anonymous)"
autoComplete="off"
className="h-7 font-mono text-[11px]"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px] uppercase tracking-wider text-muted-foreground">
Password / token
</Label>
<Input
type="password"
value={target.password ?? ""}
onChange={(e) => onPatch({ password: e.target.value })}
placeholder="ghp_…"
autoComplete="new-password"
className="h-7 font-mono text-[11px]"
/>
</div>
</div>
<div className="flex items-center gap-2">
<input
id={`push-target-${idx}-insecure`}
type="checkbox"
checked={Boolean(target.insecure)}
onChange={(e) => onPatch({ insecure: e.target.checked })}
className="size-3.5"
/>
<Label
htmlFor={`push-target-${idx}-insecure`}
className="text-[10px] text-muted-foreground"
>
Allow HTTP (only for local / private registries)
</Label>
</div>
</div>
);
}
function DeployForm({ node, onChange }: NodeFormProps) {
const target = getString(node, "target", "");
const credentialName = getString(node, "credentialName", "");
const runtimeName = getString(node, "runtimeName", "");
const image = getString(node, "image", "");
const timeout = getNumber(node, "timeoutSeconds", 600);
const rawEnv = (node.parameters?.envVars ?? {}) as Record<string, unknown>;
const envEntries: [string, string][] = Object.entries(rawEnv).map(
([k, v]) => [k, typeof v === "string" ? v : String(v ?? "")]
);
function setEnvFromEntries(entries: [string, string][]) {
const obj: Record<string, string> = {};
for (const [k, v] of entries) {
const key = k.trim();
if (key) obj[key] = v;
}
onChange(setParam(node, "envVars", obj));
}
return (
<div className="space-y-3">
<div className="space-y-1.5">
<Label>Target</Label>
<select
value={target}
onChange={(e) => onChange(setParam(node, "target", e.target.value))}
className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm"
>
<option value="">AWS Bedrock AgentCore default</option>
<option value="agentcore">AWS Bedrock AgentCore</option>
<option value="kubernetes" disabled>
Kubernetes (coming soon)
</option>
<option value="vertex" disabled>
GCP Vertex Agent Engine (coming soon)
</option>
</select>
<p className="text-[11px] text-muted-foreground">
AgentCore deploys the upstream Push image; the deploy summary
includes the public invoke URL.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="d-cred">Credential name</Label>
<Input
id="d-cred"
value={credentialName}
onChange={(e) =>
onChange(setParam(node, "credentialName", e.target.value))
}
placeholder="aws"
className="font-mono text-xs"
/>
<p className="text-[11px] text-muted-foreground">
Defaults to <code>aws</code>. Must match a credential of type{" "}
<code>aws</code> in the global pool or an agent override.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="d-runtime-name">Runtime name (override)</Label>
<Input
id="d-runtime-name"
value={runtimeName}
onChange={(e) =>
onChange(setParam(node, "runtimeName", e.target.value))
}
placeholder="(defaults to agent name; AgentCore enforces [a-zA-Z0-9_]{1,48})"
className="font-mono text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="d-image">Image (override)</Label>
<Input
id="d-image"
value={image}
onChange={(e) => onChange(setParam(node, "image", e.target.value))}
placeholder="(defaults to upstream Push __push.copies[0].imageRef)"
className="font-mono text-xs"
/>
<p className="text-[11px] text-muted-foreground">
Resolution order: this field Push output Build output.
</p>
</div>
<div className="space-y-1.5">
<Label>Runtime env vars</Label>
{envEntries.length === 0 && (
<p className="text-[11px] text-muted-foreground">
No env vars set. AgentCore receives these at runtime (not baked
into the image).
</p>
)}
{envEntries.map(([k, v], i) => (
<div key={i} className="flex gap-2">
<Input
value={k}
onChange={(e) => {
const next = envEntries.slice();
next[i] = [e.target.value, v];
setEnvFromEntries(next);
}}
placeholder="KEY"
className="font-mono text-xs"
/>
<Input
value={v}
onChange={(e) => {
const next = envEntries.slice();
next[i] = [k, e.target.value];
setEnvFromEntries(next);
}}
placeholder="value"
className="font-mono text-xs"
/>
<button
type="button"
onClick={() => {
const next = envEntries.filter((_, idx) => idx !== i);
setEnvFromEntries(next);
}}
className="rounded-md border border-input px-2 text-xs hover:bg-muted"
>
</button>
</div>
))}
<button
type="button"
onClick={() => setEnvFromEntries([...envEntries, ["", ""]])}
className="rounded-md border border-input px-2 py-1 text-xs hover:bg-muted"
>
+ Add env var
</button>
</div>
<div className="space-y-1.5">
<Label htmlFor="d-timeout">Timeout (seconds)</Label>
<Input
id="d-timeout"
type="number"
value={timeout}
onChange={(e) =>
onChange(setParam(node, "timeoutSeconds", Number(e.target.value)))
}
className="font-mono text-xs"
/>
<p className="text-[11px] text-muted-foreground">
Caps the create + endpoint-readiness wait. AgentCore endpoints
typically reach READY in 60180s.
</p>
</div>
</div>
);
}
function PromoteForm({ node, onChange }: NodeFormProps) {
const mode = getString(node, "mode", "open-pr");
const fromBranch = getString(node, "fromBranch", "");
const toBranch = getString(node, "toBranch", "");
const title = getString(node, "title", "");
const body = getString(node, "body", "Promotion opened by Flow.");
const mergeMethod = getString(node, "mergeMethod", "merge");
const timeout = getNumber(node, "timeoutSeconds", 60);
return (
<div className="space-y-3">
<div className="space-y-1.5">
<Label>Mode</Label>
<select
value={mode}
onChange={(e) => onChange(setParam(node, "mode", e.target.value))}
className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm"
>
<option value="open-pr">Open pull request</option>
<option value="merge">Merge branches directly</option>
<option value="merge-pr">Open + merge pull request</option>
</select>
<p className="text-[11px] text-muted-foreground">
Promote uses the agent&rsquo;s PAT to talk to GitHub.{" "}
<strong>open-pr</strong> opens a PR (idempotent re-finds an existing
one); <strong>merge</strong> POSTs to <code>/merges</code>;{" "}
<strong>merge-pr</strong> opens then merges.
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="pr-from">From branch (override)</Label>
<Input
id="pr-from"
value={fromBranch}
onChange={(e) =>
onChange(setParam(node, "fromBranch", e.target.value))
}
placeholder="(use Trigger fromBranch)"
className="font-mono text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="pr-to">To branch (override)</Label>
<Input
id="pr-to"
value={toBranch}
onChange={(e) =>
onChange(setParam(node, "toBranch", e.target.value))
}
placeholder="(use Trigger toBranch)"
className="font-mono text-xs"
/>
</div>
</div>
{(mode === "open-pr" || mode === "merge-pr") && (
<>
<div className="space-y-1.5">
<Label htmlFor="pr-title">PR title</Label>
<Input
id="pr-title"
value={title}
onChange={(e) =>
onChange(setParam(node, "title", e.target.value))
}
placeholder="Promote {{fromBranch}} → {{toBranch}}"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="pr-body">PR body</Label>
<textarea
id="pr-body"
value={body}
onChange={(e) =>
onChange(setParam(node, "body", e.target.value))
}
rows={3}
className="w-full rounded-md border border-input bg-transparent p-2 text-xs"
/>
</div>
</>
)}
{mode === "merge-pr" && (
<div className="space-y-1.5">
<Label>Merge method</Label>
<select
value={mergeMethod}
onChange={(e) =>
onChange(setParam(node, "mergeMethod", e.target.value))
}
className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm"
>
<option value="merge">Merge commit</option>
<option value="squash">Squash</option>
<option value="rebase">Rebase</option>
</select>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="pr-to-secs">Timeout (seconds)</Label>
<Input
id="pr-to-secs"
type="number"
value={timeout}
onChange={(e) =>
onChange(setParam(node, "timeoutSeconds", Number(e.target.value)))
}
className="font-mono text-xs"
/>
</div>
<p className="text-[11px] text-muted-foreground">
Branches default to the Trigger node&rsquo;s{" "}
<code>fromBranch</code> / <code>toBranch</code> when these fields are
empty.
</p>
</div>
);
}
function RollbackForm({ node, onChange }: NodeFormProps) {
const revision = getString(node, "revision", "previous");
return (
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="rb-rev">Revision</Label>
<Input
id="rb-rev"
value={revision}
onChange={(e) => onChange(setParam(node, "revision", e.target.value))}
placeholder='"previous" or a specific build ID'
className="font-mono text-xs"
/>
</div>
</div>
);
}