518 lines
16 KiB
TypeScript
518 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);
|
|
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>
|
|
);
|
|
}
|