Files
doktainer-upstream/src/components/ProcessLogsModal.tsx
T
2026-06-02 00:17:50 +07:00

519 lines
15 KiB
TypeScript

"use client";
import {
Check,
Circle,
ImageIcon,
Loader2,
ScrollText,
Terminal,
X,
} from "lucide-react";
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import Image from "next/image";
import { sanitizeLogText } from "@/lib/terminal-output";
export type ProcessLogStepStatus =
| "pending"
| "running"
| "success"
| "error"
| "skipped";
export type ProcessLogStep = {
id: string;
label: string;
description?: string;
progress?: number | string;
status?: ProcessLogStepStatus;
};
export type ProcessLogsModalState = {
title: string;
description?: string;
timelineLogs?: ProcessLogStep[];
terminalLogs?: string | string[];
imageAlt?: string;
imageUrl?: string;
imageSlot?: ReactNode;
initialTab?: ProcessLogsModalTab;
statusLabel?: string;
};
export type ProcessLogsModalTab = "timeline" | "terminal";
type ProcessLogsModalProps = ProcessLogsModalState & {
open: boolean;
onClose: () => void;
closeOnOverlayClick?: boolean;
};
const defaultTimelineLogs: ProcessLogStep[] = [
{
id: "queued",
label: "Waiting for process data",
progress: 0,
status: "running",
},
];
const statusTone: Record<
ProcessLogStepStatus,
{
color: string;
border: string;
background: string;
icon: ReactNode;
}
> = {
success: {
color: "#16a34a",
border: "rgba(22, 163, 74, 0.55)",
background: "rgba(34, 197, 94, 0.2)",
icon: <Check size={13} strokeWidth={3} />,
},
running: {
color: "#16a34a",
border: "rgba(22, 163, 74, 0.55)",
background: "rgba(34, 197, 94, 0.18)",
icon: <Loader2 size={13} className="animate-spin" />,
},
pending: {
color: "#8b949e",
border: "rgba(148, 163, 184, 0.48)",
background: "rgba(148, 163, 184, 0.12)",
icon: <Circle size={10} />,
},
skipped: {
color: "#8b949e",
border: "rgba(148, 163, 184, 0.42)",
background: "rgba(148, 163, 184, 0.1)",
icon: <Circle size={10} />,
},
error: {
color: "#ef4444",
border: "rgba(239, 68, 68, 0.58)",
background: "rgba(239, 68, 68, 0.16)",
icon: <X size={13} strokeWidth={3} />,
},
};
function formatProgress(progress: ProcessLogStep["progress"]) {
if (progress === undefined || progress === null || progress === "") {
return "";
}
return typeof progress === "number" ? `${progress}%` : progress;
}
export function useProcessLogsModal() {
const [modalState, setModalState] = useState<ProcessLogsModalState | null>(
null,
);
const openProcessLogs = useCallback((state: ProcessLogsModalState) => {
setModalState(state);
}, []);
const updateProcessLogs = useCallback(
(nextState: Partial<ProcessLogsModalState>) => {
setModalState((current) =>
current
? { ...current, ...nextState }
: (nextState as ProcessLogsModalState),
);
},
[],
);
const closeProcessLogs = useCallback(() => {
setModalState(null);
}, []);
return {
modalState,
isProcessLogsOpen: modalState !== null,
openProcessLogs,
updateProcessLogs,
closeProcessLogs,
};
}
export default function ProcessLogsModal({
open,
title,
description = "Detail of the request log entry",
timelineLogs = defaultTimelineLogs,
terminalLogs = "",
imageAlt = "Process preview",
imageUrl,
imageSlot,
initialTab = "timeline",
statusLabel,
closeOnOverlayClick = true,
onClose,
}: ProcessLogsModalProps) {
const [selectedTab, setSelectedTab] = useState<ProcessLogsModalTab | null>(
null,
);
const terminalViewportRef = useRef<HTMLPreElement>(null);
const activeTab = selectedTab ?? initialTab;
const sanitizedTerminalLogs = useMemo(() => {
const rawLogs = Array.isArray(terminalLogs)
? terminalLogs.join("\n")
: terminalLogs;
return sanitizeLogText(rawLogs || "No terminal logs available yet.");
}, [terminalLogs]);
useEffect(() => {
if (!open || activeTab !== "terminal") return;
const viewport = terminalViewportRef.current;
if (!viewport) return;
viewport.scrollTop = viewport.scrollHeight;
}, [activeTab, open, sanitizedTerminalLogs]);
const handleClose = useCallback(() => {
setSelectedTab(null);
onClose();
}, [onClose]);
if (!open) {
return null;
}
const latestActiveStep =
[...timelineLogs]
.reverse()
.find((step) => step.status === "running" || step.status === "success") ??
timelineLogs[0];
const progressLabel =
statusLabel ?? formatProgress(latestActiveStep?.progress);
const progressBarWidth =
typeof latestActiveStep?.progress === "number"
? `${Math.min(Math.max(latestActiveStep.progress, 0), 100)}%`
: latestActiveStep?.status === "running"
? "50%"
: "100%";
return (
<div
className="modal-overlay"
onClick={closeOnOverlayClick ? handleClose : undefined}
>
<div
className="modal animate-slide-in"
role="dialog"
aria-modal="true"
aria-labelledby="process-logs-modal-title"
style={{
width: "min(100%, 620px)",
maxWidth: 620,
padding: 0,
overflow: "hidden",
maxHeight: "92vh",
display: "flex",
flexDirection: "column",
borderRadius: 18,
}}
onClick={(event) => event.stopPropagation()}
>
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 14,
padding: "22px 26px 14px",
}}
>
<div style={{ display: "flex", gap: 12, minWidth: 0 }}>
<div
aria-hidden="true"
style={{
width: 26,
height: 26,
borderRadius: 999,
flex: "0 0 auto",
border: "1px solid rgba(22,163,74,0.65)",
background: "rgba(34,197,94,0.24)",
}}
/>
<div style={{ minWidth: 0 }}>
<h3
id="process-logs-modal-title"
style={{
color: "var(--text-primary)",
fontSize: 16,
fontWeight: 750,
lineHeight: 1.2,
overflowWrap: "anywhere",
}}
>
{title}
</h3>
<p
style={{
color: "var(--text-muted)",
fontSize: 12,
lineHeight: 1.55,
marginTop: 4,
overflowWrap: "anywhere",
}}
>
{description}
</p>
</div>
</div>
<button
type="button"
className="btn btn-ghost"
onClick={handleClose}
aria-label="Close process logs modal"
style={{
width: 25,
height: 25,
padding: 0,
flex: "0 0 auto",
justifyContent: "center",
color: "var(--text-muted)",
}}
>
<X size={21} />
</button>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 12,
padding: "8px 25px 18px",
}}
>
<button
type="button"
className="btn btn-ghost"
onClick={() => setSelectedTab("timeline")}
style={{
minHeight: 34,
justifyContent: "center",
border:
activeTab === "timeline"
? "1px solid rgba(59,130,246,0.38)"
: "1px solid var(--border)",
background:
activeTab === "timeline"
? "rgba(59,130,246,0.1)"
: "var(--bg-card)",
color: activeTab === "timeline" ? "#3b82f6" : "var(--text-muted)",
fontWeight: 700,
}}
>
<ScrollText size={14} /> Timeline
</button>
<button
type="button"
className="btn btn-ghost"
onClick={() => setSelectedTab("terminal")}
style={{
minHeight: 34,
justifyContent: "center",
border:
activeTab === "terminal"
? "1px solid rgba(59,130,246,0.38)"
: "1px solid var(--border)",
background:
activeTab === "terminal"
? "rgba(59,130,246,0.1)"
: "var(--bg-card)",
color: activeTab === "terminal" ? "#3b82f6" : "var(--text-muted)",
fontWeight: 700,
}}
>
<Terminal size={14} /> Terminal Logs
</button>
</div>
<div style={{ flex: 1, overflow: "auto", padding: "0 28px 26px" }}>
{activeTab === "timeline" ? (
<div style={{ display: "grid", gap: 22 }}>
<div
style={{
width: 144,
height: 115,
border: "1px solid var(--border)",
borderRadius: 18,
margin: "0 auto",
display: "grid",
placeItems: "center",
color: "var(--text-muted)",
background: "var(--bg-input)",
overflow: "hidden",
}}
>
{imageSlot ??
(imageUrl ? (
<Image
src={imageUrl}
alt={imageAlt}
width={144}
height={115}
unoptimized
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
) : (
<ImageIcon size={30} />
))}
</div>
<div style={{ display: "grid", gap: 14 }}>
{timelineLogs.map((step) => {
const status = step.status ?? "pending";
const tone = statusTone[status];
const stepProgress = formatProgress(step.progress);
return (
<div
key={step.id}
style={{
display: "grid",
gridTemplateColumns: "minmax(0, 1fr) auto",
alignItems: "center",
gap: 14,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
minWidth: 0,
}}
>
<span
aria-hidden="true"
style={{
width: 26,
height: 26,
borderRadius: 999,
display: "grid",
placeItems: "center",
flex: "0 0 auto",
color: tone.color,
border: `1px solid ${tone.border}`,
background: tone.background,
}}
>
{tone.icon}
</span>
<span style={{ minWidth: 0 }}>
<strong
style={{
display: "block",
color: tone.color,
fontSize: 15,
fontWeight: 750,
lineHeight: 1.35,
overflowWrap: "anywhere",
}}
>
{step.label}
</strong>
{step.description ? (
<span
style={{
display: "block",
color: "var(--text-muted)",
fontSize: 12,
lineHeight: 1.45,
marginTop: 2,
overflowWrap: "anywhere",
}}
>
{step.description}
</span>
) : null}
</span>
</div>
{stepProgress ? (
<strong
style={{
color: tone.color,
fontSize: 14,
fontWeight: 750,
whiteSpace: "nowrap",
}}
>
{stepProgress}
</strong>
) : null}
</div>
);
})}
</div>
{progressLabel ? (
<div
aria-label={`Current progress ${progressLabel}`}
style={{
height: 8,
borderRadius: 999,
background: "var(--bg-input)",
overflow: "hidden",
}}
>
<div
style={{
width: progressBarWidth,
height: "100%",
borderRadius: 999,
background: "#16a34a",
}}
/>
</div>
) : null}
</div>
) : (
<pre
ref={terminalViewportRef}
className="terminal-text"
style={{
minHeight: 360,
maxHeight: "56vh",
overflow: "auto",
margin: 0,
padding: 18,
borderRadius: 12,
border: "1px solid rgba(148,163,184,0.2)",
background: "#0d1117",
color: "#e6edf3",
lineHeight: 1.65,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{sanitizedTerminalLogs}
</pre>
)}
</div>
</div>
</div>
);
}