// frontend/components/AdminTabs/MatrixLabInstallModal.jsx import React, { useCallback, useEffect, useState } from "react"; import { apiUrl } from "../../utils/api.js"; /** * MatrixLab install modal — the "addon store" experience. * * Drives a single state machine off /api/matrixlab/* so the user sees * one coherent state at a time (not "Unreachable" + "Running" + raw * stack-trace at once). Default view exposes a single primary button; * Runner URL / token / image / network / timeout live behind an * Advanced disclosure. * * Status names mirror the backend MatrixLabStatus enum: * not_installed | installing | starting | stopping | checking | * ready | needs_attention | failed */ // Progress checklists vary by which lifecycle the operator launched. // The active "current step" key is set on the busy state so the // checklist tracks the user's chosen journey, not a generic install. const PROGRESS_STEPS = { install: [ { key: "system", label: "Checking system" }, { key: "install", label: "Downloading MatrixLab" }, { key: "start", label: "Starting runner" }, { key: "test", label: "Testing connection" }, { key: "activate", label: "Activating MatrixLab" }, ], repair: [ { key: "system", label: "Checking runner URL" }, { key: "repair", label: "Restarting runner" }, { key: "test", label: "Testing connection" }, { key: "activate", label: "Activating MatrixLab" }, ], reinstall: [ { key: "system", label: "Stopping current runner" }, { key: "remove", label: "Removing old addon" }, { key: "install", label: "Downloading fresh version" }, { key: "start", label: "Starting runner" }, { key: "test", label: "Testing connection" }, { key: "activate", label: "Activating MatrixLab" }, ], }; // Primary action label and the journey it triggers, keyed by the // addon's normalised status. Mapping is exhaustive so the modal // always has a clear "next thing to do" button. const PRIMARY_BY_STATUS = { not_installed: { label: "Install and Start", action: "install" }, installing: { label: "Installing…", action: null }, starting: { label: "Starting…", action: null }, stopping: { label: "Stopping…", action: null }, checking: { label: "Checking…", action: null }, needs_attention: { label: "Repair connection", action: "repair" }, failed: { label: "Retry installation", action: "install" }, ready: { label: "Done", action: "done" }, }; function statusPill(status) { const map = { not_installed: { label: "Not installed", bg: "#374151", fg: "#d1d5db" }, installing: { label: "Installing", bg: "#0d3320", fg: "#86efac" }, starting: { label: "Starting", bg: "#0d3320", fg: "#86efac" }, stopping: { label: "Stopping", bg: "#3d2d11", fg: "#fde68a" }, checking: { label: "Checking", bg: "#0d3320", fg: "#86efac" }, ready: { label: "Ready", bg: "#0d3320", fg: "#86efac" }, needs_attention: { label: "Needs attention", bg: "#3d2d11", fg: "#fde68a" }, failed: { label: "Failed", bg: "#3d1111", fg: "#fca5a5" }, }; return map[status] || map.not_installed; } export default function MatrixLabInstallModal({ onClose, onActivated }) { const [status, setStatus] = useState(null); // MatrixLabStatus from backend const [busy, setBusy] = useState(false); // Which journey is currently in flight ("install" | "repair" | // "reinstall") — drives the progress checklist's stage list. const [journey, setJourney] = useState(null); const [progressStep, setProgressStep] = useState(null); const [showAdvanced, setShowAdvanced] = useState(false); const [showDetails, setShowDetails] = useState(false); const [logs, setLogs] = useState(null); const [showLogs, setShowLogs] = useState(false); // Reinstall confirmation prompt + the destructive "wipe images" opt-in. const [reinstallConfirm, setReinstallConfirm] = useState(false); const [reinstallWipe, setReinstallWipe] = useState(false); // Mirror of /api/sandbox/status for the Advanced panel. const [advanced, setAdvanced] = useState(null); const [tokenInput, setTokenInput] = useState(""); const refresh = useCallback(async () => { try { const r = await fetch(apiUrl("/api/matrixlab/status")); const data = await r.json(); setStatus(data); } catch (err) { setStatus({ status: "failed", message: "GitPilot backend could not return a MatrixLab status.", errorCode: "BACKEND_UNREACHABLE", technicalDetails: { rawError: err?.message || String(err) }, }); } }, []); const refreshAdvanced = useCallback(async () => { try { const r = await fetch(apiUrl("/api/sandbox/status")); const data = await r.json(); setAdvanced(data); } catch (err) { // Non-fatal — Advanced just stays empty. } }, []); useEffect(() => { refresh(); refreshAdvanced(); }, [refresh, refreshAdvanced]); // Run a sequence of phased POSTs against /api/matrixlab/* and walk // the progress checklist as each one returns. Used for install, // repair, and reinstall — they differ only in which endpoint kicks // off the journey. const runJourney = useCallback(async (journeyKey, kickoffPath, kickoffBody) => { setBusy(true); setJourney(journeyKey); setShowDetails(false); try { // Phase 1: kickoff (install / repair / reinstall). Each backend // endpoint is itself idempotent and aggregates several docker // commands; the modal walks the checklist on the way. setProgressStep(journeyKey === "reinstall" ? "system" : journeyKey); let r = await fetch(apiUrl(kickoffPath), { method: "POST", headers: kickoffBody ? { "Content-Type": "application/json" } : undefined, body: kickoffBody ? JSON.stringify(kickoffBody) : undefined, }); let data = await r.json(); setStatus(data); if (data.status === "failed") return; // Phase 2: explicit connection test so we never claim "ready" // without a green health probe. setProgressStep("test"); r = await fetch(apiUrl("/api/matrixlab/test"), { method: "POST" }); data = await r.json(); setStatus(data); if (data.status !== "ready") return; // Phase 3: activate (always safe to call when ready). setProgressStep("activate"); r = await fetch(apiUrl("/api/matrixlab/activate"), { method: "POST" }); data = await r.json(); setStatus(data); if (data.status === "ready" && data.activeSandbox === "matrixlab") { onActivated?.(data); } } catch (err) { setStatus({ status: "failed", message: `MatrixLab ${journeyKey} could not complete.`, errorCode: "NETWORK_ERROR", technicalDetails: { rawError: err?.message || String(err) }, }); } finally { setBusy(false); setProgressStep(null); setJourney(null); refreshAdvanced(); } }, [onActivated, refreshAdvanced]); const runInstall = useCallback(() => runJourney("install", "/api/matrixlab/install"), [runJourney]); const runRepair = useCallback(() => runJourney("repair", "/api/matrixlab/repair"), [runJourney]); const runReinstall = useCallback((removeData) => runJourney( "reinstall", "/api/matrixlab/reinstall", { remove_data: !!removeData }, ), [runJourney]); // Cheap recovery: persist the discovered URL without restarting the // runner. Most "needs_attention" cases are stale URLs from the // :8000 → :8765 port move, and this resolves them in <1s. const runUrlAutoDetect = useCallback( () => runJourney("repair", "/api/matrixlab/url/auto-detect"), [runJourney], ); const openLogs = useCallback(async () => { setShowLogs(true); try { const r = await fetch(apiUrl("/api/matrixlab/logs?tail=200")); const data = await r.json(); setLogs(data); } catch (err) { setLogs({ ok: false, error: err?.message || String(err), lines: [] }); } }, []); const onPrimary = () => { if (!status) return; const next = (PRIMARY_BY_STATUS[status.status] || PRIMARY_BY_STATUS.not_installed).action; if (!next) return; // in-flight states have no primary action if (next === "done") return onClose?.(); if (next === "install") return runInstall(); if (next === "repair") return runRepair(); }; const updateAdvanced = async (patch) => { try { const r = await fetch(apiUrl("/api/sandbox/config"), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }); const data = await r.json(); if (r.ok) { setAdvanced((prev) => ({ ...(prev || {}), ...data })); if ("matrixlab_token" in patch) setTokenInput(""); // Re-probe the addon status — URL / token changes may make us // reachable again. refresh(); } } catch (err) { // surfaced through the next refresh } }; if (!status) { return (
Checking MatrixLab status…
); } const pill = statusPill(status.status); return ( {/* Status row */}
{pill.label}
{status.message}
{status.runnerUrl && status.status !== "ready" && (
Runner URL: {status.runnerUrl}
)} {/* Stale-URL recovery: backend probes candidate ports when the configured URL is down. When a live one is found we offer a one-click switch — this fixes the very common "MatrixLab is installed but GitPilot cannot connect" after the :8000 → :8765 port move. */} {status.discoveredUrl && status.discoveredUrl !== status.runnerUrl && (
Found a live runner at{" "} {status.discoveredUrl}
)}
{/* Inline body — copy depends on state */} {status.status === "not_installed" && (

MatrixLab gives GitPilot an isolated sandbox for running code, testing snippets, and executing agent actions safely. It will be downloaded, started, and connected automatically.

)} {(busy || progressStep) && ( )} {status.status === "ready" && ( )} {/* Lifecycle disabled hint — friendly copy, admin detail under disclosure */} {status.errorCode === "LIFECYCLE_DISABLED" && (
MatrixLab lifecycle automation is disabled. This GitPilot backend was started with{" "} GITPILOT_ENABLE_MATRIXLAB_LIFECYCLE=0 {" "} — restart it without that variable (the default is enabled), or use Manual setup under Advanced options.
)} {/* Technical details disclosure — only visible when there's an error */} {status.technicalDetails && (status.status === "needs_attention" || status.status === "failed") && (
setShowDetails(e.target.open)} style={{ marginBottom: 12 }} > Technical details
              {status.technicalDetails.expected &&
                `Expected: ${status.technicalDetails.expected}\n`}
              {status.technicalDetails.actual &&
                `Actual:   ${status.technicalDetails.actual}\n`}
              {status.technicalDetails.rawError &&
                `\n${status.technicalDetails.rawError}`}
            
)} {/* Action buttons — state-aware. Reinstall is offered as a normal recovery action (not buried in Advanced) once the addon exists in some form; Open logs surfaces only when there's something to diagnose. */} {(() => { const primary = PRIMARY_BY_STATUS[status.status] || PRIMARY_BY_STATUS.not_installed; const canReinstall = status.installed === true || ["needs_attention", "failed", "ready"].includes(status.status); const canSeeLogs = ["needs_attention", "failed"].includes(status.status); return (
{canReinstall && ( )} {canSeeLogs && ( )} {status.status === "ready" && ( )}
); })()} {/* Logs viewer */} {showLogs && (
MatrixLab logs {logs?.container && ( · {logs.container} )}
{/* Friendly error + hint when the backend can't read logs. We show the hint as an actionable next step (run "make install-matrixlab") and list any matrixlab- shaped containers we found so the user can see whether the runner is up under a different name. */} {logs?.ok === false && (
{logs.error || "Could not read MatrixLab logs."}
{logs.hint && (
Next step:{" "} {logs.hint}
)} {Array.isArray(logs.candidates) && logs.candidates.length > 0 && (
Found other matrixlab-shaped containers:
    {logs.candidates.map((c, i) => (
  • {c}
  • ))}
Set GITPILOT_MATRIXLAB_CONTAINER to use one of these, or run Reinstall to recreate the expected container.
)} {logs.rawError && (
docker stderr
                      {logs.rawError}
                    
)}
)}
              {logs == null
                ? "Loading logs…"
                : (logs.lines && logs.lines.length > 0)
                  ? logs.lines.join("\n")
                  : (logs.ok === false ? "(no log output captured)" : "Loading logs…")}
            
)} {/* Advanced options — collapsed by default so first-time users see the install/repair flow, not a wall of knobs. Operators who need runner URL / token / image / network / timeout / manual setup / local clone install / unsafe modes click the disclosure to expand. */}
setShowAdvanced(e.target.open)} style={{ marginTop: 14 }} > Advanced options { setStatus(data); refreshAdvanced(); }} />
{reinstallConfirm && ( { setReinstallConfirm(false); setReinstallWipe(false); }} onConfirm={() => { const wipe = reinstallWipe; setReinstallConfirm(false); setReinstallWipe(false); runReinstall(wipe); }} /> )}
); } function ReinstallConfirm({ wipe, setWipe, onCancel, onConfirm }) { // Modal-in-modal stacked at zIndex 110 (Backdrop is 100). Apes // GitHub's "Are you absolutely sure?" pattern: explicit destructive // checkbox stays off by default so a misclick can't wipe images. return (
e.stopPropagation()} style={{ width: "min(440px, 92vw)", background: "#1a1b26", border: "1px solid #2a2b36", borderRadius: 10, padding: 20, color: "#e6e8ff", }}>

