summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/chains
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/components/chains')
-rw-r--r--makima/frontend/src/components/chains/ChainEditor.tsx321
1 files changed, 306 insertions, 15 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>
+ );
+}