Merge pull request #1187 from Molecule-AI/staging

staging → main: canvas error logs + console modal (PR #1178)
This commit is contained in:
Hongming Wang 2026-04-20 17:33:26 -07:00 committed by GitHub
commit 64b17e2778
5 changed files with 288 additions and 6 deletions

View File

@ -0,0 +1,151 @@
"use client";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { api } from "@/lib/api";
interface Props {
workspaceId: string;
workspaceName?: string;
open: boolean;
onClose: () => void;
}
interface ConsoleResponse {
output: string;
instance_id?: string;
}
// ConsoleModal renders the EC2 serial console output for a workspace.
// Used by the "View Logs" button on failed/stuck workspaces so operators
// can see the actual cloud-init + runtime startup trace without SSH or
// AWS console access. The tenant platform proxies to the control plane;
// this component just consumes GET /workspaces/:id/console.
export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Props) {
const [output, setOutput] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!open) return;
let ignore = false;
setLoading(true);
setError(null);
setOutput(null);
api
.get<ConsoleResponse>(`/workspaces/${workspaceId}/console`)
.then((data) => {
if (ignore) return;
setOutput(data.output || "");
})
.catch((e) => {
if (ignore) return;
// 501 = deployment without a control plane (local docker-compose);
// we render a friendlier message than "501 Not Implemented".
const msg = e instanceof Error ? e.message : "Failed to load console output";
if (/501/.test(msg)) {
setError("Console output is only available on cloud (SaaS) deployments.");
} else if (/404/.test(msg)) {
setError("No EC2 instance found for this workspace — it may have been terminated.");
} else {
setError(msg);
}
})
.finally(() => {
if (!ignore) setLoading(false);
});
return () => {
ignore = true;
};
}, [open, workspaceId]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
if (!open || !mounted) return null;
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center">
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
<div
role="dialog"
aria-modal="true"
aria-labelledby="console-modal-title"
className="relative bg-zinc-950 border border-zinc-800 rounded-xl shadow-2xl w-[min(900px,90vw)] h-[min(70vh,700px)] flex flex-col overflow-hidden"
>
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800">
<div>
<h3 id="console-modal-title" className="text-sm font-semibold text-zinc-100">
EC2 console output
</h3>
{workspaceName && (
<div className="text-[11px] text-zinc-500 mt-0.5 truncate max-w-[600px]">
{workspaceName}
</div>
)}
</div>
<button
onClick={onClose}
aria-label="Close"
className="text-zinc-400 hover:text-zinc-100 text-sm px-2"
>
</button>
</div>
<div className="flex-1 overflow-auto bg-black/80 p-4">
{loading && (
<div className="text-[12px] text-zinc-500" data-testid="console-loading">
Loading console output
</div>
)}
{!loading && error && (
<div
className="text-[12px] text-amber-300 bg-amber-950/30 border border-amber-900/40 rounded px-3 py-2"
data-testid="console-error"
>
{error}
</div>
)}
{!loading && !error && output !== null && (
<pre
className="text-[11px] text-zinc-300 font-mono whitespace-pre-wrap break-all leading-tight"
data-testid="console-output"
>
{output || "(console output is empty — the instance may still be booting)"}
</pre>
)}
</div>
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-zinc-800 bg-zinc-900/40">
{output && (
<button
onClick={() => navigator.clipboard?.writeText(output)}
className="px-3 py-1.5 text-[11px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
>
Copy
</button>
)}
<button
onClick={onClose}
className="px-3 py-1.5 text-[11px] text-zinc-300 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
>
Close
</button>
</div>
</div>
</div>,
document.body,
);
}

View File

@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import { api } from "@/lib/api";
import { showToast } from "./Toaster";
import { ConsoleModal } from "./ConsoleModal";
/** Default provisioning timeout in milliseconds (2 minutes). */
export const DEFAULT_PROVISION_TIMEOUT_MS = 120_000;
@ -167,10 +168,16 @@ export function ProvisioningTimeout({
}
}, [confirmingCancel]);
const [consoleFor, setConsoleFor] = useState<string | null>(null);
const handleViewLogs = useCallback((workspaceId: string) => {
// Open the terminal tab for this workspace so user can see logs
useCanvasStore.getState().selectNode(workspaceId);
useCanvasStore.getState().setPanelTab("terminal");
// Open the EC2 console modal — this is the boot-trace log, which
// is what the user actually wants to see when provisioning is
// stuck (the terminal tab is post-boot, useless if the agent
// runtime never started). The modal closes over itself if the
// request returns 501 (self-hosted / docker-compose deploys) —
// the user gets a clear "console output unavailable" message
// instead of a broken button.
setConsoleFor(workspaceId);
}, []);
if (timedOut.length === 0) return null;
@ -270,6 +277,15 @@ export function ProvisioningTimeout({
</div>
</div>
)}
{/* Console output modal opens when the user clicks "View Logs" on
a stuck-provisioning banner. Fetches /workspaces/:id/console
which proxies to CP's ec2:GetConsoleOutput. */}
<ConsoleModal
workspaceId={consoleFor || ""}
open={consoleFor !== null}
onClose={() => setConsoleFor(null)}
/>
</div>
);
}

View File

