Merge pull request #1178 from Molecule-AI/feat/failed-workspace-error-logs
feat(canvas): show last_sample_error + EC2 console output on failed workspaces
This commit is contained in:
commit
df756177cf
151
canvas/src/components/ConsoleModal.tsx
Normal file
151
canvas/src/components/ConsoleModal.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
73
canvas/src/components/__tests__/ConsoleModal.test.tsx
Normal file
73
canvas/src/components/__tests__/ConsoleModal.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user