Compare commits

..
Author SHA1 Message Date
ILLISISandClaude Fable 5.2 a959ff25a4 Reject an upload for a player who is back, and take an empty reply as stored
A player rejoining before the controller's reply was skipped by
reject_upload, and the join then kept the copy the controller refused.
The rejection now counts while the join still waits for its acquire
reply, which the controller sends after it.

A controller from before the upload response replies with nothing. That
failed validation on a newer host, so its uploads were never confirmed.
It is taken as stored, as before.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JyNnVzRW4jPWaoWTxCyhM7
2026-11-14 14:57:30 +11:00
ILLISISandClaude Fable 5.2 b6bc118297 Download at the next join when the controller did not store an upload
The controller gave an upload it did not store the same empty reply as
one it stored, and the instance confirmed either to the save. An instance
restarting after a crash uploads the players it had online, and when
another instance had since uploaded the same generation the save took its
own copy for the stored one: the next join kept it, and the leave after
that stored it over the newer data.

The reply now says whether the upload was stored. One that was not marks
the save's copy outdated, so it is not uploaded again and the next join
downloads.

Fixes #1218

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JyNnVzRW4jPWaoWTxCyhM7
2026-11-14 13:20:31 +11:00
26 changed files with 139 additions and 237 deletions

No files matched your search