Reinstall MatrixLab Addon?

This will stop MatrixLab, remove the current addon container, download a fresh copy, start it again, and reconnect GitPilot. Your GitPilot settings will be kept.

); } function ProgressChecklist({ journey, current }) { // Pick the right stage list for the journey in flight; default to // the install list (the most informative one) when no journey is // active yet. Once ``current`` advances past the kickoff key, the // visited rows render with a ✓. const steps = PROGRESS_STEPS[journey] || PROGRESS_STEPS.install; const phaseIndex = current ? Math.max(0, steps.findIndex(s => s.key === current)) : 0; return (
{steps.map((s, i) => { const done = i < phaseIndex; const active = i === phaseIndex; return (
{done ? "✓" : active ? "⏳" : "○"} {s.label}
); })}
); } function Checklist({ items }) { return (
{items.map(([label, ok]) => (
{ok ? "✓" : "○"} {label}
))}
); } function LocalCloneSection({ disabled, onStatus }) { const [native, setNative] = useState(null); const [busy, setBusy] = useState(null); // "install" | "start" | "stop" const refresh = useCallback(async () => { try { const r = await fetch(apiUrl("/api/matrixlab/native/status")); setNative(await r.json()); } catch (err) { // non-fatal — leave section empty } }, []); useEffect(() => { refresh(); }, [refresh]); const runAction = useCallback(async (action) => { setBusy(action); try { const path = { install: "/api/matrixlab/install_local", start: "/api/matrixlab/start_local", stop: "/api/matrixlab/stop_local", }[action]; const r = await fetch(apiUrl(path), { method: "POST" }); const data = await r.json(); onStatus?.(data); } finally { setBusy(null); refresh(); } }, [onStatus, refresh]); if (!native) return null; return (
Local clone install (no Docker for the runner itself)
Clones MatrixLab into {native.local_dir}, creates a dedicated Python virtualenv, and runs uvicorn app.main:app from the runner directory. The Runner still spawns per-language sandboxes via Docker, so the host needs docker{" "} on PATH for code execution.
{native.running ? "Running (PID " + native.pid + ")" : native.installed ? "Installed · stopped" : "Not installed"} {!native.installed && ( )} {native.installed && !native.running && ( )} {native.running && ( )}
{!native.lifecycleEnabled && (
Local clone install needs lifecycle automation enabled on the GitPilot backend.
)}
); } function AdvancedOptions({ advanced, tokenInput, setTokenInput, onUpdate, disabled, onLocalStatus }) { if (!advanced) return null; return (
Advanced options
onUpdate({ matrixlab_url: e.target.value })} placeholder="http://localhost:8765" disabled={disabled} style={fieldInput} />
setTokenInput(e.target.value)} placeholder={advanced.has_token ? "•••••••• (saved)" : "Optional"} disabled={disabled} style={{ ...fieldInput, flex: 1 }} />
onUpdate({ matrixlab_image: e.target.value })} placeholder="matrixlab-python" disabled={disabled} style={fieldInput} />
onUpdate({ timeout_sec: Number(e.target.value) || 120 })} disabled={disabled} style={{ ...fieldInput, width: 80 }} /> seconds
Manual setup
{`# In a MatrixLab checkout:
docker compose up -d

# Or directly:
docker run -d --name gitpilot-matrixlab \\
  -p 8000:8000 \\
  -v /var/run/docker.sock:/var/run/docker.sock \\
  ruslanmv/matrixlab-runner:latest`}
