diff --git a/docs/configuration.md b/docs/configuration.md index fb0448bc..009f54bd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -48,6 +48,16 @@ Must be greater than 0. Defaults to 60. +### controller.log_retention_days + +Number of days to keep the controller and cluster log files in the log directory for. +Log files are named by the UTC date they were written and a file is deleted once its date is more than this many days in the past. +Old files are checked for on startup and every hour after that. +Set to 0 to keep logs forever. + +Defaults to 30. + + ### controller.host_pairing_timeout Time in seconds a host can wait for approval before its request expires, after which the host requests again with a new code. @@ -449,6 +459,15 @@ Must be greater than 0. Defaults to 60. +### host.log_retention_days + +Number of days to keep the host log files in the log directory for. +Works the same way as controller.log_retention_days. +Set to 0 to keep logs forever. + +Defaults to 30. + + ### host.allow_remote_updates Whether the `host update` command in clusterioctl and the update button in the web interface are allowed to update the `@clusterio/host` package with npm. diff --git a/packages/controller/controller.ts b/packages/controller/controller.ts index 29334ed4..cd1364a1 100644 --- a/packages/controller/controller.ts +++ b/packages/controller/controller.ts @@ -492,6 +492,11 @@ async function startup() { controller.stop(); }); + lib.startLogRetention( + [path.join(args.logDirectory, "cluster"), path.join(args.logDirectory, "controller")], + () => controllerConfig.get("controller.log_retention_days"), + ); + await controller.start(args); } diff --git a/packages/controller/src/WsServer.ts b/packages/controller/src/WsServer.ts index 7fcc85b0..3db50248 100644 --- a/packages/controller/src/WsServer.ts +++ b/packages/controller/src/WsServer.ts @@ -30,6 +30,42 @@ const wsRejectedConnectionsCounter = new lib.Counter( "How many WebSocket connections have been rejected during the handshake on the controller" ); +const wsReadyCounter = new lib.Counter( + "clusterio_controller_websocket_ready_total", + "How many WebSocket sessions have been established on the controller", + { labels: ["type"] } +); + +const wsResumeCounter = new lib.Counter( + "clusterio_controller_websocket_resume_total", + "How many WebSocket sessions have been resumed on the controller", + { labels: ["type"] } +); + +const wsDropCounter = new lib.Counter( + "clusterio_controller_websocket_drop_total", + "How many WebSocket sessions have lost their connection without closing on the controller", + { labels: ["type"] } +); + +const wsSessionTimeoutCounter = new lib.Counter( + "clusterio_controller_websocket_session_timeout_total", + "How many WebSocket sessions have timed out waiting for a resume on the controller", + { labels: ["type"] } +); + +const wsFailedResumeCounter = new lib.Counter( + "clusterio_controller_websocket_failed_resume_total", + "How many WebSocket resume attempts have been rejected due to an unknown or expired session on the controller" +); + +for (const type of ["host", "control"]) { + wsReadyCounter.labels(type); + wsResumeCounter.labels(type); + wsDropCounter.labels(type); + wsSessionTimeoutCounter.labels(type); +} + /** * WebSocket server * @alias module:controller/src/WsServer @@ -240,6 +276,12 @@ ${err.stack}` this.activeConnectors.delete(sessionId); }); + const type = dst.type === lib.Address.host ? "host" : "control"; + connector.on("connect", () => wsReadyCounter.labels(type).inc()); + connector.on("resume", () => wsResumeCounter.labels(type).inc()); + connector.on("drop", () => wsDropCounter.labels(type).inc()); + connector.on("timeout", () => wsSessionTimeoutCounter.labels(type).inc()); + return [connector, sessionToken]; } @@ -367,6 +409,7 @@ ${err.stack}` } } catch (err) { + wsFailedResumeCounter.inc(); socket.send(JSON.stringify(new lib.MessageInvalidate())); this.attachHandler(socket, req); return; diff --git a/packages/controller/src/WsServerConnector.ts b/packages/controller/src/WsServerConnector.ts index 3793a651..7b8fcff6 100644 --- a/packages/controller/src/WsServerConnector.ts +++ b/packages/controller/src/WsServerConnector.ts @@ -7,13 +7,17 @@ import * as lib from "@clusterio/lib"; import { logger } from "@clusterio/lib"; +type WsServerConnectorEvents = { + "timeout": [], +}; + /** * Connector for controller connections * * @extends module:lib.WebSocketBaseConnector * @alias module:controller/src/WsServerConnector */ -export default class WsServerConnector extends lib.WebSocketBaseConnector { +export default class WsServerConnector extends lib.WebSocketBaseConnector { _timeoutId: ReturnType | null = null; constructor( @@ -119,6 +123,7 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector { _timedOut() { logger.verbose(`Connector | Connection to ${this.dst} timed out`); + this.emit("timeout"); this._close(); } diff --git a/packages/create/create.js b/packages/create/create.js index d5d91505..512dc389 100755 --- a/packages/create/create.js +++ b/packages/create/create.js @@ -46,6 +46,10 @@ const factorioLocations = { ], darwin: [ "/Applications/factorio.app/Contents", + path.join( + os.homedir(), "Library", "Application Support", "Steam", "steamapps", "common", "Factorio", + "factorio.app", "Contents", + ), ], }; diff --git a/packages/host/host.ts b/packages/host/host.ts index a5302b70..a250ab06 100755 --- a/packages/host/host.ts +++ b/packages/host/host.ts @@ -210,6 +210,11 @@ async function startHost() { // From this point on the host should run await hostConfigLock.acquire(); + lib.startLogRetention( + [path.join(args.logDirectory, "host")], + () => hostConfig.get("host.log_retention_days"), + ); + // If we get here the command was run await fs.mkdir(hostConfig.get("host.instances_directory"), { recursive: true }); await fs.mkdir(hostConfig.get("host.mods_directory"), { recursive: true }); diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index e71007b6..86dd1b84 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -38,6 +38,7 @@ export interface ControllerConfigFields { "controller.default_mod_pack_id": number | null; "controller.default_role_id": number | null; "controller.autosave_interval": number; + "controller.log_retention_days": number; "controller.host_pairing_timeout": number; "controller.host_pairing_max_pending": number; "controller.mod_portal_cache_duration_minutes": number; @@ -101,6 +102,13 @@ export class ControllerConfig extends classes.Config { initialValue: 60, validator: validators.greaterThanZero, }, + "controller.log_retention_days": { + title: "Log Retention Days", + description: "Days to keep controller and cluster log files for, 0 to keep them forever.", + type: "number", + initialValue: 30, + validator: validators.all(validators.integer, validators.greaterThanEqualZero), + }, "controller.host_pairing_timeout": { title: "Host Pairing Timeout", description: "Time in seconds a host can wait for approval before it has to request again, 0 for no limit.", @@ -357,6 +365,7 @@ export interface HostConfigFields { "host.factorio_username": string | null, "host.factorio_token": string | null, "host.max_reconnect_delay": number; + "host.log_retention_days": number; "host.allow_remote_updates": boolean; "host.allow_plugin_updates": boolean; "host.allow_plugin_install": boolean; @@ -466,6 +475,13 @@ export class HostConfig extends classes.Config { initialValue: 60, validator: validators.greaterThanZero, }, + "host.log_retention_days": { + title: "Log Retention Days", + description: "Days to keep host log files for, 0 to keep them forever.", + type: "number", + initialValue: 30, + validator: validators.all(validators.integer, validators.greaterThanEqualZero), + }, "host.allow_remote_updates": { description: "When true, allows a remote event to trigger a clusterio update via npm", type: "boolean", diff --git a/packages/lib/src/logging_utils.ts b/packages/lib/src/logging_utils.ts index ab235182..ea062c96 100644 --- a/packages/lib/src/logging_utils.ts +++ b/packages/lib/src/logging_utils.ts @@ -358,8 +358,15 @@ export class LogIndex { */ async buildIndex() { let today = new Date(Date.now() - 300e3).toISOString().slice(0, 10); + let files = await fs.readdir(this.logDirectory); - for (let file of await fs.readdir(this.logDirectory)) { + for (let file of this.index.keys()) { + if (!files.includes(file)) { + this.index.delete(file); + } + } + + for (let file of files) { let match = logFileGlob.exec(file); if (!match || match[1] >= today || this.index.has(file)) { continue; @@ -412,6 +419,80 @@ export class LogIndex { } } +/** + * Delete dated log files older than the given number of days + * + * Only files named like name-YYYY-MM-DD.log are deleted, and only once + * every entry they can hold is older than retentionDays. + * + * @param logDirectory - Directory to delete log files from. + * @param retentionDays - Days to keep log files for, 0 keeps them forever. + * @param now - Current time in milliseconds since the epoch. + * @returns names of the deleted files. + */ +export async function deleteOldLogs(logDirectory: string, retentionDays: number, now = Date.now()) { + if (!(retentionDays > 0)) { + return []; + } + + let files: string[]; + try { + files = await fs.readdir(logDirectory); + } catch (err: any) { + if (err.code === "ENOENT") { + return []; + } + throw err; + } + + const cutoff = new Date(now - retentionDays * 86400e3).toISOString().slice(0, 10); + const deleted: string[] = []; + for (const file of files) { + const match = logFileGlob.exec(file); + if (!match || match[1] >= cutoff) { + continue; + } + try { + await fs.unlink(path.join(logDirectory, file)); + } catch (err: any) { + if (err.code !== "ENOENT") { + throw err; + } + } + deleted.push(file); + } + return deleted; +} + +/** + * Delete old logs now and then every hour + * + * @param logDirectories - Directories to delete old log files from. + * @param getRetentionDays - Called before each run to get the retention. + * @returns the interval timer, which does not keep the process alive. + */ +export function startLogRetention(logDirectories: string[], getRetentionDays: () => number) { + async function run() { + const retentionDays = getRetentionDays(); + for (const logDirectory of logDirectories) { + try { + const deleted = await deleteOldLogs(logDirectory, retentionDays); + if (deleted.length) { + logger.info( + `Deleted ${deleted.length} log files older than ${retentionDays} days from ${logDirectory}` + ); + } + } catch (err: any) { + logger.error(`Failed to delete old logs from ${logDirectory}:\n${err.stack}`); + } + } + } + run(); + const interval = setInterval(run, 3600e3); + interval.unref(); + return interval; +} + /** * Filter object for querying logs */ diff --git a/packages/lib/src/subscriptions.ts b/packages/lib/src/subscriptions.ts index e5deea6c..b69bc2cf 100644 --- a/packages/lib/src/subscriptions.ts +++ b/packages/lib/src/subscriptions.ts @@ -460,6 +460,39 @@ export interface SubscribableValue { * Multiple handlers can be subscribed at the same time */ export class EventSubscriber { + /** Subscribers created on each connector, they share one connect and one close listener on it */ + private static connectorSubscribers = new WeakMap>>(); + + /** + * Pass connect and close events of a connector on to a subscriber + * Uses one listener for all subscribers, so that plugins creating many subscribers do not go past the + * listener limit set on the connector. + * @param connector - connector of the link the subscriber was created on + * @param subscriber - subscriber to pass the events to + */ + private static addConnectorSubscriber( + connector: WebSocketBaseConnector, + subscriber: EventSubscriber, + ) { + let subscribers = EventSubscriber.connectorSubscribers.get(connector); + if (!subscribers) { + const created = new Set>(); + EventSubscriber.connectorSubscribers.set(connector, created); + connector.on("connect", () => { + for (const entry of created) { + entry.handleConnectionEvent("connect"); + } + }); + connector.on("close", () => { + for (const entry of created) { + entry.handleConnectionEvent("close"); + } + }); + subscribers = created; + } + subscribers.add(subscriber); + } + /** The time at which an event was last received, is less than 0 when there have been no events */ lastUpdatedMs = -1; /** True if this subscriber is currently synced with the source */ @@ -485,13 +518,7 @@ export class EventSubscriber { ) { control.handle(Event, this._handleEvent.bind(this)); // The events below exist only on websocket connectors, we can't early return because of test mocking - const webSocketConnector = control.connector as WebSocketBaseConnector; - webSocketConnector.on("connect", () => { - this.handleConnectionEvent("connect"); - }); - webSocketConnector.on("close", () => { - this.handleConnectionEvent("close"); - }); + EventSubscriber.addConnectorSubscriber(control.connector as WebSocketBaseConnector, this); // Only the web Control reports account changes const accountControl = control as Link & { onAccountUpdate?: (handler: () => void) => void }; if (typeof accountControl.onAccountUpdate === "function") { diff --git a/packages/web_ui/src/components/InstanceList.tsx b/packages/web_ui/src/components/InstanceList.tsx index bd54f153..0dc5baee 100644 --- a/packages/web_ui/src/components/InstanceList.tsx +++ b/packages/web_ui/src/components/InstanceList.tsx @@ -7,6 +7,7 @@ import type { ColumnType } from "antd/es/table/interface"; import { useAccount } from "../model/account"; import { useSystems } from "../model/system"; import { useHosts } from "../model/host"; +import { useUsers } from "../model/user"; import { useDefaultModPack, useModPacks } from "../model/mod_pack"; import { RestartRequired } from "./system_metrics"; import InstanceStatusTag from "./InstanceStatusTag"; @@ -33,6 +34,17 @@ export default function InstanceList(props: InstanceListProps) { const [systems] = useSystems(); const [modPacks] = useModPacks(); const defaultModPack = useDefaultModPack(); + const [users] = useUsers(); + const canUsers = account.hasPermission("core.user.subscribe"); + const onlineCounts = React.useMemo(() => { + const counts = new Map(); + for (const user of users.values()) { + for (const instanceId of user.instances ?? []) { + counts.set(instanceId, (counts.get(instanceId) ?? 0) + 1); + } + } + return counts; + }, [users]); const tableState = useTableQueryState({ namespace: "instance", defaultSortKey: "name", }); @@ -146,6 +158,18 @@ export default function InstanceList(props: InstanceListProps) { }, ]; + if (canUsers) { + const playerCount = (instance: lib.InstanceDetails) => onlineCounts.get(instance.id) ?? 0; + columns.push({ + title: "Players", + key: "players", + render: (_, instance) => playerCount(instance), + sorter: (a, b) => playerCount(a) - playerCount(b), + sortOrder: tableState.sortOrder("players"), + responsive: ["md"], + }); + } + if (account.hasAnyPermission(...InstanceControlButtonPermissions)) { columns.push({ key: "action", diff --git a/packages/web_ui/src/components/ModPackViewPage.tsx b/packages/web_ui/src/components/ModPackViewPage.tsx index 3bd8558f..99cad1d0 100644 --- a/packages/web_ui/src/components/ModPackViewPage.tsx +++ b/packages/web_ui/src/components/ModPackViewPage.tsx @@ -182,6 +182,7 @@ function SearchModsTable(props: SearchModsTableProps) { return <> { diff --git a/packages/web_ui/src/components/ModsPage.tsx b/packages/web_ui/src/components/ModsPage.tsx index 9f22a1a1..731a90a0 100644 --- a/packages/web_ui/src/components/ModsPage.tsx +++ b/packages/web_ui/src/components/ModsPage.tsx @@ -1,7 +1,8 @@ -import React, { Fragment, useState, useContext, useEffect, useMemo } from "react"; +import React, { Fragment, useState, useContext, useEffect, useMemo, useRef } from "react"; import { useNavigate } from "react-router-dom"; import { - Button, Form, Input, Modal, Popconfirm, Space, Table, Typography, Upload, Alert, Select, Tooltip, notification, + Button, Form, Input, InputRef, Modal, Popconfirm, Space, Table, Typography, Upload, Alert, Select, Tooltip, + notification, } from "antd"; import { ImportOutlined, PlusOutlined, SearchOutlined, DownloadOutlined, @@ -147,6 +148,7 @@ function SearchModsButton() { const account = useAccount(); const [open, setOpen] = useState(false); const [form] = Form.useForm(); + const nameInputRef = useRef(null); const [searchText, setSearchText] = useState(""); const [factorioVersion, setFactorioVersion] = useState("2.0"); @@ -333,6 +335,7 @@ function SearchModsButton() { title="Search Mod Portal" open={open} onCancel={() => { setOpen(false); }} + afterOpenChange={visible => { if (visible) { nameInputRef.current?.focus(); } }} width={1000} footer={[