diff options
Diffstat (limited to 'makima/frontend/src')
| -rw-r--r-- | makima/frontend/src/components/chains/ChainEditor.tsx | 321 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 89 |
2 files changed, 391 insertions, 19 deletions
diff --git a/makima/frontend/src/components/chains/ChainEditor.tsx b/makima/frontend/src/components/chains/ChainEditor.tsx index 5b77170..6b9aa70 100644 --- a/makima/frontend/src/components/chains/ChainEditor.tsx +++ b/makima/frontend/src/components/chains/ChainEditor.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect, useMemo } from "react"; +import { useState, useCallback, useEffect } from "react"; import { ReactFlow, Node, @@ -7,9 +7,7 @@ import { Background, useNodesState, useEdgesState, - addEdge, Connection, - NodeProps, Handle, Position, BackgroundVariant, @@ -24,6 +22,8 @@ import type { ChainContractDefinition, ChainDefinitionGraphResponse, AddContractDefinitionRequest, + ChainRepository, + AddChainRepositoryRequest, } from "../../lib/api"; import { listChainDefinitions, @@ -33,6 +33,10 @@ import { getChainDefinitionGraph, startChain, stopChain, + listChainRepositories, + addChainRepository, + deleteChainRepository, + setChainRepositoryPrimary, } from "../../lib/api"; const statusColors: Record<string, string> = { @@ -58,7 +62,14 @@ const GRID_SPACING_X = 280; const GRID_SPACING_Y = 120; // Custom node component for definitions -function DefinitionNode({ data, selected }: NodeProps) { +function DefinitionNodeComponent({ + data, + selected, +}: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: any; + selected?: boolean; +}) { const isCheckpoint = data.contractType === "checkpoint"; const status = data.isInstantiated ? data.contractStatus || "pending" : "pending"; const colors = getStatusColor(status, isCheckpoint); @@ -130,7 +141,14 @@ function DefinitionNode({ data, selected }: NodeProps) { } // Custom node for contracts (active chains) -function ContractNode({ data, selected }: NodeProps) { +function ContractNodeComponent({ + data, + selected, +}: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: any; + selected?: boolean; +}) { const colors = getStatusColor(data.status); return ( @@ -184,9 +202,10 @@ function ContractNode({ data, selected }: NodeProps) { ); } -const nodeTypes = { - definition: DefinitionNode, - contract: ContractNode, +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const nodeTypes: Record<string, any> = { + definition: DefinitionNodeComponent, + contract: ContractNodeComponent, }; function getStatusColor(status: string, isCheckpoint = false) { @@ -233,28 +252,32 @@ export function ChainEditor({ const [isStopping, setIsStopping] = useState(false); const [error, setError] = useState<string | null>(null); const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null); + const [repositories, setRepositories] = useState<ChainRepository[]>([]); + const [showAddRepo, setShowAddRepo] = useState(false); - const [nodes, setNodes, onNodesChange] = useNodesState([]); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [nodes, setNodes, onNodesChange] = useNodesState([] as Node[]); + const [edges, setEdges, onEdgesChange] = useEdgesState([] as Edge[]); const showDefinitions = chain.status === "pending" || chain.status === "archived"; const canEdit = chain.status === "pending"; - // Load definitions when chain changes + // Load definitions and repositories when chain changes useEffect(() => { - async function loadDefinitions() { + async function loadData() { try { - const [defs, defGraph] = await Promise.all([ + const [defs, defGraph, repos] = await Promise.all([ listChainDefinitions(chain.id), getChainDefinitionGraph(chain.id), + listChainRepositories(chain.id), ]); setDefinitions(defs); setDefinitionGraph(defGraph); + setRepositories(repos); } catch (err) { - console.error("Failed to load definitions:", err); + console.error("Failed to load data:", err); } } - loadDefinitions(); + loadData(); }, [chain.id]); // Convert definitions/contracts to React Flow nodes and edges @@ -523,6 +546,51 @@ export function ChainEditor({ [chain.id, definitions] ); + // Repository handlers + const handleAddRepository = useCallback( + async (req: AddChainRepositoryRequest) => { + try { + await addChainRepository(chain.id, req); + const repos = await listChainRepositories(chain.id); + setRepositories(repos); + setShowAddRepo(false); + } catch (err) { + console.error("Failed to add repository:", err); + setError(err instanceof Error ? err.message : "Failed to add repository"); + } + }, + [chain.id] + ); + + const handleDeleteRepository = useCallback( + async (repoId: string) => { + if (!confirm("Remove this repository from the chain?")) return; + try { + await deleteChainRepository(chain.id, repoId); + const repos = await listChainRepositories(chain.id); + setRepositories(repos); + } catch (err) { + console.error("Failed to delete repository:", err); + setError(err instanceof Error ? err.message : "Failed to delete repository"); + } + }, + [chain.id] + ); + + const handleSetPrimary = useCallback( + async (repoId: string) => { + try { + await setChainRepositoryPrimary(chain.id, repoId); + const repos = await listChainRepositories(chain.id); + setRepositories(repos); + } catch (err) { + console.error("Failed to set primary:", err); + setError(err instanceof Error ? err.message : "Failed to set primary repository"); + } + }, + [chain.id] + ); + return ( <div className="panel h-full flex flex-col"> {/* Header */} @@ -583,6 +651,67 @@ export function ChainEditor({ )} </div> + {/* Repository section */} + <div className="px-3 py-2 border-b border-[rgba(117,170,252,0.2)] bg-[#0a1628]/50"> + <div className="flex items-center justify-between"> + <span className="font-mono text-[10px] text-[#8b949e] uppercase"> + Repositories ({repositories.length}) + </span> + {canEdit && ( + <button + onClick={() => setShowAddRepo(true)} + className="font-mono text-[10px] text-[#9bc3ff] hover:text-[#dbe7ff]" + > + + Add + </button> + )} + </div> + {repositories.length > 0 && ( + <div className="flex flex-wrap gap-2 mt-2"> + {repositories.map((repo) => ( + <div + key={repo.id} + className={`flex items-center gap-1 px-2 py-1 rounded font-mono text-xs ${ + repo.isPrimary + ? "bg-[#75aafc]/20 border border-[#75aafc]/50 text-[#75aafc]" + : "bg-[#1a2744] border border-[rgba(117,170,252,0.2)] text-[#9bc3ff]" + }`} + > + <RepoIcon className="w-3 h-3" /> + <span className="truncate max-w-[150px]" title={repo.name}> + {repo.name} + </span> + {repo.isPrimary && ( + <span className="text-[8px] uppercase ml-1">primary</span> + )} + {canEdit && !repo.isPrimary && ( + <button + onClick={() => handleSetPrimary(repo.id)} + className="ml-1 text-[8px] text-[#556677] hover:text-[#9bc3ff]" + title="Set as primary" + > + ★ + </button> + )} + {canEdit && ( + <button + onClick={() => handleDeleteRepository(repo.id)} + className="ml-1 text-[10px] text-[#556677] hover:text-red-400" + > + ✕ + </button> + )} + </div> + ))} + </div> + )} + {repositories.length === 0 && ( + <p className="font-mono text-[10px] text-[#556677] mt-1"> + No repositories attached. {canEdit && "Add one to use with contracts."} + </p> + )} + </div> + {/* Main content */} <div className="flex-1 flex min-h-0"> {/* React Flow Canvas */} @@ -713,6 +842,14 @@ export function ChainEditor({ onCancel={() => setShowAddDefinition(false)} /> )} + + {/* Add Repository Modal */} + {showAddRepo && ( + <AddRepositoryModal + onSubmit={handleAddRepository} + onCancel={() => setShowAddRepo(false)} + /> + )} </div> ); } @@ -985,3 +1122,157 @@ function CheckIcon({ className }: { className?: string }) { </svg> ); } + +function RepoIcon({ className }: { className?: string }) { + return ( + <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> + <path + strokeLinecap="round" + strokeLinejoin="round" + d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" + /> + </svg> + ); +} + +// Add Repository Modal +interface AddRepositoryModalProps { + onSubmit: (req: AddChainRepositoryRequest) => void; + onCancel: () => void; +} + +function AddRepositoryModal({ onSubmit, onCancel }: AddRepositoryModalProps) { + const [name, setName] = useState(""); + const [repositoryUrl, setRepositoryUrl] = useState(""); + const [localPath, setLocalPath] = useState(""); + const [sourceType, setSourceType] = useState<"remote" | "local">("remote"); + const [isPrimary, setIsPrimary] = useState(false); + + const handleSubmit = () => { + if (!name.trim()) return; + if (sourceType === "remote" && !repositoryUrl.trim()) return; + if (sourceType === "local" && !localPath.trim()) return; + + const req: AddChainRepositoryRequest = { + name: name.trim(), + sourceType, + isPrimary, + ...(sourceType === "remote" + ? { repositoryUrl: repositoryUrl.trim() } + : { localPath: localPath.trim() }), + }; + onSubmit(req); + }; + + return ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"> + <div className="w-full max-w-lg p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)]"> + <h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4">Add Repository</h3> + + <div className="space-y-4"> + {/* Name */} + <div> + <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1"> + Name * + </label> + <input + type="text" + value={name} + onChange={(e) => setName(e.target.value)} + className="w-full px-3 py-2 bg-[#050d18] border border-[rgba(117,170,252,0.3)] font-mono text-sm text-[#dbe7ff] focus:border-[#75aafc] focus:outline-none" + placeholder="e.g., Main Repository" + /> + </div> + + {/* Source Type */} + <div> + <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1"> + Source Type + </label> + <div className="flex gap-4"> + <label className="flex items-center gap-2 font-mono text-sm text-[#dbe7ff]"> + <input + type="radio" + checked={sourceType === "remote"} + onChange={() => setSourceType("remote")} + className="accent-[#75aafc]" + /> + Remote (URL) + </label> + <label className="flex items-center gap-2 font-mono text-sm text-[#dbe7ff]"> + <input + type="radio" + checked={sourceType === "local"} + onChange={() => setSourceType("local")} + className="accent-[#75aafc]" + /> + Local Path + </label> + </div> + </div> + + {/* Repository URL or Local Path */} + {sourceType === "remote" ? ( + <div> + <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1"> + Repository URL * + </label> + <input + type="text" + value={repositoryUrl} + onChange={(e) => setRepositoryUrl(e.target.value)} + className="w-full px-3 py-2 bg-[#050d18] border border-[rgba(117,170,252,0.3)] font-mono text-sm text-[#dbe7ff] focus:border-[#75aafc] focus:outline-none" + placeholder="https://github.com/user/repo" + /> + </div> + ) : ( + <div> + <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1"> + Local Path * + </label> + <input + type="text" + value={localPath} + onChange={(e) => setLocalPath(e.target.value)} + className="w-full px-3 py-2 bg-[#050d18] border border-[rgba(117,170,252,0.3)] font-mono text-sm text-[#dbe7ff] focus:border-[#75aafc] focus:outline-none" + placeholder="/path/to/local/repo" + /> + </div> + )} + + {/* Primary checkbox */} + <label className="flex items-center gap-2 font-mono text-sm text-[#dbe7ff]"> + <input + type="checkbox" + checked={isPrimary} + onChange={(e) => setIsPrimary(e.target.checked)} + className="accent-[#75aafc]" + /> + Set as primary repository + </label> + </div> + + {/* Actions */} + <div className="flex justify-end gap-2 mt-6"> + <button + onClick={onCancel} + className="px-4 py-2 font-mono text-xs text-[#8b949e] border border-[rgba(117,170,252,0.3)] hover:border-[#75aafc] transition-colors" + > + Cancel + </button> + <button + onClick={handleSubmit} + disabled={ + !name.trim() || + (sourceType === "remote" && !repositoryUrl.trim()) || + (sourceType === "local" && !localPath.trim()) + } + className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50" + > + Add Repository + </button> + </div> + </div> + </div> + ); +} diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index d68c1ad..2f4ee62 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -3025,6 +3025,20 @@ export interface ChainSummary { updatedAt: string; } +/** Chain repository */ +export interface ChainRepository { + id: string; + chainId: string; + name: string; + repositoryUrl: string | null; + localPath: string | null; + sourceType: string; + status: string; + isPrimary: boolean; + createdAt: string; + updatedAt: string; +} + /** Full chain with contracts */ export interface Chain { id: string; @@ -3036,8 +3050,6 @@ export interface Chain { loopMaxIterations: number | null; loopCurrentIteration: number | null; loopProgressCheck: string | null; - repositoryUrl: string | null; - localPath: string | null; version: number; createdAt: string; updatedAt: string; @@ -3061,6 +3073,7 @@ export interface ChainContractDetail { /** Chain with contracts (chain fields are flattened via serde(flatten)) */ export interface ChainWithContracts extends Chain { contracts: ChainContractDetail[]; + repositories: ChainRepository[]; } /** Node in chain graph visualization */ @@ -3105,12 +3118,20 @@ export interface ChainListResponse { total: number; } +/** Add chain repository request */ +export interface AddChainRepositoryRequest { + name: string; + repositoryUrl?: string; + localPath?: string; + sourceType?: string; + isPrimary?: boolean; +} + /** Create chain request */ export interface CreateChainRequest { name: string; description?: string; - repositoryUrl?: string; - localPath?: string; + repositories?: AddChainRepositoryRequest[]; loopEnabled?: boolean; loopMaxIterations?: number; loopProgressCheck?: string; @@ -3446,3 +3467,63 @@ export async function stopChain(chainId: string): Promise<{ stopped: boolean; st } return res.json(); } + +// ============================================================================ +// Chain Repository Operations +// ============================================================================ + +/** List repositories for a chain */ +export async function listChainRepositories(chainId: string): Promise<ChainRepository[]> { + const res = await authFetch(`${API_BASE}/api/v1/chains/${chainId}/repositories`); + if (!res.ok) { + throw new Error(`Failed to list chain repositories: ${res.statusText}`); + } + return res.json(); +} + +/** Add a repository to a chain */ +export async function addChainRepository( + chainId: string, + req: AddChainRepositoryRequest +): Promise<ChainRepository> { + const res = await authFetch(`${API_BASE}/api/v1/chains/${chainId}/repositories`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + }); + if (!res.ok) { + const error = await res.json().catch(() => ({ message: res.statusText })); + throw new Error(error.message || `Failed to add chain repository: ${res.statusText}`); + } + return res.json(); +} + +/** Delete a repository from a chain */ +export async function deleteChainRepository( + chainId: string, + repositoryId: string +): Promise<{ deleted: boolean }> { + const res = await authFetch( + `${API_BASE}/api/v1/chains/${chainId}/repositories/${repositoryId}`, + { method: "DELETE" } + ); + if (!res.ok) { + throw new Error(`Failed to delete chain repository: ${res.statusText}`); + } + return res.json(); +} + +/** Set a repository as primary for a chain */ +export async function setChainRepositoryPrimary( + chainId: string, + repositoryId: string +): Promise<ChainRepository> { + const res = await authFetch( + `${API_BASE}/api/v1/chains/${chainId}/repositories/${repositoryId}/primary`, + { method: "PUT" } + ); + if (!res.ok) { + throw new Error(`Failed to set chain repository as primary: ${res.statusText}`); + } + return res.json(); +} |
