| |
| import React, { useCallback, useEffect, useState } from "react"; |
| import { apiUrl } from "../../utils/api.js"; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| 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" }, |
| ], |
| }; |
|
|
| |
| |
| |
| 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); |
| const [busy, setBusy] = useState(false); |
| |
| |
| 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); |
| |
| const [reinstallConfirm, setReinstallConfirm] = useState(false); |
| const [reinstallWipe, setReinstallWipe] = useState(false); |
|
|
| |
| 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) { |
| |
| } |
| }, []); |
|
|
| useEffect(() => { |
| refresh(); |
| refreshAdvanced(); |
| }, [refresh, refreshAdvanced]); |
|
|
| |
| |
| |
| |
| const runJourney = useCallback(async (journeyKey, kickoffPath, kickoffBody) => { |
| setBusy(true); |
| setJourney(journeyKey); |
| setShowDetails(false); |
| try { |
| |
| |
| |
| 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; |
|
|
| |
| |
| setProgressStep("test"); |
| r = await fetch(apiUrl("/api/matrixlab/test"), { method: "POST" }); |
| data = await r.json(); |
| setStatus(data); |
| if (data.status !== "ready") return; |
|
|
| |
| 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]); |
| |
| |
| |
| 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; |
| 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(""); |
| |
| |
| refresh(); |
| } |
| } catch (err) { |
| |
| } |
| }; |
|
|
| if (!status) { |
| return ( |
| <Backdrop onClose={onClose}> |
| <ModalShell title="Install MatrixLab Addon" subtitle="Loading…" onClose={onClose}> |
| <div style={{ padding: 40, textAlign: "center", opacity: 0.6 }}> |
| Checking MatrixLab status… |
| </div> |
| </ModalShell> |
| </Backdrop> |
| ); |
| } |
|
|
| const pill = statusPill(status.status); |
|
|
| return ( |
| <Backdrop onClose={onClose}> |
| <ModalShell |
| title="Install MatrixLab Addon" |
| subtitle="Run code safely in isolated, temporary containers." |
| onClose={onClose} |
| > |
| {/* Status row */} |
| <div style={{ |
| display: "flex", alignItems: "center", gap: 10, |
| padding: "12px 14px", background: "#0e0f24", |
| border: "1px solid #2c2d46", borderRadius: 6, marginBottom: 14, |
| }}> |
| <span style={{ |
| display: "inline-flex", alignItems: "center", gap: 6, |
| fontSize: 11, fontWeight: 600, padding: "2px 10px", borderRadius: 12, |
| background: pill.bg, color: pill.fg, |
| }}> |
| <span style={{ width: 6, height: 6, borderRadius: "50%", background: pill.fg }} /> |
| {pill.label} |
| </span> |
| <div style={{ flex: 1 }}> |
| <div style={{ fontSize: 13, color: "#e6e8ff" }}>{status.message}</div> |
| {status.runnerUrl && status.status !== "ready" && ( |
| <div style={{ fontSize: 11, color: "#9092b5", marginTop: 2 }}> |
| Runner URL: <code style={{ color: "#c3c5dd" }}>{status.runnerUrl}</code> |
| </div> |
| )} |
| {/* 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 && ( |
| <div style={{ |
| marginTop: 6, padding: "6px 10px", fontSize: 12, |
| background: "#0d3320", color: "#86efac", |
| border: "1px solid #166534", borderRadius: 4, |
| display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap", |
| }}> |
| <span> |
| Found a live runner at{" "} |
| <code style={{ background: "#000", padding: "1px 4px", borderRadius: 3 }}> |
| {status.discoveredUrl} |
| </code> |
| </span> |
| <button |
| type="button" |
| onClick={runUrlAutoDetect} |
| disabled={busy} |
| style={{ |
| padding: "3px 10px", fontSize: 11, fontWeight: 600, |
| background: "#10B981", color: "#052e1c", |
| border: "0", borderRadius: 4, cursor: "pointer", |
| }} |
| > |
| Use this URL |
| </button> |
| </div> |
| )} |
| </div> |
| </div> |
| |
| {/* Inline body — copy depends on state */} |
| {status.status === "not_installed" && ( |
| <p style={{ fontSize: 13, opacity: 0.8, lineHeight: 1.55, marginBottom: 16 }}> |
| MatrixLab gives GitPilot an isolated sandbox for running code, |
| testing snippets, and executing agent actions safely. It will be |
| downloaded, started, and connected automatically. |
| </p> |
| )} |
| |
| {(busy || progressStep) && ( |
| <ProgressChecklist journey={journey} current={progressStep} /> |
| )} |
| |
| {status.status === "ready" && ( |
| <Checklist |
| items={[ |
| ["Installed", true], |
| ["Running", true], |
| ["Connection verified", true], |
| ["Set as active sandbox", status.activeSandbox === "matrixlab"], |
| ]} |
| /> |
| )} |
| |
| {/* Lifecycle disabled hint — friendly copy, admin detail under disclosure */} |
| {status.errorCode === "LIFECYCLE_DISABLED" && ( |
| <div style={{ |
| background: "#2a210d", border: "1px solid #854d0e", |
| borderRadius: 6, padding: 10, fontSize: 12, |
| color: "#fde68a", marginBottom: 12, |
| }}> |
| MatrixLab lifecycle automation is disabled. This GitPilot backend |
| was started with{" "} |
| <code style={{ background: "#1a1b26", padding: "1px 4px", borderRadius: 3 }}> |
| GITPILOT_ENABLE_MATRIXLAB_LIFECYCLE=0 |
| </code>{" "} |
| — restart it without that variable (the default is enabled), or |
| use Manual setup under Advanced options. |
| </div> |
| )} |
| |
| {/* Technical details disclosure — only visible when there's an error */} |
| {status.technicalDetails && (status.status === "needs_attention" || status.status === "failed") && ( |
| <details |
| open={showDetails} |
| onToggle={(e) => setShowDetails(e.target.open)} |
| style={{ marginBottom: 12 }} |
| > |
| <summary style={{ cursor: "pointer", fontSize: 12, color: "#9092b5" }}> |
| Technical details |
| </summary> |
| <pre style={{ |
| marginTop: 8, padding: 10, background: "#000", |
| border: "1px solid #2c2d46", borderRadius: 4, |
| fontSize: 11, color: "#fca5a5", |
| fontFamily: "ui-monospace, monospace", |
| whiteSpace: "pre-wrap", overflow: "auto", maxHeight: 200, |
| }}> |
| {status.technicalDetails.expected && |
| `Expected: ${status.technicalDetails.expected}\n`} |
| {status.technicalDetails.actual && |
| `Actual: ${status.technicalDetails.actual}\n`} |
| {status.technicalDetails.rawError && |
| `\n${status.technicalDetails.rawError}`} |
| </pre> |
| </details> |
| )} |
| |
| {/* 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 ( |
| <div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}> |
| <button |
| type="button" |
| onClick={onPrimary} |
| disabled={busy || primary.action === null} |
| style={{ |
| padding: "10px 20px", fontSize: 13, fontWeight: 600, |
| background: busy ? "#1e3a5f" : "#3B82F6", |
| color: "#fff", border: "none", borderRadius: 6, |
| cursor: busy ? "wait" : "pointer", |
| minWidth: 180, |
| }} |
| > |
| {primary.label} |
| </button> |
| |
| {canReinstall && ( |
| <button |
| type="button" |
| onClick={() => setReinstallConfirm(true)} |
| disabled={busy} |
| style={status.status === "ready" ? btnDanger : btnSecondary} |
| title="Stop, remove, and pull a fresh copy of MatrixLab" |
| > |
| Reinstall addon |
| </button> |
| )} |
| |
| {canSeeLogs && ( |
| <button type="button" onClick={openLogs} disabled={busy} style={btnSecondary}> |
| Open logs |
| </button> |
| )} |
| |
| {status.status === "ready" && ( |
| <button type="button" onClick={runRepair} disabled={busy} style={btnSecondary}> |
| Run test snippet |
| </button> |
| )} |
| </div> |
| ); |
| })()} |
| |
| {/* Logs viewer */} |
| {showLogs && ( |
| <div style={{ marginTop: 14 }}> |
| <div style={{ |
| display: "flex", justifyContent: "space-between", |
| alignItems: "baseline", marginBottom: 6, |
| }}> |
| <div style={{ fontSize: 12, fontWeight: 600, color: "#c3c5dd" }}> |
| MatrixLab logs |
| {logs?.container && ( |
| <span style={{ |
| marginLeft: 8, fontSize: 11, color: "#9092b5", |
| fontFamily: "ui-monospace, monospace", fontWeight: 400, |
| }}> |
| · {logs.container} |
| </span> |
| )} |
| </div> |
| <button |
| type="button" |
| onClick={openLogs} |
| disabled={busy} |
| style={{ |
| fontSize: 11, padding: "2px 8px", |
| background: "transparent", color: "#9092b5", |
| border: "1px solid #2c2d46", borderRadius: 4, cursor: "pointer", |
| }} |
| > |
| ↻ Refresh |
| </button> |
| </div> |
| |
| {/* 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 && ( |
| <div style={{ |
| marginBottom: 6, padding: 10, |
| background: "#2a210d", border: "1px solid #854d0e", |
| borderRadius: 4, fontSize: 12, color: "#fde68a", |
| }}> |
| <div style={{ fontWeight: 600 }}>{logs.error || "Could not read MatrixLab logs."}</div> |
| {logs.hint && ( |
| <div style={{ marginTop: 4, fontSize: 11, color: "#fcd34d" }}> |
| Next step:{" "} |
| <code style={{ |
| background: "#000", padding: "1px 6px", |
| borderRadius: 3, color: "#86efac", |
| }}> |
| {logs.hint} |
| </code> |
| </div> |
| )} |
| {Array.isArray(logs.candidates) && logs.candidates.length > 0 && ( |
| <div style={{ marginTop: 6, fontSize: 11 }}> |
| Found other matrixlab-shaped containers: |
| <ul style={{ margin: "4px 0 0", paddingLeft: 18 }}> |
| {logs.candidates.map((c, i) => ( |
| <li key={i}> |
| <code style={{ color: "#c3c5dd" }}>{c}</code> |
| </li> |
| ))} |
| </ul> |
| <div style={{ marginTop: 4, fontSize: 10, color: "#a1a1aa" }}> |
| Set <code>GITPILOT_MATRIXLAB_CONTAINER</code> to use one of these, |
| or run Reinstall to recreate the expected container. |
| </div> |
| </div> |
| )} |
| {logs.rawError && ( |
| <details style={{ marginTop: 6 }}> |
| <summary style={{ cursor: "pointer", fontSize: 11, color: "#fbbf24" }}> |
| docker stderr |
| </summary> |
| <pre style={{ |
| marginTop: 4, padding: 6, fontSize: 10, |
| background: "#000", color: "#fca5a5", |
| borderRadius: 4, whiteSpace: "pre-wrap", |
| }}> |
| {logs.rawError} |
| </pre> |
| </details> |
| )} |
| </div> |
| )} |
| |
| <pre style={{ |
| margin: 0, padding: 10, background: "#000", |
| border: "1px solid #2c2d46", borderRadius: 4, |
| fontSize: 11, color: "#D4D4D8", |
| fontFamily: "ui-monospace, monospace", |
| maxHeight: 220, overflow: "auto", whiteSpace: "pre-wrap", |
| }}> |
| {logs == null |
| ? "Loading logs…" |
| : (logs.lines && logs.lines.length > 0) |
| ? logs.lines.join("\n") |
| : (logs.ok === false ? "(no log output captured)" : "Loading logs…")} |
| </pre> |
| </div> |
| )} |
| |
| {/* 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. */} |
| <details |
| open={showAdvanced} |
| onToggle={(e) => setShowAdvanced(e.target.open)} |
| style={{ marginTop: 14 }} |
| > |
| <summary style={{ |
| cursor: "pointer", fontSize: 12, color: "#9092b5", |
| padding: "6px 0", listStyle: "none", |
| }}> |
| Advanced options |
| </summary> |
| <AdvancedOptions |
| advanced={advanced} |
| tokenInput={tokenInput} |
| setTokenInput={setTokenInput} |
| onUpdate={updateAdvanced} |
| disabled={busy} |
| onLocalStatus={(data) => { |
| setStatus(data); |
| refreshAdvanced(); |
| }} |
| /> |
| </details> |
| |
| {reinstallConfirm && ( |
| <ReinstallConfirm |
| wipe={reinstallWipe} |
| setWipe={setReinstallWipe} |
| onCancel={() => { |
| setReinstallConfirm(false); |
| setReinstallWipe(false); |
| }} |
| onConfirm={() => { |
| const wipe = reinstallWipe; |
| setReinstallConfirm(false); |
| setReinstallWipe(false); |
| runReinstall(wipe); |
| }} |
| /> |
| )} |
| </ModalShell> |
| </Backdrop> |
| ); |
| } |
|
|
| function ReinstallConfirm({ wipe, setWipe, onCancel, onConfirm }) { |
| |
| |
| |
| return ( |
| <div onClick={onCancel} style={{ |
| position: "fixed", inset: 0, background: "rgba(0,0,0,0.65)", |
| display: "flex", alignItems: "center", justifyContent: "center", |
| zIndex: 110, |
| }}> |
| <div onClick={(e) => e.stopPropagation()} style={{ |
| width: "min(440px, 92vw)", background: "#1a1b26", |
| border: "1px solid #2a2b36", borderRadius: 10, padding: 20, |
| color: "#e6e8ff", |
| }}> |
| <h4 style={{ margin: "0 0 6px", fontSize: 15 }}>Reinstall MatrixLab Addon?</h4> |
| <p style={{ fontSize: 13, opacity: 0.8, lineHeight: 1.55, marginBottom: 14 }}> |
| 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. |
| </p> |
| <label style={{ |
| display: "flex", gap: 8, alignItems: "flex-start", |
| padding: "8px 10px", borderRadius: 6, marginBottom: 14, |
| border: "1px solid #2c2d46", background: "#0e0f24", |
| fontSize: 12, cursor: "pointer", |
| }}> |
| <input |
| type="checkbox" |
| checked={wipe} |
| onChange={(e) => setWipe(e.target.checked)} |
| style={{ marginTop: 2 }} |
| /> |
| <span> |
| <strong style={{ color: "#fde68a" }}>Also remove local MatrixLab data</strong> |
| <span style={{ display: "block", color: "#9092b5", marginTop: 2 }}> |
| Deletes the cached MatrixLab runner image. Sandbox images and |
| workspace data are preserved. |
| </span> |
| </span> |
| </label> |
| <div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}> |
| <button type="button" onClick={onCancel} style={btnSecondary}>Cancel</button> |
| <button type="button" onClick={onConfirm} style={wipe ? btnDanger : btnPrimary}> |
| {wipe ? "Reinstall and remove data" : "Reinstall MatrixLab"} |
| </button> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| function ProgressChecklist({ journey, current }) { |
| |
| |
| |
| |
| const steps = PROGRESS_STEPS[journey] || PROGRESS_STEPS.install; |
| const phaseIndex = current ? Math.max(0, steps.findIndex(s => s.key === current)) : 0; |
| return ( |
| <div style={{ marginBottom: 14 }}> |
| {steps.map((s, i) => { |
| const done = i < phaseIndex; |
| const active = i === phaseIndex; |
| return ( |
| <div key={s.key} style={{ |
| display: "flex", alignItems: "center", gap: 8, |
| fontSize: 12, padding: "3px 0", |
| color: done ? "#86efac" : active ? "#e6e8ff" : "#9092b5", |
| }}> |
| <span style={{ width: 16, textAlign: "center" }}> |
| {done ? "✓" : active ? "⏳" : "○"} |
| </span> |
| {s.label} |
| </div> |
| ); |
| })} |
| </div> |
| ); |
| } |
|
|
| function Checklist({ items }) { |
| return ( |
| <div style={{ marginBottom: 14 }}> |
| {items.map(([label, ok]) => ( |
| <div key={label} style={{ |
| display: "flex", alignItems: "center", gap: 8, |
| fontSize: 12, padding: "3px 0", |
| color: ok ? "#86efac" : "#9092b5", |
| }}> |
| <span style={{ width: 16, textAlign: "center" }}>{ok ? "✓" : "○"}</span> |
| {label} |
| </div> |
| ))} |
| </div> |
| ); |
| } |
|
|
| function LocalCloneSection({ disabled, onStatus }) { |
| const [native, setNative] = useState(null); |
| const [busy, setBusy] = useState(null); |
|
|
| const refresh = useCallback(async () => { |
| try { |
| const r = await fetch(apiUrl("/api/matrixlab/native/status")); |
| setNative(await r.json()); |
| } catch (err) { |
| |
| } |
| }, []); |
|
|
| 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 ( |
| <details style={{ marginTop: 12 }}> |
| <summary style={{ cursor: "pointer", fontSize: 11, color: "#9092b5" }}> |
| Local clone install (no Docker for the runner itself) |
| </summary> |
| <div style={{ marginTop: 8, fontSize: 11, color: "#9092b5", lineHeight: 1.6 }}> |
| Clones MatrixLab into <code style={{ color: "#c3c5dd" }}>{native.local_dir}</code>, |
| creates a dedicated Python virtualenv, and runs <code style={{ color: "#c3c5dd" }}>uvicorn app.main:app</code> |
| from the runner directory. The Runner still spawns per-language sandboxes |
| via Docker, so the host needs <code style={{ color: "#c3c5dd" }}>docker</code>{" "} |
| on PATH for code execution. |
| </div> |
| <div style={{ marginTop: 10, display: "flex", flexWrap: "wrap", gap: 8 }}> |
| <span style={{ |
| display: "inline-flex", alignItems: "center", gap: 6, |
| fontSize: 11, padding: "2px 10px", borderRadius: 10, |
| background: native.running ? "#0d3320" : native.installed ? "#3d2d11" : "#374151", |
| color: native.running ? "#86efac" : native.installed ? "#fde68a" : "#d1d5db", |
| }}> |
| <span style={{ |
| width: 6, height: 6, borderRadius: "50%", |
| background: native.running ? "#10B981" : native.installed ? "#f59e0b" : "#9ca3af", |
| }} /> |
| {native.running ? "Running (PID " + native.pid + ")" |
| : native.installed ? "Installed · stopped" |
| : "Not installed"} |
| </span> |
| {!native.installed && ( |
| <button |
| type="button" |
| disabled={disabled || busy != null || !native.lifecycleEnabled} |
| onClick={() => runAction("install")} |
| style={btnPrimarySmall} |
| > |
| {busy === "install" ? "Cloning + installing…" : "Clone and install"} |
| </button> |
| )} |
| {native.installed && !native.running && ( |
| <button |
| type="button" |
| disabled={disabled || busy != null || !native.lifecycleEnabled} |
| onClick={() => runAction("start")} |
| style={btnPrimarySmall} |
| > |
| {busy === "start" ? "Starting…" : "Start runner"} |
| </button> |
| )} |
| {native.running && ( |
| <button |
| type="button" |
| disabled={disabled || busy != null || !native.lifecycleEnabled} |
| onClick={() => runAction("stop")} |
| style={btnSecondarySmall} |
| > |
| {busy === "stop" ? "Stopping…" : "Stop runner"} |
| </button> |
| )} |
| <button |
| type="button" |
| disabled={busy != null} |
| onClick={refresh} |
| style={btnSecondarySmall} |
| > |
| Refresh |
| </button> |
| </div> |
| {!native.lifecycleEnabled && ( |
| <div style={{ |
| marginTop: 8, padding: "6px 8px", |
| background: "#2a210d", border: "1px solid #854d0e", |
| borderRadius: 4, fontSize: 11, color: "#fde68a", |
| }}> |
| Local clone install needs lifecycle automation enabled on the GitPilot |
| backend. |
| </div> |
| )} |
| </details> |
| ); |
| } |
|
|
| function AdvancedOptions({ advanced, tokenInput, setTokenInput, onUpdate, disabled, onLocalStatus }) { |
| if (!advanced) return null; |
| return ( |
| <div style={{ |
| marginTop: 14, padding: 12, |
| background: "#0e0f24", border: "1px solid #2c2d46", |
| borderRadius: 6, |
| }}> |
| <div style={{ fontSize: 12, fontWeight: 600, marginBottom: 10, color: "#c3c5dd" }}> |
| Advanced options |
| </div> |
| <div style={{ display: "grid", gridTemplateColumns: "120px 1fr", gap: 8, alignItems: "center" }}> |
| <label style={fieldLabel}>Runner URL</label> |
| <input |
| type="text" |
| defaultValue={advanced.matrixlab_url || ""} |
| onBlur={(e) => onUpdate({ matrixlab_url: e.target.value })} |
| placeholder="http://localhost:8765" |
| disabled={disabled} |
| style={fieldInput} |
| /> |
| |
| <label style={fieldLabel}>Bearer token</label> |
| <div style={{ display: "flex", gap: 6 }}> |
| <input |
| type="password" |
| value={tokenInput} |
| onChange={(e) => setTokenInput(e.target.value)} |
| placeholder={advanced.has_token ? "•••••••• (saved)" : "Optional"} |
| disabled={disabled} |
| style={{ ...fieldInput, flex: 1 }} |
| /> |
| <button |
| type="button" |
| onClick={() => onUpdate({ matrixlab_token: tokenInput })} |
| disabled={disabled} |
| style={btnSecondary} |
| > |
| Save token |
| </button> |
| </div> |
| |
| <label style={fieldLabel}>Default image</label> |
| <input |
| type="text" |
| defaultValue={advanced.matrixlab_image || ""} |
| onBlur={(e) => onUpdate({ matrixlab_image: e.target.value })} |
| placeholder="matrixlab-python" |
| disabled={disabled} |
| style={fieldInput} |
| /> |
| |
| <label style={fieldLabel}>Network access</label> |
| <label style={{ display: "flex", gap: 6, alignItems: "center", fontSize: 12, color: "#c3c5dd" }}> |
| <input |
| type="checkbox" |
| checked={!!advanced.allow_network} |
| disabled={disabled} |
| onChange={(e) => onUpdate({ allow_network: e.target.checked })} |
| /> |
| Allow network egress |
| </label> |
| |
| <label style={fieldLabel}>Timeout</label> |
| <div style={{ display: "flex", alignItems: "center", gap: 6 }}> |
| <input |
| type="number" |
| min={1} |
| max={600} |
| defaultValue={advanced.timeout_sec || 120} |
| onBlur={(e) => onUpdate({ timeout_sec: Number(e.target.value) || 120 })} |
| disabled={disabled} |
| style={{ ...fieldInput, width: 80 }} |
| /> |
| <span style={{ fontSize: 11, color: "#9092b5" }}>seconds</span> |
| </div> |
| </div> |
| |
| <LocalCloneSection disabled={disabled} onStatus={onLocalStatus} /> |
| |
| <details style={{ marginTop: 12 }}> |
| <summary style={{ cursor: "pointer", fontSize: 11, color: "#9092b5" }}> |
| Manual setup |
| </summary> |
| <pre style={{ |
| margin: "8px 0 0", padding: 10, background: "#000", |
| border: "1px solid #2c2d46", borderRadius: 4, |
| fontSize: 11, color: "#D4D4D8", |
| fontFamily: "ui-monospace, monospace", |
| }}>{`# 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`}</pre> |
| </details> |
| |
| <details style={{ marginTop: 12 }}> |
| <summary style={{ cursor: "pointer", fontSize: 11, color: "#9092b5" }}> |
| Developer options · Unsafe modes |
| </summary> |
| <div style={{ marginTop: 8, fontSize: 11, color: "#fca5a5" }}> |
| Pass-through runs code directly on the host without isolation. Use |
| only for local development. |
| </div> |
| </details> |
| </div> |
| ); |
| } |
|
|
| function Backdrop({ children, onClose }) { |
| return ( |
| <div |
| onClick={onClose} |
| style={{ |
| position: "fixed", inset: 0, |
| background: "rgba(0,0,0,0.6)", |
| display: "flex", alignItems: "center", justifyContent: "center", |
| zIndex: 100, |
| }} |
| > |
| <div onClick={(e) => e.stopPropagation()}>{children}</div> |
| </div> |
| ); |
| } |
|
|
| function ModalShell({ title, subtitle, onClose, children }) { |
| return ( |
| <div style={{ |
| width: "min(640px, 92vw)", |
| maxHeight: "90vh", overflow: "auto", |
| background: "#1a1b26", |
| border: "1px solid #2a2b36", |
| borderRadius: 10, |
| padding: 20, |
| color: "#e6e8ff", |
| }}> |
| <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 4 }}> |
| <h3 style={{ margin: 0, fontSize: 18 }}>{title}</h3> |
| <button |
| type="button" |
| onClick={onClose} |
| style={{ |
| background: "transparent", border: "none", |
| color: "#9092b5", cursor: "pointer", fontSize: 18, |
| }} |
| aria-label="Close" |
| > |
| ✕ |
| </button> |
| </div> |
| <div style={{ fontSize: 12, color: "#9092b5", marginBottom: 16 }}>{subtitle}</div> |
| {children} |
| </div> |
| ); |
| } |
|
|
| 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", |
| }; |
|
|
| |
| |
| 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", |
| }; |
|
|