Developer options · Unsafe modes
Pass-through runs code directly on the host without isolation. Use only for local development.
); } function Backdrop({ children, onClose }) { return (
e.stopPropagation()}>{children}
); } function ModalShell({ title, subtitle, onClose, children }) { return (

{title}

{subtitle}
{children}
); } const btnSecondary = { padding: "8px 14px", fontSize: 12, background: "transparent", color: "#c3c5dd", border: "1px solid #2c2d46", borderRadius: 6, cursor: "pointer", }; const btnPrimary = { padding: "8px 14px", fontSize: 12, fontWeight: 600, background: "#3B82F6", color: "#fff", border: "none", borderRadius: 6, cursor: "pointer", }; // "Reinstall and remove data" is destructive — surface it with a // danger tone so the operator pauses before clicking. const btnDanger = { padding: "8px 14px", fontSize: 12, fontWeight: 600, background: "#7f1d1d", color: "#fecaca", border: "1px solid #991b1b", borderRadius: 6, cursor: "pointer", }; const btnGhost = { padding: "8px 14px", fontSize: 12, background: "transparent", color: "#9092b5", border: "none", cursor: "pointer", }; const fieldLabel = { fontSize: 12, color: "#c3c5dd" }; const fieldInput = { fontSize: 12, padding: "4px 6px", background: "#14152a", color: "#e6e8ff", border: "1px solid #2c2d46", borderRadius: 4, }; const btnPrimarySmall = { padding: "4px 10px", fontSize: 11, fontWeight: 600, background: "#3B82F6", color: "#fff", border: "none", borderRadius: 4, cursor: "pointer", }; const btnSecondarySmall = { padding: "4px 10px", fontSize: 11, background: "transparent", color: "#c3c5dd", border: "1px solid #2c2d46", borderRadius: 4, cursor: "pointer", };