From b47457363334750b67b1ae40274508798f73e45d Mon Sep 17 00:00:00 2001 From: Bastiaan <17990055+bbassie@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:57:16 +0100 Subject: [PATCH] Show timestamps in the web UI log console (#1137) 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 --- packages/lib/src/data/messages_controller.ts | 19 ++++++++------ packages/lib/src/logging.ts | 12 +++++++++ packages/web_ui/src/components/LogConsole.tsx | 26 ++++++++++++++----- packages/web_ui/src/style.css | 4 +++ packages/web_ui/src/util/websocket.ts | 2 +- 5 files changed, 47 insertions(+), 16 deletions(-) diff --git a/packages/lib/src/data/messages_controller.ts b/packages/lib/src/data/messages_controller.ts index 732ff875..3c0cdddd 100644 --- a/packages/lib/src/data/messages_controller.ts +++ b/packages/lib/src/data/messages_controller.ts @@ -1,9 +1,15 @@ import { Type, Static } from "@sinclair/typebox"; import { JsonString, StringEnum, StringKey, plainJson } from "./composites.js"; -import { levels } from "../logging.js"; +import { type LogEntry, levels } from "../logging.js"; import { ControllerConfig, HostConfig } from "../config/index.js"; import { ExternalFactorioVersionSchema, LatestReleasesSchema } from "../external/index.js"; +const LogEntrySchema = Type.Object({ + "level": StringKey(levels), + "message": Type.String(), + "timestamp": Type.String(), +}); + export class ControllerStopRequest { declare ["constructor"]: typeof ControllerStopRequest; static type = "request" as const; @@ -236,11 +242,11 @@ export class LogQueryRequest { static Response = class Response { constructor( - public log: object[], + public log: LogEntry[], ) { } static jsonSchema = Type.Object({ - "log": Type.Array(Type.Object({})), + "log": Type.Array(LogEntrySchema), }); static fromJSON(json: Static) { @@ -256,14 +262,11 @@ export class LogMessageEvent { static dst = ["controller", "control"] as const; constructor( - public info: { level: keyof typeof levels, message: string }, + public info: LogEntry, ) { } static jsonSchema = Type.Object({ - "info": Type.Object({ - "level": StringKey(levels), - "message": Type.String(), - }), + "info": LogEntrySchema, }); static fromJSON(json: Static) { diff --git a/packages/lib/src/logging.ts b/packages/lib/src/logging.ts index 275693ec..473888fd 100644 --- a/packages/lib/src/logging.ts +++ b/packages/lib/src/logging.ts @@ -93,6 +93,18 @@ export class WebConsoleFormat { } } +/** + * Log entry as produced by the logger and sent over links + */ +export interface LogEntry { + level: keyof typeof levels; + message: string; + /** ISO 8601 time the entry was logged at. */ + timestamp: string; + /** Other fields like host_id, instance_id, plugin and parsed vary by source. */ + [key: string]: unknown; +} + /** * Filter object for logs */ diff --git a/packages/web_ui/src/components/LogConsole.tsx b/packages/web_ui/src/components/LogConsole.tsx index 0a201f0c..8b12247f 100644 --- a/packages/web_ui/src/components/LogConsole.tsx +++ b/packages/web_ui/src/components/LogConsole.tsx @@ -54,16 +54,28 @@ function formatParsedOutput(parsed: Parsed) { return <>{time}{info}{parsed.message}; } -type Info = { - level: keyof typeof lib.levels; - message: string; +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 = {formatTime(info.timestamp)}; let level = {info.level}; let message = info.level === "server" && info.parsed ? formatParsedOutput(info.parsed) : info.message; - return [{level}] {message}
; + return {time} [{level}] {message}
; } /** @@ -186,10 +198,10 @@ export default function LogConsole(props: LogConsoleProps) { setPastLines([]); } - function logHandler(info: Info) { - if (!props.actionsOnly || passesActionsOnly(info)) { + function logHandler(info: lib.LogEntry) { + if (!props.actionsOnly || passesActionsOnly(info as Info)) { setLines(currentLines => currentLines.concat( - [formatLog(info, currentLines.length)] + [formatLog(info as Info, currentLines.length)] )); } } diff --git a/packages/web_ui/src/style.css b/packages/web_ui/src/style.css index 5553ceff..b478b8f7 100644 --- a/packages/web_ui/src/style.css +++ b/packages/web_ui/src/style.css @@ -195,6 +195,10 @@ body { white-space: pre-wrap; } +.instance-console .log-time { + color: #808080; +} + .instance-console .factorio-time { color: #afaf1f; } diff --git a/packages/web_ui/src/util/websocket.ts b/packages/web_ui/src/util/websocket.ts index b98f0a72..3e51511c 100644 --- a/packages/web_ui/src/util/websocket.ts +++ b/packages/web_ui/src/util/websocket.ts @@ -11,7 +11,7 @@ type saveListHandler = (saveListEvent: lib.SaveDetails) => void; type modPackHandler = (modPack: lib.ModPack) => void; type modInfoHandler = (modInfo: lib.ModInfo) => void; type userHandler = (rawUser: lib.UserDetails) => void; -type logHandler = (info: { level: keyof typeof lib.levels, message: string }) => void; +type logHandler = (info: lib.LogEntry) => void; /** * Connector for control connection to controller