diff --git a/frontend/src/components/editor/chrome/panels/server-logs-panel.tsx b/frontend/src/components/editor/chrome/panels/server-logs-panel.tsx new file mode 100644 index 00000000000..9a66407c498 --- /dev/null +++ b/frontend/src/components/editor/chrome/panels/server-logs-panel.tsx @@ -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 { + const res = await fetch("/api/logs/list"); + const data: { files: string[] } = await res.json(); + return data.files ?? []; +} + +async function fetchLogContent(filename: string): Promise { + const res = await fetch(`/api/logs/${encodeURIComponent(filename)}`); + return res.text(); +} + +const ServerLogsPanel: React.FC = () => { + const [selectedFile, setSelectedFile] = useState(""); + const preRef = useRef(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 ( + } + /> + ); + } + + return ( +
+
+ {files && files.length > 1 && ( + + )} + {files && files.length === 1 && ( + {selectedFile} + )} + +
+
+        {contentLoading ? "Loading..." : content}
+      
+
+ ); +}; + +export default ServerLogsPanel; diff --git a/frontend/src/components/editor/chrome/types.ts b/frontend/src/components/editor/chrome/types.ts index 678cc0d7da7..5e22dcf91e5 100644 --- a/frontend/src/components/editor/chrome/types.ts +++ b/frontend/src/components/editor/chrome/types.ts @@ -12,6 +12,7 @@ import { NetworkIcon, NotebookPenIcon, ScrollTextIcon, + ServerIcon, SquareDashedBottomCodeIcon, TerminalSquareIcon, TextSearchIcon, @@ -42,7 +43,8 @@ export type PanelType = | "secrets" | "logs" | "terminal" - | "cache"; + | "cache" + | "server-logs"; export type PanelSection = "sidebar" | "developer-panel"; @@ -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( diff --git a/frontend/src/components/editor/chrome/wrapper/app-chrome.tsx b/frontend/src/components/editor/chrome/wrapper/app-chrome.tsx index 2e444d79841..9c395544c29 100644 --- a/frontend/src/components/editor/chrome/wrapper/app-chrome.tsx +++ b/frontend/src/components/editor/chrome/wrapper/app-chrome.tsx @@ -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 = ({ children }) => { const { @@ -279,6 +282,7 @@ export const AppChrome: React.FC = ({ children }) => { /> ), cache: , + "server-logs": , }; const helpPaneBody = ( diff --git a/marimo/_server/api/endpoints/logs.py b/marimo/_server/api/endpoints/logs.py new file mode 100644 index 00000000000..beb00a5a95f --- /dev/null +++ b/marimo/_server/api/endpoints/logs.py @@ -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) diff --git a/marimo/_server/api/router.py b/marimo/_server/api/router.py index 6abed452e45..9d777a2a9aa 100644 --- a/marimo/_server/api/router.py +++ b/marimo/_server/api/router.py @@ -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 @@ -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") diff --git a/tests/_server/api/endpoints/test_logs.py b/tests/_server/api/endpoints/test_logs.py new file mode 100644 index 00000000000..862337b34e3 --- /dev/null +++ b/tests/_server/api/endpoints/test_logs.py @@ -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"