mirror of
https://github.com/open-gitagent/langship.sh.git
synced 2026-08-03 07:21:04 +02:00
feat: add agents management and enhance inspector with typed forms
- Introduced a new "Agents" section in the app sidebar for better navigation. - Enhanced the Inspector component to support typed forms for various node types, improving user experience when configuring nodes. - Created a new NodeForm component to handle specific forms for different node types, including Trigger, Build, Test, Eval, Policy, Approval, Deploy, Promote, and Rollback. - Updated the API layer to manage agents, including listing, creating, deleting, and testing agent authentication. - Modified the node catalog to include new node types and their respective configurations. - Adjusted the Next.js configuration and Nginx settings to reflect changes in API endpoint ports.
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
BookOpen,
|
||||
ExternalLink,
|
||||
Github,
|
||||
Bot,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
@@ -57,6 +58,12 @@ const primary: NavItem[] = [
|
||||
icon: Activity,
|
||||
match: (p) => p.startsWith("/executions"),
|
||||
},
|
||||
{
|
||||
title: "Agents",
|
||||
href: "/agents",
|
||||
icon: Bot,
|
||||
match: (p) => p.startsWith("/agents"),
|
||||
},
|
||||
];
|
||||
|
||||
const docs = [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -9,6 +9,7 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { lookup } from "@/lib/node-catalog";
|
||||
import type { PipelineNode } from "@/lib/pipeline-graph";
|
||||
import { NodeForm, hasTypedForm } from "./node-form";
|
||||
|
||||
interface InspectorProps {
|
||||
node: PipelineNode | null;
|
||||
@@ -21,14 +22,22 @@ export function Inspector({ node, onChange, onDelete, onClose }: InspectorProps)
|
||||
const [name, setName] = useState("");
|
||||
const [paramsText, setParamsText] = useState("{}");
|
||||
const [paramsErr, setParamsErr] = useState<string | null>(null);
|
||||
const [showJSON, setShowJSON] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!node) return;
|
||||
setName(node.name);
|
||||
setParamsText(JSON.stringify(node.parameters ?? {}, null, 2));
|
||||
setParamsErr(null);
|
||||
setShowJSON(false);
|
||||
}, [node?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Keep the JSON textarea in sync when typed-form edits change parameters.
|
||||
useEffect(() => {
|
||||
if (!node) return;
|
||||
setParamsText(JSON.stringify(node.parameters ?? {}, null, 2));
|
||||
}, [node?.parameters]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!node) {
|
||||
return (
|
||||
<aside className="w-80 shrink-0 border-l bg-muted/20 p-4 text-sm text-muted-foreground">
|
||||
@@ -38,6 +47,7 @@ export function Inspector({ node, onChange, onDelete, onClose }: InspectorProps)
|
||||
}
|
||||
|
||||
const entry = lookup(node.type);
|
||||
const typed = hasTypedForm(node.type);
|
||||
|
||||
function commitName(next: string) {
|
||||
if (!node) return;
|
||||
@@ -89,21 +99,61 @@ export function Inspector({ node, onChange, onDelete, onClose }: InspectorProps)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="node-params">Parameters (JSON)</Label>
|
||||
<Textarea
|
||||
id="node-params"
|
||||
value={paramsText}
|
||||
onChange={(e) => setParamsText(e.target.value)}
|
||||
onBlur={() => commitParams(paramsText)}
|
||||
rows={14}
|
||||
spellCheck={false}
|
||||
className="text-xs"
|
||||
/>
|
||||
{paramsErr && (
|
||||
<p className="text-[11px] text-destructive">{paramsErr}</p>
|
||||
)}
|
||||
</div>
|
||||
{typed ? (
|
||||
<div className="space-y-3 rounded-md border bg-background p-3">
|
||||
<NodeForm node={node} onChange={onChange} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="node-params">Parameters (JSON)</Label>
|
||||
<Textarea
|
||||
id="node-params"
|
||||
value={paramsText}
|
||||
onChange={(e) => setParamsText(e.target.value)}
|
||||
onBlur={() => commitParams(paramsText)}
|
||||
rows={14}
|
||||
spellCheck={false}
|
||||
className="text-xs"
|
||||
/>
|
||||
{paramsErr && (
|
||||
<p className="text-[11px] text-destructive">{paramsErr}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{typed && (
|
||||
<div className="rounded-md border bg-background">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-2 px-3 py-2 text-xs font-medium hover:bg-accent"
|
||||
onClick={() => setShowJSON((v) => !v)}
|
||||
>
|
||||
<span className="flex items-center gap-1 text-muted-foreground">
|
||||
{showJSON ? (
|
||||
<ChevronDown className="size-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5" />
|
||||
)}
|
||||
Raw JSON
|
||||
</span>
|
||||
</button>
|
||||
{showJSON && (
|
||||
<div className="border-t p-2">
|
||||
<Textarea
|
||||
value={paramsText}
|
||||
onChange={(e) => setParamsText(e.target.value)}
|
||||
onBlur={() => commitParams(paramsText)}
|
||||
rows={10}
|
||||
spellCheck={false}
|
||||
className="text-[11px]"
|
||||
/>
|
||||
{paramsErr && (
|
||||
<p className="mt-1 text-[11px] text-destructive">{paramsErr}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry && (
|
||||
<div className="rounded-md border bg-background p-2 text-[11px] text-muted-foreground">
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
"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.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.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") : [];
|
||||
}
|
||||
|
||||
// --- forms ----------------------------------------------------------------
|
||||
|
||||
function TriggerForm({ node, onChange }: NodeFormProps) {
|
||||
const mode = getString(node, "mode", "manual");
|
||||
const cron = getString(node, "cron", "0 * * * *");
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function BuildForm({ node, onChange }: NodeFormProps) {
|
||||
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 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="grid grid-cols-2 gap-3">
|
||||
<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 (s)</Label>
|
||||
<Input
|
||||
id="timeout"
|
||||
type="number"
|
||||
min={10}
|
||||
max={3600}
|
||||
value={timeout}
|
||||
onChange={(e) =>
|
||||
onChange(setParam(node, "timeoutSeconds", Number(e.target.value)))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</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");
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function DeployForm({ node, onChange }: NodeFormProps) {
|
||||
const runtime = getString(node, "runtime", "kubernetes");
|
||||
const env = getString(node, "env", "dev");
|
||||
const target = getString(node, "target", "");
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Runtime</Label>
|
||||
<select
|
||||
value={runtime}
|
||||
onChange={(e) =>
|
||||
onChange(setParam(node, "runtime", e.target.value))
|
||||
}
|
||||
className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm"
|
||||
>
|
||||
<option value="kubernetes">Kubernetes</option>
|
||||
<option value="bedrock">AWS Bedrock AgentCore</option>
|
||||
<option value="vertex">GCP Vertex Agent Engine</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="d-env">Environment</Label>
|
||||
<Input
|
||||
id="d-env"
|
||||
value={env}
|
||||
onChange={(e) => onChange(setParam(node, "env", e.target.value))}
|
||||
placeholder="dev"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="d-target">Target</Label>
|
||||
<Input
|
||||
id="d-target"
|
||||
value={target}
|
||||
onChange={(e) => onChange(setParam(node, "target", e.target.value))}
|
||||
placeholder="cluster name / project / agent ID"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Runtime-specific. E.g. for K8s: cluster + namespace; for Vertex: GCP
|
||||
project + agent ID.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PromoteForm({ node, onChange }: NodeFormProps) {
|
||||
const fromEnv = getString(node, "fromEnv", "staging");
|
||||
const toEnv = getString(node, "toEnv", "prod");
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pr-from">From env</Label>
|
||||
<Input
|
||||
id="pr-from"
|
||||
value={fromEnv}
|
||||
onChange={(e) => onChange(setParam(node, "fromEnv", e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pr-to">To env</Label>
|
||||
<Input
|
||||
id="pr-to"
|
||||
value={toEnv}
|
||||
onChange={(e) => onChange(setParam(node, "toEnv", e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Promote follows the project’s branching strategy. Stub today;
|
||||
will execute the real promotion (tag/branch/merge) when wired.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user