mirror of
https://github.com/open-gitagent/langship.sh.git
synced 2026-08-03 07:21:04 +02:00
- 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.
1485 lines
50 KiB
TypeScript
1485 lines
50 KiB
TypeScript
"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 & 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><owner>/<repo></code> from the
|
||
agent’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’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’t pause)</option>
|
||
</select>
|
||
<p className="text-[11px] text-muted-foreground">
|
||
<strong>auto</strong> emits straight to the approved output without
|
||
pausing. Quorum N>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’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 60–180s.
|
||
</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’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’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>
|
||
);
|
||
}
|