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 (