From e0129103328a28113fa5900d451a9002dd6351a6 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 4 Sep 2026 13:16:22 -0700 Subject: [PATCH] add loading older logs --- .../Components/HeaderLogs.tsx | 71 +++++++- .../ContainerDetails/Components/RowLog.tsx | 6 +- .../Pages/Docker/ContainerDetails/TabLogs.tsx | 172 ++++++++++++++---- client/src/locales/en.json | 12 ++ 4 files changed, 219 insertions(+), 42 deletions(-) diff --git a/client/src/Pages/Docker/ContainerDetails/Components/HeaderLogs.tsx b/client/src/Pages/Docker/ContainerDetails/Components/HeaderLogs.tsx index b5cdec704..b1ccddc2c 100644 --- a/client/src/Pages/Docker/ContainerDetails/Components/HeaderLogs.tsx +++ b/client/src/Pages/Docker/ContainerDetails/Components/HeaderLogs.tsx @@ -1,16 +1,69 @@ import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; -export const HeaderLogs = ({ - lines, - isValidating, -}: { +import { Dot } from "@/Components/design-elements"; + +// Hooks +import { useEffect, useState } from "react"; +import { useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; + +// Utils +import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; +import { MS_PER_SECOND, MS_PER_MINUTE } from "@/Utils/TimeUtils"; + +interface HeaderLogsProps { lines: number; - isValidating: boolean; -}) => { + loading: boolean; + lastUpdatedAt: number | null; +} + +export const HeaderLogs = ({ lines, loading, lastUpdatedAt }: HeaderLogsProps) => { + const theme = useTheme(); + const { t } = useTranslation(); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + if (lastUpdatedAt === null) return; + setNow(Date.now()); + const id = window.setInterval(() => setNow(Date.now()), MS_PER_SECOND); + return () => window.clearInterval(id); + }, [lastUpdatedAt]); + + const renderStatus = () => { + if (loading) return t("pages.docker.container.logs.loading"); + if (lastUpdatedAt === null) return null; + const ageMs = Math.max(0, now - lastUpdatedAt); + return ageMs < MS_PER_MINUTE + ? t("pages.docker.container.logs.updatedSecondsAgo", { + count: Math.floor(ageMs / MS_PER_SECOND), + }) + : t("pages.docker.container.logs.updatedMinutesAgo", { + count: Math.floor(ageMs / MS_PER_MINUTE), + }); + }; + return ( - - {`${lines} recent lines`} - {isValidating && "Refreshing..."} + + + {t("pages.docker.container.logs.recentLines", { count: lines })} + + + {lastUpdatedAt !== null && !loading && ( + + )} + {renderStatus()} + ); }; diff --git a/client/src/Pages/Docker/ContainerDetails/Components/RowLog.tsx b/client/src/Pages/Docker/ContainerDetails/Components/RowLog.tsx index 82ba9f259..4a022e44e 100644 --- a/client/src/Pages/Docker/ContainerDetails/Components/RowLog.tsx +++ b/client/src/Pages/Docker/ContainerDetails/Components/RowLog.tsx @@ -8,6 +8,7 @@ import { Fragment } from "react"; // Hooks import { useTheme } from "@mui/material"; import { useSelector } from "react-redux"; +import { useTranslation } from "react-i18next"; // Utils import type { DockerLogRow } from "@/Utils/MonitorUtils"; @@ -18,6 +19,7 @@ import type { RootState } from "@/store"; export const RowLog = ({ row }: { row: DockerLogRow }) => { const theme = useTheme(); const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + const { t } = useTranslation(); return ( @@ -63,7 +65,9 @@ export const RowLog = ({ row }: { row: DockerLogRow }) => { alignItems={"center"} py={SPACING.MD} > - Some log entries were skipped + + {t("pages.docker.container.logs.skipped")} + ) : ( diff --git a/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx index 6d4087c53..eb5f9ce88 100644 --- a/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx +++ b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx @@ -1,28 +1,33 @@ // Components import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; import { BaseBox } from "@/Components/design-elements"; +import { Button } from "@/Components/inputs"; import { HeaderLogs } from "@/Pages/Docker/ContainerDetails/Components/HeaderLogs"; +import { RowLog } from "@/Pages/Docker/ContainerDetails/Components/RowLog"; // Types import type { DockerContainerLogsResponse } from "@/Types/Monitor"; +import type { DockerLog } from "@/Types/Check"; +import type { ApiResponse } from "@/Hooks/UseApi"; // Hooks -import { useGet } from "@/Hooks/UseApi"; import { useState, useEffect, useLayoutEffect, useMemo, - Fragment, useRef, useCallback, } from "react"; -import type { DockerLog } from "@/Types/Check"; import { useTheme } from "@mui/material"; -// Util -import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; +import { useTranslation } from "react-i18next"; +import { useToast } from "@/Hooks/UseToast"; + +// Utils +import { get } from "@/Utils/ApiClient"; +import { LAYOUT } from "@/Utils/Theme/constants"; import { flattenDockerLogs } from "@/Utils/MonitorUtils"; -import { RowLog } from "@/Pages/Docker/ContainerDetails/Components/RowLog"; interface TabLogsProps { monitorId?: string; @@ -30,74 +35,177 @@ interface TabLogsProps { } const SCROLL_PIN_THRESHOLD_PX = 8; +const TAIL_POLL_MS = 10_000; + const isScrolledToBottom = (el: HTMLElement) => el.scrollHeight - el.scrollTop - el.clientHeight <= SCROLL_PIN_THRESHOLD_PX; +const fetchLogs = async (url: string, params: { before?: string; after?: string }) => { + const res = await get>(url, { params }); + return res.data.data; +}; + export const TabLogs = ({ monitorId, containerName }: TabLogsProps) => { const theme = useTheme(); + const { t } = useTranslation(); + const { toastError } = useToast(); - // State - const [olderLogs, setOlderLogs] = useState([]); - const [olderCursor, setOlderCursor] = useState(undefined); // undefined -> no older requested, string -> cursor, null -> no more data + // logs is newest-first, matching the API and what flattenDockerLogs expects. + const [logs, setLogs] = useState([]); + const [olderExhausted, setOlderExhausted] = useState(false); + const [loadingInitial, setLoadingInitial] = useState(false); const [loadingOlder, setLoadingOlder] = useState(false); - const [odlerError, setOlderError] = useState(null); + const [lastUpdatedAt, setLastUpdatedAt] = useState(null); - const baseLogsUrl = + const logsUrl = monitorId && containerName ? `/monitors/docker/details/${encodeURIComponent(monitorId)}/containers/${encodeURIComponent(containerName)}/logs` : null; - const { - data: head, - isLoading, - isValidating, - error: headError, - refetch, - } = useGet( - baseLogsUrl, - {}, - { - refreshInterval: 10_000, - keepPreviousData: true, - revalidateOnFocus: true, - } - ); + const newestCursor = logs[0]?.checkedAt; + const oldestCursor = logs[logs.length - 1]?.checkedAt; - const rows = useMemo(() => flattenDockerLogs(head?.logs ?? []), [head?.logs]); + // Initial page. Resets everything when the container changes. + useEffect(() => { + setLogs([]); + setOlderExhausted(false); + if (!logsUrl) return; + let cancelled = false; + setLoadingInitial(true); + fetchLogs(logsUrl, {}) + .then((page) => { + if (cancelled) return; + setLogs(page.logs); + setOlderExhausted(page.nextCursor === null); + setLastUpdatedAt(Date.now()); + }) + .catch(() => { + if (cancelled) return; + toastError(t("pages.docker.container.logs.loadError")); + }) + .finally(() => { + if (!cancelled) setLoadingInitial(false); + }); + return () => { + cancelled = true; + }; + }, [logsUrl, t, toastError]); + + const newestCursorRef = useRef(newestCursor); + newestCursorRef.current = newestCursor; + + useEffect(() => { + if (!logsUrl) return; + let inFlight = false; + const tick = async () => { + if (inFlight || document.hidden) return; + inFlight = true; + try { + const cursor = newestCursorRef.current; + const page = await fetchLogs(logsUrl, cursor ? { after: cursor } : {}); + if (page.logs.length > 0) setLogs((prev) => [...page.logs, ...prev]); + setLastUpdatedAt(Date.now()); + } catch (err: unknown) { + console.error(err); + } finally { + inFlight = false; + } + }; + const id = window.setInterval(tick, TAIL_POLL_MS); + document.addEventListener("visibilitychange", tick); + return () => { + window.clearInterval(id); + document.removeEventListener("visibilitychange", tick); + }; + }, [logsUrl]); + + const rows = useMemo(() => flattenDockerLogs(logs), [logs]); const logBoxRef = useRef(null); const pinnedToBottom = useRef(true); + const prependAnchor = useRef<{ scrollHeight: number; scrollTop: number } | null>(null); // Always pin to bottom when container/monitor changes useLayoutEffect(() => { pinnedToBottom.current = true; }, [monitorId, containerName]); - // New log rows + // Rows changed: restore position after a prepend, otherwise honour the pin useLayoutEffect(() => { const el = logBoxRef.current; - if (!el || !pinnedToBottom.current) return; - el.scrollTop = el.scrollHeight; + if (!el) return; + const anchor = prependAnchor.current; + if (anchor) { + prependAnchor.current = null; + el.scrollTop = el.scrollHeight - anchor.scrollHeight + anchor.scrollTop; + return; + } + if (pinnedToBottom.current) el.scrollTop = el.scrollHeight; }, [rows]); const handleScroll = useCallback((event: React.UIEvent) => { pinnedToBottom.current = isScrolledToBottom(event.currentTarget); }, []); + const loadOlder = useCallback(async () => { + if (!logsUrl || !oldestCursor || loadingOlder) return; + setLoadingOlder(true); + try { + const page = await fetchLogs(logsUrl, { before: oldestCursor }); + const el = logBoxRef.current; + if (el) + prependAnchor.current = { + scrollHeight: el.scrollHeight, + scrollTop: el.scrollTop, + }; + setLogs((prev) => [...prev, ...page.logs]); + setOlderExhausted(page.nextCursor === null); + } catch { + toastError(t("pages.docker.container.logs.loadOlderError")); + } finally { + setLoadingOlder(false); + } + }, [logsUrl, oldestCursor, loadingOlder, t, toastError]); + return ( + {rows.length > 0 && ( + + {olderExhausted ? ( + + {t("pages.docker.container.logs.noOlder")} + + ) : ( + + )} + + )} {rows.map((row) => { return (