@ -0,0 +1,73 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react";
vi.mock("@/lib/api", () => ({
api: { get: vi.fn() },
}));
import { api } from "@/lib/api";
import { ConsoleModal } from "../ConsoleModal";
const mockGet = vi.mocked(api.get);
beforeEach(() => vi.clearAllMocks());
afterEach(cleanup);
describe("ConsoleModal", () => {
it("returns null when closed — no fetch triggered", () => {
const { container } = render(
<ConsoleModal workspaceId="ws-1" open={false} onClose={() => {}} />,
);
expect(container.firstChild).toBeNull();
expect(mockGet).not.toHaveBeenCalled();
});
it("fetches console output when opened", async () => {
mockGet.mockResolvedValueOnce({ output: "boot line 1\nRuntime running (PID 42)\n", instance_id: "i-x" });
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
await waitFor(() =>
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/console"),
);
await waitFor(() => {
const out = screen.getByTestId("console-output");
expect(out.textContent).toContain("Runtime running (PID 42)");
});
});
it("renders a friendly message on 501 (non-CP deploy)", async () => {
mockGet.mockRejectedValueOnce(new Error("GET /workspaces/ws-1/console: 501 Not Implemented"));
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
await waitFor(() => {
const err = screen.getByTestId("console-error");
expect(err.textContent).toMatch(/only available on cloud/i);
});
});
it("renders a specific message on 404 (instance terminated)", async () => {
mockGet.mockRejectedValueOnce(new Error("GET /workspaces/ws-1/console: 404 Not Found"));
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
await waitFor(() => {
const err = screen.getByTestId("console-error");
expect(err.textContent).toMatch(/No EC2 instance found/i);
});
});
it("Close button invokes onClose", async () => {
mockGet.mockResolvedValueOnce({ output: "" });
const onClose = vi.fn();
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={onClose} />);
await waitFor(() => screen.getByText("Close"));
fireEvent.click(screen.getByText("Close"));
expect(onClose).toHaveBeenCalled();
});
it("Escape key invokes onClose", async () => {
mockGet.mockResolvedValueOnce({ output: "" });
const onClose = vi.fn();
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={onClose} />);
await waitFor(() => screen.getByText("Close"));
fireEvent.keyDown(window, { key: "Escape" });
expect(onClose).toHaveBeenCalled();
});
});

View File

@ -6,6 +6,7 @@ import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import { StatusDot } from "../StatusDot";
import { BudgetSection } from "./BudgetSection";
import { WorkspaceUsage } from "../WorkspaceUsage";
import { ConsoleModal } from "../ConsoleModal";
interface Props {
workspaceId: string;
@ -33,6 +34,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
const [deleteError, setDeleteError] = useState<string | null>(null);
const [restarting, setRestarting] = useState(false);
const [restartError, setRestartError] = useState<string | null>(null);
const [consoleOpen, setConsoleOpen] = useState(false);
const updateNodeData = useCanvasStore((s) => s.updateNodeData);
const removeNode = useCanvasStore((s) => s.removeNode);
const selectNode = useCanvasStore((s) => s.selectNode);
@ -204,6 +206,31 @@ export function DetailsTab({ workspaceId, data }: Props) {
)}
</Section>
{/* Error details shown when the workspace failed to boot. The
control plane's bootstrap watcher writes last_sample_error with
the real traceback from the EC2 serial console, so users see
"ModuleNotFoundError: ..." instead of a generic timeout. */}
{(data.status === "failed" || (data.status === "degraded" && data.lastSampleError)) && (
<Section title="Error">
{data.lastSampleError ? (
<pre
data-testid="details-error-log"
className="text-[11px] text-red-300 font-mono whitespace-pre-wrap break-all bg-red-950/20 border border-red-900/40 rounded p-2 max-h-[240px] overflow-auto leading-tight"
>
{data.lastSampleError}
</pre>
) : (
<p className="text-xs text-zinc-500">No error detail recorded.</p>
)}
<button
onClick={() => setConsoleOpen(true)}
className="mt-2 px-3 py-1 bg-zinc-800 hover:bg-zinc-700 text-xs rounded text-zinc-300 border border-zinc-700"
>
View console output
</button>
</Section>
)}
{/* Budget — dedicated section with live usage stats (#541) */}
<BudgetSection workspaceId={workspaceId} />
@ -296,6 +323,15 @@ export function DetailsTab({ workspaceId, data }: Props) {
</button>
)}
</Section>
{/* Portal-rendered console modal mounted at the root of this tab
but appears above everything via createPortal(document.body). */}
<ConsoleModal
workspaceId={workspaceId}
workspaceName={data.name}
open={consoleOpen}
onClose={() => setConsoleOpen(false)}
/>
</div>
);
}

View File

@ -88,9 +88,9 @@ type memoryImportEntry struct {
// + scope already exist. Returns counts of imported and skipped entries.
//
// SECURITY (F1085 / #1132): calls redactSecrets on each content field
// before inserting so that secrets embedded in imported memories cannot
// land unredacted in the agent_memories table (SAFE-T1201 / #838 parity
// with the commit_memory MCP bridge path).
// before both the deduplication check and the INSERT so that imported memories
// with embedded credentials cannot land unredacted in agent_memories (SAFE-T1201
// parity with the commit_memory MCP bridge path).
func (h *AdminMemoriesHandler) Import(c *gin.Context) {
ctx := c.Request.Context()
@ -128,6 +128,12 @@ func (h *AdminMemoriesHandler) Import(c *gin.Context) {
// the redacted content so that two backups with the same original
// secret (same placeholder output) are treated as duplicates.
var exists bool
// F1085 / #1132: scrub credential patterns before persistence. Must run
// BEFORE the dedup check so the redacted content is what gets stored —
// otherwise two backups with the same original secret would each get a
// different placeholder, producing duplicate rows with different content.
content, _ := redactSecrets(workspaceID, entry.Content)
err = db.DB.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM agent_memories WHERE workspace_id = $1 AND content = $2 AND scope = $3)`,
workspaceID, content, entry.Scope,