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

519 lines
16 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 databases = anatomy?.infrastructure.databases ?? [];
const deployments: { uuid: string; name: string }[] = [];
live.forEach((app) => {
if (app.inFlightBuild) {
deployments.push({
uuid: app.inFlightBuild.uuid,
name: `Build (Active): ${app.name}`,
});
}
if (app.lastBuild) {
deployments.push({
uuid: app.lastBuild.uuid,
name: `Build (Latest): ${app.name}`,
});
}
});
const [activeItem, setActiveItem] = useState<{
type: "app" | "preview" | "database" | "deployment";
id: string;
name: 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, name: live[0].name });
} else if (previews.length > 0) {
setActiveItem({
type: "preview",
id: previews[0].id,
name: previews[0].name || `Port ${previews[0].port}`,
});
} else if (databases.length > 0) {
setActiveItem({
type: "database",
id: databases[0].uuid,
name: databases[0].name,
});
} else if (deployments.length > 0) {
setActiveItem({
type: "deployment",
id: deployments[0].uuid,
name: deployments[0].name,
});
}
}, [live, previews, databases, deployments, activeItem]);
const fetchLogs = async (item: {
type: "app" | "preview" | "database" | "deployment";
id: string;
name: string;
}) => {
setLogsLoading(true);
setLogs(null); // Clear previous logs immediately so user sees it loading
try {
let action = "apps.logs";
let payloadParams: Record<string, any> = { uuid: item.id, lines: 100 };
if (item.type === "preview") {
action = "dev_server.logs";
payloadParams = { id: item.id, lines: 100, projectId };
} else if (item.type === "database") {
action = "databases.logs";
} else if (item.type === "deployment") {
action = "deployments.logs";
}
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 {}
}
const reverseLogs = (str: string) =>
str.trim().split("\n").reverse().join("\n");
if (typeof obj === "object" && obj !== null) {
if (obj.services) {
out = Object.values(obj.services)
.map((s: any) => reverseLogs(s.logs || ""))
.join("\n\n");
} else if (obj.log) {
out = reverseLogs(obj.log);
} else if (obj.logs) {
out = reverseLogs(obj.logs);
} else {
out = JSON.stringify(obj, null, 2);
}
} else {
out = String(obj || d.error || "No logs available.");
if (typeof obj === "string") {
out = reverseLogs(out);
}
}
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 &&
databases.length === 0 &&
deployments.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,
name: app.name,
})
}
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,
name: preview.name || `Port ${preview.port}`,
})
}
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>
{databases.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,
}}
>
Databases
</h3>
</div>
)}
<div
style={{
display: "flex",
flexDirection: "column",
gap: 4,
padding: "0 20px",
}}
>
{databases.map((db) => (
<button
key={db.uuid}
onClick={() =>
setActiveItem({
type: "database",
id: db.uuid,
name: db.name,
})
}
style={{
textAlign: "left",
padding: "8px 12px",
background:
activeItem?.id === db.uuid
? THEME.subtleBg
: "transparent",
border: "1px solid transparent",
borderRadius: THEME.radiusSm,
cursor: "pointer",
fontSize: "0.875rem",
fontWeight: activeItem?.id === db.uuid ? 500 : 400,
color: activeItem?.id === db.uuid ? THEME.ink : THEME.mid,
transition: "all 0.1s ease",
}}
>
{db.name}
</button>
))}
</div>
{deployments.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,
}}
>
Build Logs
</h3>
</div>
)}
<div
style={{
display: "flex",
flexDirection: "column",
gap: 4,
padding: "0 20px",
}}
>
{deployments.map((dep) => (
<button
key={dep.uuid}
onClick={() =>
setActiveItem({
type: "deployment",
id: dep.uuid,
name: dep.name,
})
}
style={{
textAlign: "left",
padding: "8px 12px",
background:
activeItem?.id === dep.uuid
? THEME.subtleBg
: "transparent",
border: "1px solid transparent",
borderRadius: THEME.radiusSm,
cursor: "pointer",
fontSize: "0.875rem",
fontWeight: activeItem?.id === dep.uuid ? 500 : 400,
color:
activeItem?.id === dep.uuid ? THEME.ink : THEME.mid,
transition: "all 0.1s ease",
}}
>
{dep.name}
</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?.name ?? "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>
);
}