mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-14 15:24:52 +09:00
Add a LogEntry type for log entries sent over links and returned by log queries. It names the fields every entry has (level, message, timestamp) and leaves the rest as unknown. The console now prefixes each line with the entry's time in the browser's local time zone, which makes it possible to tell when an error happened after the fact. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
235 lines
7.3 KiB
TypeScript
235 lines
7.3 KiB
TypeScript
import React, { useEffect, useContext, useRef, useState, ReactElement } from "react";
|
|
import { Select, Typography } from "antd";
|
|
|
|
import * as lib from "@clusterio/lib";
|
|
|
|
import { useAccount } from "../model/account";
|
|
import useLocalStorage from "../util/useLocalStorage";
|
|
import ControlContext from "./ControlContext";
|
|
|
|
const { Paragraph } = Typography;
|
|
|
|
const consoleHeightKey = "instance-console-height";
|
|
const defaultConsoleHeight = 300;
|
|
|
|
type Parsed = {
|
|
format: string;
|
|
type: string;
|
|
level: string;
|
|
time: string;
|
|
file: string;
|
|
action: string;
|
|
message: string;
|
|
};
|
|
|
|
function formatParsedOutput(parsed: Parsed) {
|
|
let time: ReactElement|string = "";
|
|
if (parsed.format === "seconds") {
|
|
time = <span className="factorio-time">{parsed.time.padStart(8)} </span>;
|
|
} else if (parsed.format === "date") {
|
|
time = <span className="factorio-time">{parsed.time} </span>;
|
|
}
|
|
|
|
let info: ReactElement|string = "";
|
|
if (parsed.type === "log") {
|
|
let level: ReactElement|string = parsed.level;
|
|
if (level === "Script") {
|
|
level = <span className="factorio-script">{level}</span>;
|
|
} else if (level === "Verbose") {
|
|
level = <span className="factorio-verbose">{level}</span>;
|
|
} else if (level === "Info") {
|
|
level = <span className="factorio-info">{level}</span>;
|
|
} else if (parsed.level === "Warning") {
|
|
level = <span className="factorio-warning">{level}</span>;
|
|
} else if (parsed.level === "Error") {
|
|
level = <span className="factorio-error">{level}</span>;
|
|
}
|
|
|
|
info = <>{level} <span className="factorio-filename">{parsed.file}: </span></>;
|
|
|
|
} else if (parsed.type === "action") {
|
|
info = <>[<span className="factorio-action">{parsed.action}</span>] </>;
|
|
}
|
|
|
|
return <>{time}{info}{parsed.message}</>;
|
|
}
|
|
|
|
type Info = lib.LogEntry & {
|
|
parsed?: Parsed;
|
|
};
|
|
|
|
/**
|
|
* Format an ISO 8601 timestamp as local YYYY-MM-DD HH:MM:SS
|
|
*/
|
|
function formatTime(timestamp: string) {
|
|
const date = new Date(timestamp);
|
|
if (Number.isNaN(date.getTime())) {
|
|
return timestamp;
|
|
}
|
|
const pad = (value: number) => String(value).padStart(2, "0");
|
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
|
|
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
}
|
|
|
|
function formatLog(info: Info, key: number): ReactElement {
|
|
let time = <span className="log-time">{formatTime(info.timestamp)}</span>;
|
|
let level = <span className={`log-${info.level}`}>{info.level}</span>;
|
|
let message = info.level === "server" && info.parsed ? formatParsedOutput(info.parsed) : info.message;
|
|
return <span key={key}>{time} [{level}] {message}<br/></span>;
|
|
}
|
|
|
|
/**
|
|
* Test if a log entry survives the console's actions only filter
|
|
*/
|
|
function passesActionsOnly(info: Info) {
|
|
// Clusterio's own entries are bookkeeping, only keep those reporting a problem.
|
|
if (info.level !== "server") {
|
|
return lib.levels[info.level] <= lib.levels.warn;
|
|
}
|
|
|
|
// Factorio's seconds stamped engine log is verbose diagnostics, only keep warnings and errors.
|
|
if (info.parsed?.format === "seconds") {
|
|
return info.parsed.level === "Warning" || info.parsed.level === "Error";
|
|
}
|
|
|
|
// What is left is the date stamped console log: the actions done to the server and the replies to them.
|
|
return true;
|
|
}
|
|
|
|
type LogConsoleProps = {
|
|
all?: boolean;
|
|
controller?: boolean;
|
|
hosts?: number[];
|
|
instances?: number[];
|
|
maxLevel?: keyof typeof lib.levels;
|
|
actionsOnly?: boolean;
|
|
};
|
|
|
|
export function SelectMaxLogLevel(props: {
|
|
value: keyof typeof lib.levels,
|
|
hidden?: (keyof typeof lib.levels)[],
|
|
onChange: (option: keyof typeof lib.levels) => void
|
|
}) {
|
|
return <Select
|
|
showSearch
|
|
style={{ width: 100 }}
|
|
defaultValue={props.value}
|
|
onChange={props.onChange}
|
|
options={(Object.entries(lib.levels) as [keyof typeof lib.levels, number][])
|
|
.filter(([level]) => !props.hidden || !props.hidden.includes(level))
|
|
.map(([level, index]) => ({
|
|
value: level, label: level.charAt(0).toUpperCase() + level.slice(1), index: index,
|
|
}))
|
|
.sort((a, b) => a.index - b.index)
|
|
}
|
|
/>;
|
|
}
|
|
|
|
export default function LogConsole(props: LogConsoleProps) {
|
|
let account = useAccount();
|
|
let control = useContext(ControlContext);
|
|
let anchor = useRef<any>();
|
|
let [pastLines, setPastLines] = useState([<span key={0}>{"Loading past entries..."}<br/></span>]);
|
|
let [lines, setLines] = useState<ReactElement[]>([]);
|
|
const [consoleHeight, setConsoleHeight] = useLocalStorage(consoleHeightKey, defaultConsoleHeight);
|
|
const consoleHeightRef = useRef(consoleHeight);
|
|
consoleHeightRef.current = consoleHeight;
|
|
|
|
// The console is resized with the browser's native resize handle, which owns
|
|
// the element's inline height. Driving the height from React would rewrite
|
|
// that inline style on every re-render and fight the drag (locking the size),
|
|
// so the height is only read here (on mount) and persisted (on resize), never
|
|
// written back through React. The scroll container is the <code> element
|
|
// wrapping the anchor.
|
|
useEffect(() => {
|
|
const element = anchor.current?.parentElement as HTMLElement | undefined;
|
|
if (!element) {
|
|
return undefined;
|
|
}
|
|
element.style.height = `${consoleHeightRef.current}px`;
|
|
|
|
if (typeof ResizeObserver === "undefined") {
|
|
return undefined;
|
|
}
|
|
const observer = new ResizeObserver(() => {
|
|
const next = Math.round(element.offsetHeight);
|
|
if (next && next !== consoleHeightRef.current) {
|
|
consoleHeightRef.current = next;
|
|
setConsoleHeight(next);
|
|
}
|
|
});
|
|
observer.observe(element);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let logFilter = {
|
|
all: props.all || false,
|
|
controller: props.controller || false,
|
|
hostIds: props.hosts || [],
|
|
instanceIds: props.instances || [],
|
|
maxLevel: props.maxLevel || undefined,
|
|
};
|
|
|
|
// Scroll view to the anchor so it sticks to the bottom
|
|
let parent = anchor.current.parentElement;
|
|
parent.scrollTop = parent.scrollHeight - parent.clientHeight;
|
|
|
|
if (account.hasPermission("core.log.query")) {
|
|
setPastLines([<span key={0}>{"Loading past entries..."}<br/></span>]);
|
|
control.send(new lib.LogQueryRequest(
|
|
logFilter.all,
|
|
logFilter.controller,
|
|
logFilter.hostIds,
|
|
logFilter.instanceIds,
|
|
logFilter.maxLevel,
|
|
400,
|
|
"desc",
|
|
)).then(result => {
|
|
setPastLines(result.log
|
|
.filter(info => !props.actionsOnly || passesActionsOnly(info as Info))
|
|
.map((info, index) => formatLog(info as Info, -index - 1))
|
|
.reverse()
|
|
);
|
|
}).catch(err => {
|
|
setPastLines([<span key={0}>{`Error loading log: ${err.message}`}<br/></span>]);
|
|
});
|
|
} else {
|
|
setPastLines([]);
|
|
}
|
|
|
|
function logHandler(info: lib.LogEntry) {
|
|
if (!props.actionsOnly || passesActionsOnly(info as Info)) {
|
|
setLines(currentLines => currentLines.concat(
|
|
[formatLog(info as Info, currentLines.length)]
|
|
));
|
|
}
|
|
}
|
|
|
|
setLines([]);
|
|
control.onLog(logFilter, logHandler);
|
|
return () => {
|
|
control.offLog(logFilter, logHandler);
|
|
};
|
|
}, [
|
|
props.all,
|
|
props.controller,
|
|
(props.hosts || []).join(),
|
|
(props.instances || []).join(),
|
|
props.maxLevel,
|
|
props.actionsOnly,
|
|
]);
|
|
|
|
return <>
|
|
<Paragraph code className="instance-console">
|
|
{/* Scrollable spacer (one console-height tall) that keeps short output
|
|
pinned to the bottom and leaves room to scroll up. Unlike a
|
|
padding-top it is scroll content, so it does not constrain resize. */}
|
|
<div className="console-spacer" key="spacer" />
|
|
{pastLines}
|
|
{lines}
|
|
<div className="scroll-anchor" key="anchor" ref={anchor} />
|
|
</Paragraph>
|
|
</>;
|
|
}
|