mirror of
https://github.com/arcan1s/ahriman.git
synced 2026-04-07 02:53:38 +00:00
206 lines
7.7 KiB
TypeScript
206 lines
7.7 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { Box, Button, Menu, MenuItem, Typography } from "@mui/material";
|
|
import ListIcon from "@mui/icons-material/List";
|
|
import { skipToken, useQuery } from "@tanstack/react-query";
|
|
import hljs from "highlight.js/lib/core";
|
|
import plaintext from "highlight.js/lib/languages/plaintext";
|
|
import "highlight.js/styles/github.css";
|
|
import { Client } from "api/client/AhrimanClient";
|
|
import { QueryKeys } from "api/QueryKeys";
|
|
import { formatTimestamp } from "components/common/formatTimestamp";
|
|
import CopyButton from "components/common/CopyButton";
|
|
import type { LogRecord } from "api/types/LogRecord";
|
|
import type { RepositoryId } from "api/types/RepositoryId";
|
|
|
|
hljs.registerLanguage("plaintext", plaintext);
|
|
|
|
interface LogVersion {
|
|
version: string;
|
|
processId: string;
|
|
created: number;
|
|
logs: string;
|
|
}
|
|
|
|
interface BuildLogsTabProps {
|
|
packageBase: string;
|
|
repo: RepositoryId;
|
|
refetchInterval: number | false;
|
|
}
|
|
|
|
function convertLogs(records: LogRecord[], filter?: (r: LogRecord) => boolean): string {
|
|
return records
|
|
.filter(filter || Boolean)
|
|
.map((r) => `[${new Date(r.created * 1000).toISOString()}] ${r.message}`)
|
|
.join("\n");
|
|
}
|
|
|
|
export default function BuildLogsTab({ packageBase, repo, refetchInterval }: BuildLogsTabProps): React.JSX.Element {
|
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
|
const codeRef = useRef<HTMLElement>(null);
|
|
const preRef = useRef<HTMLElement>(null);
|
|
const initialScrollDone = useRef(false);
|
|
const wasAtBottom = useRef(true);
|
|
|
|
const handleScroll = useCallback(() => {
|
|
if (preRef.current) {
|
|
const el = preRef.current;
|
|
wasAtBottom.current = el.scrollTop + el.clientHeight >= el.scrollHeight - 50;
|
|
}
|
|
}, []);
|
|
|
|
const { data: allLogs } = useQuery<LogRecord[]>({
|
|
queryKey: QueryKeys.logs(packageBase, repo),
|
|
queryFn: () => Client.fetchLogs(packageBase, repo),
|
|
enabled: !!packageBase,
|
|
});
|
|
|
|
// Build version selectors from all logs
|
|
const versions = useMemo<LogVersion[]>(() => {
|
|
if (!allLogs || allLogs.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const grouped: Record<string, LogRecord & { minCreated: number }> = {};
|
|
for (const record of allLogs) {
|
|
const key = `${record.version}-${record.process_id}`;
|
|
if (!grouped[key]) {
|
|
grouped[key] = { ...record, minCreated: record.created };
|
|
} else {
|
|
grouped[key].minCreated = Math.min(grouped[key].minCreated, record.created);
|
|
}
|
|
}
|
|
|
|
return Object.values(grouped)
|
|
.sort((a, b) => b.minCreated - a.minCreated)
|
|
.map((v) => ({
|
|
version: v.version,
|
|
processId: v.process_id,
|
|
created: v.minCreated,
|
|
logs: convertLogs(
|
|
allLogs,
|
|
(r) => r.version === v.version && r.process_id === v.process_id,
|
|
),
|
|
}));
|
|
}, [allLogs]);
|
|
|
|
// Reset active index when logs data changes (React "adjusting state from props" pattern)
|
|
const [prevAllLogs, setPrevAllLogs] = useState(allLogs);
|
|
if (prevAllLogs !== allLogs) {
|
|
setPrevAllLogs(allLogs);
|
|
setActiveIndex(0);
|
|
}
|
|
|
|
// Reset scroll tracking when active version changes
|
|
const activeVersionKey = versions[activeIndex] ? `${versions[activeIndex].version}-${versions[activeIndex].processId}` : null;
|
|
useEffect(() => {
|
|
initialScrollDone.current = false;
|
|
}, [activeVersionKey]);
|
|
|
|
// Refresh active version logs when using auto-refresh
|
|
const activeVersion = versions[activeIndex];
|
|
const { data: versionLogs } = useQuery<LogRecord[]>({
|
|
queryKey: activeVersion
|
|
? QueryKeys.logsVersion(packageBase, repo, activeVersion.version, activeVersion.processId)
|
|
: ["logs-none"],
|
|
queryFn: activeVersion
|
|
? () => Client.fetchLogs(packageBase, repo, activeVersion.version, activeVersion.processId)
|
|
: skipToken,
|
|
enabled: !!activeVersion && !!refetchInterval,
|
|
refetchInterval,
|
|
});
|
|
|
|
// Derive displayed logs: prefer fresh polled data when available
|
|
const displayedLogs = useMemo(() => {
|
|
if (versionLogs && versionLogs.length > 0) {
|
|
return convertLogs(versionLogs);
|
|
}
|
|
return activeVersion?.logs ?? "";
|
|
}, [versionLogs, activeVersion]);
|
|
|
|
// Highlight code
|
|
useEffect(() => {
|
|
if (codeRef.current && displayedLogs) {
|
|
codeRef.current.textContent = displayedLogs;
|
|
delete codeRef.current.dataset.highlighted;
|
|
hljs.highlightElement(codeRef.current);
|
|
}
|
|
}, [displayedLogs]);
|
|
|
|
// Auto-scroll: always scroll to bottom on initial load, then only if already near bottom
|
|
// and the user has no active text selection (to avoid disrupting copy workflows).
|
|
// wasAtBottom is tracked via onScroll so it reflects position *before* new content arrives.
|
|
useEffect(() => {
|
|
if (preRef.current && displayedLogs) {
|
|
const el = preRef.current;
|
|
if (!initialScrollDone.current) {
|
|
el.scrollTop = el.scrollHeight;
|
|
initialScrollDone.current = true;
|
|
} else {
|
|
const hasSelection = !document.getSelection()?.isCollapsed;
|
|
if (wasAtBottom.current && !hasSelection) {
|
|
el.scrollTop = el.scrollHeight;
|
|
}
|
|
}
|
|
}
|
|
}, [displayedLogs]);
|
|
|
|
return (
|
|
<Box sx={{ display: "flex", gap: 1, mt: 1 }}>
|
|
<Box>
|
|
<Button
|
|
size="small"
|
|
startIcon={<ListIcon />}
|
|
onClick={(e) => setAnchorEl(e.currentTarget)}
|
|
/>
|
|
<Menu
|
|
anchorEl={anchorEl}
|
|
open={Boolean(anchorEl)}
|
|
onClose={() => setAnchorEl(null)}
|
|
>
|
|
{versions.map((v, idx) => (
|
|
<MenuItem
|
|
key={`${v.version}-${v.processId}`}
|
|
selected={idx === activeIndex}
|
|
onClick={() => {
|
|
setActiveIndex(idx);
|
|
setAnchorEl(null);
|
|
initialScrollDone.current = false;
|
|
}}
|
|
>
|
|
<Typography variant="body2">{formatTimestamp(v.created)}</Typography>
|
|
</MenuItem>
|
|
))}
|
|
{versions.length === 0 && (
|
|
<MenuItem disabled>No logs available</MenuItem>
|
|
)}
|
|
</Menu>
|
|
</Box>
|
|
|
|
<Box sx={{ flex: 1, position: "relative" }}>
|
|
<Box
|
|
ref={preRef}
|
|
component="pre"
|
|
onScroll={handleScroll}
|
|
sx={{
|
|
backgroundColor: "grey.100",
|
|
p: 2,
|
|
borderRadius: 1,
|
|
overflow: "auto",
|
|
height: 400,
|
|
fontSize: "0.8rem",
|
|
fontFamily: "monospace",
|
|
whiteSpace: "pre-wrap",
|
|
wordBreak: "break-all",
|
|
}}
|
|
>
|
|
<code ref={codeRef} className="language-plaintext" />
|
|
</Box>
|
|
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
|
|
<CopyButton getText={() => displayedLogs} />
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|