-125
View File
@@ -31,131 +31,6 @@ Many thanks to the following for contributing to this release:
[@username](https://github.com/username) [@username](https://github.com/username)
--> -->
## Unreleased
### Fixes
- Fixed cramped plugin rows and wrapping header branding on mobile screens.
## Version 2.1.0
### Features
- Added `Instance.autoPaused` and the `autoPauseChanged` instance hook so plugins can skip periodic commands while the game is auto paused. [#258](<https://github.com/clusterio/clusterio/issues/258>)
- Hosts ask to join the cluster when first started and are approved in the web interface or with clusterioctl host approve. [#1161](<https://github.com/clusterio/clusterio/pull/1161>)
- Exporting data from an instance running the headless build of Factorio now reads the base game's icons from a full install of the same version in `host.factorio_directory`, if there is one. [#1068](<https://github.com/clusterio/clusterio/issues/1068>)
- Added instance templates that other instances inherit config values from, with linked inherited values in the web interface, see `instance.is_template` and `instance.template_id`. [#200](<https://github.com/clusterio/clusterio/issues/200>)
- Added the mod pack an instance uses to the instance list and instance page, with a link to it. [#852](<https://github.com/clusterio/clusterio/issues/852>)
- Added controller metrics for WebSocket ready, resume, drop, session timeout and failed resume events. [#1192](<https://github.com/clusterio/clusterio/issues/1192>)
- Added online player count column to the instance list in the web interface. [#1193](<https://github.com/clusterio/clusterio/issues/1193>)
- Added controller.log_retention_days and host.log_retention_days to delete log files older than the given number of days. [#1200](<https://github.com/clusterio/clusterio/pull/1200>)
- Added a plugin browser to the plugins page that searches npm for packages with the clusterio-plugin keyword and installs them on a chosen set of machines, defaulting to every connected machine that allows remote installs. [#1001](<https://github.com/clusterio/clusterio/issues/1001>)
- Added a field to the plugin browser for installing a package by name. [#1001](<https://github.com/clusterio/clusterio/issues/1001>)
- Added a Versions column to the plugins page showing how many machines run each plugin version now and after a restart, with restart pending, disabled, not installed and offline counts. [#1001](<https://github.com/clusterio/clusterio/issues/1001>)
- Added a table of machines to the plugin page with the version and status of the plugin on each, and an install or update button for machines that aren't on the latest version. Plugins only installed on hosts have a page too. [#1001](<https://github.com/clusterio/clusterio/issues/1001>)
- Added "Update to X" and "Update all" actions to the plugins page that bring every machine to the latest version of a plugin on npm, installing it where it's missing, with a checklist of the changes to make. [#1001](<https://github.com/clusterio/clusterio/issues/1001>)
- Added a "Restart pending" action to the plugins page that restarts the chosen machines with a pending restart, hosts first and the controller last. [#1001](<https://github.com/clusterio/clusterio/issues/1001>)
- Added support for `name@version` in plugin install and update requests and the matching clusterioctl commands, for installing a specific version or updating past the range saved in package.json. [#1001](<https://github.com/clusterio/clusterio/issues/1001>)
- Added `PluginLatestVersionsRequest` for looking up the latest version of plugins published on npm. [#1001](<https://github.com/clusterio/clusterio/issues/1001>)
- Added `controller plugin search` command to clusterioctl. [#1001](<https://github.com/clusterio/clusterio/issues/1001>)
- Added `core.plugin.search` permission. [#1001](<https://github.com/clusterio/clusterio/issues/1001>)
- Added a `handleSignals` constructor argument to `LockFile` for locks that should only be released on exit. [#1001](<https://github.com/clusterio/clusterio/issues/1001>)
### Fixes
- Fixed the plugin template asking for an older TypeBox version than lib, which made new plugins fail to compile. [#1127](<https://github.com/clusterio/clusterio/issues/1127>)
- Fixed subspace_storage and statistics_exporter keeping auto paused servers running with their periodic RCON commands. [#258](<https://github.com/clusterio/clusterio/issues/258>)
- Fixed research_sync losing research progress and finished research while the controller was unreachable. [#1123](<https://github.com/clusterio/clusterio/issues/1123>)
- Fixed stopping an instance while it is starting failing with "Expected state running but state is stopping" or leaving it running. The stop now takes effect once the instance has started, and the control button shows as loading while the instance is starting or stopping. [#879](<https://github.com/clusterio/clusterio/issues/879>)
- Raised the required Node.js version to 22.18 or 24.2, since earlier 22.x and 24.x releases silently skipped CLI startup. [#1109](<https://github.com/clusterio/clusterio/issues/1109>)
- Fixed user view page not updating the displayed roles when they change. [#775](<https://github.com/clusterio/clusterio/issues/775>)
- Fixed role view page discarding unsaved edits when the role is updated, and not showing changes made elsewhere alongside local edits. [#776](<https://github.com/clusterio/clusterio/issues/776>)
- Fixed data export failing completely when one icon could not be read or decoded, the icon is now skipped with a warning. [#841](<https://github.com/clusterio/clusterio/issues/841>)
- Fixed mod settings added by a data export showing up empty on an open mod pack page until it was reloaded. [#840](<https://github.com/clusterio/clusterio/issues/840>)
- Fixed Player Sync dropping recipe notifications on download when the player had no cleared recipes, which also replaced the stored list on the next upload. [#964](<https://github.com/clusterio/clusterio/issues/964>)
- Fixed export-data on headless Factorio counting blank layered icons as exported and not reporting that the base game icons were missing. [#1068](<https://github.com/clusterio/clusterio/issues/1068>)
- Controller drops export data whose files were deleted, at startup or when a missing file is requested, and the web interface falls back cleanly instead of showing errors. [#878](<https://github.com/clusterio/clusterio/issues/878>)
- Fixed player_sync leaving a player as a spectator with no body, and uploading that empty state, after a download stalled while the controller was down. [#1124](<https://github.com/clusterio/clusterio/issues/1124>)
- Fixed player_sync teleporting a player back after another mod, such as universal edges, teleported them while their player data downloaded. [#908](<https://github.com/clusterio/clusterio/issues/908>)
- Fixed plugins that need save patching, script commands or Lua UDP defaulting to loaded on instances with that feature disabled, which made saving the instance config fail. [#651](<https://github.com/clusterio/clusterio/issues/651>)
- Fixed Player Sync keeping cleared recipe notifications and losing ones for recipes missing here when a download changed the player's force. [#964](<https://github.com/clusterio/clusterio/issues/964>)
- Fixed the instance filter on the users page and the icon reference search being lost when navigating back to the page. [#1185](<https://github.com/clusterio/clusterio/pull/1185>)
- Fixed the web UI ignoring `localised_name` and `localised_description` of mod settings and showing rich text tags in setting names. [#768](<https://github.com/clusterio/clusterio/issues/768>)
- Fixed saves patched by a newer version of Clusterio not being rejected. [#1184](<https://github.com/clusterio/clusterio/pull/1184>)
- Fixed the research_sync queue chat line showing the same level for every queued level of an infinite technology. [#1186](<https://github.com/clusterio/clusterio/issues/1186>)
- Fixed the web interface logging MaxListenersExceededWarning when plugins create more than 7 subscribers. [#1145](<https://github.com/clusterio/clusterio/issues/1145>)
- Fixed Player Sync failing with "Failed to create character" when the player was in a chunk that was not generated yet or had no land nearby. [#1187](<https://github.com/clusterio/clusterio/issues/1187>)
- Fixed player_sync not syncing which page each quick bar row shows, so the quick bar could show other slots after switching servers. [#1201](<https://github.com/clusterio/clusterio/issues/1201>)
- Fixed the controller reading the whole body of save, mod and export uploads it had rejected, when running behind a reverse proxy. [#595](<https://github.com/clusterio/clusterio/issues/595>)
- Fixed uploads to the controller taking more than 5 minutes failing. [#1173](<https://github.com/clusterio/clusterio/pull/1173>)
- Fixed a plugin list entry for a path that no longer exists taking precedence over an npm package providing the same plugin. [#1001](<https://github.com/clusterio/clusterio/issues/1001>)
- Fixed npm plugins already in the plugin list logging "already in the plugin list, but with a different path" on every startup. [#1001](<https://github.com/clusterio/clusterio/issues/1001>)
### Changes
- Errors thrown by request handlers are received by the requester as the same class instead of a generic `RequestError`. Plugins can register their own error classes with `errors` in the plugin declaration. [#900](<https://github.com/clusterio/clusterio/issues/900>)
- Replaced webpack with Vite for building the web interface and plugin web modules. `--dev` now uses the Vite development server with hot module replacement. [#1127](<https://github.com/clusterio/clusterio/issues/1127>)
- Removed the separate web interface load state from the plugins pages, web load errors now show with the plugin's versions. [#1140](<https://github.com/clusterio/clusterio/pull/1140>)
- Build the published packages with TypeScript 7, and use it in the plugin template. Plugins can keep using TypeScript 5.9 or 6. [#1147](<https://github.com/clusterio/clusterio/pull/1147>)
- `ctl host plugin list` shows the plugin state in place of the loaded and enabled columns. [#1134](<https://github.com/clusterio/clusterio/issues/1134>)
- Reduced the size of a host install by replacing jimp with only the jimp modules the export uses. [#1162](<https://github.com/clusterio/clusterio/pull/1162>)
- `@clusterio/web_ui` no longer installs typescript. Plugins that compile with `tsc` need typescript in their own devDependencies. [#1163](<https://github.com/clusterio/clusterio/pull/1163>)
- Commands of clusteriocontroller and clusteriohost that don't start the server, like config and bootstrap, start about 45% faster. [#1159](<https://github.com/clusterio/clusterio/pull/1159>)
- Made web interface view pages show resources that are loading, missing, deleted or failed to load the same way, instead of spinning forever without the subscribe permission. [#1165](<https://github.com/clusterio/clusterio/issues/1165>)
- Added `ResourceState` and `useResourceState` to the web_ui package, and `useHost`, `useInstance`, `useUser`, `useRole` and `useModPack` now also return the subscription error. [#1165](<https://github.com/clusterio/clusterio/issues/1165>)
- Reduced the download size of the create tool by replacing inquirer 8 with the standalone @inquirer prompt packages. [#1164](<https://github.com/clusterio/clusterio/pull/1164>)
- `clusterioctl instance config edit` only sends the fields changed in the editor. [#200](<https://github.com/clusterio/clusterio/issues/200>)
- Mod settings that no mod in a mod pack's export data defines are now hidden in a collapsed section on the mod pack page and can be removed from there. [#773](<https://github.com/clusterio/clusterio/issues/773>)
- Added `UserManager.verifyUserToken` for checking user tokens. [#1180](<https://github.com/clusterio/clusterio/pull/1180>)
- subspace_storage now requires save patching on the instance, it did not work without it. [#651](<https://github.com/clusterio/clusterio/issues/651>)
- Saves started with save patching disabled now have the Clusterio modules removed and their original control.lua restored. [#651](<https://github.com/clusterio/clusterio/issues/651>)
- The research_sync chat line for a research queue change names only the technologies added, removed or moved, instead of printing the whole queue. [#1186](<https://github.com/clusterio/clusterio/issues/1186>)
- Focused the search input when opening mod search on the mods page and the mod pack view. [#1194](<https://github.com/clusterio/clusterio/issues/1194>)
- The online player list on the instance page is always shown, collapsed when no players are online. [#1191](<https://github.com/clusterio/clusterio/issues/1191>)
- The installer detects Factorio installed through Steam on macOS. [#1190](<https://github.com/clusterio/clusterio/issues/1190>)
- Log files older than 30 days are now deleted by default. Existing installs will lose logs older than 30 days on the first start after upgrading. To keep them, set controller.log_retention_days and host.log_retention_days to 0 with the config command after upgrading but before starting the controller and hosts. [#1200](<https://github.com/clusterio/clusterio/pull/1200>)
- Requests to the controller moving less than controller.transfer_min_progress KiB in 30 seconds are now closed. [#842](<https://github.com/clusterio/clusterio/issues/842>)
- Plugin lists now include the version installed on disk and npm plugins installed since startup. [#1001](<https://github.com/clusterio/clusterio/issues/1001>)
- System metrics now include the plugins on each machine, kept for hosts while they are offline. [#1001](<https://github.com/clusterio/clusterio/issues/1001>)
- The "Updates" button on the host and controller pages is now "Update Clusterio" and no longer installs or updates plugins. [#1001](<https://github.com/clusterio/clusterio/issues/1001>)
- Plugin installs and updates are rejected while another npm operation is running on the same machine, including between a standalone controller and host sharing an installation directory. [#1001](<https://github.com/clusterio/clusterio/issues/1001>)
- Plugin installs and pinned updates are rejected when the package by that name is installed from a path, git, url, tarball or alias, either by the root package.json or by another plugin, as installing by name would replace it. Such plugins no longer report an npm package. [#1001](<https://github.com/clusterio/clusterio/issues/1001>)
- Hosts and the controller refuse to restart while a plugin install or update is running on the machine. [#1001](<https://github.com/clusterio/clusterio/issues/1001>)
- Lock files now include the process start time, so a lock left by an earlier process with the same pid is treated as stale. A lock file is only removed by the process that wrote it. [#1001](<https://github.com/clusterio/clusterio/issues/1001>)
- Plugins in `optionalDependencies` are now found on startup, and their entries override `dependencies` of the same name like they do in npm. [#1001](<https://github.com/clusterio/clusterio/issues/1001>)
### Breaking Changes
- Removed `lib.findUnusedName`, its result could be taken by another process before use. [#1128](<https://github.com/clusterio/clusterio/pull/1128>)
- Replaced `DatastoreProvider.bootstrap()` with `KeyValueDatastore.fromProvider()` and `SubscribableDatastore.fromProvider()`, and `Controller.bootstrap()` / `Host.bootstrap()` with `Controller.fromConfig()` / `Host.fromConfig()`. [#1126](<https://github.com/clusterio/clusterio/issues/1126>)
- Replaced `FactorioServer.init()` and `setTargetVersion()` with `FactorioServer.fromOptions()`, and `FactorioServer.checkForUpdates()` with an exported `checkForUpdates()` function. [#1126](<https://github.com/clusterio/clusterio/issues/1126>)
- Replaced `Instance.init()` with `Instance.fromConfig()` followed by `instance.loadPlugins()`. The `Instance` constructor now takes the `FactorioServer` instead of the factorio directory. [#1126](<https://github.com/clusterio/clusterio/issues/1126>)
- Replaced `RequestError` with error classes following HTTP status codes: `ClientError` (`ValidationError`, `ForbiddenError`, `NotFoundError`, `TimeoutError`, `ConflictError`) and `ServerError` (`InternalServerError`, `UnavailableError`). [#900](<https://github.com/clusterio/clusterio/issues/900>)
- Renamed `InvalidValue`, `InvalidField` and `InvalidAccess` to `InvalidValueError`, `InvalidFieldError` and `InvalidAccessError`. [#900](<https://github.com/clusterio/clusterio/issues/900>)
- Plugin web modules must be built with Vite using `clusterioPlugin` from `@clusterio/web_ui/vite.mjs`. Plugins built with webpack no longer load. See docs/devs/plugin-migration-to-2.1.md. [#1127](<https://github.com/clusterio/clusterio/issues/1127>)
- Renamed `lib.PluginWebpackEnvInfo` to `lib.PluginWebEnvInfo`. [#1127](<https://github.com/clusterio/clusterio/issues/1127>)
- `PluginDetails` takes a `state` in place of `loaded` and `enabled`, use the `loaded` and `loadsOnRestart` getters to read them. [#1134](<https://github.com/clusterio/clusterio/issues/1134>)
- Node.js 22.0 to 22.17 and 24.0 to 24.1 are no longer supported. [#1109](<https://github.com/clusterio/clusterio/issues/1109>)
- Removed host token management: clusterioctl host generate-token, revoke-token and create-config, bootstrap generate-host-token and their permissions. Hosts are revoked from the host page or with clusterioctl host revoke. [#1161](<https://github.com/clusterio/clusterio/pull/1161>)
- Renamed `lib.safeOutputFile` to `lib.atomicOverwriteFile`. [#876](<https://github.com/clusterio/clusterio/issues/876>)
- Added `HostManager` as `controller.hosts`; the hosts datastore moved to `controller.hosts.records`, `Controller.generateHostToken` and `verifyHostToken` became `controller.hosts.signHostToken` and `verifyHostToken`, and `HostPairingManager.createHost` became `controller.hosts.createHost`, which returns the host record. [#1180](<https://github.com/clusterio/clusterio/pull/1180>)
### Meta
- Replaced ESLint with Oxlint. [#1127](<https://github.com/clusterio/clusterio/issues/1127>)
- Removed TypeDoc and the `pnpm docs` script. [#1147](<https://github.com/clusterio/clusterio/pull/1147>)
### Contributors
Many thanks to the following for contributing to this release:
[@psihius](<https://github.com/psihius>), [@bbassie](<https://github.com/bbassie>), [@Danielv123](<https://github.com/Danielv123>), [@ILLISIS](<https://github.com/ILLISIS>)
### Statistics
Commits: 148 Pull Requests: 55 Files Changed: 296 Additions: +17,343 Deletions: -4,190
## Version 2.0.3 ## Version 2.0.3
### Features ### Features
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clusterio/controller", "name": "@clusterio/controller",
"version": "2.1.0", "version": "2.0.3",
"description": "Implementation of Clusterio controller", "description": "Implementation of Clusterio controller",
"homepage": "https://github.com/clusterio/clusterio#readme", "homepage": "https://github.com/clusterio/clusterio#readme",
"author": "Hornwitser <github@hornwitser.no>", "author": "Hornwitser <github@hornwitser.no>",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clusterio/create", "name": "@clusterio/create",
"version": "2.1.0", "version": "2.0.3",
"description": "Installer for clusterio", "description": "Installer for clusterio",
"homepage": "https://github.com/clusterio/clusterio#readme", "homepage": "https://github.com/clusterio/clusterio#readme",
"author": "Hornwitser <github@hornwitser.no>", "author": "Hornwitser <github@hornwitser.no>",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clusterio/ctl", "name": "@clusterio/ctl",
"version": "2.1.0", "version": "2.0.3",
"description": "Clusterio command line management tool", "description": "Clusterio command line management tool",
"homepage": "https://github.com/clusterio/clusterio#readme", "homepage": "https://github.com/clusterio/clusterio#readme",
"author": "Hornwitser <github@hornwitser.no>", "author": "Hornwitser <github@hornwitser.no>",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clusterio/host", "name": "@clusterio/host",
"version": "2.1.0", "version": "2.0.3",
"description": "Implementation of Clusterio host server", "description": "Implementation of Clusterio host server",
"homepage": "https://github.com/clusterio/clusterio#readme", "homepage": "https://github.com/clusterio/clusterio#readme",
"author": "Hornwitser <github@hornwitser.no>", "author": "Hornwitser <github@hornwitser.no>",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clusterio/lib", "name": "@clusterio/lib",
"version": "2.1.0", "version": "2.0.3",
"description": "Shared library for Clusterio", "description": "Shared library for Clusterio",
"homepage": "https://github.com/clusterio/clusterio#readme", "homepage": "https://github.com/clusterio/clusterio#readme",
"author": "Hornwitser <github@hornwitser.no>", "author": "Hornwitser <github@hornwitser.no>",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clusterio/web_ui", "name": "@clusterio/web_ui",
"version": "2.1.0", "version": "2.0.3",
"description": "Clusterio web interface implementation", "description": "Clusterio web interface implementation",
"homepage": "https://github.com/clusterio/clusterio#readme", "homepage": "https://github.com/clusterio/clusterio#readme",
"author": "Hornwitser", "author": "Hornwitser",
@@ -172,12 +172,10 @@ export function PluginVersions(props: { status: PluginStatus, latest?: string })
{versions.map(version => { {versions.map(version => {
const now = count(machine => machine.running === version); const now = count(machine => machine.running === version);
const after = count(machine => machine.afterRestart === version); const after = count(machine => machine.afterRestart === version);
return <div key={version} className="plugin-version"> return <div key={version}>
<span> <Text>{version}</Text>
<Text>{version}</Text> {props.latest === version && <Tag color="success" style={{ marginLeft: 6 }}>latest</Tag>}
{props.latest === version && <Tag color="success" style={{ marginLeft: 6 }}>latest</Tag>} <Text type="secondary" style={{ marginLeft: 12 }}>
</span>
<Text type="secondary">
{plural(now, "machine")}{now !== after && ` → ${after} after restart`} {plural(now, "machine")}{now !== after && ` → ${after} after restart`}
</Text> </Text>
</div>; </div>;
+22 -36
View File
@@ -1,5 +1,5 @@
import React, { useContext, useEffect, useState } from "react"; import React, { useContext, useEffect, useState } from "react";
import { Grid, Space, Table, Tooltip, Typography } from "antd"; import { Space, Table, Tooltip, Typography } from "antd";
import CloseCircleFilled from "@ant-design/icons/CloseCircleFilled"; import CloseCircleFilled from "@ant-design/icons/CloseCircleFilled";
import type { PluginWebApi } from "@clusterio/lib"; import type { PluginWebApi } from "@clusterio/lib";
@@ -48,8 +48,6 @@ function WebError(props: { error: string }) {
export default function PluginsPage() { export default function PluginsPage() {
const control = useContext(ControlContext); const control = useContext(ControlContext);
const account = useAccount(); const account = useAccount();
const screens = Grid.useBreakpoint();
const isMobile = screens.md === false;
let [pluginList, setPluginList] = useState<PluginWebApi[]>([]); let [pluginList, setPluginList] = useState<PluginWebApi[]>([]);
const tableState = useTableQueryState<PluginRow>({ const tableState = useTableQueryState<PluginRow>({
namespace: "plugin", defaultSortKey: "name", namespace: "plugin", defaultSortKey: "name",
@@ -98,24 +96,6 @@ export default function PluginsPage() {
const latestOf = (plugin: PluginRow) => ( const latestOf = (plugin: PluginRow) => (
plugin.status?.npmPackage ? latestVersions[plugin.status.npmPackage] : undefined plugin.status?.npmPackage ? latestVersions[plugin.status.npmPackage] : undefined
); );
const versions = (plugin: PluginRow) => <>
{plugin.status
? <PluginVersions status={plugin.status} latest={latestOf(plugin)} />
: plugin.meta?.version}
{plugin.webError && <WebError error={plugin.webError} />}
</>;
const updateAction = (plugin: PluginRow) => (plugin.status
// Keep clicks on the update action from opening the plugin.
? <span onClick={event => { event.stopPropagation(); }}>
<PluginUpdateButton
status={plugin.status}
title={plugin.title}
latest={latestOf(plugin)}
installer={installer}
/>
</span>
: null
);
return <PageLayout nav={[{ name: "Plugins" }]}> return <PageLayout nav={[{ name: "Plugins" }]}>
<PageHeader <PageHeader
@@ -135,22 +115,14 @@ export default function PluginsPage() {
</Space>} </Space>}
/> />
<Table <Table
className="plugins-table"
tableLayout={isMobile ? "fixed" : "auto"}
columns={[ columns={[
{ {
title: "Name", title: "Name",
key: "name", key: "name",
className: "table-link-cell", className: "table-link-cell",
render: (_, plugin) => <> render: (_, plugin) => <Link to={pluginPath(plugin)} style={{ color: "inherit" }}>
<Link to={pluginPath(plugin)} style={{ color: "inherit" }}> {plugin.title}
{plugin.title} </Link>,
</Link>
{isMobile && <div className="plugin-mobile-details">
<div>{versions(plugin)}</div>
{updateAction(plugin)}
</div>}
</>,
sorter: (a, b) => strcmp(a.title, b.title), sorter: (a, b) => strcmp(a.title, b.title),
sortOrder: tableState.sortOrder("name"), sortOrder: tableState.sortOrder("name"),
filteredValue: tableState.filteredValue("name"), filteredValue: tableState.filteredValue("name"),
@@ -159,14 +131,28 @@ export default function PluginsPage() {
{ {
title: "Versions", title: "Versions",
key: "versions", key: "versions",
responsive: ["md"], render: (_, plugin) => <>
render: (_, plugin) => versions(plugin), {plugin.status
? <PluginVersions status={plugin.status} latest={latestOf(plugin)} />
: plugin.meta?.version}
{plugin.webError && <WebError error={plugin.webError} />}
</>,
}, },
{ {
key: "actions", key: "actions",
align: "right", align: "right",
responsive: ["md"], render: (_, plugin) => (plugin.status
render: (_, plugin) => updateAction(plugin), // Keep clicks on the update action from opening the plugin
? <span onClick={event => { event.stopPropagation(); }}>
<PluginUpdateButton
status={plugin.status}
title={plugin.title}
latest={latestOf(plugin)}
installer={installer}
/>
</span>
: null
),
}, },
]} ]}
dataSource={tableContents} dataSource={tableContents}
@@ -169,10 +169,8 @@ export default function SiteLayout() {
/>; />;
let accountMenu = <Dropdown menu={accountMenuProps} trigger={["click"]} placement="bottomRight"> let accountMenu = <Dropdown menu={accountMenuProps} trigger={["click"]} placement="bottomRight">
<Button type="text" icon={<UserOutlined />} style={{ marginInlineStart: "auto" }} <Button type="text" icon={<UserOutlined />} style={{ marginInlineStart: "auto" }}>
aria-label={`Account menu for ${account.name}`} {account.name}
>
<span className="site-layout-account-name">{account.name}</span>
</Button> </Button>
</Dropdown>; </Dropdown>;
@@ -230,7 +228,7 @@ export default function SiteLayout() {
<Link to="/" style={{ display: "flex", alignItems: "center" }}> <Link to="/" style={{ display: "flex", alignItems: "center" }}>
<img src={logo} width={56} height={56} alt="Clusterio logo" /> <img src={logo} width={56} height={56} alt="Clusterio logo" />
</Link> </Link>
<Flex vertical justify="center" className="site-layout-brand"> <Flex vertical justify="center">
<Typography.Title level={4} style={{ margin: 0, lineHeight: 1.2 }}>Clusterio</Typography.Title> <Typography.Title level={4} style={{ margin: 0, lineHeight: 1.2 }}>Clusterio</Typography.Title>
<Tooltip title="View changelog" placement="right"> <Tooltip title="View changelog" placement="right">
<Typography.Text <Typography.Text
-32
View File
@@ -72,38 +72,6 @@ body {
background: #001529; background: #001529;
} }
.site-layout-brand {
flex-shrink: 0;
white-space: nowrap;
}
@media (max-width: 767px) {
.site-layout-account-name {
display: none;
}
.plugins-table .table-link-cell > a {
font-weight: 600;
overflow-wrap: anywhere;
}
}
.plugin-mobile-details {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
margin-top: 8px;
overflow-wrap: anywhere;
}
.plugin-version {
display: flex;
flex-wrap: wrap;
align-items: baseline;
column-gap: 12px;
}
/* Keep the sidebar fixed in place (below the header) while content scrolls. */ /* Keep the sidebar fixed in place (below the header) while content scrolls. */
.site-layout-sider { .site-layout-sider {
position: sticky; position: sticky;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clusterio/plugin-global_chat", "name": "@clusterio/plugin-global_chat",
"version": "2.1.0", "version": "2.0.3",
"description": "Clusterio plugin forwarding between Factorio servers", "description": "Clusterio plugin forwarding between Factorio servers",
"homepage": "https://github.com/clusterio/clusterio#readme", "homepage": "https://github.com/clusterio/clusterio#readme",
"author": "Hornwitser <github@hornwitser.no>", "author": "Hornwitser <github@hornwitser.no>",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clusterio/plugin-player_auth", "name": "@clusterio/plugin-player_auth",
"version": "2.1.0", "version": "2.0.3",
"description": "Clusterio plugin authenticating logged in players to the web interface", "description": "Clusterio plugin authenticating logged in players to the web interface",
"homepage": "https://github.com/clusterio/clusterio#readme", "homepage": "https://github.com/clusterio/clusterio#readme",
"author": "Hornwitser <github@hornwitser.no>", "author": "Hornwitser <github@hornwitser.no>",
+1 -1
View File
@@ -6,7 +6,7 @@ As a player, it mostly just works. Most important to know is that factorio data
A note on crashing servers: A note on crashing servers:
When a server crashes while you are online there won't be time to immediately upload the inventory. Instead, the inventory is uploaded as soon as the server restarts. This means if you have been playing on a different server from the one that crashed and gathered/consumed items but are offline at the time of the first server coming online again your inventory will reset to the autosave. When a server crashes while you are online there won't be time to immediately upload the inventory. Instead, the inventory is uploaded as soon as the server restarts. If you have been playing on a different server in the meantime the controller keeps what that server uploaded, and the crashed server downloads it the next time you join it.
## Installation ## Installation
+1
View File
@@ -258,6 +258,7 @@ export default async function(context: ControllerPluginContext) {
prefetch(instanceId, request.destinationInstanceId, merged); prefetch(instanceId, request.destinationInstanceId, merged);
} }
} }
return new msg.UploadRequest.Response(store);
}); });
controller.handle(msg.DownloadRequest, async (request: msg.DownloadRequest) => { controller.handle(msg.DownloadRequest, async (request: msg.DownloadRequest) => {
+6 -3
View File
@@ -200,11 +200,12 @@ export default async function(context: InstancePluginContext) {
delete player_data.destination_instance_id; delete player_data.destination_instance_id;
logger.verbose(`Uploading ${player_data.name} (${JSON.stringify(player_data).length / 1000}kB)`); logger.verbose(`Uploading ${player_data.name} (${JSON.stringify(player_data).length / 1000}kB)`);
let stored: boolean;
try { try {
await instance.sendTo( ({ stored } = await instance.sendTo(
"controller", "controller",
new UploadRequest(instance.id, player_data.name, player_data, destinationInstanceId), new UploadRequest(instance.id, player_data.name, player_data, destinationInstanceId),
); ));
} catch (err: any) { } catch (err: any) {
if (!(err instanceof lib.SessionLost)) { if (!(err instanceof lib.SessionLost)) {
@@ -213,8 +214,10 @@ export default async function(context: InstancePluginContext) {
return; return;
} }
// A copy the controller did not store must not pass for the stored one: the save downloads at the next join
const reply = stored ? "confirm_upload" : "reject_upload";
await instance.sendRcon( await instance.sendRcon(
`/sc player_sync.confirm_upload("${player_data.name}", ${player_data.generation})`, true, plugin.name `/sc player_sync.${reply}("${player_data.name}", ${player_data.generation})`, true, plugin.name
); );
} }
+18
View File
@@ -262,12 +262,30 @@ export class ReleaseRequest {
} }
} }
export class UploadResponse {
constructor(
/** False when the controller kept what it has: another copy is newer, locked or from another epoch */
public stored: boolean,
) {
}
// No type: a controller from before this response replies with nothing, which counts as stored
static jsonSchema = Type.Unsafe<{ stored: boolean } | undefined>({
properties: { "stored": { type: "boolean" } },
});
static fromJSON(json: Static<typeof UploadResponse.jsonSchema>) {
return new this(json?.stored ?? true);
}
}
export class UploadRequest { export class UploadRequest {
declare ["constructor"]: typeof UploadRequest; declare ["constructor"]: typeof UploadRequest;
static type = "request" as const; static type = "request" as const;
static src = "instance" as const; static src = "instance" as const;
static dst = "controller" as const; static dst = "controller" as const;
static plugin = "player_sync" as const; static plugin = "player_sync" as const;
static Response = UploadResponse;
constructor( constructor(
public instanceId: number, public instanceId: number,
+32 -1
View File
@@ -384,12 +384,14 @@ function player_sync.check_players_in_cutscene()
end end
-- Whether a join should take the stored data: there is some, and this save hasn't synced the player in that epoch at -- Whether a join should take the stored data: there is some, and this save hasn't synced the player in that epoch at
-- that generation or newer with every component synced here now. A player without a record here has nothing to keep. -- that generation or newer with every component synced here now, or the controller did not store its last upload
-- (reject_upload). A player without a record here has nothing to keep.
local function download_needed(player_record, acquire_response, script_data) local function download_needed(player_record, acquire_response, script_data)
return acquire_response.has_data return acquire_response.has_data
and ( and (
not player_record not player_record
or not player_record.sync or not player_record.sync
or player_record.outdated
or (player_record.epoch or 0) ~= (acquire_response.epoch or 0) or (player_record.epoch or 0) ~= (acquire_response.epoch or 0)
or acquire_response.generation > player_record.generation or acquire_response.generation > player_record.generation
or serialize.newly_synced(player_record.components, script_data.components) or serialize.newly_synced(player_record.components, script_data.components)
@@ -543,6 +545,8 @@ function player_sync.sync_player(acquire_response)
start_sync(player_record, script_data.components) start_sync(player_record, script_data.components)
player_record.dirty = true player_record.dirty = true
player_record.sync_in_progress = false player_record.sync_in_progress = false
-- Nothing stored to download: the copy here is all there is
player_record.outdated = nil
-- Deleted data keeps its generation and epoch so the next upload is accepted as the new baseline -- Deleted data keeps its generation and epoch so the next upload is accepted as the new baseline
player_record.epoch = epoch player_record.epoch = epoch
-- A copy from another epoch has nothing to do with the stored generation -- A copy from another epoch has nothing to do with the stored generation
@@ -573,6 +577,7 @@ function player_sync.finish_download(player, finished_record)
player_record.dirty = true player_record.dirty = true
player_record.sync_in_progress = false player_record.sync_in_progress = false
player_record.outdated = nil
start_sync(player_record, finished_record.components) start_sync(player_record, finished_record.components)
player_record.generation = finished_record.generation player_record.generation = finished_record.generation
player_record.epoch = finished_record.epoch player_record.epoch = finished_record.epoch
@@ -724,6 +729,32 @@ function player_sync.confirm_upload(player_name, generation)
player_record.dirty = false player_record.dirty = false
end end
-- Invoked by the instance code when the controller did not store the uploaded data: it holds another copy that is
-- newer, locked by another instance or from another epoch. This save's copy lost, even at the same generation, so it
-- is not uploaded again and the next join downloads. A retry of an upload the controller already stored ends up here
-- too, and then downloads what this save sent.
function player_sync.reject_upload(player_name, generation)
local player = game.get_player(player_name)
if not player then
return
end
local script_data = get_script_data()
-- A player who is back already still waits for the acquire reply, which the controller sent after this one, and
-- that join then downloads. Past that reply the join has decided and this is not about the copy here any more.
if player.connected and not script_data.players_waiting_for_acquire[player_name] then
return
end
local player_record = script_data.players[player.name]
if not player_record or player_record.generation ~= generation then
return
end
log("Upload of " .. player_name .. " was not stored, the next join downloads")
script_data.active_uploads[player_name] = nil
player_record.dirty = false
player_record.outdated = true
end
function player_sync.initiate_inventory_download(player, player_record, generation, epoch) function player_sync.initiate_inventory_download(player, player_record, generation, epoch)
player.print("Initiating inventory download...") player.print("Initiating inventory download...")
local record = { local record = {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clusterio/plugin-player_sync", "name": "@clusterio/plugin-player_sync",
"version": "2.1.0", "version": "2.0.3",
"description": "Clusterio plugin forwarding between Factorio servers", "description": "Clusterio plugin forwarding between Factorio servers",
"homepage": "https://github.com/clusterio/clusterio#readme", "homepage": "https://github.com/clusterio/clusterio#readme",
"author": "Danielv <danielv@danielv.no>", "author": "Danielv <danielv@danielv.no>",
+12
View File
@@ -77,6 +77,18 @@ describe("player_sync controller", function() {
.playerData; .playerData;
} }
it("should reply whether the upload was stored", async function() {
const handler = mock.getHandler(controller, msg.UploadRequest);
const send = (instanceId, generation) => handler(
{ instanceId, playerName: "test", playerData: { generation, name: "test" } }
);
assert.deepEqual(await send(1, 2), new msg.UploadRequest.Response(true));
// A crashed instance restarting with the generation another instance already uploaded
assert.deepEqual(await send(1, 2), new msg.UploadRequest.Response(false));
await mock.getHandler(controller, msg.AcquireRequest)(new msg.AcquireRequest(1, "test"));
controller.instances.set(2, controller.instances.get(1));
assert.deepEqual(await send(2, 3), new msg.UploadRequest.Response(false));
});
it("should store the upload as is when it has no components", async function() { it("should store the upload as is when it has no components", async function() {
const playerData = { generation: 2, name: "test", force: "enemy", tag: "uploaded" }; const playerData = { generation: 2, name: "test", force: "enemy", tag: "uploaded" };
assert.deepEqual(await upload(playerData), playerData); assert.deepEqual(await upload(playerData), playerData);
+20 -1
View File
@@ -5,7 +5,7 @@ import * as lib from "@clusterio/lib";
import * as mock from "../../../test/mock.js"; import * as mock from "../../../test/mock.js";
import loadInstancePlugin, { recipeNotificationDelta, applyRecipeNotificationDelta } from "../dist/node/instance.js"; import loadInstancePlugin, { recipeNotificationDelta, applyRecipeNotificationDelta } from "../dist/node/instance.js";
import { AcquireResponse, DownloadResponse, PrefetchEvent } from "../dist/node/messages.js"; import { AcquireResponse, DownloadResponse, PrefetchEvent, UploadResponse } from "../dist/node/messages.js";
import { components } from "../dist/node/components.js"; import { components } from "../dist/node/components.js";
import { plugin as info } from "../dist/node/index.js"; import { plugin as info } from "../dist/node/index.js";
@@ -275,6 +275,25 @@ describe("player_sync", function() {
const request = await upload({ name: "test", generation: 2 }); const request = await upload({ name: "test", generation: 2 });
assert.equal(request.destinationInstanceId, undefined); assert.equal(request.destinationInstanceId, undefined);
}); });
async function rconAfterReply(stored) {
instance.server.reset();
instance.connector.once("send", message => {
instance.connector.emit("message", new lib.MessageResponse(
1, message.dst, message.src, new UploadResponse(stored)
));
});
instance.server.emit("ipc-player_sync_upload", { name: "test", generation: 2 });
for (let i = 0; i < 100 && !instance.server.rconCommands.length; i++) {
await lib.wait(1);
}
return instance.server.rconCommands;
}
it("should confirm an upload the controller stored", async function() {
assert.deepEqual(await rconAfterReply(true), ['/sc player_sync.confirm_upload("test", 2)']);
});
it("should reject an upload the controller did not store", async function() {
assert.deepEqual(await rconAfterReply(false), ['/sc player_sync.reject_upload("test", 2)']);
});
}); });
describe("PrefetchEvent", function() { describe("PrefetchEvent", function() {
+8
View File
@@ -60,6 +60,14 @@ describe("player_sync plugin", function() {
assert(!validate({ instanceId: 1, playerName: "player", playerData: data }), `accepted ${deleted}`); assert(!validate({ instanceId: 1, playerName: "player", playerData: data }), `accepted ${deleted}`);
} }
}); });
it("should take the empty reply of an older controller as a stored upload", function() {
const validate = lib.compile(msg.UploadResponse.jsonSchema);
assert(validate(undefined));
assert.equal(msg.UploadResponse.fromJSON(undefined).stored, true);
assert(validate({ stored: false }));
assert.equal(msg.UploadResponse.fromJSON({ stored: false }).stored, false);
assert(!validate({ stored: "no" }));
});
describe("SetPlayerDataRequest schema", function() { describe("SetPlayerDataRequest schema", function() {
const validate = lib.compile(msg.SetPlayerDataRequest.jsonSchema); const validate = lib.compile(msg.SetPlayerDataRequest.jsonSchema);
// Shaped like module/serialize.lua output // Shaped like module/serialize.lua output
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clusterio/plugin-research_sync", "name": "@clusterio/plugin-research_sync",
"version": "2.1.0", "version": "2.0.3",
"description": "Clusterio plugin for synchronizing research between Factorio servers", "description": "Clusterio plugin for synchronizing research between Factorio servers",
"homepage": "https://github.com/clusterio/clusterio#readme", "homepage": "https://github.com/clusterio/clusterio#readme",
"author": "Hornwitser <github@hornwitser.no>", "author": "Hornwitser <github@hornwitser.no>",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clusterio/plugin-statistics_exporter", "name": "@clusterio/plugin-statistics_exporter",
"version": "2.1.0", "version": "2.0.3",
"description": "Clusterio plugin for gathering statistics from Factorio servers", "description": "Clusterio plugin for gathering statistics from Factorio servers",
"homepage": "https://github.com/clusterio/clusterio#readme", "homepage": "https://github.com/clusterio/clusterio#readme",
"author": "Hornwitser <github@hornwitser.no>", "author": "Hornwitser <github@hornwitser.no>",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clusterio/plugin-subspace_storage", "name": "@clusterio/plugin-subspace_storage",
"version": "2.1.0", "version": "2.0.3",
"description": "Clusterio plugin for sharing storage between Factorio servers", "description": "Clusterio plugin for sharing storage between Factorio servers",
"homepage": "https://github.com/clusterio/clusterio#readme", "homepage": "https://github.com/clusterio/clusterio#readme",
"author": "Hornwitser <github@hornwitser.no>", "author": "Hornwitser <github@hornwitser.no>",
-15
View File
@@ -2,21 +2,6 @@
This folder contains files used for performing manual tests that are non-trivial to automate. This folder contains files used for performing manual tests that are non-trivial to automate.
## Plugins page on small screens
Use a cluster with several plugins, including one with different running and installed versions and one with a web load error.
Compare the Plugins page at 390px and 1440px viewport widths, and check the 768px breakpoint.
1. On phones, check that each row shows its name, versions, machine counts, status and update action vertically, without clipping or breaking ordinary words.
2. Check that a long plugin name wraps and the header branding stays on one line.
3. Search and sort by name, reload, and check that the selected filter and sort order remain applied.
4. Open an update or restart checklist, then cancel it; update actions must not navigate to the plugin page.
5. Open a plugin by its name and by clicking elsewhere in its row.
6. Open the account menu using the user icon on mobile; the full account name should appear in the menu.
7. On desktop, check that the name, versions and update action remain separate columns.
## Setup test mod ## Setup test mod
Run `node packages/lib/dist/node/build_mod.js --source-dir test/manual/test_mod/ --output-dir mods/` to create the test mod. Run `node packages/lib/dist/node/build_mod.js --source-dir test/manual/test_mod/ --output-dir mods/` to create the test mod.