mirror of
https://github.com/fccview/cronmaster.git
synced 2025-12-23 22:18:20 -05:00
180 lines
5.1 KiB
TypeScript
180 lines
5.1 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useRef } from "react";
|
|
import { Loader2, CheckCircle2, XCircle } from "lucide-react";
|
|
import { Modal } from "@/app/_components/GlobalComponents/UIElements/Modal";
|
|
import { useSSEContext } from "@/app/_contexts/SSEContext";
|
|
import { SSEEvent } from "@/app/_utils/sse-events";
|
|
import { usePageVisibility } from "@/app/_hooks/usePageVisibility";
|
|
|
|
interface LiveLogModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
runId: string;
|
|
jobId: string;
|
|
jobComment?: string;
|
|
}
|
|
|
|
export const LiveLogModal = ({
|
|
isOpen,
|
|
onClose,
|
|
runId,
|
|
jobId,
|
|
jobComment,
|
|
}: LiveLogModalProps) => {
|
|
const [logContent, setLogContent] = useState<string>("");
|
|
const [status, setStatus] = useState<"running" | "completed" | "failed">(
|
|
"running"
|
|
);
|
|
const [exitCode, setExitCode] = useState<number | null>(null);
|
|
const logEndRef = useRef<HTMLDivElement>(null);
|
|
const { subscribe } = useSSEContext();
|
|
const isPageVisible = usePageVisibility();
|
|
const lastOffsetRef = useRef<number>(0);
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen || !runId || !isPageVisible) return;
|
|
|
|
lastOffsetRef.current = 0;
|
|
setLogContent("");
|
|
|
|
const fetchLogs = 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}`;
|
|
const response = await fetch(url, {
|
|
signal: abortController.signal,
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (data.fileSize !== undefined) {
|
|
lastOffsetRef.current = data.fileSize;
|
|
}
|
|
|
|
if (lastOffsetRef.current === 0 && data.content) {
|
|
setLogContent(data.content);
|
|
} else if (data.newContent) {
|
|
setLogContent((prev) => prev + data.newContent);
|
|
}
|
|
|
|
setStatus(data.status || "running");
|
|
|
|
if (data.exitCode !== undefined) {
|
|
setExitCode(data.exitCode);
|
|
}
|
|
} catch (error: any) {
|
|
if (error.name !== "AbortError") {
|
|
console.error("Failed to fetch logs:", error);
|
|
}
|
|
}
|
|
};
|
|
|
|
fetchLogs();
|
|
|
|
let interval: NodeJS.Timeout | null = null;
|
|
if (isPageVisible) {
|
|
interval = setInterval(fetchLogs, 2000);
|
|
}
|
|
|
|
return () => {
|
|
if (interval) {
|
|
clearInterval(interval);
|
|
}
|
|
if (abortControllerRef.current) {
|
|
abortControllerRef.current.abort();
|
|
}
|
|
};
|
|
}, [isOpen, runId, isPageVisible]);
|
|
|
|
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}`)
|
|
.then((res) => res.json())
|
|
.then((data) => {
|
|
if (data.content) {
|
|
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}`)
|
|
.then((res) => res.json())
|
|
.then((data) => {
|
|
if (data.content) {
|
|
setLogContent(data.content);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
return unsubscribe;
|
|
}, [isOpen, runId, subscribe]);
|
|
|
|
useEffect(() => {
|
|
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
}, [logContent]);
|
|
|
|
const titleWithStatus = (
|
|
<div className="flex items-center gap-3">
|
|
<span>Live Job Execution{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" />
|
|
Running...
|
|
</span>
|
|
)}
|
|
{status === "completed" && (
|
|
<span className="flex items-center gap-1 text-sm text-green-500">
|
|
<CheckCircle2 className="w-4 h-4" />
|
|
Completed (Exit: {exitCode})
|
|
</span>
|
|
)}
|
|
{status === "failed" && (
|
|
<span className="flex items-center gap-1 text-sm text-red-500">
|
|
<XCircle className="w-4 h-4" />
|
|
Failed (Exit: {exitCode})
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<Modal
|
|
isOpen={isOpen}
|
|
onClose={onClose}
|
|
title={titleWithStatus as any}
|
|
size="xl"
|
|
preventCloseOnClickOutside={status === "running"}
|
|
>
|
|
<div className="space-y-4">
|
|
<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 ||
|
|
"Waiting for job to start...\n\nLogs will appear here in real-time."}
|
|
<div ref={logEndRef} />
|
|
</pre>
|
|
</div>
|
|
|
|
<div className="text-xs text-muted-foreground">
|
|
Run ID: {runId} | Job ID: {jobId}
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
};
|