Files
homarr/packages/modals-collection/src/docker/docker-logs-modal.tsx
T

135 lines
3.8 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { Box, Group, Loader, ScrollArea, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
interface DockerLogsModalInnerProps {
id: string;
name: string;
tail?: number;
}
const SCROLL_THRESHOLD = 100;
const isViewportAtBottom = (viewport: HTMLDivElement | null): boolean => {
if (!viewport) return false;
return viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < SCROLL_THRESHOLD;
};
export const DockerLogsModal = createModal<DockerLogsModalInnerProps>(({ innerProps }) => {
const t = useI18n();
const viewportRef = useRef<HTMLDivElement | null>(null);
const [logs, setLogs] = useState<string>("");
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const shouldScrollRef = useRef(true);
const hasInitializedRef = useRef(false);
// Get initial logs
const { data: initialData } = clientApi.docker.logs.useQuery(
{ id: innerProps.id, tail: innerProps.tail ?? 200 },
{
refetchOnWindowFocus: false,
retry: 0,
},
);
// Maximum log size to prevent memory issues (500KB)
const MAX_LOG_SIZE = 500 * 1024;
// Subscribe to streaming logs
clientApi.docker.subscribeLogs.useSubscription(
{ id: innerProps.id, tail: innerProps.tail ?? 200 },
{
onData(data) {
setLogs((prev) => {
const newLogs = prev + data;
// Truncate from the beginning if logs exceed maximum size
if (newLogs.length > MAX_LOG_SIZE) {
const truncated = newLogs.slice(-MAX_LOG_SIZE);
// Try to start from a newline to avoid partial lines
const firstNewline = truncated.indexOf("\n");
return firstNewline > 0 ? truncated.slice(firstNewline + 1) : truncated;
}
return newLogs;
});
setIsLoading(false);
},
onError(err) {
setError(err.message);
setIsLoading(false);
},
},
);
// Initialize with static logs (only once)
useEffect(() => {
if (initialData?.logs && !hasInitializedRef.current) {
setLogs(initialData.logs);
setIsLoading(false);
hasInitializedRef.current = true;
}
}, [initialData]);
// Auto-scroll to bottom when new logs arrive
useEffect(() => {
if (!shouldScrollRef.current) return;
const viewport = viewportRef.current;
if (!viewport) return;
if (isViewportAtBottom(viewport)) {
viewport.scrollTop = viewport.scrollHeight;
}
}, [logs]);
// Handle manual scroll to determine auto-scroll behavior
const handleScroll = () => {
const viewport = viewportRef.current;
shouldScrollRef.current = isViewportAtBottom(viewport);
};
return (
<Box>
<Stack gap="sm">
{isLoading ? (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : error ? (
<Text c="red" size="sm">
{error}
</Text>
) : (
<ScrollArea h="75vh" viewportRef={viewportRef} onScrollPositionChange={handleScroll}>
{logs ? (
<pre
style={{
margin: 0,
whiteSpace: "pre-wrap",
overflowWrap: "anywhere",
}}
>
<code>{logs}</code>
</pre>
) : (
<Text size="sm" c="dimmed">
{t("common.state.empty")}
</Text>
)}
</ScrollArea>
)}
</Stack>
</Box>
);
}).withOptions({
size: "95%",
centered: true,
defaultTitle: (t) => t("docker.action.logs.label"),
});