Files
cronmaster/app/_components/FeatureComponents/Modals/LiveLogModal.tsx
2025-11-20 18:59:01 +00:00

368 lines
12 KiB
TypeScript

"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { Loader2, CheckCircle2, XCircle, AlertTriangle, Minimize2, Maximize2 } from "lucide-react";
import { Modal } from "@/app/_components/GlobalComponents/UIElements/Modal";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import { useSSEContext } from "@/app/_contexts/SSEContext";
import { SSEEvent } from "@/app/_utils/sse-events";
import { usePageVisibility } from "@/app/_hooks/usePageVisibility";
import { useTranslations } from "next-intl";
interface LiveLogModalProps {
isOpen: boolean;
onClose: () => void;
runId: string;
jobId: string;
jobComment?: string;
}
const MAX_LINES_FULL_RENDER = 10000;
const TAIL_LINES = 5000;
export const LiveLogModal = ({
isOpen,
onClose,
runId,
jobId,
jobComment,
}: LiveLogModalProps) => {
const t = useTranslations();
const [logContent, setLogContent] = useState<string>("");
const [status, setStatus] = useState<"running" | "completed" | "failed">(
"running"
);
const [exitCode, setExitCode] = useState<number | null>(null);
const [tailMode, setTailMode] = useState<boolean>(false);
const [showSizeWarning, setShowSizeWarning] = useState<boolean>(false);
const logEndRef = useRef<HTMLDivElement>(null);
const { subscribe } = useSSEContext();
const isPageVisible = usePageVisibility();
const lastOffsetRef = useRef<number>(0);
const abortControllerRef = useRef<AbortController | null>(null);
const [fileSize, setFileSize] = useState<number>(0);
const [lineCount, setLineCount] = useState<number>(0);
const [maxLines, setMaxLines] = useState<number>(500);
const [totalLines, setTotalLines] = useState<number>(0);
const [truncated, setTruncated] = useState<boolean>(false);
const [showFullLog, setShowFullLog] = useState<boolean>(false);
const [isJobComplete, setIsJobComplete] = useState<boolean>(false);
useEffect(() => {
if (isOpen) {
lastOffsetRef.current = 0;
setLogContent("");
setTailMode(false);
setShowSizeWarning(false);
setFileSize(0);
setLineCount(0);
setShowFullLog(false);
setIsJobComplete(false);
}
}, [isOpen, runId]);
useEffect(() => {
if (isOpen && runId && !isJobComplete) {
lastOffsetRef.current = 0;
setLogContent("");
fetchLogs();
}
}, [maxLines]);
const fetchLogs = useCallback(async () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const abortController = new AbortController();
abortControllerRef.current = abortController;
try {
const url = `/api/logs/stream?runId=${runId}&offset=${lastOffsetRef.current}&maxLines=${maxLines}`;
const response = await fetch(url, {
signal: abortController.signal,
});
const data = await response.json();
if (data.fileSize !== undefined) {
lastOffsetRef.current = data.fileSize;
setFileSize(data.fileSize);
if (data.fileSize > 10 * 1024 * 1024) {
setShowSizeWarning(true);
}
}
if (data.totalLines !== undefined) {
setTotalLines(data.totalLines);
setLineCount(data.displayedLines || data.totalLines);
}
if (data.truncated !== undefined) {
setTruncated(data.truncated);
}
if (lastOffsetRef.current === 0 && data.content) {
setLogContent(data.content);
if (data.truncated) {
setTailMode(true);
}
} else if (data.newContent) {
setLogContent((prev) => {
const combined = prev + data.newContent;
const lines = combined.split("\n");
if (lines.length > maxLines) {
return lines.slice(-maxLines).join("\n");
}
return combined;
});
}
const jobStatus = data.status || "running";
setStatus(jobStatus);
if (jobStatus === "completed" || jobStatus === "failed") {
setIsJobComplete(true);
}
if (data.exitCode !== undefined) {
setExitCode(data.exitCode);
}
} catch (error: any) {
if (error.name !== "AbortError") {
console.error("Failed to fetch logs:", error);
}
}
}, [runId, maxLines]);
useEffect(() => {
if (!isOpen || !runId || !isPageVisible) return;
fetchLogs();
let interval: NodeJS.Timeout | null = null;
if (isPageVisible && !isJobComplete) {
interval = setInterval(fetchLogs, 3000);
}
return () => {
if (interval) {
clearInterval(interval);
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [isOpen, runId, isPageVisible, fetchLogs, isJobComplete]);
useEffect(() => {
if (!isOpen) return;
const unsubscribe = subscribe((event: SSEEvent) => {
if (event.type === "job-completed" && event.data.runId === runId) {
setStatus("completed");
setExitCode(event.data.exitCode);
fetch(`/api/logs/stream?runId=${runId}&offset=0`)
.then((res) => res.json())
.then((data) => {
if (data.content) {
const lines = data.content.split("\n");
setLineCount(lines.length);
if (tailMode && lines.length > TAIL_LINES) {
setLogContent(lines.slice(-TAIL_LINES).join("\n"));
} else {
setLogContent(data.content);
}
}
});
} else if (event.type === "job-failed" && event.data.runId === runId) {
setStatus("failed");
setExitCode(event.data.exitCode);
fetch(`/api/logs/stream?runId=${runId}&offset=0`)
.then((res) => res.json())
.then((data) => {
if (data.content) {
const lines = data.content.split("\n");
setLineCount(lines.length);
if (tailMode && lines.length > TAIL_LINES) {
setLogContent(lines.slice(-TAIL_LINES).join("\n"));
} else {
setLogContent(data.content);
}
}
});
}
});
return unsubscribe;
}, [isOpen, runId, subscribe, tailMode]);
useEffect(() => {
if (logEndRef.current) {
logEndRef.current.scrollIntoView({ behavior: "instant" });
}
}, [logContent]);
const toggleTailMode = () => {
setTailMode(!tailMode);
if (!tailMode) {
const lines = logContent.split("\n");
if (lines.length > TAIL_LINES) {
setLogContent(lines.slice(-TAIL_LINES).join("\n"));
}
}
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const titleWithStatus = (
<div className="flex items-center gap-3">
<span>{t("cronjobs.liveJobExecution")}{jobComment && `: ${jobComment}`}</span>
{status === "running" && (
<span className="flex items-center gap-1 text-sm text-blue-500">
<Loader2 className="w-4 h-4 animate-spin" />
{t("cronjobs.running")}
</span>
)}
{status === "completed" && (
<span className="flex items-center gap-1 text-sm text-green-500">
<CheckCircle2 className="w-4 h-4" />
{t("cronjobs.completed", { exitCode: exitCode ?? 0 })}
</span>
)}
{status === "failed" && (
<span className="flex items-center gap-1 text-sm text-red-500">
<XCircle className="w-4 h-4" />
{t("cronjobs.jobFailed", { exitCode: exitCode ?? 1 })}
</span>
)}
</div>
);
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={titleWithStatus as any}
size="xl"
preventCloseOnClickOutside={status === "running"}
>
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
{!showFullLog ? (
<>
<label htmlFor="maxLines" className="text-sm text-muted-foreground">
{t("cronjobs.showLast")}
</label>
<select
id="maxLines"
value={maxLines}
onChange={(e) => setMaxLines(parseInt(e.target.value, 10))}
className="bg-background border border-border rounded px-2 py-1 text-sm"
>
<option value="100">{t("cronjobs.nLines", { count: "100" })}</option>
<option value="500">{t("cronjobs.nLines", { count: "500" })}</option>
<option value="1000">{t("cronjobs.nLines", { count: "1,000" })}</option>
<option value="2000">{t("cronjobs.nLines", { count: "2,000" })}</option>
<option value="5000">{t("cronjobs.nLines", { count: "5,000" })}</option>
</select>
{truncated && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setShowFullLog(true);
setMaxLines(50000);
}}
className="text-xs"
>
{t("cronjobs.viewFullLog", { totalLines: totalLines.toLocaleString() })}
</Button>
)}
</>
) : (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{t("cronjobs.viewingFullLog", { totalLines: totalLines.toLocaleString() })}
</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setShowFullLog(false);
setMaxLines(500);
}}
className="text-xs"
>
{t("cronjobs.backToWindowedView")}
</Button>
</div>
)}
</div>
{truncated && !showFullLog && (
<div className="text-sm text-orange-500 flex items-center gap-1">
<AlertTriangle className="h-4 w-4" />
{t("cronjobs.showingLastOf", {
lineCount: lineCount.toLocaleString(),
totalLines: totalLines.toLocaleString()
})}
</div>
)}
</div>
{showSizeWarning && (
<div className="bg-orange-500/10 border border-orange-500/30 rounded-lg p-3 flex items-start gap-3">
<AlertTriangle className="h-4 w-4 text-orange-500 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm text-foreground">
<span className="font-medium">{t("cronjobs.largeLogFileDetected")}</span> ({formatFileSize(fileSize)})
{tailMode && ` - ${t("cronjobs.tailModeEnabled", { tailLines: TAIL_LINES.toLocaleString() })}`}
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={toggleTailMode}
className="text-orange-500 hover:text-orange-400 hover:bg-orange-500/10 h-auto py-1 px-2 text-xs"
title={tailMode ? t("cronjobs.showAllLines") : t("cronjobs.enableTailMode")}
>
{tailMode ? <Maximize2 className="h-3 w-3" /> : <Minimize2 className="h-3 w-3" />}
</Button>
</div>
)}
<div className="bg-black/90 dark:bg-black/60 rounded-lg p-4 max-h-[60vh] overflow-auto">
<pre className="text-xs font-mono text-green-400 whitespace-pre-wrap break-words">
{logContent || t("cronjobs.waitingForJobToStart")}
<div ref={logEndRef} />
</pre>
</div>
<div className="flex justify-between items-center text-xs text-muted-foreground">
<span>
{t("cronjobs.runIdJobId", { runId, jobId })}
</span>
<span>
{lineCount.toLocaleString()} lines
{tailMode && ` (showing last ${TAIL_LINES.toLocaleString()})`}
{fileSize > 0 && `${formatFileSize(fileSize)}`}
</span>
</div>
</div>
</Modal>
);
};