Files
vibn-agent-runner/vibn-frontend/app/[workspace]/project/[projectId]/(home)/logs/page.tsx

347 lines
11 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import { useParams } from "next/navigation";
import { Activity, Loader2, RefreshCw } from "lucide-react";
import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy";
import {
THEME,
PageHeader,
Card,
EmptyState,
SecondaryButton,
} from "@/components/project/dashboard-ui";
import { Terminal } from "@/components/ui/terminal";
type LiveApp = Anatomy["hosting"]["live"][number];
export default function LogsPage() {
const params = useParams();
const projectId = params.projectId as string;
const { anatomy, loading } = useAnatomy(projectId, { pollMs: 8000 });
const live = anatomy?.hosting.live ?? [];
const previews = anatomy?.hosting.previews ?? [];
const [activeItem, setActiveItem] = useState<{
type: "app" | "preview";
id: string;
} | null>(null);
const [logs, setLogs] = useState<string | null>(null);
const [logsLoading, setLogsLoading] = useState(false);
// Auto-select first available item
useEffect(() => {
if (activeItem) return;
if (live.length > 0) {
setActiveItem({ type: "app", id: live[0].uuid });
} else if (previews.length > 0) {
setActiveItem({ type: "preview", id: previews[0].id });
}
}, [live, previews, activeItem]);
const fetchLogs = async (item: { type: "app" | "preview"; id: string }) => {
setLogsLoading(true);
try {
const action = item.type === "app" ? "apps.logs" : "dev_server.logs";
const payloadParams =
item.type === "app"
? { uuid: item.id, lines: 100 }
: { id: item.id, lines: 100, projectId };
const r = await fetch(`/api/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action,
params: payloadParams,
}),
});
const d = await r.json();
let out = "";
let obj = d.result;
if (typeof obj === "string") {
try {
obj = JSON.parse(obj);
} catch {}
}
if (typeof obj === "object" && obj !== null) {
if (obj.services) {
out = Object.values(obj.services)
.map((s: any) => s.logs)
.join("\n\n");
} else if (obj.log) {
out = obj.log;
} else if (obj.logs) {
out = obj.logs;
} else {
out = JSON.stringify(obj, null, 2);
}
} else {
out = String(obj || d.error || "No logs available.");
}
setLogs(out || "No logs available.");
} catch {
setLogs("Failed to load logs. Is the container running?");
} finally {
setLogsLoading(false);
}
};
// Fetch when active item changes
useEffect(() => {
if (activeItem) fetchLogs(activeItem);
}, [activeItem]);
return (
<div
style={{
flex: 1,
minHeight: 0,
boxSizing: "border-box",
background: THEME.canvasGradient,
fontFamily: THEME.font,
padding: "16px",
display: "flex",
flexDirection: "column",
}}
>
<Card
padding={0}
style={{
maxWidth: "100%",
margin: "0 auto",
width: "100%",
display: "flex",
flexDirection: "column",
flex: 1,
minHeight: 0,
}}
>
{loading && !anatomy ? (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
color: THEME.mid,
fontSize: "0.875rem",
padding: "24px",
}}
>
<Loader2 size={15} className="animate-spin" /> Loading
</div>
) : live.length === 0 && previews.length === 0 ? (
<div style={{ padding: 24 }}>
<EmptyState
icon={<Activity size={22} />}
title="No active servers"
hint="Once you deploy an app or start a dev server, its runtime logs will appear here."
/>
</div>
) : (
<div
style={{
display: "flex",
flex: 1,
minHeight: 0,
}}
>
{/* App Picker Column */}
<div
style={{
width: 300,
flexShrink: 0,
display: "flex",
flexDirection: "column",
borderRight: `1px solid ${THEME.borderSoft}`,
overflowY: "auto",
paddingBottom: 24,
}}
>
{live.length > 0 && (
<div style={{ padding: "16px 20px 8px" }}>
<h3
style={{
fontSize: "0.7rem",
fontWeight: 600,
color: THEME.muted,
textTransform: "uppercase",
letterSpacing: "0.06em",
margin: 0,
}}
>
Live Apps
</h3>
</div>
)}
<div
style={{
display: "flex",
flexDirection: "column",
gap: 4,
padding: "0 20px",
}}
>
{live.map((app) => (
<button
key={app.uuid}
onClick={() => setActiveItem({ type: "app", id: app.uuid })}
style={{
textAlign: "left",
padding: "8px 12px",
background:
activeItem?.id === app.uuid
? THEME.subtleBg
: "transparent",
border: "1px solid transparent",
borderRadius: THEME.radiusSm,
cursor: "pointer",
fontSize: "0.875rem",
fontWeight: activeItem?.id === app.uuid ? 500 : 400,
color:
activeItem?.id === app.uuid ? THEME.ink : THEME.mid,
transition: "all 0.1s ease",
}}
>
{app.name}
</button>
))}
</div>
{previews.length > 0 && (
<div style={{ padding: "24px 20px 8px" }}>
<h3
style={{
fontSize: "0.7rem",
fontWeight: 600,
color: THEME.muted,
textTransform: "uppercase",
letterSpacing: "0.06em",
margin: 0,
}}
>
Dev Previews
</h3>
</div>
)}
<div
style={{
display: "flex",
flexDirection: "column",
gap: 4,
padding: "0 20px",
}}
>
{previews.map((preview) => (
<button
key={preview.id}
onClick={() =>
setActiveItem({ type: "preview", id: preview.id })
}
style={{
textAlign: "left",
padding: "8px 12px",
background:
activeItem?.id === preview.id
? THEME.subtleBg
: "transparent",
border: "1px solid transparent",
borderRadius: THEME.radiusSm,
cursor: "pointer",
fontSize: "0.875rem",
fontWeight: activeItem?.id === preview.id ? 500 : 400,
color:
activeItem?.id === preview.id ? THEME.ink : THEME.mid,
transition: "all 0.1s ease",
}}
>
{preview.name || `Port ${preview.port}`}
</button>
))}
</div>
</div>
{/* Log Viewer Column */}
<div
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
position: "relative",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "10px 16px",
background: THEME.subtleBg,
borderBottom: `1px solid ${THEME.borderSoft}`,
height: "41px",
boxSizing: "border-box",
}}
>
<span
style={{
fontSize: "0.85rem",
fontWeight: 500,
color: THEME.mid,
}}
>
{activeItem?.type === "app"
? (live.find((a) => a.uuid === activeItem.id)?.name ??
"Logs")
: previews.find((p) => p.id === activeItem?.id)?.name ||
"Dev Server Logs"}
</span>
<SecondaryButton
icon={
logsLoading ? (
<Loader2 size={13} className="animate-spin" />
) : (
<RefreshCw size={13} />
)
}
onClick={() => activeItem && fetchLogs(activeItem)}
disabled={logsLoading}
style={{ padding: "4px 8px", fontSize: "0.75rem" }}
>
Refresh
</SecondaryButton>
</div>
<Terminal
sequence={false}
className="w-full max-w-none shadow-none border-none rounded-none flex-1"
>
<div
style={{
color: THEME.mid,
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, monospace",
fontSize: "13px",
lineHeight: "24px",
padding: "16px 24px",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
}}
>
{logsLoading && !logs
? "Loading..."
: logs || "No logs available."}
</div>
</Terminal>
</div>
</div>
)}
</Card>
</div>
);
}