mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-14 07:24:52 +09:00
Merge branch 'master' into fix/stream-reject-closes-connection
This commit is contained in:
21 files changed
+465
-31
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<WsServerConnectorEvents> {
|
||||
_timeoutId: ReturnType<typeof setTimeout> | 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<ControllerConfigFields> {
|
||||
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<HostConfigFields> {
|
||||
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",
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -460,6 +460,39 @@ export interface SubscribableValue {
|
||||
* Multiple handlers can be subscribed at the same time
|
||||
*/
|
||||
export class EventSubscriber<E, S = null> {
|
||||
/** Subscribers created on each connector, they share one connect and one close listener on it */
|
||||
private static connectorSubscribers = new WeakMap<object, Set<EventSubscriber<any, any>>>();
|
||||
|
||||
/**
|
||||
* 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<any, any>,
|
||||
) {
|
||||
let subscribers = EventSubscriber.connectorSubscribers.get(connector);
|
||||
if (!subscribers) {
|
||||
const created = new Set<EventSubscriber<any, any>>();
|
||||
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<E, S = null> {
|
||||
) {
|
||||
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") {
|
||||
|
||||
@@ -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<number, number>();
|
||||
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<lib.InstanceDetails>({
|
||||
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",
|
||||
|
||||
@@ -182,6 +182,7 @@ function SearchModsTable(props: SearchModsTableProps) {
|
||||
|
||||
return <>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Search Mods"
|
||||
style={{ marginBottom: 8 }}
|
||||
onChange={(event) => {
|
||||
|
||||
@@ -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<InputRef>(null);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [factorioVersion, setFactorioVersion] = useState<lib.MajorMinorVersion>("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={[
|
||||
<Button key="close" onClick={() => { setOpen(false); }}>
|
||||
@@ -349,7 +352,7 @@ function SearchModsButton() {
|
||||
initialValues={{ factorioVersion: factorioVersion, name: searchText }}
|
||||
>
|
||||
<Form.Item name="name" label="Filter by Name or Title">
|
||||
<Input placeholder="Start typing to filter..." />
|
||||
<Input ref={nameInputRef} placeholder="Start typing to filter..." />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="factorioVersion"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Collapse } from "antd";
|
||||
|
||||
import { useUsers } from "../model/user";
|
||||
import UsersTable from "./UsersTable";
|
||||
@@ -9,23 +10,26 @@ type OnlinePlayersListProps = {
|
||||
|
||||
export default function OnlinePlayersList({ instanceId }: OnlinePlayersListProps) {
|
||||
const [users] = useUsers();
|
||||
// undefined until the user toggles the panel, then their choice sticks.
|
||||
const [open, setOpen] = useState<boolean>();
|
||||
|
||||
// Filter users to only show those online on this specific instance
|
||||
const onlineUsers = [...users.values()].filter(user => user.instances && user.instances.has(instanceId));
|
||||
const onlineCount = [...users.values()].filter(user => user.instances?.has(instanceId)).length;
|
||||
const expanded = open ?? onlineCount > 0;
|
||||
|
||||
if (onlineUsers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<h4>Online Players ({onlineUsers.length})</h4>
|
||||
<UsersTable instanceId={instanceId} onlyOnline size="small" pagination={{
|
||||
return <Collapse
|
||||
size="small"
|
||||
style={{ marginTop: 16 }}
|
||||
activeKey={expanded ? ["players"] : []}
|
||||
onChange={keys => setOpen(keys.length > 0)}
|
||||
items={[{
|
||||
key: "players",
|
||||
label: `Online Players (${onlineCount})`,
|
||||
children: <UsersTable instanceId={instanceId} onlyOnline size="small" pagination={{
|
||||
defaultPageSize: 10,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ["10", "20", "50", "100", "200"],
|
||||
showTotal: (total: number) => `${total} Users`,
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
}} />,
|
||||
}]}
|
||||
/>;
|
||||
}
|
||||
@@ -170,7 +170,7 @@ Defaults to `true`.
|
||||
|
||||
### player_sync.sync_quick_bar
|
||||
|
||||
Sync the quick bar.
|
||||
Sync the quick bar and the page shown on each of its rows.
|
||||
|
||||
Defaults to `true`.
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ export const components = {
|
||||
// inventories holds the god inventory and the hidden ghost inventory
|
||||
inventories: ["character", "crafting_queue", "inventories"],
|
||||
logistics: ["personal_logistic_slots"],
|
||||
quick_bar: ["quick_bar", "hotbar"],
|
||||
quick_bar: ["quick_bar", "quick_bar_pages", "hotbar"],
|
||||
settings: ["flashlight", "shortcuts", "game_view_settings"],
|
||||
recipe_notifications: ["recipe_notifications"],
|
||||
} as const;
|
||||
|
||||
@@ -23,6 +23,7 @@ export type IpcPlayerData = {
|
||||
character?: any,
|
||||
inventories?: any,
|
||||
quick_bar?: any,
|
||||
quick_bar_pages?: number[],
|
||||
hotbar?: Record<string, string>,
|
||||
personal_logistic_slots?: any,
|
||||
crafting_queue?: any,
|
||||
@@ -181,6 +182,7 @@ const jsonImportedPlayerData = Type.Object({
|
||||
character: Type.Optional(jsonCharacter),
|
||||
inventories: Type.Optional(jsonInventories),
|
||||
quick_bar: Type.Optional(Type.Record(Type.String(), jsonQuickBarSlot)),
|
||||
quick_bar_pages: Type.Optional(Type.Array(Type.Integer({ minimum: 1, maximum: 10 }), { maxItems: 10 })),
|
||||
hotbar: Type.Optional(Type.Record(Type.String(), Type.String())),
|
||||
personal_logistic_slots: Type.Optional(jsonPersonalLogisticSlots),
|
||||
crafting_queue: Type.Optional(jsonCraftingQueue),
|
||||
|
||||
@@ -662,13 +662,20 @@ function serialize.deserialize_quick_bar_slot(entry, player)
|
||||
return entry --[[@as string]]
|
||||
end
|
||||
|
||||
--- Slots over all 10 quick bar pages, a page is as wide as the player set it from 2.1 and 10 slots before
|
||||
--- @param player LuaPlayer
|
||||
--- @return integer
|
||||
local function quick_bar_slot_count(player)
|
||||
return 10 * (v2_1_quick_bar_api and player.quick_bar_width or 10)
|
||||
end
|
||||
|
||||
--- @param player LuaPlayer
|
||||
--- @return table<string, QuickBarSlotEncoded>?
|
||||
function serialize.serialize_quick_bar(player)
|
||||
local serialized
|
||||
local width = v2_1_quick_bar_api and player.quick_bar_width
|
||||
|
||||
for i = 1, 100 do
|
||||
for i = 1, quick_bar_slot_count(player) do
|
||||
local slot
|
||||
|
||||
if v2_1_quick_bar_api then
|
||||
@@ -699,7 +706,7 @@ function serialize.deserialize_quick_bar(player, serialized)
|
||||
-- previous server does not come back from this server's old copy
|
||||
serialized = serialized or {}
|
||||
local width = v2_1_quick_bar_api and player.quick_bar_width
|
||||
for i = 1, 100 do
|
||||
for i = 1, quick_bar_slot_count(player) do
|
||||
local entry = serialized[tostring(i)]
|
||||
local slot = entry ~= nil and serialize.deserialize_quick_bar_slot(entry, player) or nil
|
||||
if v2_1_quick_bar_api then
|
||||
@@ -713,6 +720,30 @@ function serialize.deserialize_quick_bar(player, serialized)
|
||||
end
|
||||
end
|
||||
|
||||
--- Quick bar page shown on each quick bar row of the screen, kept by every save on its own
|
||||
--- @param player LuaPlayer
|
||||
--- @return integer[]?
|
||||
function serialize.serialize_quick_bar_pages(player)
|
||||
local pages = {}
|
||||
for screen = 1, 10 do
|
||||
-- pcall: the number of rows the API knows is not documented for older versions
|
||||
local ok, page = pcall(player.get_active_quick_bar_page, screen)
|
||||
if not ok or not page then
|
||||
break
|
||||
end
|
||||
pages[screen] = page
|
||||
end
|
||||
return pages[1] and pages or nil
|
||||
end
|
||||
|
||||
--- @param player LuaPlayer
|
||||
--- @param pages integer[]? Absent in data from before pages were synced, the rows then stay as they are here
|
||||
function serialize.deserialize_quick_bar_pages(player, pages)
|
||||
for screen, page in ipairs(pages or {}) do
|
||||
player.set_active_quick_bar_page(screen, page)
|
||||
end
|
||||
end
|
||||
|
||||
-- Crafting queue is a table with the following fields:
|
||||
-- crafting_queue
|
||||
-- ingredients
|
||||
@@ -977,6 +1008,7 @@ end
|
||||
--- @field inventories table<string, table>?
|
||||
--- @field hotbar table<string, string>?
|
||||
--- @field quick_bar table<string, QuickBarSlotEncoded>?
|
||||
--- @field quick_bar_pages integer[]? Page shown on each quick bar row
|
||||
--- @field personal_logistic_slots table?
|
||||
--- @field crafting_queue table?
|
||||
--- @field recipe_notifications string?
|
||||
@@ -1181,6 +1213,7 @@ function serialize.serialize_player(player, failed_deserialization, components)
|
||||
-- Serialize quick bar
|
||||
if syncs(components, "quick_bar") then
|
||||
serialized.quick_bar = serialize.serialize_quick_bar(player)
|
||||
serialized.quick_bar_pages = serialize.serialize_quick_bar_pages(player)
|
||||
end
|
||||
|
||||
-- Serialize crafting queue
|
||||
@@ -1271,8 +1304,23 @@ local function ensure_character(player)
|
||||
player.teleport(force.get_spawn_position(planet_surface), planet_surface)
|
||||
end
|
||||
|
||||
-- Create and return the character
|
||||
-- create_character needs a free spot within 200 tiles and does not generate chunks to find one. It fails in a chunk
|
||||
-- which does not exist yet, such as one the player was teleported into a moment ago, and where water or void is all
|
||||
-- there is. The character is then made at the force's spawn. From a missing chunk it is moved back, as the game
|
||||
-- generates the chunks around a character. Otherwise there is nowhere to stand, and it stays at the spawn.
|
||||
if not player.create_character() then
|
||||
surface = player.surface
|
||||
local position = player.position --[[@as MapPosition.struct]]
|
||||
local force = player.force --[[@as LuaForce]]
|
||||
local missing_chunk = not surface.is_chunk_generated({ math.floor(position.x / 32), math.floor(position.y / 32) })
|
||||
player.teleport(force.get_spawn_position(surface), surface)
|
||||
if player.create_character() and missing_chunk then
|
||||
player.teleport(position, surface)
|
||||
end
|
||||
end
|
||||
|
||||
-- Return the character
|
||||
if not player.character then
|
||||
error(string.format(
|
||||
"Failed to create character (controller: %s, physical: %s, surface: %s, connected: %s, driving: %s)",
|
||||
controller_to_name[player.controller_type],
|
||||
@@ -1448,6 +1496,7 @@ function serialize.deserialize_player(player, serialized, components, local_cont
|
||||
-- Deserialize quick bar (named hotbar in old data)
|
||||
if syncs(components, "quick_bar") then
|
||||
serialize.deserialize_quick_bar(player, serialized.quick_bar or serialized.hotbar)
|
||||
serialize.deserialize_quick_bar_pages(player, serialized.quick_bar_pages)
|
||||
end
|
||||
|
||||
-- Deserialize recipe notifications
|
||||
|
||||
@@ -117,6 +117,7 @@ describe("player_sync plugin", function() {
|
||||
3: { t: "i", n: "main", s: 4, b: [2] },
|
||||
4: { t: "r", p: [1], y: "blueprint" },
|
||||
},
|
||||
quick_bar_pages: [2, 1, 3],
|
||||
crafting_queue: {
|
||||
crafting_queue: [{ recipe: "iron-gear-wheel", count: 5 }],
|
||||
crafting_queue_progress: 0.25,
|
||||
@@ -172,6 +173,8 @@ describe("player_sync plugin", function() {
|
||||
["quick_bar", { 1: { t: "i", n: "main" } }],
|
||||
["quick_bar", { 1: { t: "r", y: "blueprint" } }],
|
||||
["quick_bar", { 1: 5 }],
|
||||
["quick_bar_pages", [0]],
|
||||
["quick_bar_pages", [11]],
|
||||
["personal_logistic_slots", { enabled: true, sections: [] }],
|
||||
["personal_logistic_slots", { enabled: true, trash_not_requested: false, sections: [{}] }],
|
||||
["personal_logistic_slots", { 1: { min: 50 } }],
|
||||
|
||||
@@ -113,6 +113,58 @@ describe("controller/src/WsServer", function() {
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("connection metrics", function() {
|
||||
const req = { socket: { remoteAddress: "10.0.0.1", remoteFamily: "IPv4" }, headers: {} };
|
||||
/** @type {WsServer} */
|
||||
let wsServer;
|
||||
before(function() {
|
||||
const config = new ControllerConfig("controller");
|
||||
config.set("controller.auth_secret", Buffer.from("TestSecretDoNotUse").toString("base64"));
|
||||
wsServer = new Controller(lib.logger, [], config).wsServer;
|
||||
});
|
||||
|
||||
function counter(name) {
|
||||
return lib.defaultRegistry.collectors.find(collector => collector.metric.name === name);
|
||||
}
|
||||
function value(name, type) {
|
||||
const collector = counter(`clusterio_controller_websocket_${name}_total`);
|
||||
return type ? collector.labels(type).get() : collector.get();
|
||||
}
|
||||
|
||||
[["host", lib.Address.host], ["control", lib.Address.control]].forEach(([type, addressType]) => {
|
||||
it(`should count ready, drop, resume and timeout for ${type} sessions`, function() {
|
||||
const before = Object.fromEntries(
|
||||
["ready", "drop", "resume", "session_timeout"].map(name => [name, value(name, type)])
|
||||
);
|
||||
const [connector, sessionToken] = wsServer.createSession(new lib.Address(addressType, 1));
|
||||
connector.ready(new MockWebSocket(), new lib.Address(addressType, 1), sessionToken, undefined);
|
||||
assert.equal(value("ready", type), before.ready + 1);
|
||||
|
||||
connector._socket.terminate();
|
||||
assert.equal(value("drop", type), before.drop + 1);
|
||||
|
||||
wsServer.resumeSession(new lib.ResumeData(sessionToken, 0), new MockWebSocket(), req);
|
||||
assert.equal(value("resume", type), before.resume + 1);
|
||||
|
||||
connector._socket.terminate();
|
||||
connector._timedOut();
|
||||
assert.equal(value("session_timeout", type), before.session_timeout + 1);
|
||||
assert.equal(value("drop", type), before.drop + 2);
|
||||
assert.equal(value("ready", type), before.ready + 1);
|
||||
});
|
||||
});
|
||||
it("should count resumes of unknown sessions as failed", function() {
|
||||
const before = value("failed_resume");
|
||||
const socket = new MockWebSocket();
|
||||
wsServer.resumeSession(new lib.ResumeData("invalid", 0), socket, req);
|
||||
try {
|
||||
assert.equal(value("failed_resume"), before + 1);
|
||||
assert.equal(socket.sent[0].type, "invalidate");
|
||||
} finally {
|
||||
socket.emit("message", "not json"); // ends the handshake and its timeout
|
||||
}
|
||||
});
|
||||
});
|
||||
describe(".registerHost()", function() {
|
||||
const secret = Buffer.from("TestSecretDoNotUse");
|
||||
const req = { socket: { remoteAddress: "10.0.0.1", remoteFamily: "IPv4" }, headers: {} };
|
||||
|
||||
@@ -45,6 +45,19 @@ describe("lib/logging_utils.js", function() {
|
||||
logIndex
|
||||
);
|
||||
});
|
||||
it("should drop entries for files no longer present", async function() {
|
||||
let tempDir = path.join("temp", "test", "log_index_prune");
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
await fs.cp(logDir, tempDir, { recursive: true });
|
||||
let logIndex = await lib.LogIndex.load(tempDir);
|
||||
await logIndex.buildIndex();
|
||||
await fs.unlink(path.join(tempDir, "test-2021-12-10.log"));
|
||||
await logIndex.buildIndex();
|
||||
assert.deepEqual(
|
||||
new Set(logIndex.index.keys()),
|
||||
new Set(["test-2021-12-11.log", "test-2021-12-12.log"])
|
||||
);
|
||||
});
|
||||
it("should handle loading broken index", async function() {
|
||||
let tempDir = path.join("temp", "test", "log");
|
||||
await fs.writeFile(path.join(tempDir, "index.json"), "Broken JSON");
|
||||
@@ -53,6 +66,43 @@ describe("lib/logging_utils.js", function() {
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteOldLogs", function() {
|
||||
let tempDir = path.join("temp", "test", "log_retention");
|
||||
let files = [
|
||||
"cluster-2026-10-01.log",
|
||||
"cluster-2026-10-12.log",
|
||||
"cluster-2026-10-13.log",
|
||||
"cluster-2026-11-12.log",
|
||||
"index.json",
|
||||
"notes-2026-10-01.txt",
|
||||
".0123abcd-audit.json",
|
||||
];
|
||||
let now = Date.parse("2026-11-12T08:00:00Z");
|
||||
beforeEach(async function() {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
for (let file of files) {
|
||||
await fs.writeFile(path.join(tempDir, file), "");
|
||||
}
|
||||
});
|
||||
|
||||
it("should delete only dated log files past the retention", async function() {
|
||||
let deleted = await lib.deleteOldLogs(tempDir, 30, now);
|
||||
assert.deepEqual(deleted.sort(), ["cluster-2026-10-01.log", "cluster-2026-10-12.log"]);
|
||||
assert.deepEqual(
|
||||
(await fs.readdir(tempDir)).sort(),
|
||||
files.filter(file => !deleted.includes(file)).sort(),
|
||||
);
|
||||
});
|
||||
it("should keep everything with a retention of 0", async function() {
|
||||
assert.deepEqual(await lib.deleteOldLogs(tempDir, 0, now), []);
|
||||
assert.deepEqual((await fs.readdir(tempDir)).sort(), [...files].sort());
|
||||
});
|
||||
it("should ignore a missing directory", async function() {
|
||||
assert.deepEqual(await lib.deleteOldLogs(path.join(tempDir, "missing"), 30, now), []);
|
||||
});
|
||||
});
|
||||
|
||||
let allLines = [];
|
||||
let logIndex = null;
|
||||
before(async function() {
|
||||
|
||||
@@ -1021,6 +1021,47 @@ describe("lib/subscriptions", function() {
|
||||
it("should handle the provided event", function() {
|
||||
assert.equal(mockControl._eventHandlers.has(RegisteredEvent), true);
|
||||
});
|
||||
|
||||
it("should share one connect and close listener on the connector", function() {
|
||||
const connector = mockControl.connector;
|
||||
const connectListeners = connector.listenerCount("connect");
|
||||
const closeListeners = connector.listenerCount("close");
|
||||
const subscribers = [
|
||||
new lib.EventSubscriber(StringPermissionEvent, mockControl),
|
||||
new lib.EventSubscriber(FunctionPermissionEvent, mockControl),
|
||||
];
|
||||
assert.equal(subscribers.length, 2);
|
||||
assert.equal(connector.listenerCount("connect"), connectListeners);
|
||||
assert.equal(connector.listenerCount("close"), closeListeners);
|
||||
});
|
||||
|
||||
it("should pass connect and close to every subscriber on the connector", function() {
|
||||
const subscribers = [
|
||||
eventSubscriber,
|
||||
new lib.EventSubscriber(StringPermissionEvent, mockControl),
|
||||
new lib.EventSubscriber(FunctionPermissionEvent, mockControl),
|
||||
];
|
||||
const received = subscribers.map(() => []);
|
||||
subscribers.forEach((subscriber, index) => {
|
||||
subscriber.handleConnectionEvent = event => { received[index].push(event); };
|
||||
});
|
||||
mockControl.connector.emit("close");
|
||||
mockControl.connector.emit("connect");
|
||||
assert.deepEqual(received, [["close", "connect"], ["close", "connect"], ["close", "connect"]]);
|
||||
});
|
||||
|
||||
it("should only pass events to subscribers on the same connector", function() {
|
||||
const otherControl = new MockControl(new MockConnector(
|
||||
addr({ controlId: 1 }),
|
||||
addr("controller"),
|
||||
));
|
||||
const other = new lib.EventSubscriber(RegisteredEvent, otherControl);
|
||||
const received = [];
|
||||
eventSubscriber.handleConnectionEvent = event => { received.push(["first", event]); };
|
||||
other.handleConnectionEvent = event => { received.push(["other", event]); };
|
||||
otherControl.connector.emit("close");
|
||||
assert.deepEqual(received, [["other", "close"]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subscribe()", function() {
|
||||
|
||||
Reference in new issue
Block a user