// 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 (
MatrixLab gives GitPilot an isolated sandbox for running code,
testing snippets, and executing agent actions safely. It will be
downloaded, started, and connected automatically.
{status.runnerUrl}
{status.discoveredUrl}
GITPILOT_ENABLE_MATRIXLAB_LIFECYCLE=0
{" "}
— restart it without that variable (the default is enabled), or
use Manual setup under Advanced options.
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}`}
{logs.hint}
{logs.candidates.map((c, i) => (
{c}
GITPILOT_MATRIXLAB_CONTAINER to use one of these,
or run Reinstall to recreate the expected container.
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
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.
{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.
{`# 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`}