Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions frontend/src/components/editor/chrome/panels/server-logs-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/* Copyright 2026 Marimo. All rights reserved. */

import { RefreshCwIcon, ServerIcon } from "lucide-react";
import React, { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { useAsyncData } from "@/hooks/useAsyncData";
import { useInterval } from "@/hooks/useInterval";
import { PanelEmptyState } from "./empty-state";

const REFRESH_INTERVAL_MS = 10000; // 10 seconds

async function fetchLogFiles(): Promise<string[]> {
const res = await fetch("/api/logs/list");
const data: { files: string[] } = await res.json();
return data.files ?? [];
}

async function fetchLogContent(filename: string): Promise<string> {
const res = await fetch(`/api/logs/${encodeURIComponent(filename)}`);
return res.text();
}

const ServerLogsPanel: React.FC = () => {
const [selectedFile, setSelectedFile] = useState<string>("");
const preRef = useRef<HTMLPreElement>(null);

const { data: files, isPending: filesLoading } = useAsyncData(
fetchLogFiles,
[],
);

if (files && files.length > 0 && !selectedFile) {
setSelectedFile(files.includes("marimo.log") ? "marimo.log" : files[0]);
}

const {
data: content,
isPending: contentLoading,
refetch,
} = useAsyncData(
async () => (selectedFile ? fetchLogContent(selectedFile) : ""),
[selectedFile],
);

// Auto-scroll to bottom when content changes
useEffect(() => {
if (preRef.current) {
preRef.current.scrollTop = preRef.current.scrollHeight;
}
}, [content]);

// Refetch every 10 seconds
useInterval(() => refetch(), {
delayMs: REFRESH_INTERVAL_MS,
whenVisible: true,
});

if (!filesLoading && (!files || files.length === 0)) {
return (
<PanelEmptyState
title="No server logs"
description="No log files found in the server log directory."
icon={<ServerIcon />}
/>
);
}

return (
<div className="flex flex-col h-full overflow-hidden">
<div className="flex flex-row items-center gap-2 px-2 py-1 border-b shrink-0">
{files && files.length > 1 && (
<select
className="text-xs border rounded px-1 py-0.5 bg-background"
value={selectedFile}
onChange={(e) => setSelectedFile(e.target.value)}
>
{files.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
)}
{files && files.length === 1 && (
<span className="text-xs text-muted-foreground">{selectedFile}</span>
)}
<Button size="xs" variant="text" onClick={refetch}>
<RefreshCwIcon className="w-3 h-3" />
Refresh
</Button>
</div>
<pre
ref={preRef}
className="flex-1 overflow-auto text-xs font-mono p-2 whitespace-pre-wrap"
>
{contentLoading ? "Loading..." : content}
</pre>
</div>
);
};

export default ServerLogsPanel;
12 changes: 11 additions & 1 deletion frontend/src/components/editor/chrome/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
NetworkIcon,
NotebookPenIcon,
ScrollTextIcon,
ServerIcon,
SquareDashedBottomCodeIcon,
TerminalSquareIcon,
TextSearchIcon,
Expand Down Expand Up @@ -42,7 +43,8 @@ export type PanelType =
| "secrets"
| "logs"
| "terminal"
| "cache";
| "cache"
| "server-logs";

export type PanelSection = "sidebar" | "developer-panel";

Expand Down Expand Up @@ -177,6 +179,14 @@ export const PANELS: PanelDescriptor[] = [
defaultSection: "developer-panel",
hidden: !getFeatureFlag("cache_panel"),
},
{
type: "server-logs",
Icon: ServerIcon,
label: "Server Logs",
tooltip: "View server logs",
hidden: !import.meta.env.DEV,
defaultSection: "developer-panel",
},
];

export const PANEL_MAP = new Map<PanelType, PanelDescriptor>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ const LazySecretsPanel = React.lazy(() => import("../panels/secrets-panel"));
const LazySnippetsPanel = React.lazy(() => import("../panels/snippets-panel"));
const LazyTracingPanel = React.lazy(() => import("../panels/tracing-panel"));
const LazyCachePanel = React.lazy(() => import("../panels/cache-panel"));
const LazyServerLogsPanel = React.lazy(
() => import("../panels/server-logs-panel"),
);

export const AppChrome: React.FC<PropsWithChildren> = ({ children }) => {
const {
Expand Down Expand Up @@ -279,6 +282,7 @@ export const AppChrome: React.FC<PropsWithChildren> = ({ children }) => {
/>
),
cache: <LazyCachePanel />,
"server-logs": <LazyServerLogsPanel />,
};

const helpPaneBody = (
Expand Down
106 changes: 106 additions & 0 deletions marimo/_server/api/endpoints/logs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Copyright 2026 Marimo. All rights reserved.
from __future__ import annotations

from typing import TYPE_CHECKING

from starlette.authentication import requires
from starlette.responses import JSONResponse, PlainTextResponse

from marimo._loggers import get_log_directory
from marimo._server.router import APIRouter

if TYPE_CHECKING:
from pathlib import Path

from starlette.requests import Request

router = APIRouter()

MAX_LINES = 500


def list_log_files_in_directory(log_dir: Path) -> list[str]:
"""Return sorted list of .log filenames in the given directory."""
if not log_dir.exists():
return []
files = [
f.name
for f in log_dir.iterdir()
# Only include .log files; rotated backups like
# marimo.log.2026-02-13 have a different suffix
if f.is_file() and f.suffix == ".log"
]
files.sort()
return files


def read_log_file(log_dir: Path, filename: str) -> tuple[str | None, int]:
"""Read the tail of a log file.

Returns (content, status_code). If content is None, it's an error message.
"""
# Validate filename to prevent path traversal
if ".." in filename or "/" in filename or "\\" in filename:
return ("Invalid filename", 400)

file_path = log_dir / filename

# Ensure the resolved path is within the log directory
try:
file_path.resolve().relative_to(log_dir.resolve())
except ValueError:
return ("Invalid filename", 400)

if not file_path.exists() or not file_path.is_file():
return ("File not found", 404)

try:
text = file_path.read_text(encoding="utf-8")
lines = text.splitlines(keepends=True)
tail = lines[-MAX_LINES:]
return ("".join(tail), 200)
except Exception:
return ("Failed to read file", 500)


@router.get("/list")
@requires("edit")
async def list_log_files(request: Request) -> JSONResponse:
"""
responses:
200:
description: List available log files
content:
application/json:
schema:
type: object
properties:
files:
type: array
items:
type: string
"""
del request # Unused
files = list_log_files_in_directory(get_log_directory())
return JSONResponse({"files": files})


@router.get("/{filename}")
@requires("edit")
async def get_log_file(request: Request) -> PlainTextResponse:
"""
responses:
200:
description: Tail a log file
content:
text/plain:
schema:
type: string
400:
description: Invalid filename
404:
description: File not found
"""
filename = request.path_params["filename"]
content, status_code = read_log_file(get_log_directory(), filename)
return PlainTextResponse(content, status_code=status_code)
2 changes: 2 additions & 0 deletions marimo/_server/api/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from marimo._server.api.endpoints.health import router as health_router
from marimo._server.api.endpoints.home import router as home_router
from marimo._server.api.endpoints.login import router as login_router
from marimo._server.api.endpoints.logs import router as logs_router
from marimo._server.api.endpoints.lsp import router as lsp_router
from marimo._server.api.endpoints.mpl import router as mpl_router
from marimo._server.api.endpoints.packages import router as packages_router
Expand Down Expand Up @@ -76,6 +77,7 @@ def build_routes(base_url: str = "") -> list[BaseRoute]:
packages_router, prefix="/api/packages", name="packages"
)
app_router.include_router(lsp_router, prefix="/api/lsp", name="lsp")
app_router.include_router(logs_router, prefix="/api/logs", name="logs")
app_router.include_router(health_router, name="health")
app_router.include_router(ws_router, name="ws")
app_router.include_router(mpl_router, name="mpl")
Expand Down
68 changes: 68 additions & 0 deletions tests/_server/api/endpoints/test_logs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Copyright 2026 Marimo. All rights reserved.
from __future__ import annotations

from typing import TYPE_CHECKING

from marimo._server.api.endpoints.logs import (
list_log_files_in_directory,
read_log_file,
)

if TYPE_CHECKING:
from pathlib import Path


def test_list_log_files_empty(tmp_path: Path) -> None:
assert list_log_files_in_directory(tmp_path) == []


def test_list_log_files_nonexistent(tmp_path: Path) -> None:
assert list_log_files_in_directory(tmp_path / "nonexistent") == []


def test_list_log_files(tmp_path: Path) -> None:
(tmp_path / "marimo.log").write_text("log content")
(tmp_path / "other.log").write_text("other content")
# Rotated backup should be excluded (suffix is not .log)
(tmp_path / "marimo.log.2026-02-13").write_text("old content")
# Non-log file should be excluded
(tmp_path / "readme.txt").write_text("readme")

result = list_log_files_in_directory(tmp_path)
assert result == ["marimo.log", "other.log"]


def test_read_log_file(tmp_path: Path) -> None:
(tmp_path / "marimo.log").write_text("line1\nline2\nline3\n")
content, status = read_log_file(tmp_path, "marimo.log")
assert status == 200
assert content == "line1\nline2\nline3\n"


def test_read_log_file_not_found(tmp_path: Path) -> None:
content, status = read_log_file(tmp_path, "nonexistent.log")
assert status == 404


def test_read_log_file_path_traversal(tmp_path: Path) -> None:
content, status = read_log_file(tmp_path, "../../etc/passwd")
assert status == 400

content, status = read_log_file(tmp_path, "foo/bar.log")
assert status == 400

content, status = read_log_file(tmp_path, "foo\\bar.log")
assert status == 400


def test_read_log_file_truncates(tmp_path: Path) -> None:
lines = [f"line {i}\n" for i in range(1000)]
(tmp_path / "big.log").write_text("".join(lines))
content, status = read_log_file(tmp_path, "big.log")
assert status == 200
assert content is not None
# Should only have the last 500 lines
result_lines = content.splitlines()
assert len(result_lines) == 500
assert result_lines[0] == "line 500"
assert result_lines[-1] == "line 999"
Loading