Compare commits

...
Author SHA1 Message Date
Cooldude2606 95cd830c01 2.0.0-alpha.27 2026-07-19 00:10:11 +01:00
Cooldude2606 ddc03eaf30 Bump engines requirement 2026-07-18 23:50:33 +01:00
Cooldude2606 b5d8cde93f Fix test for player auth controller plugin 2026-07-18 23:48:32 +01:00
Cooldude2606 4e8a833d62 Fix write permission in publish ci 2026-07-18 23:32:48 +01:00
Cooldude2606 3add08b100 Update naming within exchange string lib 2026-07-18 23:32:15 +01:00
Daniel f2b28968a0 Merge pull request #943 from Cooldude2606/ci/oidc
OIDC Publish, Node version bump, and 2.1 tests working
2026-07-19 00:20:31 +02:00
Daniel 599cf3ed22 Merge pull request #915 from Cooldude2606/feature/player-auth-display-version
Add version and connect button to player auth
2026-07-18 23:29:14 +02:00
Cooldude2606 ff28357d7a Remove hard coded versions 2026-07-18 22:12:15 +01:00
Cooldude2606 8b4c8ad337 New exchange string format for enemy expansion settings 2026-07-18 22:12:15 +01:00
Cooldude2606 07b030d045 Add 2.1 version to clusterio_lib 2026-07-18 22:12:14 +01:00
Cooldude2606 7f799c8490 Bump node version 2026-07-18 22:12:14 +01:00
Cooldude2606 91a5108b85 Add OIDC permissions to publish workflow 2026-07-18 22:12:13 +01:00
Cooldude2606 90c8188952 Address feedback
* Fix typo in toJSON
* Handle the case of hostId being 0
* Hide address when host address is empty string
2026-07-18 22:07:54 +01:00
Cooldude2606 198ea76674 Add support for recommended mod dependencies (#946) 2026-07-18 21:57:50 +01:00
Daniel 5758cd94b6 Merge pull request #945 from Cooldude2606/fix/duplicate-instances
Setting filepath on config makes it dirty
2026-07-18 21:14:56 +02:00
Cooldude2606 eab1a42e07 Make showing the connect address a config option 2026-07-11 23:04:42 +01:00
Cooldude2606 fdfd77df9a Add modal containing details of how to connect to server 2026-07-11 22:46:53 +01:00
Cooldude2606 58f2ad9810 Merge pull request #930 from bbassie/feature/table-url-state
Persist table sort/filter state in the URL
2026-07-10 23:13:53 +01:00
Cooldude2606 3cc99a1ea6 Fix commit filter logic for empty search strings 2026-07-10 23:10:54 +01:00
Cooldude2606 15781bb038 Setting filepath on config makes it dirty 2026-07-10 22:00:42 +01:00
Cooldude2606 9ec12ac7d7 Merge pull request #944 from Cooldude2606/fix/quick-bar-slots
Add string encoding/decoding and update quick bar support
2026-07-10 20:59:37 +01:00
Cooldude2606 2559c0478c Use item name only for better size usage 2026-07-10 20:59:06 +01:00
Cooldude2606 07fcdb0d4e Merge pull request #942 from Cooldude2606/refactor/sub-filters-again
Make SubscriberFilter extend Set and expose errors in EventSubscriber
2026-07-08 08:30:37 +01:00
bbassieandClaude Opus 4.8 40407e52d6 Fix clearing a live-but-uncommitted search re-committing the old value
commitFilter now reads the live overrides from a ref that setFilter updates
synchronously, so committing within the same event that changed a filter no
longer sees a stale render closure. antd's Input.Search fires onSearch (before
onChange) when the clear button is clicked, so onSearch now commits the cleared
state instead of closing the dropdown, which had re-committed the still-live
typed value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 22:02:25 +00:00
bbassieandClaude Opus 4.8 2898fdf5d3 Write the URL immediately when a search filter is cleared
The search box clear (X) button cleared the filter live but only wrote the URL
when the dropdown closed. commitFilter now accepts an explicit value so the
clear handlers can commit right away (avoiding the stale-state race of reading
the live override in the same event), for both useColumnSearch and the Users
filter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:37:32 +00:00
bbassieandClaude Opus 4.8 bb4c82ea5a Make all table search live and only write the URL on close
Add a live filter override to useTableQueryState: setFilter updates the filter
immediately (so every table's search filters as you type) without touching the
URL, and commitFilter writes it to the URL when the filter dropdown closes.
useColumnSearch and useUserFilter now filter live via setFilter and commit on
close, so the Users table no longer rewrites the URL on every keystroke and the
other tables gain live search.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:14:32 +00:00
bbassieandClaude Opus 4.8 1f2730a64f Add name-column search to the Mods and Mod Packs tables
These were the only tables without the column search filter; wire useColumnSearch
into both name columns with URL-persisted filteredValue, matching the other tables.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:58:16 +00:00
Cooldude2606 43238feada Merge pull request #941 from bbassie/feature/lenient-mod-version-parsing
Parse mod versions leniently like the game does
2026-07-02 00:50:06 +01:00
Cooldude2606 573274875c Rename to SourceVersion 2026-07-02 00:27:05 +01:00
Cooldude2606 2e27c4f499 Maintain strict tagging of GameVersion 2026-07-02 00:08:09 +01:00
bbassieandClaude Opus 4.8 8029ad3b2d Resolve GameVersion type as a plain-string supertype
Cooldude's GameVersion brand was disjoint from the clean version types in
both directions, forcing safeVersion/safeFactorioVersion companions and
breaking widening from FullVersion at file-name call sites.

Make GameVersion a plain string alias: the raw, un-normalised version as the
game reads it. The stricter template-literal types (FullVersion,
MajorMinorVersion, PartialVersion) now widen into it for free, while a
GameVersion still can't be used where a normalised version is required
without going through normaliseGameVersion/integerGameVersion. Runtime
validation stays in GameVersionSchema.

- Drop safeVersion/safeFactorioVersion; ModInfo.version stays raw (verbatim so
  it matches the mod's file name), integerVersion normalises via the new
  integerGameVersion. factorioVersion reverts to a normalised MajorMinorVersion
  since it is never part of a file name.
- ModRecord.version becomes a GameVersion so mod packs round-trip the exact
  file-name version.
- ModVersionEquality.testVersion / ModVersionRange.testVersion accept a raw
  GameVersion and normalise it; the equality bound stays strictly validated.
- Migration only pads clean partial versions; raw versions are kept verbatim.
- Cover the leading-zeros case: version kept verbatim in the file name while
  comparing equal to its normalised form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:54:06 +00:00
Cooldude2606 eca5cdcd9e Merge pull request #939 from bbassie/feature/resizable-console
Make the Web UI console resizable
2026-07-01 00:25:28 +01:00
Cooldude2606 523293d4d9 Manual adjustments to GameVersion and ReleaseChannel types
Through the creation of this commit, it was discovered that ModInfo.version must be typed as GameVersion due to file names. This results in wide spread propagation of the unsafe type.  The full impact including on ModRecord and ModVersion.testVersion have no been implemented until a path forward is decided.
2026-06-30 23:39:08 +01:00
bbassieandClaude Opus 4.8 5d20f8fc84 Revert strict ModInfo.jsonSchema and cover the factorio_version error
The strict major.minor jsonSchema did not improve any consuming type (the
factorioVersion field is already typed) and only complicated the fromJSON
signature, so revert it (and drop the now-unused MajorMinorVersionSchema).
Add a test for the unparseable factorio_version branch in fromJSON.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 23:20:37 +00:00
bbassie e27ad5fdf9 Merge remote-tracking branch 'upstream/master' into feature/lenient-mod-version-parsing
# Conflicts:
#	packages/lib/src/data/version.ts
2026-06-29 22:57:48 +00:00
bbassieandClaude Opus 4.8 5b1f7d796d Make ModInfo.jsonSchema strict about the major.minor factorio_version
The stored ModInfo always normalises factorio_version to a major.minor (see
fromJSON), so the serialised schema can use the strict MajorMinorVersionSchema
while infoJsonSchema keeps the lenient GameVersionSchema for raw info.json.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 22:49:59 +00:00
bbassie 0874c26208 Merge remote-tracking branch 'upstream/master' into feature/table-url-state
# Conflicts:
#	packages/web_ui/src/components/HostsPage.tsx
#	packages/web_ui/src/components/InstanceList.tsx
#	packages/web_ui/src/components/ModsPage.tsx
#	packages/web_ui/src/components/PluginsPage.tsx
#	packages/web_ui/src/components/RolesPage.tsx
#	packages/web_ui/src/components/UserViewPage.tsx
#	packages/web_ui/src/components/UsersTable.tsx
#	packages/web_ui/src/index.ts
2026-06-29 22:37:03 +00:00
bbassieandClaude Opus 4.8 1e7b8f08cf Address review: tidy pagination handling and comments
Let useTableQueryState accept a full TablePaginationConfig (only defaultPageSize
is read) so UsersTable passes its pagination prop straight through instead of
extracting and casting it. Drop the redundant pagination: false from the tables
that just want it disabled (that is already the default). Clarify the comment on
the Last Seen online-only default to mention the onlyOnline prop, and explain why
onChange only uses the first sorter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 22:26:39 +00:00
bbassieandClaude Opus 4.8 7c2793752d Add MajorMinorVersion type and dedupe the game version regex
Address review feedback: introduce a MajorMinorVersion type with
isMajorMinorVersion, rename majorMinorVersion to normaliseMajorMinorVersion,
and type ModInfo.factorioVersion as MajorMinorVersion so already-reduced
values are not re-reduced (ModPackViewPage uses mod.info.factorioVersion
directly). Reuse the single gameVersionRegExp in both GameVersionSchema and
normaliseGameVersion instead of duplicating the pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 22:12:36 +00:00
bbassieandClaude Opus 4.8 5cde390c24 Use a spacer element for console scroll headroom
Replace the padding-top scroll headroom with a dedicated .console-spacer
element so the pin-to-bottom keeps working without padding inflating the
measured height, and trim the style.css comments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 22:01:06 +00:00
Cooldude2606 46ebb13fda Update quick bar to support 2.1 and quality 2026-06-29 02:17:03 +01:00
Cooldude2606 4775a5fd06 Add encode_string and decode_string to compat 2026-06-29 01:35:05 +01:00
Cooldude2606 7e89e7fb6f Fix assert in test case 2026-06-26 22:15:28 +01:00
Cooldude2606 95ea073b77 Expose error on event subscriber snapshot 2026-06-26 22:04:06 +01:00
Cooldude2606 e135d37878 Change SubscriptionFilters to extend Set 2026-06-26 21:41:48 +01:00
bbassieandClaude Opus 4.8 2dedb44372 Fix being unable to shrink the console after enlarging it
The resize observer measured offsetHeight, which on the content-box console
includes the pin-to-bottom padding-top. Feeding that back into the height
(and thus the padding) meant the padding inflated the measured height, so
the console could be enlarged but never dragged smaller again.

Make the console border-box so its height already includes the padding and
offsetHeight matches the dragged height, breaking the feedback. The default
height is bumped to 600 so the rendered size stays the same as the previous
content-box height + padding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:38:35 +00:00
bbassieandClaude Opus 4.8 97a6c32838 Parse mod versions leniently like the game does
The mod portal "ApiVersion" enum was validated strictly when loading a
mod's info.json, which rejected versions the game itself accepts. Versions
are now normalised the same lenient way the game reads them: leading
whitespace is allowed, the major and minor are required, the patch is
optional, any trailing content is ignored, and numbers are read as integers.

This applies to both a mod's version and its factorio_version, validated by
a regex schema rather than the fixed enum. The enum is no longer used to
validate versions at all - the mod portal is the source of truth, so its
error is now surfaced when a version is unsupported. The known-version list
kept for the web interface fallback gains 2.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:24:43 +00:00
BastiaanandClaude Opus 4.8 a56fcbd785 Fix antd deprecation and TreeSelect duplicate-value warnings (#940)
Rename the deprecated Modal `destroyOnClose` prop to `destroyOnHidden`
(antd 5.25+) across the web UI.

Skip a version-tree child equal to its major.minor group key in
InputVersion, which triggered "Same value exist in the tree" warnings when
the version list contained bare major.minor entries (the ApiVersions
fallback shown before the version fetch resolves).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:21:52 +01:00
Cooldude2606 e16603bbd0 Merge pull request #937 from bbassie/feature/instance-version-channels
Add release channels (stable/experimental) as instance version targets
2026-06-26 17:00:44 +01:00
bbassieandClaude Opus 4.8 dfe132699e Add integration test for starting an instance with a release channel
Configures an instance with the "experimental" channel and starts it,
exercising channel resolution end to end across the controller and host.
The resolved version may or may not be installed depending on the host's
Factorio install, so the test asserts the instance reaches a terminal state
rather than a specific outcome.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:38:29 +00:00
bbassieandClaude Opus 4.8 f793ec33b3 Add tests for release channel resolution and caching
Cover fetchLatestReleases and resolveReleaseChannel, the controller's
latest-releases cache with its on-disk offline fallback, the
LatestReleasesRequest round-trip, and FactorioServer.setTargetVersion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:10:06 +00:00
Cooldude2606 ce8bddad8f Merge pull request #931 from bbassie/feature/open-in-new-tab
Make navigation open in new tabs (real links)
2026-06-26 15:47:33 +01:00
bbassieandClaude Opus 4.8 3fe0c85055 Make the Web UI console resizable
The instance console was locked to a fixed 300px height. It can now be
drag-resized vertically (native resize handle), and the chosen height is
persisted with useLocalStorage so it is restored on the next visit.

Both the height and the scroll-anchor padding are driven by a
--console-height CSS variable, so the pin-to-bottom behaviour keeps working
at any size.

Closes #938

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:47:32 +00:00
bbassieandClaude Opus 4.8 9da48d5387 Add release channels as instance version targets
An instance's factorio.version can now target a release channel such as
"stable" or "experimental" (any key from factorio.com/api/latest-releases)
in addition to "latest" and specific X.Y / X.Y.Z versions.

The controller proxies the latest-releases API through a ValueCache and
persists the last successful response to latest-releases.json, serving that
copy when the API is unreachable. On instance start the host resolves a
channel to the concrete version it points to and feeds it into the existing
checkForUpdates/findVersion path, so a channel auto-installs and runs like a
pinned version. The version selector in the web UI lists the available
channels alongside "latest".

Also clarifies the host log/error when a newer version cannot be downloaded
automatically because the host uses a direct (single-version) Factorio
install rather than a versioned directory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:19:34 +00:00
bbassieandClaude Opus 4.8 2bd9bc6138 Fill table link cells with a block link instead of an overlay
Replace the absolutely-positioned a::after overlay with display:block on
the link. The overlay covered sibling cell content (the restart-required
icon), swallowing its hover so the tooltip never triggered. A block link
fills the cell width without overlapping siblings.

Also skip rendering the assigned-host link when an instance has no
assigned host, avoiding a dead /hosts/undefined/view link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:24:34 +00:00
Daniel a274fe74e1 Merge pull request #936 from Cooldude2606/fix/925
Add "recycler" as builtin mod for 2.1
2026-06-26 10:25:41 +02:00
Daniel ea5c35198f Merge pull request #924 from bbassie/feature/sidebar-layout
Rework site layout into a fixed sidebar
2026-06-26 10:06:23 +02:00
Cooldude2606 0f6f3bbb8a Add "recycler" as builtin mod for 2.1 2026-06-26 02:20:32 +01:00
bbassieandClaude Opus 4.8 448eb9223d Make table link cells fill the cell and fix focus colour
Stretch the navigation link over the whole table cell (a position:relative
cell with an ::after overlay) so the entire name cell - and the Instances
Assigned Host cell - is clickable and right-click "open in new tab"-able,
not just the text. Also force color:inherit on the sidebar and account links
so the focus left behind by a middle-click no longer shows them blue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:55:29 +00:00
bbassieandClaude Opus 4.8 cc0be69019 Enlarge the sidebar logo to 56px
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:41:25 +00:00
Cooldude2606andClaude Opus 4.8 9c627f4c16 Add cluster overview landing page (#933)
* Add cluster overview landing page

Add an Overview page at / (top of the sidebar, filling the previously
blank root route) summarising the cluster: hosts connected, instances
running, users online, aggregate CPU/memory/disk usage, controller
uptime, a recent-errors panel (LogConsole), and a link to Grafana when
configured. Each card is gated by the relevant permission.

Adds a controller.grafana_url config field, read on the page via
ControllerConfigGetRequest, for the Grafana link.

Closes #928.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Add saves card and tidy the overview grid

Add a "Saves loaded" card (loaded vs total save size) to the overview, lay
the cards out four-per-row on desktop, and add a compact date format option
to MetricRelativeDate used for the controller uptime card.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Change order of cards and fix permission check

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
2026-06-25 16:27:55 +01:00
Cooldude2606 118154a43a Change order of cards and fix permission check 2026-06-25 16:20:48 +01:00
BastiaanandClaude Opus 4.8 b29f4db52c Show the public address on the instance details page (#935)
The instance list shows the public connect address but the instance detail
page did not. Add a Public address row (host:port) with a copy button, and
lay the details out 2x2 so the extra row stays balanced.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:15:18 +01:00
Cooldude2606 5efa257a36 Merge pull request #932 from bbassie/feature/remember-console-toggle
Remember the instance console chat/log toggle
2026-06-25 16:14:44 +01:00
bbassieandClaude Opus 4.8 09ec6d6ec5 Add saves card and tidy the overview grid
Add a "Saves loaded" card (loaded vs total save size) to the overview, lay
the cards out four-per-row on desktop, and add a compact date format option
to MetricRelativeDate used for the controller uptime card.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 02:40:22 +00:00
bbassieandClaude Opus 4.8 8cacdc5112 Use inline sort/filter getters instead of mapping columns
Replace the satisfies-ColumnsType + .map(applyColumn) approach with inline tableState.sortOrder()/filteredValue() getters set per column, so the column arrays stay plain literals (no satisfies, no constraints; UsersTable stays any[]). Move useColumnSearch into util/ next to useTableQueryState.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 02:32:04 +00:00
bbassieandClaude Opus 4.8 d4394f1190 Link the sidebar logo to the overview page
Make the Clusterio logo in the top bar a link to / so it lands on the overview/dashboard page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 02:32:03 +00:00
bbassieandClaude Opus 4.8 6feb9c2762 Add useLocalStorage hook for the console toggle
Move the instance console chat/log toggle persistence into a generic useLocalStorage hook that defers the write so it never blocks the toggle update.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 02:31:49 +00:00
bbassieandClaude Opus 4.8 79c6bd1ac4 Add cluster overview landing page
Add an Overview page at / (top of the sidebar, filling the previously
blank root route) summarising the cluster: hosts connected, instances
running, users online, aggregate CPU/memory/disk usage, controller
uptime, a recent-errors panel (LogConsole), and a link to Grafana when
configured. Each card is gated by the relevant permission.

Adds a controller.grafana_url config field, read on the page via
ControllerConfigGetRequest, for the Grafana link.

Closes #928.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 01:21:17 +00:00
bbassieandClaude Opus 4.8 4e94092730 Type UsersTable columns instead of any
UsersTable declared its columns as any[], which masked a few type
mismatches. Type them as ColumnType<lib.UserDetails>[] (matching
InstanceList) and fix what surfaced: the Roles column and onFilterUser
value types (antd keys include bigint) and useUserFilter's filterDropdown
prop type (FilterDropdownProps instead of a hand-written string[] shape).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:55:47 +00:00
bbassieandClaude Opus 4.8 82629a57c6 Always persist table state; drop the persistState prop
Every render site of InstanceList and UsersTable already passed persistState,
so the prop and its uncontrolled fallback branch were dead config. Remove the
prop and the conditionals so these tables always persist their state to the
URL. No runtime behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:40:21 +00:00
bbassieandClaude Opus 4.8 92f607a864 Remember the instance console chat/log toggle
The Console "Chat/Log" filter toggle on the instance page now persists its
state to localStorage, so turning it off (to see full logs) is remembered
across reloads. It still defaults to on (chat only) when nothing is stored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:30:12 +00:00
bbassieandClaude Opus 4.8 245483cb35 Adopt agreed URL scheme and drop the dist-based test
Following discussion on the PR, the sort value now packs the direction as
`<field>.<asc|desc>` (e.g. user.sort=name.desc) instead of a separate
`.order` param, page size uses `.size` instead of `.pageSize`, and the
reserved system keys are sort/page/size. `.sort` is read tolerant of repeats
so multi-column sort can be added later without changing the URL shape.

Filterable columns must not use the reserved system names. Removed the
web_ui unit test since it required importing from the built dist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:24:31 +00:00
bbassieandClaude Opus 4.8 f04fc68513 Use real links for navigation so they open in new tabs
In-app navigation was done with JS onClick -> navigate() handlers instead of
real <a href> anchors, so the browser's right-click "Open in new tab" (and
middle-click / ctrl-click) was unavailable. (closes #929)

- Sidebar nav items and the account "user" entry now render their label as the
  existing Link component (a real anchor via react-router useHref), keeping the
  Menu onClick for plain left-clicks; stopPropagation avoids double navigation.
- The "back to instances" alert action becomes a Link.
- Table rows (Hosts, Roles, Plugins, Instances, Users, the user instance-stats
  table, Mod Packs): the name cell becomes a real Link (color inherit, so it
  looks unchanged) which carries the right-click context menu, and a new
  useRowNavigation hook handles the whole row — left-click navigates in place,
  middle-click / ctrl-click open the target in a new tab. The hook ignores
  clicks landing on a real link inside the row so the anchor handles those.

Genuine actions (download/delete/modify/logout), post-create navigation and
external links are left as-is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 23:52:15 +00:00
bbassieandClaude Opus 4.8 49eb594e5b Persist table sort/filter state in the URL
Tables such as Hosts, Instances, Users, Roles, etc. now write their sort
order, column filters and pagination into the URL query string, so reload,
link-sharing and browser back/forward restore the same view (closes #927).

Every parameter is namespaced with the table name so the URL is
self-describing and two tables can share a page without colliding, e.g.
?user.sort=name&user.name=bob&user.admin=true or ?instance.name=foo.

Adds two reusable hooks, exported from @clusterio/web_ui so plugins can use
them too:
- useTableQueryState: makes an antd Table controlled and round-trips its
  sort/filter/pagination through the URL via useSearchParams. The pure
  serialisation helpers live in util/tableQuery.ts and are unit tested.
- useColumnSearch: a column-header text-search filter (matching the Users
  table style), persisted through the same filter mechanism.

Applied to Hosts, Roles, Plugins, the instance list (standalone and on the
host detail page), Users (main table and the online-players list on the
instance detail page), instance saves, the per-instance user stats, the mod
packs and stored mods tables, and the subspace_storage storage page. The
storage page's resource search moves from a standalone box into the Resource
column header, keeping its regex matching.

The bundled user filter persists as separate readable params (user.name,
user.admin, ...) via a per-column FilterCodec rather than one JSON blob.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 23:23:54 +00:00
bbassieandClaude Opus 4.8 ed84a476c6 Add top bar and overlay nav drawer on mobile
Reintroduce a slim top bar to host the menu toggle, branding and the
account menu, which gives the hamburger a natural home now that the
header is gone. The bar is more compact than the original header and
uses the dark sider colour for better contrast.

On phone screens the sider becomes an overlay Drawer (opening below the
bar and closing on navigation), following normal hamburger conventions
instead of shifting the content across. On desktop the same toggle
collapses the sider, with its width animation disabled to avoid the
content reflow glitch while resizing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 19:19:36 +00:00
bbassieandClaude Opus 4.8 262357acef Rework site layout into a fixed sidebar
Remove the top header and move its contents into the sidebar, which is
now sticky and full height: logo and version at the top, navigation in
the middle, and the account menu pinned to the bottom (opening to the
right).

The sidebar is rebuilt with antd Flex, Typography and Menu so almost all
styling comes from antd, reducing the custom css to a single sticky rule.
The nav menus also use theme="dark" to match the sider and fix the low
contrast active item.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:16:49 +00:00
Cooldude2606 0482f4ae08 Added test for invalid assigned host 2026-06-01 01:06:06 +01:00
Cooldude2606 16ce748ffa Improved test coverage 2026-06-01 00:58:25 +01:00
Cooldude2606 2c199028ea Add tests 2026-06-01 00:31:29 +01:00
Cooldude2606 22b16a7ac2 Small refactor of verifyToken effect 2026-06-01 00:13:57 +01:00
Cooldude2606 f2ccde0dd6 Add version and connect button to player auth 2026-05-31 23:55:43 +01:00
94 changed files with 2907 additions and 630 deletions

No files matched your search

+4 -4
View File
@@ -13,9 +13,9 @@ jobs:
strategy:
matrix:
# Supported versions are active lts and maintenance see https://nodejs.org/en/about/releases/
node-version: [20.x, 22.x]
node-version: [22.x, 24.x]
# Supported versions are latest stable and one minor version before stable (even if from a previous major)
factorio-version: [1.1.110, 2.0.47]
factorio-version: [1.1.110, 2.0.77, 2.1.8]
steps:
- uses: actions/checkout@v4
@@ -54,7 +54,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
node-version: 24.x
- run: pnpm i --no-frozen-lockfile
- run: pnpm run lint
@@ -65,7 +65,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
node-version: 24.x
- run: pnpm i --no-frozen-lockfile
- run: pnpm run build-mod
# Upload build to artifacts
+6 -4
View File
@@ -6,6 +6,10 @@ on:
types:
- released
permissions:
id-token: write
contents: write
jobs:
npm:
runs-on: ubuntu-latest
@@ -15,12 +19,10 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22.x
node-version: 24.x
registry-url: https://registry.npmjs.org
- run: pnpm install --no-frozen-lockfile
- run: pnpm -r --filter='./{plugins,packages}/*' publish --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- uses: softprops/action-gh-release@v2
with:
files: pnpm-lock.yaml
@@ -33,7 +35,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22.x
node-version: 24.x
- run: pnpm install --no-frozen-lockfile
- run: pnpm -r --filter='./{plugins,packages}/*' run build-mod --output-dir ../../dist
- run: node ./publish.js factorio ./dist
+38
View File
@@ -31,6 +31,44 @@ Many thanks to the following for contributing to this release:
[@username](https://github.com/username)
-->
## Version 2.0.0-alpha.27
### Features
- Added a cluster overview landing page. [#928](<https://github.com/clusterio/clusterio/issues/928>)
- Remember the instance console chat/log toggle. [#932](<https://github.com/clusterio/clusterio/pull/932>)
- Show the public address on the instance details page. [#934](<https://github.com/clusterio/clusterio/issues/934>)
- Navigation links can now be opened in a new tab. [#929](<https://github.com/clusterio/clusterio/issues/929>)
- Instances can target a Factorio release channel (e.g. `stable` or `experimental`) as their version, resolved from `factorio.com/api/latest-releases`. [#937](<https://github.com/clusterio/clusterio/pull/937>)
- The instance console in the Web UI can be resized by dragging its bottom edge, and the chosen height is remembered across page reloads. [#938](<https://github.com/clusterio/clusterio/issues/938>)
- Tables now remember their sort and filters in the URL. [#927](<https://github.com/clusterio/clusterio/issues/927>)
- Display Factorio server version in login server list. [#906](<https://github.com/clusterio/clusterio/issues/906>)
- Add "Connect via Steam" button to login server list. [#915](<https://github.com/clusterio/clusterio/pull/915>)
### Fixes
- Silenced two Ant Design console warnings in the Web UI: the deprecated Modal `destroyOnClose` prop, and a duplicate-value warning from the instance version selector. [#940](<https://github.com/clusterio/clusterio/pull/940>)
- Surface the mod portal's own error when downloading mods for an unsupported Factorio version. [#941](<https://github.com/clusterio/clusterio/pull/941>)
- Instances always create their config file on assignment preventing duplicate folder creation. [#945](<https://github.com/clusterio/clusterio/pull/945>)
### Changes
- Reworked the site layout into a fixed sidebar with a top bar. [#924](<https://github.com/clusterio/clusterio/pull/924>)
- The builtin "recycler" mod is now included correctly as builtin. [#925](<https://github.com/clusterio/clusterio/issues/925>)
- Mod `info.json` versions (a mod's version and its `factorio_version`) are now parsed leniently, matching how the game reads them, instead of being validated against a fixed list of Factorio versions. [#941](<https://github.com/clusterio/clusterio/pull/941>)
- SubscriptionFilter now directly extends Set<string>. [#942](<https://github.com/clusterio/clusterio/pull/942>)
- EventSubscriber now exposes network errors, include permission errors. [#942](<https://github.com/clusterio/clusterio/pull/942>)
- Quick bar syncing now supports quality filters. [#944](<https://github.com/clusterio/clusterio/pull/944>)
- Added support for recommended mod dependencies. [#946](<https://github.com/clusterio/clusterio/pull/946>)
### Meta
- Node 20 is no longer officially supported as it went EoL in March 2026. Please update to v22 or v24. [#943](<https://github.com/clusterio/clusterio/pull/943>)
Many thanks to the following for contributing to this release:
[@bbassie](<https://github.com/bbassie>)
[@Cooldude2606](<https://github.com/Cooldude2606>)
## Version 2.0.0-alpha.26
### Features
+1 -1
View File
@@ -26,7 +26,7 @@
]
},
"engines": {
"node": ">=20"
"node": ">=22"
},
"workspaces": [
"packages/*",
+2 -2
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio controller",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.26",
"version": "2.0.0-alpha.27",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --mode production",
@@ -17,7 +17,7 @@
"factorio"
],
"engines": {
"node": ">=20"
"node": ">=22"
},
"dependencies": {
"@clusterio/lib": "workspace:^",
+6 -1
View File
@@ -33,6 +33,7 @@ export default class BaseConnection extends lib.Link {
this.handle(lib.ModPackGetDefaultRequest, this.handleModPackGetDefaultRequest.bind(this));
this.handle(lib.ModDownloadRequest, this.handleModDownloadRequest.bind(this));
this.handle(lib.FactorioVersionsRequest, this.handleFactorioVersionsRequest.bind(this));
this.handle(lib.LatestReleasesRequest, this.handleLatestReleasesRequest.bind(this));
this.handle(lib.SubscriptionRequest, this.handleSubscriptionRequest.bind(this));
this.connector.on("close", () => {
@@ -79,7 +80,7 @@ export default class BaseConnection extends lib.Link {
return modPack;
}
getMod(mod: { name: string, version: lib.FullVersion, sha1?: string }) {
getMod(mod: { name: string, version: lib.SourceVersion, sha1?: string }) {
let filename = lib.ModInfo.filename(mod.name, mod.version);
let modInfo = this._controller.modStore.files.get(filename);
if (!modInfo) {
@@ -113,4 +114,8 @@ export default class BaseConnection extends lib.Link {
async handleFactorioVersionsRequest(request: lib.FactorioVersionsRequest) {
return await this._controller.factorioVersions.get(request.maxAgeMs);
}
async handleLatestReleasesRequest(request: lib.LatestReleasesRequest) {
return await this._controller.latestReleases.get(request.maxAgeMs);
}
}
+29 -11
View File
@@ -675,6 +675,7 @@ export default class ControlConnection extends BaseConnection {
type ModPortalModType = InstanceType<typeof lib.ModPortalGetAllRequest.Response>["mods"][number];
type ModPortalReleaseType = NonNullable<ModPortalModType["releases"]>[number];
const dependencyRequirements = new Map<string, lib.ModVersionRange>();
const recommendedRequirements = new Map<string, lib.ModVersionRange>();
const optionalRequirements = new Map<string, lib.ModVersionRange>();
const candidateReleases = new Map<string, lib.ModInfo>();
const errors = new Map<string, lib.ModDependencyResolveErrors>();
@@ -707,7 +708,7 @@ export default class ControlConnection extends BaseConnection {
const release = releases.releases
.filter(info => versionRange.testVersion(info.version))
.reduce<ModPortalReleaseType | undefined>((max, cur) => (
max && lib.integerFullVersion(max.version) > lib.integerFullVersion(cur.version) ? max : cur
max && lib.integerSourceVersion(max.version) > lib.integerSourceVersion(cur.version) ? max : cur
), undefined);
if (!release) {
@@ -739,16 +740,32 @@ export default class ControlConnection extends BaseConnection {
continue;
}
// Get the current version range (optionals are tracked in case they become required)
let versionRange = dependencyRequirements.get(mod.name);
if (!versionRange) {
versionRange = optionalRequirements.get(mod.name) ?? new lib.ModVersionRange();
if (mod.required) {
dependencyRequirements.set(mod.name, versionRange);
// Get the current version range (dependencies can be promoted to a stricter requirement)
const versionRange =
dependencyRequirements.get(mod.name)
?? recommendedRequirements.get(mod.name)
?? optionalRequirements.get(mod.name)
?? new lib.ModVersionRange();
if (mod.required) {
// Add to required, remove from recommended and optional
dependencyRequirements.set(mod.name, versionRange);
recommendedRequirements.delete(mod.name);
optionalRequirements.delete(mod.name);
} else if (mod.recommended) {
// If not required, add to recommended and remove from optional
if (!dependencyRequirements.has(mod.name)) {
recommendedRequirements.set(mod.name, versionRange);
optionalRequirements.delete(mod.name);
} else {
optionalRequirements.set(mod.name, versionRange);
}
} else if (
!dependencyRequirements.has(mod.name)
&& !recommendedRequirements.has(mod.name)
) {
// If not required or recommended, add to optional
optionalRequirements.set(mod.name, versionRange);
}
// Update the version range if needed
@@ -757,6 +774,7 @@ export default class ControlConnection extends BaseConnection {
if (!versionRange.valid) {
candidateReleases.delete(mod.name);
errors.set(mod.name, "unsatisfiable");
continue;
}
}
@@ -804,9 +822,9 @@ export default class ControlConnection extends BaseConnection {
}
}
// Remove incompatible dependencies if they aren't required by others
// Remove incompatible and unsatisfiable dependencies if they aren't required by others
for (const [modName, error] of errors) {
if (error === "incompatible" && !dependencyRequirements.has(modName)) {
if (!dependencyRequirements.has(modName) && (error === "incompatible" || error === "unsatisfiable")) {
errors.delete(modName);
}
}
+27 -2
View File
@@ -121,6 +121,28 @@ export default class Controller {
}
});
// Cache for the latest stable/experimental factorio releases. Persisted to
// disk so that release channel targets can still be resolved when
// factorio.com is unavailable.
latestReleases = new lib.ValueCache(async () => {
const cachePath = path.join(this.config.get("controller.database_directory"), "latest-releases.json");
try {
const releases = await lib.fetchLatestReleases();
await lib.safeOutputFile(cachePath, JSON.stringify(releases, null, "\t"));
return releases;
} catch (err: any) {
logger.warn(`Failed to retrieve Factorio releases ${err.message}`);
try {
return JSON.parse(await fs.readFile(cachePath, "utf8")) as lib.LatestReleases;
} catch (readErr: any) {
if (readErr.code !== "ENOENT") {
logger.warn(`Failed to read cached Factorio releases ${readErr.message}`);
}
return {};
}
}
});
static async bootstrap(config: lib.ControllerConfig) {
let databaseDirectory = config.get("controller.database_directory");
await fs.mkdir(databaseDirectory, { recursive: true });
@@ -741,8 +763,11 @@ export default class Controller {
const serialized = rawJson as Static<typeof lib.ModPack.jsonSchema>[];
return serialized.map(json => {
for (const mod of json.mods) {
// migrate: 2.0.0.alpha.22 - json schema now enforces X.Y.Z for mod version, builtins would use X.Y only
mod.version = lib.normaliseFullVersion(mod.version);
// migrate: 2.0.0-alpha.22 - old buildins used X.Y rather than X.Y.Z
// All other versions are kept verbatim so they still match the mod's file name.
if (lib.isPartialVersion(mod.version)) {
mod.version = lib.normaliseFullVersion(mod.version) as lib.SourceVersion;
}
}
return json;
});
+2 -2
View File
@@ -3,7 +3,7 @@
"description": "Installer for clusterio",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.26",
"version": "2.0.0-alpha.27",
"main": "create.js",
"bin": {
"clusteriocreate": "./create.js"
@@ -13,7 +13,7 @@
"factorio"
],
"engines": {
"node": ">=20"
"node": ">=22"
},
"dependencies": {
"chalk": "catalog:",
+2 -2
View File
@@ -3,7 +3,7 @@
"description": "Clusterio command line management tool",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.26",
"version": "2.0.0-alpha.27",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
@@ -17,7 +17,7 @@
"factorio"
],
"engines": {
"node": ">=20"
"node": ">=22"
},
"dependencies": {
"@clusterio/lib": "workspace:^",
+20
View File
@@ -9,3 +9,23 @@ debugCommands.add(new lib.Command({
control.keepOpen = true;
},
}));
debugCommands.add(new lib.Command({
definition: [
"parse-exchange-string <exchange-string>",
"Parse and print the contents of an exchange string",
(yargs) => {
yargs.positional("exchange-string", { describe: "String to parse", type: "string" });
yargs.option("pretty", { alias: "p", type: "boolean", description: "Pretty output" });
}],
handler: async function(args: { exchangeString: string, pretty: boolean }, control: Control) {
const result = lib.readMapExchangeString(args.exchangeString);
if (args.pretty) {
// eslint-disable-next-line no-console
console.log(JSON.stringify(result, null, 2));
} else {
// eslint-disable-next-line no-console
console.log(JSON.stringify(result));
}
},
}));
+8 -4
View File
@@ -20,7 +20,7 @@ modCommands.add(new lib.Command({
yargs.positional("mod-version", { describe: "Version of the mod", type: "string" });
}],
handler: async function(args: { name: string, modVersion: string }, control: Control) {
if (!lib.isFullVersion(args.modVersion)) {
if (!lib.isSourceVersion(args.modVersion)) {
throw new lib.CommandError("mod-version must match format digit.digit.digit");
}
let modInfo = await control.send(new lib.ModGetRequest(args.name, args.modVersion));
@@ -108,9 +108,13 @@ modCommands.add(new lib.Command({
},
control: Control
) {
const searchVersion = lib.normaliseSourceVersion(String(args.factorioVersion));
if (searchVersion === undefined) {
throw new Error(`Invalid factorio version "${args.factorioVersion}"`);
}
let response = await control.send(new lib.ModSearchRequest(
args.query,
lib.normaliseApiVersion(args.factorioVersion as any),
lib.normaliseMajorMinorVersion(searchVersion),
args.page,
args.pageSize,
args.sort,
@@ -187,7 +191,7 @@ modCommands.add(new lib.Command({
yargs.positional("mod-version", { describe: "Version of mod to download", type: "string" });
}],
handler: async function(args: { name: string, modVersion: string }, control: Control) {
if (!lib.isFullVersion(args.modVersion)) {
if (!lib.isSourceVersion(args.modVersion)) {
throw new lib.CommandError("mod-version must match format digit.digit.digit");
}
let streamId = await control.send(new lib.ModDownloadRequest(args.name, args.modVersion));
@@ -207,7 +211,7 @@ modCommands.add(new lib.Command({
yargs.positional("mod-version", { describe: "Version of mod to delete", type: "string" });
}],
handler: async function(args: { name: string, modVersion: string }, control: Control) {
if (!lib.isFullVersion(args.modVersion)) {
if (!lib.isSourceVersion(args.modVersion)) {
throw new lib.CommandError("mod-version must match format digit.digit.digit");
}
await control.send(new lib.ModDeleteRequest(args.name, args.modVersion));
+1 -1
View File
@@ -132,7 +132,7 @@ function setModPackMods(modPack: lib.ModPack, mods: string[] | undefined) {
if (!version) {
throw new lib.CommandError("Added mod must be formatted as name:version or name:version:sha1");
}
if (!lib.isFullVersion(version)) {
if (!lib.isSourceVersion(version)) {
throw new lib.CommandError("version must match the format digit.digit.digit");
}
if (sha1 && !/^[0-9a-f]{40}$/.test(sha1)) {
@@ -16,6 +16,10 @@
{
"version": "2.0.20",
"factorio_version": "2.0"
},
{
"version": "2.0.21",
"factorio_version": "2.1"
}
],
"title": "Clusterio Library (Alpha)",
@@ -17,6 +17,8 @@ local lib_json = require("json")
--- @field prototypes LuaPrototypes
--- @field active_mods table<string, string>
--- @field write_file fun(filename: string, data: LocalisedString, append: boolean?, for_player: uint?)
--- @field encode_string fun(string: string): string
--- @field decode_string fun(string: string): string
--- @field table_to_json fun(data: table): string
--- @field json_to_table fun(json: string): AnyBasic?
local compat = {}
@@ -177,6 +179,24 @@ function runtime_properties.write_file()
end
end
--- Deflates and base64 encodes the given string.
function runtime_properties.encode_string()
if (v0_18 and compat.version_ge("0.18.7")) or v1 then
return game.encode_string
elseif v2 then
return helpers.encode_string
end
end
--- Base64 decodes and inflates the given string.
function runtime_properties.decode_string()
if (v0_18 and compat.version_ge("0.18.7")) or v1 then
return game.decode_string
elseif v2 then
return helpers.decode_string
end
end
--- Convert tables to and from json, we have backported this to work before runtime
if v0 or v1 then
compat.table_to_json = function(tbl)
+2 -2
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio host server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.26",
"version": "2.0.0-alpha.27",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
@@ -18,7 +18,7 @@
"factorio"
],
"engines": {
"node": ">=20"
"node": ">=22"
},
"dependencies": {
"@clusterio/lib": "workspace:^",
+2 -4
View File
@@ -659,10 +659,8 @@ export default class Host extends lib.Link {
return await this.downloadMod(mod);
}
async fetchMods(mods: Iterable<lib.ModRecord>) {
// This is better than the previous hard coded names
// But it really shouldn't be a hard coded version either
const builtinModNames = lib.ModPack.getBuiltinModNames("2.0");
async fetchMods(mods: Iterable<lib.ModRecord>, factorioVersion: lib.PartialVersion) {
const builtinModNames = lib.ModPack.getBuiltinModNames(factorioVersion);
const modInfos: Promise<lib.ModInfo>[] = [];
for (const mod of mods) {
if (builtinModNames.includes(mod.name)) {
+16 -3
View File
@@ -642,6 +642,21 @@ end`.replace(/\r?\n/g, " ");
try {
const factorioVersions = await this.sendTo("controller", new lib.FactorioVersionsRequest());
await this._loadStats();
// Resolve a release channel target (e.g. "stable") to a concrete
// version using the controller's cached latest-releases data, so the
// existing download/lookup logic can fetch and run it.
const targetVersion = this.config.get("factorio.version") as string;
if (lib.isReleaseChannel(targetVersion)) {
const releases = await this.sendTo("controller", new lib.LatestReleasesRequest());
const resolved = lib.resolveReleaseChannel(releases, targetVersion);
if (!resolved) {
throw new Error(
`Cannot resolve Factorio release channel '${targetVersion}': ` +
"latest-releases unavailable and no cached copy on the controller."
);
}
this.server.setTargetVersion(resolved);
}
await this.server.checkForUpdates(factorioVersions);
await this.server.init();
} catch (err) {
@@ -782,9 +797,7 @@ end`.replace(/\r?\n/g, " ");
}
this.activeModPack = modPack;
// TODO validate factorioVersion
const mods = await this._host.fetchMods(modPack.mods.values());
const mods = await this._host.fetchMods(modPack.mods.values(), modPack.factorioVersion);
await fs.mkdir(this.path("mods"), { recursive: true });
+31 -3
View File
@@ -190,7 +190,11 @@ async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion
if (targetVersion === "latest" || directVersion === targetVersion || directVersion.startsWith(targetVersion)) {
return [path.join(factorioDir, "data"), directVersion];
}
throw new Error(`Unable to find Factorio version ${targetVersion}`);
throw new Error(
`Unable to find Factorio version ${targetVersion}: ${factorioDir} is a direct (single-version) ` +
`install of ${directVersion}. Use a versioned layout, where ${factorioDir} contains a subdirectory ` +
"per version, to run other versions or download them automatically."
);
}
// Otherwise assume it is a directory of multiple versions
@@ -952,6 +956,18 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
}
}
/**
* Override the target version of Factorio to run
*
* Used to substitute a concrete version for a release channel target
* (e.g. "stable" or "experimental") before {@link checkForUpdates} and
* {@link init} run. Must be called before the server is initialized.
*/
setTargetVersion(version: lib.TargetVersion) {
this._check(["new"]);
this._targetVersion = version;
}
/**
* Initialize class instance
*
@@ -987,8 +1003,20 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
if (!installedVersions.versions.has(latestVersion.version)) {
const v = latestVersion.version;
// Can't download if not linux or this is a direct install
if (installedVersions.direct || process.platform !== "linux") {
this._logger.info(`A newer version of factorio is available (${v}) but must be manually downloaded`);
if (installedVersions.direct) {
this._logger.info(
`A newer version of factorio is available (${v}) but cannot be downloaded automatically ` +
`because ${this._factorioDir} is a direct (single-version) Factorio install. Use a versioned ` +
`layout, where ${this._factorioDir} contains a subdirectory per version, to enable automatic ` +
"downloads."
);
return;
}
if (process.platform !== "linux") {
this._logger.info(
`A newer version of factorio is available (${v}) but must be manually downloaded ` +
"(automatic downloads are only supported on Linux)."
);
return;
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@clusterio/lib",
"description": "Shared library for Clusterio",
"version": "2.0.0-alpha.26",
"version": "2.0.0-alpha.27",
"repository": "https://github.com/clusterio/clusterio",
"license": "MIT",
"scripts": {
+18 -7
View File
@@ -5,7 +5,7 @@ import { Static } from "@sinclair/typebox";
import { logger } from "./logging";
import { downloadFile, safeOutputFile } from "./file_ops";
import { ModPortalReleaseSchema, ModPortalDetailsSchema, ModNameVersionPair } from "./data/messages_mod";
import { ModInfo, ModPack, FullVersion, ApiVersion, ModVersionEquality } from "./data";
import { ModInfo, ModPack, MajorMinorVersion, ModVersionEquality, SourceVersion } from "./data";
export interface ModStoreEvents {
/** A stored mod was created, updated or deleted */
@@ -44,7 +44,7 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
super();
}
getMod(name: string, version: FullVersion, sha1?: string) {
getMod(name: string, version: SourceVersion, sha1?: string) {
const mod = this.files.get(ModInfo.filename(name, version));
if (!mod || sha1 && sha1 !== mod.sha1) {
return undefined;
@@ -86,11 +86,11 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
this.emit("change", modInfo);
}
async loadMod(name: string, version: FullVersion) {
async loadMod(name: string, version: SourceVersion) {
return await this.loadFile(ModInfo.filename(name, version));
}
async deleteMod(name: string, version: FullVersion) {
async deleteMod(name: string, version: SourceVersion) {
return await this.deleteFile(ModInfo.filename(name, version));
}
@@ -198,7 +198,7 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
private async downloadModsChunk(
mods: ModNameVersionPair[],
username: string, token: string,
factorioVersion: ApiVersion,
factorioVersion: MajorMinorVersion,
) {
const url = new URL("https://mods.factorio.com/api/mods");
url.searchParams.set("page_size", "max");
@@ -209,7 +209,18 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
});
if (response.status !== 200) {
throw Error(`Fetch: ${url} returned ${response.status} ${response.statusText}`);
// Surface the portal's own error (e.g. an unsupported version) rather
// than a generic status line.
let detail = `${response.status} ${response.statusText}`;
try {
const body = await response.json() as { message?: string };
if (body && typeof body.message === "string") {
detail = body.message;
}
} catch {
// Response body was not JSON; keep the status line.
}
throw new Error(`Mod portal request to ${url} failed: ${detail}`);
}
const modReleases = (await response.json() as ModsInfoResponse).results;
@@ -236,7 +247,7 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
* @param factorioVersion - Factorio version to filter for (mods are not guaranteed to work between Middle versions)
*/
async downloadMods(
mods: ModNameVersionPair[], username: string, token: string, factorioVersion: ApiVersion = "1.1"
mods: ModNameVersionPair[], username: string, token: string, factorioVersion: MajorMinorVersion = "1.1"
) {
const chunkSize = 500;
const futures: Promise<void>[] = [];
+10 -1
View File
@@ -251,7 +251,7 @@ export class Config<
constructor(
public location: ConfigLocation,
fields?: Static<typeof Config.jsonSchema>,
public filepath?: string,
private _filepath?: string,
) {
if (typeof location !== "string") {
throw new Error("location must be a string");
@@ -273,6 +273,15 @@ export class Config<
}
}
get filepath() {
return this._filepath;
}
set filepath(filepath: string | undefined) {
this.dirty = true;
this._filepath = filepath;
}
/** A staging version of the config with changes tracked, invalided by revertStaging */
private _staging?: { config: Config<Fields>, changes: Set<string>};
+11 -2
View File
@@ -18,6 +18,7 @@ export interface ControllerConfigFields {
"controller.bind_address": string | null;
"controller.trusted_proxies": string | null;
"controller.public_url": string | null;
"controller.grafana_url": string | null;
"controller.tls_certificate": string | null;
"controller.tls_private_key": string | null;
"controller.auth_secret": string | null;
@@ -137,6 +138,12 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
optional: true,
// TODO extendedValidation, valid url including the protocol
},
"controller.grafana_url": {
title: "Grafana URL",
description: "URL of a Grafana dashboard to link from the overview page, including the protocol.",
type: "string",
optional: true,
},
"controller.tls_certificate": {
title: "TLS Certificate",
description: "Path to the certificate to use for HTTPS.",
@@ -501,14 +508,16 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
initialValue: false,
},
"factorio.version": {
description: "Version of the game to run, use latest to run the latest installed version",
description:
"Version of the game to run. Use 'latest' for the latest installed version, a release " +
"channel such as 'stable' or 'experimental', or a specific X.Y or X.Y.Z version.",
restartRequired: true,
type: "string",
initialValue: "latest",
inputComponent: "target_version",
validator: function(value) {
if (!isTargetVersion(value)) {
throw new Error("Value must be be 'latest', or match X.Y, or match X.Y.Z");
throw new Error("Value must be 'latest', a release channel, or match X.Y or X.Y.Z");
}
},
},
+45 -21
View File
@@ -8,14 +8,13 @@ import { findRoot } from "../zip_ops";
import { ModRecord } from "./ModPack";
import {
ApiVersion,
ApiVersionSchema,
FullVersion, FullVersionSchema, integerFullVersion,
integerPartialVersion,
MajorMinorVersion, MajorMinorVersionSchema, normaliseMajorMinorVersion, integerMajorMinorVersion,
SourceVersion, SourceVersionSchema, isSourceVersion, normaliseSourceVersion, integerSourceVersion,
ModVersionEquality,
} from "./version";
type ModDependencyType = "incompatible" | "optional" | "hidden" | "unordered" | "required";
type ModDependencyType = "incompatible" | "optional" | "hidden" | "unordered" | "required" | "recommended";
export type ModDependencyUnsatisfiedReason = "incompatible" | "missing_dependency" | "wrong_version";
const UnsatisfiedSeverity: Record<ModDependencyUnsatisfiedReason, number> = {
@@ -40,6 +39,8 @@ export class ModDependency {
return "hidden";
case "~":
return "unordered";
case "+":
return "recommended";
case "":
return "required";
default:
@@ -87,6 +88,10 @@ export class ModDependency {
return this.type === "incompatible";
}
get recommended() {
return this.type === "recommended";
}
get required() {
return this.type === "unordered" || this.type === "required";
}
@@ -129,16 +134,23 @@ export default class ModInfo {
name = "";
/**
* Version of the mod.
* Sourced from info.json.
* Version of the mod, exactly as it appears in info.json and the mod's file
* name. This is the raw, un-normalised version the game reads leniently, so
* it is only safe to use for building file names and identifiers; use
* {@link integerVersion} to compare it.
*/
version = "0.0.0" as FullVersion;
version = "0.0.0" as SourceVersion;
/**
* Integer representation of the version
* Integer representation of the version, normalised the lenient way the
* game reads it so it can be compared and sorted.
*/
get integerVersion() {
return integerFullVersion(this.version);
return integerSourceVersion(this.version);
}
get normalisedVersion() {
return normaliseSourceVersion(this.version);
}
/**
@@ -172,17 +184,17 @@ export default class ModInfo {
description = "";
/**
* Major version of Factorio this mod supports.
* Sourced from info.json.
* Major version of Factorio this mod supports, reduced to major.minor.
* Sourced from info.json and normalised when read in fromJSON.
*/
factorioVersion = "0.12" as "0.12" | ApiVersion;
factorioVersion = "0.12" as MajorMinorVersion;
/**
* Integer representation of the factorioVersion
* @type {number}
*/
get integerFactorioVersion() {
return integerPartialVersion(this.factorioVersion);
return integerMajorMinorVersion(this.factorioVersion);
}
/**
@@ -221,7 +233,7 @@ export default class ModInfo {
* @param version - Mod's version
* @returns string containing {name}_{version}.zip
*/
static filename(name: string, version: FullVersion) {
static filename(name: string, version: SourceVersion) {
return `${name}_${version}.zip`;
}
@@ -248,16 +260,17 @@ export default class ModInfo {
*/
isDeleted = false;
// Content of info.json found in mod files
// Content of info.json found in mod files. The version fields use the same
// lenient format the game accepts; they are normalised when read in fromJSON.
static infoJsonSchema = Type.Object({
"name": Type.String(),
"version": FullVersionSchema,
"version": SourceVersionSchema,
"title": Type.String(),
"author": Type.String(),
"contact": Type.Optional(Type.String()),
"homepage": Type.Optional(Type.String()),
"description": Type.Optional(Type.String()),
"factorio_version": Type.Optional(ApiVersionSchema),
"factorio_version": Type.Optional(SourceVersionSchema),
"dependencies": Type.Optional(Type.Array(Type.String())),
});
@@ -279,13 +292,24 @@ export default class ModInfo {
// info.json fields
if (json.name) { modInfo.name = json.name; }
if (json.version) { modInfo.version = json.version; }
if (json.version) {
if (!isSourceVersion(json.version)) {
throw new Error(`Invalid mod version "${json.version}"`);
}
modInfo.version = json.version;
}
if (json.title) { modInfo.title = json.title; }
if (json.author) { modInfo.author = json.author; }
if (json.contact) { modInfo.contact = json.contact; }
if (json.homepage) { modInfo.homepage = json.homepage; }
if (json.description) { modInfo.description = json.description; }
if (json.factorio_version) { modInfo.factorioVersion = json.factorio_version; }
if (json.factorio_version) {
const factorioVersion = normaliseSourceVersion(json.factorio_version);
if (factorioVersion === undefined) {
throw new Error(`Invalid factorio_version "${json.factorio_version}"`);
}
modInfo.factorioVersion = normaliseMajorMinorVersion(factorioVersion);
}
// Parse the dependencies
try {
@@ -314,7 +338,7 @@ export default class ModInfo {
if (this.contact) { json.contact = this.contact; }
if (this.homepage) { json.homepage = this.homepage; }
if (this.description) { json.description = this.description; }
if (this.factorioVersion !== "0.12") { json.factorio_version = this.factorioVersion; }
if (this.factorioVersion !== "0.12") { json.factorio_version = this.factorioVersion as SourceVersion; }
if (this.dependencies.length !== 1 || this.dependencies[0].name !== "base") {
json.dependencies = this.dependencySpecifications;
}
+29 -11
View File
@@ -12,7 +12,7 @@ import ModInfo, { ModDependencyUnsatisfiedReason } from "./ModInfo";
import {
PartialVersion, PartialVersionSchema, integerPartialVersion,
FullVersion, FullVersionSchema, normaliseFullVersion,
SourceVersion, SourceVersionSchema, normaliseFullVersion,
} from "./version";
@@ -46,8 +46,8 @@ export interface ModRecord {
name: string,
/** if mod is to be loaded. */
enabled: boolean,
/** version of the mod. */
version: FullVersion,
/** version of the mod, verbatim as it appears in the mod's file name. */
version: SourceVersion,
/** SHA1 hash of the zip file. */
sha1?: string,
/** Used inside packages\web_ui\src\components\ModPackViewPage.tsx to define an error type. */
@@ -61,7 +61,7 @@ export interface ModRecord {
const ModRecordJsonSchema = Type.Object({
"name": Type.String(),
"enabled": Type.Boolean(),
"version": FullVersionSchema,
"version": SourceVersionSchema,
"sha1": Type.Optional(Type.String()),
});
@@ -396,7 +396,7 @@ export default class ModPack {
/** Array of default mod packs which should exist on a newly installed cluster */
static defaultModPacks = [
...(["0.17", "0.18", "1.0", "1.1", "2.0"] as const)
...(["0.17", "0.18", "1.0", "1.1", "2.0", "2.1"] as const)
.map(version => this.fromJSON({
name: `Base Game ${version}`,
description: `Factorio ${version} with no extra mods.`,
@@ -413,6 +413,18 @@ export default class ModPack {
{ name: "space-age", enabled: true, version: normaliseFullVersion(version) },
],
} as any)),
...(["2.1"] as const)
.map(version => this.fromJSON({
name: `Space Age ${version}`,
description: `Factorio ${version} with Space Age expansion.`,
factorio_version: version,
mods: [
{ name: "elevated-rails", enabled: true, version: normaliseFullVersion(version) },
{ name: "quality", enabled: true, version: normaliseFullVersion(version) },
{ name: "space-age", enabled: true, version: normaliseFullVersion(version) },
{ name: "recycler", enabled: true, version: normaliseFullVersion(version) },
],
} as any)),
];
/**
@@ -422,18 +434,24 @@ export default class ModPack {
* @return Built in mods for the given version
*/
static getBuiltinMods(factorioVersion: PartialVersion) {
factorioVersion = normaliseFullVersion(factorioVersion);
const version = normaliseFullVersion(factorioVersion) as SourceVersion;
const integerVersion = integerPartialVersion(factorioVersion);
let defaultMods: ModRecord[] = [
// "core" not included because core cannot be disabled
{ name: "base", enabled: true, version: factorioVersion },
{ name: "base", enabled: true, version: version },
];
const integerVersion = integerPartialVersion(factorioVersion);
if (integerVersion >= integerPartialVersion("1.2")) {
defaultMods = defaultMods.concat([
{ name: "elevated-rails", enabled: false, version: factorioVersion },
{ name: "quality", enabled: false, version: factorioVersion },
{ name: "space-age", enabled: false, version: factorioVersion },
{ name: "elevated-rails", enabled: false, version: version },
{ name: "quality", enabled: false, version: version },
{ name: "space-age", enabled: false, version: version },
]);
}
if (integerVersion >= integerPartialVersion("2.1")) {
defaultMods = defaultMods.concat([
{ name: "recycler", enabled: false, version: version },
]);
}
+23 -1
View File
@@ -2,7 +2,7 @@ import { Type, Static } from "@sinclair/typebox";
import { JsonString, StringEnum, StringKey, plainJson } from "./composites";
import { levels } from "../logging";
import { ControllerConfig, HostConfig } from "../config";
import { ExternalFactorioVersionSchema } from "../external";
import { ExternalFactorioVersionSchema, LatestReleasesSchema } from "../external";
export class ControllerStopRequest {
declare ["constructor"]: typeof ControllerStopRequest;
@@ -442,3 +442,25 @@ export class FactorioVersionsRequest {
static Response = plainJson(Type.Array(ExternalFactorioVersionSchema));
}
export class LatestReleasesRequest {
declare ["constructor"]: typeof LatestReleasesRequest;
static type = "request" as const;
static src = ["control", "instance"] as const;
static dst = "controller" as const;
static permission = "core.external.get_latest_releases" as const;
constructor(
public maxAgeMs: number = 5 * 60 * 1000, // Default 5 minutes
) {}
static jsonSchema = Type.Object({
"maxAgeMs": Type.Number(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.maxAgeMs);
}
static Response = plainJson(LatestReleasesSchema);
}
+20 -20
View File
@@ -4,8 +4,8 @@ import ModPack from "./ModPack";
import { JsonString, jsonArray } from "./composites";
import {
FullVersion, FullVersionSchema,
ApiVersion, ApiVersionSchema, normaliseApiVersion,
MajorMinorVersion, MajorMinorVersionSchema, normaliseMajorMinorVersion,
SourceVersion, SourceVersionSchema,
ModVersionEquality,
} from "./version";
@@ -119,13 +119,13 @@ export class ModGetRequest {
constructor(
public name: string,
public version: FullVersion,
public version: SourceVersion,
public sha1?: string,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"version": FullVersionSchema,
"version": SourceVersionSchema,
"sha1": Type.Optional(Type.String()),
});
@@ -154,7 +154,7 @@ export class ModSearchRequest {
constructor(
public query: string,
public factorioVersion: ApiVersion,
public factorioVersion: MajorMinorVersion,
public page: number,
public pageSize?: number,
public sort?: string,
@@ -163,7 +163,7 @@ export class ModSearchRequest {
static jsonSchema = Type.Object({
"query": Type.String(),
"factorioVersion": ApiVersionSchema,
"factorioVersion": MajorMinorVersionSchema,
"page": Type.Integer(),
"pageSize": Type.Optional(Type.Integer()),
"sort": Type.Optional(Type.String()),
@@ -205,9 +205,9 @@ export class ModSearchRequest {
// Define the structure for the latest release info from the portal
export const ModPortalReleaseSchema = Type.Object({
version: FullVersionSchema,
version: SourceVersionSchema,
// Match the structure from ModStore's ModRelease/ModDetails
info_json: Type.Object({ factorio_version: ApiVersionSchema }),
info_json: Type.Object({ factorio_version: SourceVersionSchema }),
released_at: Type.String(), // ISO 8601 date string
download_url: Type.String(),
file_name: Type.String(),
@@ -236,12 +236,12 @@ export class ModPortalGetAllRequest {
static permission = "core.mod.search_portal" as const;
constructor(
public factorioVersion: ApiVersion,
public factorioVersion: MajorMinorVersion,
public hide_deprecated?: boolean,
) { }
static jsonSchema = Type.Object({
"factorioVersion": ApiVersionSchema,
"factorioVersion": MajorMinorVersionSchema,
"hide_deprecated": Type.Optional(Type.Boolean()),
});
@@ -276,13 +276,13 @@ export class ModDownloadRequest {
constructor(
public name: string,
public version: FullVersion,
public version: SourceVersion,
public sha1?: string,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"version": FullVersionSchema,
"version": SourceVersionSchema,
"sha1": Type.Optional(Type.String()),
});
@@ -302,12 +302,12 @@ export class ModDeleteRequest {
constructor(
public name: string,
public version: FullVersion,
public version: SourceVersion,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"version": FullVersionSchema,
"version": SourceVersionSchema,
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
@@ -385,12 +385,12 @@ export class ModPortalDownloadRequest {
constructor(
public mods: ModNameVersionPair[],
public factorioVersion: ApiVersion,
public factorioVersion: MajorMinorVersion,
) { }
static jsonSchema = Type.Object({
"mods": Type.Array(ModNameVersionPairSchema),
"factorioVersion": ApiVersionSchema,
"factorioVersion": MajorMinorVersionSchema,
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
@@ -422,13 +422,13 @@ export class ModDependencyResolveRequest {
constructor(
public mods: ModDependency[],
public factorioVersion: ApiVersion,
public factorioVersion: MajorMinorVersion,
public checkForUpdates: boolean = false,
) { }
static jsonSchema = Type.Object({
"mods": Type.Array(ModDependency.jsonSchema),
"factorioVersion": ApiVersionSchema,
"factorioVersion": MajorMinorVersionSchema,
"checkForUpdates": Type.Boolean(),
});
@@ -441,7 +441,7 @@ export class ModDependencyResolveRequest {
return new this(
[...modPack.mods.values()]
.map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)),
normaliseApiVersion(modPack.factorioVersion),
normaliseMajorMinorVersion(modPack.factorioVersion),
checkForUpdates,
);
}
@@ -452,7 +452,7 @@ export class ModDependencyResolveRequest {
[...modPack.mods.values()]
.filter(mod => mod.enabled)
.map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)),
normaliseApiVersion(modPack.factorioVersion),
normaliseMajorMinorVersion(modPack.factorioVersion),
checkForUpdates,
);
}
+101 -21
View File
@@ -24,24 +24,55 @@ export function isVersionEquality(input: string): input is VersionEquality {
}
/**
* Matches valid factorio versions accepts by the web API
* https://wiki.factorio.com/Mod_portal_API#/api/mods (see version enum)
* Known Factorio major versions, offered as fallback options in the web
* interface before the live version list has been fetched. Versions are no
* longer validated against this list; the mod portal is the source of truth.
*/
export const ApiVersions = ["0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0"] as const;
export const ApiVersionSchema = Type.Union(ApiVersions.map(v => Type.Literal(v)));
export type ApiVersion = Static<typeof ApiVersionSchema>;
export const ApiVersions = ["0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0", "2.1"] as const;
export function isApiVersion(input: string): input is ApiVersion {
return ApiVersions.includes(input as any);
/**
* Matches a version string the same lenient way the game reads it: each number
* may be preceded by whitespace, the major and minor are required, the patch is
* optional, and any trailing content is ignored. The pattern is intentionally
* not anchored at the end.
*/
const sourceVersionRegExp = /^\s*(\d+)\.\s*(\d+)(?:\.\s*(\d+))?/;
/**
* A raw, un-normalised version string exactly as the game reads it, such as a
* mod's version taken from info.json or a mod file name.
*/
declare const sourceVersionSymbol: unique symbol;
export type SourceVersion = string & { [sourceVersionSymbol]: void };
export const SourceVersionSchema = Type.Unsafe<SourceVersion>(
Type.String({ pattern: sourceVersionRegExp.source })
);
export function isSourceVersion(input: string): input is SourceVersion {
return sourceVersionRegExp.test(input);
}
export function normaliseApiVersion(version: PartialVersion) {
const parts = version.split(".");
const apiVersion = `${parts[0]}.${parts[1]}`;
if (!isApiVersion(apiVersion)) {
throw new Error(`Factorio version not supported by mod portal: ${apiVersion}`);
export function integerSourceVersion(version: SourceVersion) {
return integerFullVersion(normaliseSourceVersion(version));
}
/**
* Normalise a version string the same lenient way the game reads it. Numbers
* are read as integers, so leading whitespace and zeros are dropped and any
* content after the patch is ignored.
*
* @returns The normalised full version, or undefined if no major and minor
* could be read.
*/
export function normaliseSourceVersion(input: SourceVersion): FullVersion;
export function normaliseSourceVersion(input: string): FullVersion | undefined;
export function normaliseSourceVersion(input: string | SourceVersion): FullVersion | undefined {
const match = sourceVersionRegExp.exec(input);
if (match === null) {
return undefined;
}
return apiVersion;
return `${Number(match[1])}.${Number(match[2])}.${Number(match[3] ?? "0")}` as FullVersion;
}
/**
@@ -68,11 +99,38 @@ export function normaliseFullVersion(version: PartialVersion) {
return (parts.length === 3 ? version : `${version}.0`) as FullVersion;
}
/**
* Matches a major.minor version, as used by the mod portal's version filter.
*/
const majorMinorVersionRegExp = /^\d+\.\d+$/;
export type MajorMinorVersion = `${number}.${number}`;
export const MajorMinorVersionSchema = Type.Unsafe<MajorMinorVersion>(
Type.String({ pattern: majorMinorVersionRegExp.source })
);
export function isMajorMinorVersion(input: string): input is MajorMinorVersion {
return majorMinorVersionRegExp.test(input);
}
export function integerMajorMinorVersion(version: MajorMinorVersion) {
const [major, minor] = version.split(".").map(n => Number.parseInt(n, 10));
// Can't use bitwise here because this is 48-bits. sub is always 0 so is omitted.
return major * 0x100000000 + minor * 0x10000 as IntegerVersion;
}
/**
* Reduce a version to its major.minor, as used by the mod portal's version filter.
*/
export function normaliseMajorMinorVersion(version: PartialVersion): MajorMinorVersion {
const [major, minor] = version.split(".");
return `${major}.${minor}` as MajorMinorVersion;
}
/**
* Matches valid factorio versions and mod dependencies specifications where 2 or 3 parts are specified.
*/
const partialVersionRegExp = /^\d+\.\d+(?:\.\d+)?$/;
export type PartialVersion = FullVersion | `${number}.${number}`;
export type PartialVersion = FullVersion | MajorMinorVersion;
export const PartialVersionSchema = Type.Unsafe<PartialVersion>(
Type.String({ pattern: partialVersionRegExp.source })
);
@@ -88,16 +146,35 @@ export function integerPartialVersion(version: PartialVersion) {
}
/**
* Matches valid factorio target versions, this is the same as partial but also accepts "latest".
* Matches a release channel name from the latest-releases API, such as
* "stable" or "experimental". Excludes "latest", which is handled separately.
* The set of channels is dynamic, so any lowercase identifier is accepted here
* and resolved against the live API when the instance starts.
*/
export type TargetVersion = PartialVersion | "latest";
const releaseChannelRegExp = /^[a-z][a-z0-9-]*$/;
declare const releaseChannelSymbol: unique symbol;
export type ReleaseChannel = string & { [releaseChannelSymbol]: void };
export const ReleaseChannelSchema = Type.Unsafe<ReleaseChannel>(
Type.String({ pattern: releaseChannelRegExp.source })
);
export function isReleaseChannel(input: string): input is ReleaseChannel {
return input !== "latest" && releaseChannelRegExp.test(input);
}
/**
* Matches valid factorio target versions: a partial version, "latest", or a
* release channel name (e.g. "stable" or "experimental").
*/
export type TargetVersion = PartialVersion | ReleaseChannel | "latest";
export const TargetVersionSchema = Type.Union([
PartialVersionSchema,
ReleaseChannelSchema,
Type.Literal("latest"),
]);
export function isTargetVersion(input: string): input is TargetVersion {
return input === "latest" || partialVersionRegExp.test(input);
return input === "latest" || releaseChannelRegExp.test(input) || partialVersionRegExp.test(input);
}
/**
@@ -136,8 +213,8 @@ export class ModVersionEquality {
}
}
testVersion(version: PartialVersion) {
return this.testIntegerVersion(integerPartialVersion(version));
testVersion(version: SourceVersion) {
return this.testIntegerVersion(integerSourceVersion(version));
}
toString() {
@@ -199,8 +276,11 @@ export class ModVersionRange {
return this.minVersion.testIntegerVersion(other) && this.maxVersion.testIntegerVersion(other);
}
testVersion(version: PartialVersion) {
return this.testIntegerVersion(integerPartialVersion(version));
testVersion(version: PartialVersion | SourceVersion) {
if (isPartialVersion(version)) {
return this.testIntegerVersion(integerPartialVersion(version));
}
return this.testIntegerVersion(integerSourceVersion(version));
}
combineVersion(other: ModVersionEquality) {
+62
View File
@@ -0,0 +1,62 @@
import { Static, Type } from "@sinclair/typebox";
import { FullVersion, FullVersionSchema } from "../data/version";
const LATEST_RELEASES_URL = "https://factorio.com/api/latest-releases";
/**
* The set of builds (e.g. alpha, demo, headless, expansion) and their version
* for a single release channel.
*/
export const LatestReleaseBuildsSchema = Type.Record(Type.String(), FullVersionSchema);
/**
* The latest released version of each build, keyed by release channel name
* (e.g. "stable" and "experimental").
*/
export const LatestReleasesSchema = Type.Record(Type.String(), LatestReleaseBuildsSchema);
export type LatestReleases = Static<typeof LatestReleasesSchema>
/**
* Fetch the latest stable and experimental factorio releases
*
* @returns The latest version of each build keyed by release channel
* @throws Network errors and non-ok status responses
*/
export async function fetchLatestReleases(): Promise<LatestReleases> {
let res;
try {
res = await fetch(LATEST_RELEASES_URL);
} catch (err) {
throw new Error(`Failed to fetch Factorio releases from ${LATEST_RELEASES_URL}: ${err}`);
}
if (!res.ok) {
throw new Error(
`Failed to fetch Factorio releases from ${LATEST_RELEASES_URL}: HTTP ${res.status} ${res.statusText}`
);
}
return await res.json() as LatestReleases;
}
/**
* Resolve a release channel to a concrete factorio version
*
* @param releases Releases data as returned by {@link fetchLatestReleases}
* @param channel Release channel name, e.g. "stable" or "experimental"
* @param build Build to read the version of, defaults to the headless build
* @returns The version for the channel, or undefined if it cannot be resolved
*/
export function resolveReleaseChannel(
releases: LatestReleases,
channel: string,
build = "headless",
): FullVersion | undefined {
const builds = releases[channel];
if (!builds) {
return undefined;
}
return (builds[build] ?? Object.values(builds)[0]) as FullVersion | undefined;
}
+1
View File
@@ -3,3 +3,4 @@
* @module lib/external
*/
export * from "./FactorioVersions";
export * from "./LatestReleases";
+52 -17
View File
@@ -4,7 +4,10 @@ class MapReaderState {
pos = 0;
last_position = { x: 0, y: 0 };
/** True when a version greater than 2.0.0 is detected */
v2 = false;
gt_v2_0 = false;
/** True when a version greater than 2.1.0 is detected */
gt_v2_1 = false;
// If more versions are required then we should expose version directly
constructor(
public buf: Buffer
) { }
@@ -161,7 +164,7 @@ function readBoundingBox(state: MapReaderState) {
}
function readCliffSettings(state: MapReaderState) {
return state.v2 ? {
return state.gt_v2_0 ? {
name: readString(state),
control: readString(state), // v2
cliff_elevation_0: readFloat(state),
@@ -186,7 +189,7 @@ function readTerritorySettings(state: MapReaderState) {
}
function readMapGenSettings(state: MapReaderState) {
return state.v2 ? {
return state.gt_v2_0 ? {
autoplace_controls: Object.fromEntries(readDict(state, readString, readFrequencySizeRichness)),
autoplace_settings: Object.fromEntries(readDict(state, readString, readAutoplaceSetting)),
default_enable_all_autoplace_controls: readBool(state),
@@ -262,7 +265,23 @@ function readEnemyEvolution(state: MapReaderState) {
}
function readEnemyExpansion(state: MapReaderState) {
return {
return state.gt_v2_1 ? {
enabled: readOptional(state, readBool),
max_expansion_distance: readOptional(state, readUInt32),
min_expansion_distance: readOptional(state, readUInt32), // v2.1
friendly_base_influence_radius: readOptional(state, readUInt32),
enemy_building_influence_radius: readOptional(state, readUInt32),
building_coefficient: readOptional(state, readDouble),
other_base_coefficient: readOptional(state, readDouble),
neighbouring_chunk_coefficient: readOptional(state, readDouble),
neighbouring_base_chunk_coefficient: readOptional(state, readDouble),
max_colliding_tiles_coefficient: readOptional(state, readDouble),
settler_group_min_size: readOptional(state, readUInt32),
settler_group_max_size: readOptional(state, readUInt32),
evolution_group_size_factor: readOptional(state, readDouble), // v2.1
min_expansion_cooldown: readOptional(state, readUInt32),
max_expansion_cooldown: readOptional(state, readUInt32),
} : {
enabled: readOptional(state, readBool),
max_expansion_distance: readOptional(state, readUInt32),
friendly_base_influence_radius: readOptional(state, readUInt32),
@@ -336,7 +355,7 @@ function readPathFinder(state: MapReaderState) {
}
function readDifficultySettings(state: MapReaderState) {
return state.v2 ? {
return state.gt_v2_0 ? {
technology_price_multiplier: readDouble(state),
spoil_time_modifier: readDouble(state), // v2
} : {
@@ -355,17 +374,32 @@ function readAsteroids(state: MapReaderState) {
}
function readMapSettings(state: MapReaderState) {
return state.v2 ? {
pollution: readPollution(state),
steering: readSteering(state),
enemy_evolution: readEnemyEvolution(state),
enemy_expansion: readEnemyExpansion(state),
unit_group: readUnitGroup(state),
path_finder: readPathFinder(state),
max_failed_behavior_count: readUInt32(state),
difficulty_settings: readDifficultySettings(state),
asteroids: readAsteroids(state), // v2
} : {
if (state.gt_v2_1) {
return {
pollution: readPollution(state),
// steering removed v2.1
enemy_evolution: readEnemyEvolution(state),
enemy_expansion: readEnemyExpansion(state),
unit_group: readUnitGroup(state),
path_finder: readPathFinder(state),
max_failed_behavior_count: readUInt32(state),
difficulty_settings: readDifficultySettings(state),
asteroids: readAsteroids(state), // v2
};
} else if (state.gt_v2_0) {
return {
pollution: readPollution(state),
steering: readSteering(state),
enemy_evolution: readEnemyEvolution(state),
enemy_expansion: readEnemyExpansion(state),
unit_group: readUnitGroup(state),
path_finder: readPathFinder(state),
max_failed_behavior_count: readUInt32(state),
difficulty_settings: readDifficultySettings(state),
asteroids: readAsteroids(state), // v2
};
}
return {
pollution: readPollution(state),
steering: readSteering(state),
enemy_evolution: readEnemyEvolution(state),
@@ -426,7 +460,8 @@ export function readMapExchangeString(exchangeString: string) {
try {
const version = readVersion(state);
state.v2 = version[0] >= 2;
state.gt_v2_0 = version[0] >= 2;
state.gt_v2_1 = version[0] >= 2 && version[1] >= 1;
data = {
version: version,
unknown: readUInt8(state),
+1
View File
@@ -37,6 +37,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
controller.DebugDumpWsRequest,
controller.DebugWsMessageEvent,
controller.FactorioVersionsRequest,
controller.LatestReleasesRequest,
subscriptions.SubscriptionRequest,
+6
View File
@@ -524,3 +524,9 @@ definePermission({
title: "Get factorio versions",
description: "Get list of all factorio versions from Wube",
});
definePermission({
name: "core.external.get_latest_releases",
title: "Get factorio releases",
description: "Get the latest stable and experimental factorio releases from Wube",
});
+37 -25
View File
@@ -1,6 +1,6 @@
import { Type, Static } from "@sinclair/typebox";
import { Link, Event, EventClass, RequestHandler, WebSocketBaseConnector } from "./link";
import { Address, MessageRequest, IUser, JsonBoolean, StringEnum } from "./data";
import { Address, MessageRequest, IUser, JsonBoolean, StringEnum, AccountDetails } from "./data";
import isDeepStrictEqual from "./is_deep_strict_equal";
import { RequestError } from "./errors";
import { logger } from "./logging";
@@ -14,25 +14,27 @@ export type EventSubscriberCallback<T> = (event: T | null, synced: boolean) => v
* When isEmpty() is true, no strings match this filter.
* Otherwise, literal string matching is performed against a set.
*/
export class SubscriptionFilters {
export class SubscriptionFilters extends Set<string> {
private constructor(
private _all: boolean,
private _filters: Set<string>,
) {}
_filters: Iterable<string> = [],
) {
super(_filters);
}
static jsonSchema = Type.Array(Type.String());
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(false, new Set(json));
return new this(false, json);
}
toJSON() {
return [...this._filters];
return [...this];
}
/** Creates a subscription filter accepting all strings */
static all() {
return new this(true, new Set());
return new this(true);
}
isAll() {
@@ -41,11 +43,11 @@ export class SubscriptionFilters {
/** Creates a subscription filter rejecting all strings */
static empty() {
return new this(false, new Set());
return new this(false);
}
isEmpty() {
return !this._all && this._filters.size === 0;
return !this._all && this.size === 0;
}
/**
@@ -67,20 +69,20 @@ export class SubscriptionFilters {
if (this._all) {
return "[SubscriptionFilters All]";
}
if (this._filters.size === 0) {
if (this.size === 0) {
return "[SubscriptionFilters Empty]";
}
return `[SubscriptionFilters Set<${this._filters.size}>]`;
return `[SubscriptionFilters Set<${this.size}>]`;
}
/** Returns true if this filter accepts the given string */
accepts(value: string) {
return this._all || this._filters.has(value);
return this._all || this.has(value);
}
/** Returns true if this filter extends the other */
extends(other: this) {
return this._all || (!other._all && [...other._filters.values()].every(value => this._filters.has(value)));
return this._all || (!other._all && [...other.values()].every(value => this.has(value)));
}
/** Returns true if there is overlap between two filters */
@@ -91,8 +93,8 @@ export class SubscriptionFilters {
if (other._all) {
return !this.isEmpty();
}
for (const filter of other._filters) {
if (this._filters.has(filter)) {
for (const filter of other) {
if (this.has(filter)) {
return true;
}
}
@@ -106,11 +108,11 @@ export class SubscriptionFilters {
}
if (other._all) {
this._all = true;
this._filters.clear();
this.clear();
return;
}
for (const filter of other._filters) {
this._filters.add(filter);
for (const filter of other) {
this.add(filter);
}
}
@@ -122,14 +124,14 @@ export class SubscriptionFilters {
subtract(other: this) {
if (other._all) {
this._all = false;
this._filters.clear();
this.clear();
return;
}
if (this._all) {
return; // Cannot subtract from all
}
for (const filter of other._filters) {
this._filters.delete(filter);
for (const filter of other) {
this.delete(filter);
}
}
}
@@ -406,8 +408,10 @@ export class EventSubscriber<E, S = null> {
lastUpdatedMs = -1;
/** True if this subscriber is currently synced with the source */
synced = false;
/** Error value from last attempted subscription event */
error = null as Error | null;
/** Repeat calls to getSnapshot will return the same readonly copy unless values has updated */
private _snapshot: readonly [S, boolean] = [this.makeSnapshot(), false];
private _snapshot: readonly [S, boolean, Error | null] = [this.makeSnapshot(), false, null];
private _snapshotLastUpdatedMs = -1;
/** Callbacks will be called when an event is received or the synced state changes */
private _callbacks = new Array<EventSubscriberCallback<E>>();
@@ -555,10 +559,14 @@ export class EventSubscriber<E, S = null> {
* Obtain a snapshot of the current state of the tracked resource
* @returns tuple of values map snapshot and synced property.
*/
getSnapshot(): readonly [S, boolean] {
if (this._snapshotLastUpdatedMs !== this.lastUpdatedMs || this._snapshot[1] !== this.synced) {
getSnapshot(): readonly [S, boolean, Error | null] {
if (
this._snapshotLastUpdatedMs !== this.lastUpdatedMs
|| this._snapshot[1] !== this.synced
|| this._snapshot[2] !== this.error
) {
this._snapshotLastUpdatedMs = this.lastUpdatedMs;
this._snapshot = [this.makeSnapshot(), this.synced];
this._snapshot = [this.makeSnapshot(), this.synced, this.error];
}
return this._snapshot;
}
@@ -581,6 +589,7 @@ export class EventSubscriber<E, S = null> {
const updatesSent = await this.control.sendTo("controller", new SubscriptionRequest(
entry.name, action, this.lastUpdatedMs, filters
));
this.error = null;
if (!updatesSent) {
this.synced = this._hasSubscriptions();
this._notify(null);
@@ -589,6 +598,9 @@ export class EventSubscriber<E, S = null> {
if (!(err instanceof RequestError) || err.code !== "SessionLost") {
logger.error(`Unexpected error updating ${entry.name} subscription:\n${err.stack}`);
}
if (err instanceof Error) {
this.error = err;
}
}
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
"description": "Clusterio web interface implementation",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.26",
"version": "2.0.0-alpha.27",
"browser": "src/index.ts",
"main": "dist/browser/src/index.js",
"scripts": {
@@ -67,7 +67,7 @@ export default function AssignInstanceModal(props: AssignInstanceModalProps) {
confirmLoading={applying}
onOk={handleAssign}
onCancel={handleCancel}
destroyOnClose
destroyOnHidden
>
<Paragraph style={{ maxWidth: "30em" }}>
Select a Host to assign this instance to. Assignment
@@ -106,7 +106,7 @@ export default function CreateSaveModal(props: CreateSaveModalProps) {
confirmLoading={creatingSave}
onOk={createSave}
onCancel={handleCancel}
destroyOnClose
destroyOnHidden
>
<Form form={form} layout="vertical">
<Form.Item name="saveName" label="Save Name">
+29 -9
View File
@@ -1,5 +1,4 @@
import React, { useContext, useRef, useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Button, Flex, Form, InputNumber, Modal, Space, Switch, Table, Tooltip, Typography } from "antd";
import { ExclamationCircleOutlined, CopyOutlined } from "@ant-design/icons";
@@ -20,6 +19,10 @@ import {
import { useHosts } from "../model/host";
import { useSystems } from "../model/system";
import notify, { notifyErrorHandler } from "../util/notify";
import useTableQueryState from "../util/useTableQueryState";
import useColumnSearch from "../util/useColumnSearch";
import useRowNavigation from "../util/useRowNavigation";
import Link from "./Link";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
@@ -199,12 +202,16 @@ function CopyButton({ text, message }: { text:string, message:string }) {
export default function HostsPage() {
let account = useAccount();
let navigate = useNavigate();
let [hosts] = useHosts();
const [systems] = useSystems();
const [showRatios, setShowRatios] = useState(true);
const [showNumbers, setShowNumbers] = useState(false);
const [showCpuModel, setShowCpuModel] = useState(false);
const tableState = useTableQueryState<lib.HostDetails>({
namespace: "host", defaultSortKey: "name",
});
const nameSearch = useColumnSearch<lib.HostDetails>(tableState, "name", host => host.name, "Search hosts");
const rowNav = useRowNavigation();
return <PageLayout nav={[{ name: "Hosts" }]}>
<PageHeader
@@ -217,13 +224,20 @@ export default function HostsPage() {
{
title: "Name",
dataIndex: "name",
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a.name, b.name),
sortOrder: tableState.sortOrder("name"),
filteredValue: tableState.filteredValue("name"),
className: "table-link-cell",
render: (_, host) => <Link to={`/hosts/${host.id}/view`} style={{ color: "inherit" }}>
{host.name}
</Link>,
...nameSearch,
},
{
title: "CPU Model",
dataIndex: "cpuModel",
sorter: (a, b) => strcmp(systems.get(a.id)?.cpuModel ?? "", systems.get(b.id)?.cpuModel ?? ""),
sortOrder: tableState.sortOrder("cpuModel"),
render: (_, host) => systems.get(host.id)?.cpuModel,
hidden: !showCpuModel,
},
@@ -231,6 +245,7 @@ export default function HostsPage() {
title: "CPU%",
key: "cpuPct",
sorter: (a, b) => (systems.get(a.id)?.cpuRatio ?? 0) - (systems.get(b.id)?.cpuRatio ?? 0),
sortOrder: tableState.sortOrder("cpuPct"),
render: (_, host) => <MetricCpuRatio system={systems.get(host.id)} />,
hidden: !showRatios,
},
@@ -240,6 +255,7 @@ export default function HostsPage() {
sorter: (a, b) => (
(systems.get(a.id)?.cpuUsed ?? 0) - (systems.get(b.id)?.cpuUsed ?? 0)
),
sortOrder: tableState.sortOrder("cores"),
render: (_, host) => <MetricCpuUsed system={systems.get(host.id)} />,
hidden: !showNumbers,
},
@@ -249,6 +265,7 @@ export default function HostsPage() {
sorter: (a, b) => (
(systems.get(a.id)?.memoryRatio ?? 0) - (systems.get(b.id)?.memoryRatio ?? 0)
),
sortOrder: tableState.sortOrder("memPct"),
render: (_, host) => <MetricMemoryRatio system={systems.get(host.id)} />,
hidden: !showRatios,
},
@@ -258,6 +275,7 @@ export default function HostsPage() {
sorter: (a, b) => (
(systems.get(a.id)?.memoryUsed ?? 0) - (systems.get(b.id)?.memoryUsed ?? 0)
),
sortOrder: tableState.sortOrder("memory"),
render: (_, host) => <MetricMemoryUsed system={systems.get(host.id)} />,
hidden: !showNumbers,
},
@@ -265,6 +283,7 @@ export default function HostsPage() {
title: "Disk%",
key: "diskPct",
sorter: (a, b) => (systems.get(a.id)?.diskAvailable ?? 0) - (systems.get(b.id)?.diskAvailable ?? 0),
sortOrder: tableState.sortOrder("diskPct"),
render: (_, host) => <MetricDiskRatio system={systems.get(host.id)} />,
hidden: !showRatios,
},
@@ -274,6 +293,7 @@ export default function HostsPage() {
sorter: (a, b) => (
(systems.get(a.id)?.diskUsed ?? 0) - (systems.get(b.id)?.diskUsed ?? 0)
),
sortOrder: tableState.sortOrder("disk"),
render: (_, host) => <MetricDiskUsed system={systems.get(host.id)} />,
hidden: !showNumbers,
},
@@ -287,11 +307,13 @@ export default function HostsPage() {
</Tooltip>}
</Space>,
sorter: (a, b) => strcmp(a.version, b.version),
sortOrder: tableState.sortOrder("version"),
},
{
title: "Public address",
dataIndex: "publicAddress",
sorter: (a, b) => strcmp(a.publicAddress??"", b.publicAddress??""),
sortOrder: tableState.sortOrder("publicAddress"),
},
{
title: "Connected",
@@ -301,16 +323,14 @@ export default function HostsPage() {
<RestartRequired system={systems.get(host.id)}/>
</>,
sorter: (a, b) => Number(a.connected) - Number(b.connected),
sortOrder: tableState.sortOrder("connected"),
},
]}
dataSource={[...hosts.values()]}
rowKey={host => host.id}
pagination={false}
onRow={(record, rowIndex) => ({
onClick: event => {
navigate(`/hosts/${record.id}/view`);
},
})}
pagination={tableState.pagination}
onChange={tableState.onChange}
onRow={record => rowNav(`/hosts/${record.id}/view`)}
/>
<Flex wrap="wrap" style={{ margin: "16px 0 0 0" }} gap="small">
<label style={{ width: "14em", display: "flex", justifyContent: "space-between", marginRight: 16 }}>
@@ -23,6 +23,7 @@ function InputVersion<
) {
const fieldDefinition = props.fieldDefinition || defaultFieldDefinition;
const [versions, setVersions] = useState<readonly string[]>(lib.ApiVersions);
const [channels, setChannels] = useState<{ name: string, version: string }[]>([]);
const [customVersion, setCustomVersion] = useState("");
const [open, setOpen] = useState(false);
const inputRef = useRef<InputRef>(null);
@@ -39,6 +40,21 @@ function InputVersion<
})();
}, [control]);
// Release channels (e.g. stable, experimental) only apply to target versions
useEffect(() => {
(async () => {
if (props.version.includeLatest && account.hasPermission("core.external.get_latest_releases")) {
const res = await control.latestReleases.get(5 * 60 * 1000);
// Read the headless build's version directly rather than via a lib
// helper: lib is a federation-shared module here and unused exports
// can be tree-shaken out of it.
setChannels(Object.entries(res).map(([name, builds]) => (
{ name, version: builds.headless ?? Object.values(builds)[0] ?? "" }
)));
}
})();
}, [control]);
// Split the versions into groups based on major minor
const groups = new Map<string, string[]>();
@@ -58,7 +74,10 @@ function InputVersion<
title: majorMinor,
value: majorMinor,
key: majorMinor,
children: patchVersions.map((v) => ({
// Skip a patch equal to the group key (a bare major.minor version,
// e.g. the ApiVersions fallback): the selectable group node already
// represents it, and a duplicate value warns in the TreeSelect.
children: patchVersions.filter((v) => v !== majorMinor).map((v) => ({
title: v,
value: v,
key: v,
@@ -66,9 +85,17 @@ function InputVersion<
}
));
// Add "latest" option for TargetVersion
// Add "latest" and release channel options for TargetVersion
if (props.version.includeLatest) {
tree.unshift({ title: "latest", value: "latest", key: "latest", children: [] });
tree.unshift(
{ title: "latest", value: "latest", key: "latest", children: [] },
...channels.map(({ name, version }) => ({
title: version ? `${name} (${version})` : name,
value: name,
key: name,
children: [],
})),
);
}
return <TreeSelect
+33 -16
View File
@@ -1,9 +1,8 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import { message, Button, Table, Space } from "antd";
import CopyOutlined from "@ant-design/icons/CopyOutlined";
import type { SizeType } from "antd/es/config-provider/SizeContext";
import type { ColumnsType } from "antd/es/table";
import type { ColumnType } from "antd/es/table/interface";
import { useAccount } from "../model/account";
import { useSystems } from "../model/system";
@@ -14,6 +13,9 @@ import InstanceControlButton, { InstanceControlButtonPermissions } from "./Insta
import * as lib from "@clusterio/lib";
import Link from "./Link";
import { instancePublicAddress } from "../util/instance";
import useTableQueryState from "../util/useTableQueryState";
import useColumnSearch from "../util/useColumnSearch";
import useRowNavigation from "../util/useRowNavigation";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
@@ -25,9 +27,15 @@ type InstanceListProps = {
export default function InstanceList(props: InstanceListProps) {
let account = useAccount();
let navigate = useNavigate();
let [hosts] = useHosts();
const [systems] = useSystems();
const tableState = useTableQueryState<lib.InstanceDetails>({
namespace: "instance", defaultSortKey: "name",
});
const nameSearch = useColumnSearch<lib.InstanceDetails>(
tableState, "name", instance => instance.name, "Search instances"
);
const rowNav = useRowNavigation();
function hostName(hostId?: number) {
if (hostId === undefined) {
@@ -37,32 +45,41 @@ export default function InstanceList(props: InstanceListProps) {
}
function integerFactorioVersionOrDefault(instance: lib.InstanceDetails) {
if (instance.factorioVersion === undefined || instance.factorioVersion === "latest") {
return -1;
const v = instance.factorioVersion;
if (v && lib.isPartialVersion(v)) {
return lib.integerPartialVersion(v);
}
return lib.integerPartialVersion(instance.factorioVersion);
return -1;
}
let columns: ColumnsType<lib.InstanceDetails> = [
let columns: ColumnType<lib.InstanceDetails>[] = [
{
title: "Name",
dataIndex: "name",
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a["name"], b["name"]),
sortOrder: tableState.sortOrder("name"),
filteredValue: tableState.filteredValue("name"),
className: "table-link-cell",
render: (_, instance) => <Link to={`/instances/${instance.id}/view`} style={{ color: "inherit" }}>
{instance.name}
</Link>,
...nameSearch,
},
{
title: "Assigned Host",
key: "assignedHost",
className: "table-link-cell",
render: (_, instance) => <Space>
<Link
{instance.assignedHost !== undefined && <Link
to={`/hosts/${instance.assignedHost}/view`}
onClick={e => e.stopPropagation()}
>
{hostName(instance.assignedHost)}
</Link>
</Link>}
<RestartRequired system={instance.assignedHost ? systems.get(instance.assignedHost) : undefined}/>
</Space>,
sorter: (a, b) => strcmp(hostName(a.assignedHost), hostName(b.assignedHost)),
sortOrder: tableState.sortOrder("assignedHost"),
responsive: ["sm"],
},
{
@@ -87,6 +104,7 @@ export default function InstanceList(props: InstanceListProps) {
instancePublicAddress(a, hosts.get(a.assignedHost!) ?? null),
instancePublicAddress(b, hosts.get(b.assignedHost!) ?? null)
),
sortOrder: tableState.sortOrder("publicAddress"),
responsive: ["lg"],
},
{
@@ -94,6 +112,7 @@ export default function InstanceList(props: InstanceListProps) {
key: "version",
render: (_, instance) => instance.factorioVersion ?? "unknown",
sorter: (a, b) => integerFactorioVersionOrDefault(a) - integerFactorioVersionOrDefault(b),
sortOrder: tableState.sortOrder("version"),
responsive: ["xl"],
},
{
@@ -101,6 +120,7 @@ export default function InstanceList(props: InstanceListProps) {
key: "status",
render: (_, instance) => <InstanceStatusTag status={instance["status"]} />,
sorter: (a, b) => strcmp(a["status"], b["status"]),
sortOrder: tableState.sortOrder("status"),
},
];
@@ -123,11 +143,8 @@ export default function InstanceList(props: InstanceListProps) {
columns={columns}
dataSource={[...props.instances.values()]}
rowKey={instance => instance["id"]}
pagination={false}
onRow={(record, rowIndex) => ({
onClick: event => {
navigate(`/instances/${record.id}/view`);
},
})}
pagination={tableState.pagination}
onChange={tableState.onChange}
onRow={record => rowNav(`/instances/${record.id}/view`)}
/>;
}
@@ -1,6 +1,9 @@
import React, { useContext, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Alert, Button, Descriptions, Dropdown, Flex, MenuProps, Modal, Space, Spin, Switch, Typography } from "antd";
import {
Alert, Button, Descriptions, Dropdown, Flex, MenuProps, Modal, Space, Spin, Switch, Typography, message,
} from "antd";
import CopyOutlined from "@ant-design/icons/CopyOutlined";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import DownOutlined from "@ant-design/icons/DownOutlined";
@@ -25,11 +28,15 @@ import { useHost } from "../model/host";
import InstanceStatusTag from "./InstanceStatusTag";
import Link from "./Link";
import { instancePublicAddress } from "../util/instance";
import useLocalStorage from "../util/useLocalStorage";
import { MetricRelativeDate } from "./system_metrics";
type MenuItem = Required<MenuProps>["items"][number];
const { Title } = Typography;
// Remembers the console "Chat/Log" toggle across reloads (defaults to on).
const consoleActionsOnlyKey = "instance-console-actions-only";
type InstanceDescriptionProps = {
host?: Readonly<lib.HostDetails>;
instance: Readonly<lib.InstanceDetails>;
@@ -39,10 +46,11 @@ function InstanceDescription(props: InstanceDescriptionProps) {
const { host, instance } = props;
let assigned = instance.assignedHost !== undefined;
const publicAddress = instancePublicAddress(instance, host);
return <Descriptions
bordered
size="small"
column={{ xs: 1, md: 3, lg: 3, xl: 3, xxl: 3 }}
column={{ xs: 1, md: 2, lg: 2, xl: 2, xxl: 2 }}
>
<Descriptions.Item label="Host">
{!assigned
@@ -63,6 +71,22 @@ function InstanceDescription(props: InstanceDescriptionProps) {
buttonContent={assigned ? "Reassign" : "Assign"}
/>}
</Descriptions.Item>
<Descriptions.Item label="Public address">
{publicAddress
? <Space>
{publicAddress}
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => {
navigator.clipboard.writeText(publicAddress);
message.success("Copied public address!");
}}
/>
</Space>
: <em>N/A</em>}
</Descriptions.Item>
<Descriptions.Item label="Version">
{instance.factorioVersion ?? "unknown"}
</Descriptions.Item>
@@ -177,7 +201,7 @@ export default function InstanceViewPage() {
let params = useParams();
let instanceId = Number(params.id);
const [maxLevel, setMaxLevel] = useState<keyof typeof lib.levels>("server");
const [actionsOnly, setActionsOnly] = useState<boolean>(true);
const [actionsOnly, setActionsOnly] = useLocalStorage(consoleActionsOnlyKey, true);
let navigate = useNavigate();
@@ -198,12 +222,7 @@ export default function InstanceViewPage() {
description={<>Instance with id {instanceId} was not found on the controller.</>}
type="warning"
action={
<Button
type="text"
onClick={() => { navigate("/instances"); }}
>
Go back to instances list
</Button>
<Link to="/instances">Go back to instances list</Link>
}
/>
</PageLayout>;
@@ -53,7 +53,7 @@ function CreateInstanceButton(props: { instances: ReturnType<typeof useInstances
open={open}
onOk={() => { createInstance().catch(notifyErrorHandler("Error creating instance")); }}
onCancel={() => { setOpen(false); }}
destroyOnClose
destroyOnHidden
>
<Form form={form}>
<Form.Item name="instanceName" label="Name">
@@ -103,7 +103,7 @@ export default function LoadScenarioModal(props: LoadScenarioModalProps) {
confirmLoading={loadingScenario}
onOk={loadScenario}
onCancel={handleCancel}
destroyOnClose
destroyOnHidden
>
<Form form={form} layout="vertical">
<Form.Item name="scenario" label="Scenario">
@@ -4,10 +4,14 @@ import { Select, Typography } from "antd";
import * as lib from "@clusterio/lib";
import { useAccount } from "../model/account";
import useLocalStorage from "../util/useLocalStorage";
import ControlContext from "./ControlContext";
const { Paragraph } = Typography;
const consoleHeightKey = "instance-console-height";
const defaultConsoleHeight = 300;
type Parsed = {
format: string;
type: string;
@@ -99,6 +103,36 @@ export default function LogConsole(props: LogConsoleProps) {
let anchor = useRef<any>();
let [pastLines, setPastLines] = useState([<span key={0}>{"Loading past entries..."}<br/></span>]);
let [lines, setLines] = useState<ReactElement[]>([]);
const [consoleHeight, setConsoleHeight] = useLocalStorage(consoleHeightKey, defaultConsoleHeight);
const consoleHeightRef = useRef(consoleHeight);
consoleHeightRef.current = consoleHeight;
// The console is resized with the browser's native resize handle, which owns
// the element's inline height. Driving the height from React would rewrite
// that inline style on every re-render and fight the drag (locking the size),
// so the height is only read here (on mount) and persisted (on resize), never
// written back through React. The scroll container is the <code> element
// wrapping the anchor.
useEffect(() => {
const element = anchor.current?.parentElement as HTMLElement | undefined;
if (!element) {
return undefined;
}
element.style.height = `${consoleHeightRef.current}px`;
if (typeof ResizeObserver === "undefined") {
return undefined;
}
const observer = new ResizeObserver(() => {
const next = Math.round(element.offsetHeight);
if (next && next !== consoleHeightRef.current) {
consoleHeightRef.current = next;
setConsoleHeight(next);
}
});
observer.observe(element);
return () => observer.disconnect();
}, []);
useEffect(() => {
let logFilter = {
@@ -164,6 +198,10 @@ export default function LogConsole(props: LogConsoleProps) {
return <>
<Paragraph code className="instance-console">
{/* Scrollable spacer (one console-height tall) that keeps short output
pinned to the bottom and leaves room to scroll up. Unlike a
padding-top it is scroll content, so it does not constrain resize. */}
<div className="console-spacer" key="spacer" />
{pastLines}
{lines}
<div className="scroll-anchor" key="anchor" ref={anchor} />
@@ -77,12 +77,12 @@ function SearchModsTable(props: SearchModsTableProps) {
const [modResultPageSize, setModResultPageSize] = useState<number>(10);
const [modResultCount, setModResultCount] = useState<number>(2);
const [modResultSelectedVersion, setModResultSelectedVersion] = useState<Map<string, number>>(new Map());
const [factorioVersion, setFactorioVersion] = useState<lib.ApiVersion | null>(null);
const [factorioVersion, setFactorioVersion] = useState<lib.MajorMinorVersion | null>(null);
// Get a valid factorio version
useEffect(() => {
try {
setFactorioVersion(lib.normaliseApiVersion(props.modPack.factorioVersion));
setFactorioVersion(lib.normaliseMajorMinorVersion(props.modPack.factorioVersion));
} catch (err) {
setFactorioVersion(null);
}
@@ -271,7 +271,7 @@ function DownloadDependenciesButton(props: DownloadDependenciesProps) {
const [open, setOpen] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [factorioVersion, setFactorioVersion] = useState<lib.ApiVersion | null>(null);
const [factorioVersion, setFactorioVersion] = useState<lib.MajorMinorVersion | null>(null);
const [mods, setMods] = useState<lib.ModInfo[]>([]); // All resolved mods and dependencies
const [missing, setMissing] = useState<lib.ModInfo[]>([]); // Missing mods and dependencies, excluding builtin
@@ -295,7 +295,7 @@ function DownloadDependenciesButton(props: DownloadDependenciesProps) {
// Get a valid factorio version
useEffect(() => {
try {
setFactorioVersion(lib.normaliseApiVersion(props.modPack.factorioVersion));
setFactorioVersion(lib.normaliseMajorMinorVersion(props.modPack.factorioVersion));
} catch (err) {
setError(new Error(`Invalid factorio version: ${props.modPack.factorioVersion}`));
setFactorioVersion(null);
@@ -403,7 +403,9 @@ function DownloadDependenciesButton(props: DownloadDependenciesProps) {
control.send(
new lib.ModPortalDownloadRequest(
missing.map(mod => ({
name: mod.name, version: new lib.ModVersionEquality("=", mod.version),
name: mod.name,
// mod.version is the raw version; the equality bound must be canonical.
version: new lib.ModVersionEquality("=", mod.normalisedVersion),
})),
factorioVersion,
)
@@ -577,8 +579,8 @@ function ModsTable(props: ModsTableProps) {
if (mod.enabled && mod.info) {
mod.warning = mod.info.checkDependencySatisfaction(mods.filter(m => m.enabled));
try {
const packFactorioVersion = lib.normaliseApiVersion(props.modPack.factorioVersion);
const modFactorioVersion = lib.normaliseApiVersion(mod.info.factorioVersion);
const packFactorioVersion = lib.normaliseMajorMinorVersion(props.modPack.factorioVersion);
const modFactorioVersion = mod.info.factorioVersion;
if (packFactorioVersion !== modFactorioVersion) {
mod.warning = "wrong_factorio_version";
}
@@ -1052,7 +1054,7 @@ function ExportButton(props: { modPack: lib.ModPack }) {
open={open}
onOk={close}
onCancel={close}
destroyOnClose
destroyOnHidden
footer={<Space>
<CopyButton content={exportString} />
<Button onClick={close}>Close</Button>
+44 -16
View File
@@ -6,7 +6,6 @@ import {
import {
ImportOutlined, PlusOutlined, SearchOutlined, DownloadOutlined,
} from "@ant-design/icons";
import * as lib from "@clusterio/lib";
import { useAccount } from "../model/account";
@@ -18,6 +17,10 @@ import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import SectionHeader from "./SectionHeader";
import useTableQueryState from "../util/useTableQueryState";
import useColumnSearch from "../util/useColumnSearch";
import useRowNavigation from "../util/useRowNavigation";
import Link from "./Link";
import ModDetails from "./ModDetails";
import { Dropzone } from "./Dropzone";
import UploadButton from "./UploadButton";
@@ -145,7 +148,7 @@ function SearchModsButton() {
const [open, setOpen] = useState(false);
const [form] = Form.useForm();
const [searchText, setSearchText] = useState("");
const [factorioVersion, setFactorioVersion] = useState<lib.ApiVersion>("2.0");
const [factorioVersion, setFactorioVersion] = useState<lib.MajorMinorVersion>("2.0");
// State for all mods fetched from backend
const [allMods, setAllMods] = useState<ModPortalModType[]>([]);
@@ -162,13 +165,16 @@ function SearchModsButton() {
const handleControllerDownload = (
modName: string,
modTitle: string | undefined,
modVersion: lib.FullVersion | undefined,
portalFactorioVersion: lib.ApiVersion,
modVersion: lib.SourceVersion | undefined,
portalFactorioVersion: lib.MajorMinorVersion,
) => {
if (modVersion) {
control.send(
new lib.ModPortalDownloadRequest(
[{ name: modName, version: new lib.ModVersionEquality("=", modVersion)}],
[{
name: modName,
version: new lib.ModVersionEquality("=", lib.normaliseSourceVersion(modVersion)),
}],
portalFactorioVersion
)
).then(() => {
@@ -269,7 +275,7 @@ function SearchModsButton() {
// Update search text and factorio version from form
const handleSearch = (changedValues: any, allValues: any) => {
const nameValue = allValues.name;
const versionValue = allValues.factorioVersion as lib.ApiVersion | undefined;
const versionValue = allValues.factorioVersion as lib.MajorMinorVersion | undefined;
// Only trigger state updates if values actually changed
if (nameValue !== searchText) {
@@ -507,9 +513,19 @@ function SearchModsButton() {
export default function ModsPage() {
let account = useAccount();
let control = useContext(ControlContext);
let navigate = useNavigate();
let [mods] = useMods();
let [modPacks] = useModPacks();
const modPackTable = useTableQueryState<lib.ModPack>({
namespace: "modPack", defaultSortKey: "name",
});
const modTable = useTableQueryState<lib.ModInfo>({
namespace: "mod", defaultSortKey: "title",
});
const modPackNameSearch = useColumnSearch<lib.ModPack>(
modPackTable, "name", modPack => modPack.name, "Search mod packs"
);
const modNameSearch = useColumnSearch<lib.ModInfo>(modTable, "title", mod => mod.title, "Search mods");
const rowNav = useRowNavigation();
function actions(mod: lib.ModInfo) {
return <Space>
@@ -575,13 +591,23 @@ export default function ModsPage() {
{
title: "Name",
dataIndex: "name",
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a.name, b.name),
sortOrder: modPackTable.sortOrder("name"),
filteredValue: modPackTable.filteredValue("name"),
className: "table-link-cell",
render: (_, modPack) => <Link
to={`/mods/mod-packs/${modPack.id}/view`}
style={{ color: "inherit" }}
>
{modPack.name}
</Link>,
...modPackNameSearch,
},
{
title: "Factorio Version",
dataIndex: "factorioVersion",
sorter: (a, b) => a.integerFactorioVersion - b.integerFactorioVersion,
sortOrder: modPackTable.sortOrder("factorioVersion"),
},
{
title: "Mods",
@@ -590,13 +616,10 @@ export default function ModsPage() {
},
]}
dataSource={[...modPacks.values()]}
pagination={false}
pagination={modPackTable.pagination}
onChange={modPackTable.onChange}
rowKey={modPack => Number(modPack.id)}
onRow={(modPack, rowIndex) => ({
onClick: event => {
navigate(`/mods/mod-packs/${modPack.id}/view`);
},
})}
onRow={modPack => rowNav(`/mods/mod-packs/${modPack.id}/view`)}
/>
<SectionHeader title="Stored Mods" extra={<Space wrap>
<SearchModsButton />
@@ -621,10 +644,12 @@ export default function ModsPage() {
{
title: "Name",
dataIndex: "title",
defaultSortOrder: "ascend",
sorter: (a, b) => (
strcmp(a.name, b.name) || a.integerVersion - b.integerVersion
),
sortOrder: modTable.sortOrder("title"),
filteredValue: modTable.filteredValue("title"),
...modNameSearch,
},
{
title: "Version",
@@ -640,6 +665,7 @@ export default function ModsPage() {
dataIndex: "filename",
responsive: ["xl"],
sorter: (a, b) => strcmp(a.filename, b.filename),
sortOrder: modTable.sortOrder("filename"),
},
{
title: "Size",
@@ -648,6 +674,7 @@ export default function ModsPage() {
render: (_, mod) => lib.formatBytes(mod.size),
align: "right",
sorter: (a, b) => a.size - b.size,
sortOrder: modTable.sortOrder("size"),
},
{
title: "Action",
@@ -661,7 +688,8 @@ export default function ModsPage() {
expandedRowClassName: () => "no-expanded-padding",
}}
dataSource={[...mods.values()]}
pagination={false}
pagination={modTable.pagination}
onChange={modTable.onChange}
rowKey={mod => mod.id}
/>
</Upload.Dragger>
+1 -1
View File
@@ -170,7 +170,7 @@ export function NpmButton(props: {
okButtonProps={{disabled: formAction === undefined}}
onOk={() => { onOk().catch(notifyErrorHandler(`Error running ${formAction}`)); }}
onCancel={() => { setOpen(false); }}
destroyOnClose
destroyOnHidden
>
<Form form={form} onValuesChange={onValuesChange} clearOnDestroy>
<Form.Item label="Action" name="action">
@@ -0,0 +1,145 @@
import React, { useContext, useEffect, useState } from "react";
import { Row, Col, Card, Statistic, Progress, Button, Typography } from "antd";
import { LineChartOutlined } from "@ant-design/icons";
import * as lib from "@clusterio/lib";
import { useAccount } from "../model/account";
import { useHosts } from "../model/host";
import { useInstances } from "../model/instance";
import { useUsers, isUserOnline } from "../model/user";
import { useSystems } from "../model/system";
import { useSaves } from "../model/saves";
import ControlContext from "./ControlContext";
import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import LogConsole from "./LogConsole";
import { MetricRelativeDate } from "./system_metrics";
const { Title } = Typography;
const sum = (values: number[]) => values.reduce((a, b) => a + b, 0);
/** A resource card showing used / total with a usage bar. */
function ResourceCard(
props: { title: string, used: number, total: number, format: (n: number) => string, unit?: string }
) {
const percent = props.total > 0 ? Math.round((props.used / props.total) * 100) : 0;
const suffix = `/ ${props.format(props.total)}${props.unit ? ` ${props.unit}` : ""}`;
return <Card>
<Statistic title={props.title} value={props.format(props.used)} suffix={suffix} />
<Progress percent={percent} size="small" />
</Card>;
}
/** Format a core count: a fraction for partial use, a whole number for the total. */
const formatCores = (n: number) => n.toLocaleString(undefined, { maximumFractionDigits: 1 });
export default function OverviewPage() {
const account = useAccount();
const control = useContext(ControlContext);
const [hosts] = useHosts();
const [instances] = useInstances();
const [users] = useUsers();
const [systems] = useSystems();
const [saves] = useSaves();
const [grafanaUrl, setGrafanaUrl] = useState<string | null>(null);
const canHosts = account.hasPermission("core.host.subscribe");
const canSystems = account.hasPermission("core.system.subscribe");
const canInstances = account.hasPermission("core.instance.subscribe");
const canUsers = account.hasPermission("core.user.subscribe");
const canSaves = account.hasPermission("core.instance.save.subscribe");
const canLogs = account.hasPermission("core.log.follow");
const canConfig = account.hasPermission("core.controller.get_config");
useEffect(() => {
if (!canConfig) {
return undefined;
}
let canceled = false;
control.send(new lib.ControllerConfigGetRequest()).then(serialized => {
if (canceled) {
return;
}
const config = lib.ControllerConfig.fromJSON(serialized, "control");
const url = config.get("controller.grafana_url");
setGrafanaUrl(typeof url === "string" ? url : null);
}).catch(() => { /* link stays hidden if the config can't be read */ });
return () => { canceled = true; };
}, [control, canConfig]);
const hostList = [...hosts.values()];
const instanceList = [...instances.values()];
const userList = [...users.values()];
// Resource totals across the connected host machines (the controller is shown only for uptime).
const hostSystems = [...systems.values()].filter(system => system.id !== "controller");
const controllerSystem = systems.get("controller");
const saveList = [...saves.values()];
const loadedSaveSize = sum(saveList.filter(save => save.loaded).map(save => save.size));
const totalSaveSize = sum(saveList.map(save => save.size));
const colProps = { xs: 24, sm: 12, lg: 6 };
return <PageLayout nav={[{ name: "Overview" }]}>
<PageHeader
title="Overview"
extra={grafanaUrl
? <Button icon={<LineChartOutlined />} href={grafanaUrl} target="_blank" rel="noreferrer">
Grafana
</Button>
: undefined}
/>
<Row gutter={[16, 16]}>
{canHosts && <Col {...colProps}>
<Card>
<Statistic title="Hosts connected" value={hostList.filter(h => h.connected).length}
suffix={`/ ${hostList.length}`} />
</Card>
</Col>}
{canInstances && <Col {...colProps}>
<Card>
<Statistic title="Instances running" value={instanceList.filter(i => i.status === "running").length}
suffix={`/ ${instanceList.length}`} />
</Card>
</Col>}
{canUsers && <Col {...colProps}>
<Card>
<Statistic title="Users online" value={userList.filter(u => isUserOnline(u)).length}
suffix={`/ ${userList.length}`} />
</Card>
</Col>}
{canSystems && <>
<Col {...colProps}>
<Card>
<Statistic title="Controller running since" formatter={() => (
<MetricRelativeDate timeMs={controllerSystem?.processStartedAtMs} compact />
)} />
</Card>
</Col>
<Col {...colProps}>
<ResourceCard title="CPU" used={sum(hostSystems.map(s => s.cpuUsed))}
total={sum(hostSystems.map(s => s.cpuCapacity))} format={formatCores} unit="cores" />
</Col>
<Col {...colProps}>
<ResourceCard title="Memory" used={sum(hostSystems.map(s => s.memoryUsed))}
total={sum(hostSystems.map(s => s.memoryCapacity))} format={lib.formatBytes} />
</Col>
<Col {...colProps}>
<ResourceCard title="Disk" used={sum(hostSystems.map(s => s.diskUsed))}
total={sum(hostSystems.map(s => s.diskCapacity))} format={lib.formatBytes} />
</Col>
</>}
{canSaves && <Col {...colProps}>
<ResourceCard title="Saves loaded" used={loadedSaveSize} total={totalSaveSize}
format={lib.formatBytes} />
</Col>}
</Row>
{canLogs && <>
<Title level={5} style={{ marginTop: 16 }}>Recent errors</Title>
<LogConsole all maxLevel="warn" />
</>}
<PluginExtra component="OverviewPage" />
</PageLayout>;
}
+34 -12
View File
@@ -1,23 +1,38 @@
import React, { useContext, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Table } from "antd";
import CloseCircleFilled from "@ant-design/icons/CloseCircleFilled";
import InfoCircleFilled from "@ant-design/icons/InfoCircleFilled";
import type { PluginWebApi } from "@clusterio/lib";
import type { PluginWebApi, PluginWebpackEnvInfo } from "@clusterio/lib";
import notify from "../util/notify";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import PageHeader from "./PageHeader";
import useTableQueryState from "../util/useTableQueryState";
import useColumnSearch from "../util/useColumnSearch";
import useRowNavigation from "../util/useRowNavigation";
import Link from "./Link";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
type PluginRow = {
meta: PluginWebApi;
info?: PluginWebpackEnvInfo;
package?: any;
};
export default function PluginsPage() {
const control = useContext(ControlContext);
let navigate = useNavigate();
let [pluginList, setPluginList] = useState<PluginWebApi[]>([]);
const tableState = useTableQueryState<PluginRow>({
namespace: "plugin", defaultSortKey: "name",
});
const nameSearch = useColumnSearch<PluginRow>(
tableState, "name", plugin => (plugin.info ? plugin.info.title : plugin.meta.name), "Search plugins"
);
const rowNav = useRowNavigation();
useEffect(() => {
(async () => {
@@ -30,7 +45,7 @@ export default function PluginsPage() {
})();
}, []);
let tableContents = [];
let tableContents: PluginRow[] = [];
for (let meta of pluginList) {
if (control.plugins.has(meta.name)) {
let plugin = control.plugins.get(meta.name)!;
@@ -54,9 +69,17 @@ export default function PluginsPage() {
{
title: "Name",
key: "name",
render: (_, plugin) => (plugin.info ? plugin.info.title : plugin.meta.name),
defaultSortOrder: "ascend",
className: "table-link-cell",
render: (_, plugin) => <Link
to={`/plugins/${plugin.meta.name}/view`}
style={{ color: "inherit" }}
>
{plugin.info ? plugin.info.title : plugin.meta.name}
</Link>,
sorter: (a, b) => strcmp(a.info ? a.info.title : a.meta.name, b.info ? b.info.title : b.meta.name),
sortOrder: tableState.sortOrder("name"),
filteredValue: tableState.filteredValue("name"),
...nameSearch,
},
{
title: "Version",
@@ -74,23 +97,22 @@ export default function PluginsPage() {
return plugin.package.version;
},
sorter: (a, b) => strcmp(a.meta.version, b.meta.version),
sortOrder: tableState.sortOrder("version"),
},
{
title: "Loaded",
key: "loaded",
render: (_, plugin) => (plugin.package ? "Yes" : null),
sorter: (a, b) => Number(Boolean(a.package)) - Number(Boolean(b.package)),
sortOrder: tableState.sortOrder("loaded"),
responsive: ["sm"],
},
]}
dataSource={tableContents}
rowKey={plugin => plugin.meta.name}
pagination={false}
onRow={plugin => ({
onClick: event => {
navigate(`/plugins/${plugin.meta.name}/view`);
},
})}
pagination={tableState.pagination}
onChange={tableState.onChange}
onRow={plugin => rowNav(`/plugins/${plugin.meta.name}/view`)}
/>
</PageLayout>;
}
+18 -8
View File
@@ -11,6 +11,10 @@ import { notifyErrorHandler } from "../util/notify";
import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import useTableQueryState from "../util/useTableQueryState";
import useColumnSearch from "../util/useColumnSearch";
import useRowNavigation from "../util/useRowNavigation";
import Link from "./Link";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
@@ -45,7 +49,7 @@ function CreateRoleButton() {
open={open}
onOk={() => { createRole().catch(notifyErrorHandler("Error creating role")); }}
onCancel={() => { setOpen(false); }}
destroyOnClose
destroyOnHidden
>
<Form form={form}>
<Form.Item name="roleName" label="Name">
@@ -61,8 +65,10 @@ function CreateRoleButton() {
export default function RolesPage() {
let account = useAccount();
let navigate = useNavigate();
const [roles] = useRoles();
const tableState = useTableQueryState<lib.Role>({ namespace: "role" });
const nameSearch = useColumnSearch<lib.Role>(tableState, "name", role => role.name, "Search roles");
const rowNav = useRowNavigation();
return <PageLayout nav={[{ name: "Roles" }]}>
<PageHeader
@@ -75,6 +81,13 @@ export default function RolesPage() {
title: "Name",
dataIndex: "name",
sorter: (a, b) => strcmp(a.name, b.name),
sortOrder: tableState.sortOrder("name"),
filteredValue: tableState.filteredValue("name"),
className: "table-link-cell",
render: (_, role) => <Link to={`/roles/${role.id}/view`} style={{ color: "inherit" }}>
{role.name}
</Link>,
...nameSearch,
},
{
title: "Description",
@@ -82,13 +95,10 @@ export default function RolesPage() {
},
]}
dataSource={[...roles.values()]}
pagination={false}
pagination={tableState.pagination}
rowKey={role => role.id}
onRow={(role, rowIndex) => ({
onClick: event => {
navigate(`/roles/${role.id}/view`);
},
})}
onChange={tableState.onChange}
onRow={role => rowNav(`/roles/${role.id}/view`)}
/>
<PluginExtra component="RolesPage" />
</PageLayout>;
+10 -2
View File
@@ -9,6 +9,7 @@ import LeftOutlined from "@ant-design/icons/LeftOutlined";
import * as lib from "@clusterio/lib";
import { useAccount } from "../model/account";
import useTableQueryState from "../util/useTableQueryState";
import ControlContext from "./ControlContext";
import CreateSaveModal from "./CreateSaveModal";
import SectionHeader from "./SectionHeader";
@@ -119,7 +120,7 @@ function TransferModal(props: ModalProps) {
open={open}
onOk={() => form.submit()}
onCancel={() => setOpen(false)}
destroyOnClose
destroyOnHidden
>
<Form
form={form}
@@ -204,6 +205,9 @@ export default function SavesList(props: { instance: lib.InstanceDetails }) {
let [saves] = useSavesOfInstance(props.instance.id);
let [starting, setStarting] = useState(false);
let [uploadingFiles, setUploadingFiles] = useState<File[]>([]);
const tableState = useTableQueryState<lib.SaveDetails>({
namespace: "save", defaultSortKey: "mtimeMs", defaultSortOrder: "descend",
});
let hostOffline = ["unassigned", "unknown"].includes(props.instance.status!);
const saveTable = <Table
@@ -219,6 +223,7 @@ export default function SavesList(props: { instance: lib.InstanceDetails }) {
{save.loadByDefault && <Tooltip title="Save loaded by default"><LeftOutlined /></Tooltip>}
</>,
sorter: (a, b) => a.name.localeCompare(b.name),
sortOrder: tableState.sortOrder("name"),
},
{
title: "Size",
@@ -227,16 +232,19 @@ export default function SavesList(props: { instance: lib.InstanceDetails }) {
render: (_, save) => lib.formatBytes(save.size),
align: "right",
sorter: (a, b) => a.size - b.size,
sortOrder: tableState.sortOrder("size"),
},
{
title: "Last Modified",
key: "mtimeMs",
render: (_, save) => new Date(save.mtimeMs).toLocaleString(),
sorter: (a, b) => a.mtimeMs - b.mtimeMs,
defaultSortOrder: "descend",
sortOrder: tableState.sortOrder("mtimeMs"),
},
]}
dataSource={[...saves.values()]}
pagination={tableState.pagination}
onChange={tableState.onChange}
rowKey={save => save.name}
expandable={{
columnWidth: 33,
+107 -52
View File
@@ -1,25 +1,35 @@
import React, { Fragment, useContext, useEffect, useState } from "react";
import { Route, Routes, useNavigate } from "react-router-dom";
import { Button, Dropdown, Layout, Menu, MenuProps, Tooltip } from "antd";
import { UserOutlined, DownloadOutlined } from "@ant-design/icons";
import { Button, Drawer, Dropdown, Flex, Grid, Layout, Menu, MenuProps, Tooltip, Typography } from "antd";
import { UserOutlined, DownloadOutlined, MenuOutlined } from "@ant-design/icons";
import ErrorBoundary from "./ErrorBoundary";
import ErrorPage from "./ErrorPage";
import ControlContext from "./ControlContext";
import ChangeLogModal from "./ChangeLogModal";
import AboutModal from "./AboutModal";
import Link from "./Link";
import { pages } from "../pages";
import { saveJson } from "../util/save_file";
import { useAccount } from "../model/account";
import { DraggingContext } from "../model/is_dragging";
import webUiPackage from "../../package.json";
import logo from "../images/logo.png";
import { ControlConfig } from "@clusterio/lib";
type MenuItem = Required<MenuProps>["items"][number];
const { Header, Sider } = Layout;
const { Sider, Header } = Layout;
// Render a sidebar menu label as a real link so right-click/middle-click can
// open it in a new tab; stopPropagation keeps the Menu onClick from also firing.
function navLink(path: string, label: string) {
// color: inherit keeps the menu colour in every state (incl. the focus left
// behind by a middle-click, which would otherwise show the link blue).
return <Link to={path} style={{ color: "inherit" }} onClick={e => e.stopPropagation()}>{label}</Link>;
}
function isActiveDropzone(element: HTMLElement | null): boolean {
if (!element) {
@@ -42,6 +52,13 @@ function isActiveDropzone(element: HTMLElement | null): boolean {
export default function SiteLayout() {
let navigate = useNavigate();
let [currentSidebarPath, setCurrentSidebarPath] = useState<string | null>(null);
const screens = Grid.useBreakpoint();
// On phone screens the sidebar becomes an overlay drawer instead of a fixed sider.
// `md` matches the previous Sider breakpoint; `screens.md` is undefined until measured,
// so treat the initial render as desktop to avoid flashing the hamburger.
const isMobile = screens.md === false;
const [drawerOpen, setDrawerOpen] = useState(false);
const [collapsed, setCollapsed] = useState(false);
let account = useAccount();
let plugins = useContext(ControlContext).plugins;
const control = useContext(ControlContext);
@@ -74,7 +91,13 @@ export default function SiteLayout() {
}
},
items: [
{ label: account.name, key: "user" },
{
label: <Link to={`/users/${account.name}/view`} style={{ color: "inherit" }}
onClick={e => e.stopPropagation()}>
{account.name}
</Link>,
key: "user",
},
{
label: <Tooltip title="Download credentials and configuration file for cli interface">
Ctl Config <Button size="small" icon={<DownloadOutlined />} />
@@ -110,9 +133,9 @@ export default function SiteLayout() {
group = [];
menuGroups.set(sidebarGroup, group);
}
group.push({ label: sidebarName, key: path });
group.push({ label: navLink(path, sidebarName), key: path });
} else {
menuItems.push({ label: sidebarName, key: path });
menuItems.push({ label: navLink(path, sidebarName), key: path });
}
}
for (let [name, group] of menuGroups) {
@@ -126,7 +149,39 @@ export default function SiteLayout() {
setDragging(dragging + dragChange);
}
// Navigate and dismiss the mobile drawer, matching hamburger menu conventions.
function navigateAndClose(key: string) {
navigate(key);
setDrawerOpen(false);
}
// The header trigger collapses the sider on desktop and toggles the overlay drawer on phones.
function toggleSidebar() {
if (isMobile) {
setDrawerOpen(value => !value);
} else {
setCollapsed(value => !value);
}
}
let sidebarContent = <Menu
theme="dark"
mode="inline"
defaultOpenKeys={[...menuGroups.keys()]}
selectedKeys={currentSidebarPath ? [currentSidebarPath] : []}
style={{ height: "100%", overflow: "auto", borderInlineEnd: 0 }}
onClick={({ key }) => navigateAndClose(key)}
items={menuItems}
/>;
let accountMenu = <Dropdown menu={accountMenuProps} trigger={["click"]} placement="bottomRight">
<Button type="text" icon={<UserOutlined />} style={{ marginInlineStart: "auto" }}>
{account.name}
</Button>
</Dropdown>;
return <Layout
className="site-layout"
style={{ minHeight: "100vh" }}
onDragEnter={() => setDraggingProxy(1)}
onDragLeave={() => setDraggingProxy(-1)}
@@ -169,56 +224,56 @@ export default function SiteLayout() {
setAboutOpen(wasAboutOpen);
}}
/>
<DraggingContext.Provider value={dragging > 0}>
<Header className="header">
<div className="site-logo" />
<span className="site-name">Clusterio</span>
<Tooltip
title="View changelog"
placement="bottom"
align={{ offset: [0, -10] }}
>
<span
className="site-version"
style={{ cursor: "pointer" }}
<Header className="site-layout-header">
<Button
type="text"
icon={<MenuOutlined />}
onClick={toggleSidebar}
aria-label="Toggle navigation menu"
/>
<Link to="/" style={{ display: "flex", alignItems: "center" }}>
<img src={logo} width={56} height={56} alt="Clusterio logo" />
</Link>
<Flex vertical justify="center">
<Typography.Title level={4} style={{ margin: 0, lineHeight: 1.2 }}>Clusterio</Typography.Title>
<Tooltip title="View changelog" placement="right">
<Typography.Text
type="danger"
style={{ cursor: "pointer", lineHeight: 1.2 }}
onClick={() => setChangeLogOpen(true)}
>
{webUiPackage.version}
</span>
</Typography.Text>
</Tooltip>
<Menu
theme="dark"
mode="horizontal"
defaultSelectedKeys={["1"]}
items={[{ label: "Dashboard", key: "1" }]}
/>
<Dropdown
className="account-dropdown-header"
placement="bottomRight"
trigger={["click"]}
menu={accountMenuProps}
>
<UserOutlined />
</Dropdown>
</Header>
<Layout className="site-layout">
<Sider
collapsible
collapsedWidth={0}
breakpoint="md"
zeroWidthTriggerStyle={{ top: 6, zIndex: -1 }}
width={250}
className="site-layout-sider"
>
<Menu
mode="inline"
defaultOpenKeys={[...menuGroups.keys()]}
selectedKeys={currentSidebarPath ? [currentSidebarPath] : []}
style={{ height: "100%", borderRight: 0 }}
onClick={({ key }) => navigate(key)}
items={menuItems}
/>
</Sider>
</Flex>
{accountMenu}
</Header>
<DraggingContext.Provider value={dragging > 0}>
<Layout hasSider={!isMobile}>
{isMobile
? <Drawer
placement="left"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={250}
closable={false}
// Start below the sticky 64px header so it stays visible and usable.
rootStyle={{ top: 64 }}
styles={{ body: { padding: 0, background: "#001529" } }}
>
{sidebarContent}
</Drawer>
: <Sider
width={250}
collapsible
collapsed={collapsed}
collapsedWidth={0}
trigger={null}
className="site-layout-sider"
>
{sidebarContent}
</Sider>
}
<Layout className="site-layout-content-container">
<Routes>
{combinedPages.map(({ path, sidebarPath, content }) => <Route
@@ -9,6 +9,7 @@ import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import * as lib from "@clusterio/lib";
import { useRoles } from "../model/roles";
import useTableQueryState from "../util/useTableQueryState";
import { useAccount } from "../model/account";
import { useInstances } from "../model/instance";
import ControlContext from "./ControlContext";
@@ -16,6 +17,8 @@ import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import SectionHeader from "./SectionHeader";
import useRowNavigation from "../util/useRowNavigation";
import Link from "./Link";
import notify, { notifyErrorHandler } from "../util/notify";
import { formatDuration } from "../util/time_format";
import { formatFirstSeen, formatLastSeen, sortFirstSeen, sortLastSeen, useUser } from "../model/user";
@@ -31,6 +34,7 @@ export default function UserViewPage() {
let userName = params.name as string;
let navigate = useNavigate();
const rowNav = useRowNavigation();
let account = useAccount();
let control = useContext(ControlContext);
@@ -38,6 +42,9 @@ export default function UserViewPage() {
const [user, synced] = useUser(userName);
let [roles] = useRoles();
let [form] = Form.useForm();
const statsTable = useTableQueryState<[number, lib.PlayerStats]>({
namespace: "stats", defaultSortKey: "instance",
});
let [rolesDirty, setRolesDirty] = useState<boolean>(false);
let [banReasonDirty, setBanReasonDirty] = useState<boolean>(false);
let [applyingRoles, setApplyingRoles] = useState<boolean>(false);
@@ -298,21 +305,26 @@ export default function UserViewPage() {
{
title: "Instance",
key: "instance",
render: (_, [id]) => instanceName(id),
defaultSortOrder: "ascend",
className: "table-link-cell",
render: (_, [id]) => <Link to={`/instances/${id}/view`} style={{ color: "inherit" }}>
{instanceName(id)}
</Link>,
sorter: (a, b) => strcmp(instanceName(a[0]), instanceName(b[0])),
sortOrder: statsTable.sortOrder("instance"),
},
{
title: "Online time",
key: "onlineTime",
render: (_, [, stats]) => formatDuration(stats.onlineTimeMs || 0),
sorter: (a, b) => (a[1].onlineTimeMs || 0) - (b[1].onlineTimeMs || 0),
sortOrder: statsTable.sortOrder("onlineTime"),
},
{
title: "Join count",
key: "joinCoint",
render: (_, [, stats]) => stats.joinCount || 0,
sorter: (a, b) => (a[1].joinCount || 0) - (b[1].joinCount || 0),
sortOrder: statsTable.sortOrder("joinCoint"),
responsive: ["sm"],
},
{
@@ -320,22 +332,21 @@ export default function UserViewPage() {
key: "firstSeen",
render: (_, [id]) => formatFirstSeen(user, id),
sorter: (a, b) => sortFirstSeen(user, user, a[0], b[0]),
sortOrder: statsTable.sortOrder("firstSeen"),
},
{
title: "Last seen",
key: "lastSeen",
render: (_, [id]) => formatLastSeen(user, id),
sorter: (a, b) => sortLastSeen(user, user, a[0], b[0]),
sortOrder: statsTable.sortOrder("lastSeen"),
},
]}
dataSource={[...(user.instanceStats || []).entries()]}
pagination={false}
pagination={statsTable.pagination}
onChange={statsTable.onChange}
rowKey={([id]) => id}
onRow={([id], rowIndex) => ({
onClick: event => {
navigate(`/instances/${id}/view`);
},
})}
onRow={([id]) => rowNav(`/instances/${id}/view`)}
/>
<PluginExtra component="UserViewPage" user={user} />
</PageLayout>;
+60 -23
View File
@@ -1,8 +1,10 @@
import React, { useRef } from "react";
import { InputRef, Input, Space, Segmented, Button, Typography, Tag } from "antd";
import { InputRef, Input, Space, Segmented, Typography, Tag } from "antd";
import type { FilterDropdownProps } from "antd/es/table/interface";
import { CheckOutlined, CloseOutlined, MinusOutlined } from "@ant-design/icons";
import { UserDetails } from "@clusterio/lib";
import type { FilterCodec } from "../util/tableQuery";
import type { TableQueryState } from "../util/useTableQueryState";
const { Text } = Typography;
export interface UserFilter {
@@ -24,6 +26,43 @@ function decodeFilter(value: string): UserFilter {
}
}
function triFromParam(value: string | null): boolean | undefined {
if (value === "true") { return true; }
if (value === "false") { return false; }
return undefined;
}
/**
* Persists the bundled user filter as separate readable query parameters
* (`filter.name` for the text search plus `filter.admin`/`filter.whitelisted`/
* `filter.banned`) instead of one opaque JSON blob. Plug into
* useTableQueryState via `filterCodecs: { name: userFilterCodec }`.
*/
export const userFilterCodec: FilterCodec = {
decode(params, prefix) {
const filter: UserFilter = {};
const search = params.get(`${prefix}name`);
if (search) { filter.search = search; }
const admin = triFromParam(params.get(`${prefix}admin`));
if (admin !== undefined) { filter.admin = admin; }
const whitelisted = triFromParam(params.get(`${prefix}whitelisted`));
if (whitelisted !== undefined) { filter.whitelisted = whitelisted; }
const banned = triFromParam(params.get(`${prefix}banned`));
if (banned !== undefined) { filter.banned = banned; }
return Object.keys(filter).length ? [encodeFilter(filter)] : null;
},
encode(params, value, prefix) {
const filter = value && value[0] ? decodeFilter(String(value[0])) : {};
const set = (key: string, val: string | undefined) => (
val !== undefined ? params.set(prefix + key, val) : params.delete(prefix + key)
);
set("name", filter.search || undefined);
set("admin", filter.admin === undefined ? undefined : String(filter.admin));
set("whitelisted", filter.whitelisted === undefined ? undefined : String(filter.whitelisted));
set("banned", filter.banned === undefined ? undefined : String(filter.banned));
},
};
function triStateToSegmented(value?: boolean) {
if (value === true) { return "yes"; }
if (value === false) { return "no"; }
@@ -41,7 +80,7 @@ function matchesTriState(value: boolean, filter?: boolean) {
}
export function onFilterUser(
value: string | number | boolean,
value: boolean | React.Key,
record: UserDetails
) {
const filter = decodeFilter(String(value));
@@ -80,32 +119,29 @@ export function Username({
);
}
export function useUserFilter(withStatus?: boolean) {
export function useUserFilter(
tableState: TableQueryState<UserDetails>,
columnKey: string,
withStatus?: boolean,
) {
const searchInput = useRef<InputRef>(null);
function filterDropdown({
selectedKeys,
setSelectedKeys,
confirm,
clearFilters,
}: {
selectedKeys: string[];
setSelectedKeys: (keys: string[]) => void;
confirm: FilterDropdownProps["confirm"];
clearFilters: FilterDropdownProps["clearFilters"];
}) {
close,
}: FilterDropdownProps) {
const filter: UserFilter = selectedKeys[0]
? decodeFilter(selectedKeys[0])
? decodeFilter(String(selectedKeys[0]))
: {};
// Update the live filter as the user types/toggles; the URL is only written when the
// dropdown closes (see onOpenChange) or when enter is pressed (see onPressEnter)
function update(next: Partial<UserFilter>) {
const encoded = encodeFilter({ ...filter, ...next });
if (encoded === "{}") {
clearFilters?.({ closeDropdown: false });
} else {
setSelectedKeys([encoded]);
}
confirm({ closeDropdown: false });
const values = encoded === "{}" ? null : [encoded];
setSelectedKeys(values ?? []);
tableState.setFilter(columnKey, values);
}
return (
@@ -116,12 +152,11 @@ export function useUserFilter(withStatus?: boolean) {
placeholder="Search username"
value={filter.search}
allowClear
onSearch={() => confirm({ closeDropdown: true })}
onChange={(e) => update({ search: e.target.value !== "" ? e.target.value : undefined })}
onClear={() => {
clearFilters?.({ closeDropdown: false });
confirm({ closeDropdown: true });
onPressEnter={e => {
close();
tableState.commitFilter(columnKey);
}}
onChange={(e) => update({ search: e.target.value !== "" ? e.target.value : undefined })}
/>
{withStatus &&<>
@@ -176,6 +211,8 @@ export function useUserFilter(withStatus?: boolean) {
onOpenChange: (open: boolean) => {
if (open) {
setTimeout(() => searchInput.current?.select(), 100);
} else {
tableState.commitFilter(columnKey);
}
},
};
+2 -2
View File
@@ -50,7 +50,7 @@ function CreateUserButton() {
open={open}
onOk={() => { createUser().catch(notifyErrorHandler("Error creating user")); }}
onCancel={() => { setOpen(false); }}
destroyOnClose
destroyOnHidden
>
<Form form={form}>
<Form.Item name="userName" label="Name">
@@ -345,7 +345,7 @@ function BulkUserActionButton() {
okButtonProps={{ disabled: formAction === undefined }}
onOk={() => { onOk().catch(notifyErrorHandler(`Error running ${formAction}`)); }}
onCancel={() => { setOpen(false); }}
destroyOnClose
destroyOnHidden
>
<Form form={form} onValuesChange={onValuesChange} clearOnDestroy>
<Form.Item label="Action" name="action">
+49 -31
View File
@@ -1,7 +1,6 @@
import React from "react";
import { Table, Tag } from "antd";
import { Table, Tag, type TablePaginationConfig } from "antd";
import { SearchOutlined } from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import * as lib from "@clusterio/lib";
@@ -13,8 +12,10 @@ import {
useUsers, getUserStats, isUserOnline,
} from "../model/user";
import { onFilterUser, Username, useUserFilter } from "./UsersFilters";
import { onFilterUser, Username, useUserFilter, userFilterCodec } from "./UsersFilters";
import Link from "./Link";
import useTableQueryState from "../util/useTableQueryState";
import useRowNavigation from "../util/useRowNavigation";
export interface UsersTableProps {
/** Optional instance id. If provided, stats will be filtered to that instance only */
@@ -22,19 +23,28 @@ export interface UsersTableProps {
/** Show only players that are currently online, works with instanceId to show online of a particular instance */
onlyOnline?: boolean;
/** Ant Design pagination prop. Pass `false` to disable pagination. */
pagination?: false | object;
pagination?: false | TablePaginationConfig;
/** Ant Design size prop */
size?: "small" | "middle" | "large";
}
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
export default function UsersTable({ instanceId, onlyOnline = false, pagination, size }: UsersTableProps) {
export default function UsersTable(
{ instanceId, onlyOnline = false, pagination, size }: UsersTableProps
) {
const [roles, rolesSynced] = useRoles();
const [users, usersSynced] = useUsers();
const navigate = useNavigate();
const tableState = useTableQueryState<lib.UserDetails>({
namespace: "user",
defaultSortKey: "name",
// Default to 50 per page when the caller does not specify a pagination config.
pagination: pagination === false ? false : (pagination ?? { defaultPageSize: 50 }),
filterCodecs: { name: userFilterCodec },
});
const rowNav = useRowNavigation();
const {filterDropdown, filterDropdownProps} = useUserFilter(true);
const {filterDropdown, filterDropdownProps} = useUserFilter(tableState, "name", true);
const data = [...users.values()];
const roleFilters = [...roles.values()].map(role => ({ text: role.name, value: role.id }));
@@ -84,11 +94,15 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
{
title: "Name",
key: "name",
className: "table-link-cell",
render: (_: any, user: lib.UserDetails) => (
<Username user={user} withStatus />
<Link to={`/users/${user.name}/view`} style={{ color: "inherit" }}>
<Username user={user} withStatus />
</Link>
),
defaultSortOrder: "ascend",
sorter: (a: lib.UserDetails, b: lib.UserDetails) => strcmp(a.name, b.name),
sortOrder: tableState.sortOrder("name"),
filteredValue: tableState.filteredValue("name"),
filterIcon: (filtered: boolean) => (
<SearchOutlined style={{ color: filtered ? "#1677ff" : undefined }} />
),
@@ -101,8 +115,9 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
key: "roles",
filters: roleFilters,
filterMultiple: true,
onFilter: (value: string | number | boolean, record: lib.UserDetails) => (
record.roleIds.has(value as number)
filteredValue: tableState.filteredValue("roles"),
onFilter: (value: boolean | React.Key, record: lib.UserDetails) => (
record.roleIds.has(Number(value))
),
render: (_: any, user: lib.UserDetails) => (
[...user.roleIds].map((id) => (
@@ -124,6 +139,7 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
const statsB = getUserStats(b, instanceId);
return (statsA?.onlineTimeMs ?? 0) - (statsB?.onlineTimeMs ?? 0);
},
sortOrder: tableState.sortOrder("onlineTime"),
},
{
title: "Join Count",
@@ -136,56 +152,58 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
const statsB = getUserStats(b, instanceId);
return (statsA?.joinCount ?? 0) - (statsB?.joinCount ?? 0);
},
sortOrder: tableState.sortOrder("joinCount"),
},
{
title: "First Seen",
key: "firstSeen",
filterMultiple: false,
filters: durationFilters,
filteredValue: tableState.filteredValue("firstSeen"),
onFilter: (value: any, record: lib.UserDetails) => onFilterDuration(value, record, calculateFirstSeen),
render: (_: any, user: lib.UserDetails) => formatFirstSeen(user, instanceId),
sorter: (a: lib.UserDetails, b: lib.UserDetails) => sortFirstSeen(a, b, instanceId, instanceId),
sortOrder: tableState.sortOrder("firstSeen"),
},
{
title: "Last Seen",
key: "lastSeen",
filterMultiple: false,
filters: durationFilters,
defaultFilteredValue: onlyOnline ? ["online"] : undefined,
// With the onlyOnline prop set, seed this column's filter to "online" until the
// URL provides one, so the table starts out showing only online players.
filteredValue: tableState.filteredValue("lastSeen") ?? (onlyOnline ? ["online"] : null),
onFilter: (value: any, record: lib.UserDetails) => onFilterDuration(value, record, calculateLastSeen),
sorter: (a: lib.UserDetails, b: lib.UserDetails) => sortLastSeen(a, b, instanceId, instanceId),
sortOrder: tableState.sortOrder("lastSeen"),
render: (_: any, user: lib.UserDetails) => formatLastSeen(user, instanceId),
// Responsive breaks defaultFilteredValue, see: https://github.com/ant-design/ant-design/issues/32847
// responsive: ["lg"],
},
];
const defaultPagination = pagination === undefined
? {
defaultPageSize: 50,
showSizeChanger: true,
pageSizeOptions: ["10", "20", "50", "100", "200"],
showTotal: (total: number) => `${total} Users`,
}
: pagination;
const displayPagination = {
showSizeChanger: true,
pageSizeOptions: ["10", "20", "50", "100", "200"],
showTotal: (total: number) => `${total} Users`,
};
let tablePagination: false | object;
if (pagination === false) {
tablePagination = false;
} else {
// Controlled current/pageSize from the URL win over the caller's display props.
tablePagination = { ...displayPagination, ...pagination, ...(tableState.pagination || {}) };
}
return (
<Table
columns={columns}
dataSource={data}
rowKey={(user) => user.name}
pagination={defaultPagination}
pagination={tablePagination}
onChange={tableState.onChange}
size={size}
scroll={{ x: "max-content" }}
loading={!usersSynced || !rolesSynced}
onRow={(user) => ({
onClick: (event) => {
if ((event.target as HTMLElement).closest("a")) {
return;
}
navigate(`/users/${user.name}/view`);
},
})}
onRow={(user) => rowNav(`/users/${user.name}/view`)}
/>
);
}
@@ -106,9 +106,13 @@ function humanAbsTimeDiff(date1: number, date2 = 0) {
return `${seconds}s`;
}
export function MetricRelativeDate(props: { timeMs?: number }) {
export function MetricRelativeDate(props: { timeMs?: number, compact?: boolean }) {
if (!props.timeMs || props.timeMs === 0) {
return "N/A";
}
return `${new Date(props.timeMs).toLocaleString()} (${humanAbsTimeDiff(props.timeMs, Date.now())})`;
const diff = humanAbsTimeDiff(props.timeMs, Date.now());
const absolute = props.compact
? new Date(props.timeMs).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" })
: new Date(props.timeMs).toLocaleString();
return `${absolute} (${diff})`;
}
+4
View File
@@ -42,3 +42,7 @@ export { default as SectionHeader } from "./components/SectionHeader";
export { default as Link } from "./components/Link";
export { default as FactorioIcon } from "./components/FactorioIcon";
export { default as useTableQueryState } from "./util/useTableQueryState";
export { default as useColumnSearch } from "./util/useColumnSearch";
export { default as useRowNavigation } from "./util/useRowNavigation";
+6
View File
@@ -2,6 +2,7 @@ import React, { ReactElement } from "react";
import { PluginPage, UserAccount } from "./BaseWebPlugin";
import OverviewPage from "./components/OverviewPage";
import ControllerPage from "./components/ControllerPage";
import HostsPage from "./components/HostsPage";
import HostViewPage from "./components/HostViewPage";
@@ -21,6 +22,11 @@ import IconReferencePage from "./components/IconReferencePage";
// When adding or editing page paths here the corresponding webRoutes entry
// in packages/controller/src/routes.ts also needs to be updated.
export const pages: PluginPage[] = [
{
path: "/",
sidebarName: "Overview",
content: <OverviewPage />,
},
{
path: "/controller",
sidebarName: "Controller",
+42 -83
View File
@@ -10,6 +10,14 @@ body {
cursor: pointer;
}
/* Make a table link-cell's link fill the cell width, so the whole column
width is clickable and right-click "open in new tab"-able, not just the
text. Uses display:block rather than an overlay so sibling content (e.g.
the restart-required icon) stays interactive. */
.table-link-cell a {
display: block;
}
.login-container {
min-height: 100vh;
display: flex;
@@ -33,17 +41,13 @@ body {
}
.site-breadcrumb {
margin: 16px 0 16px 52px;
margin: 16px 0;
}
@media (min-width: 576px) {
.site-layout-content-container {
padding: 0 24px 24px;
}
.site-breadcrumb {
margin-left: 28px;
}
}
@media (min-width: 768px) {
@@ -56,13 +60,30 @@ body {
}
}
.site-layout-sider {
z-index: 0;
/* Top bar hosting the menu toggle and branding, sticky above the content. */
.site-layout-header {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
gap: 16px;
padding-inline: 16px;
background: #001529;
}
.header {
padding-left: 250px;
padding-right: 64px;
/* Keep the sidebar fixed in place (below the header) while content scrolls. */
.site-layout-sider {
position: sticky;
top: 64px;
height: calc(100vh - 64px);
align-self: flex-start;
}
/* Snap the sider open/closed instead of animating its width — the width
transition reflowed the content area and caused styling glitches. */
.ant-layout-sider.site-layout-sider {
transition: none;
}
.page-header {
@@ -105,75 +126,6 @@ body {
margin-left: auto;
}
.site-name {
position: absolute;
top: -10px;
left: 112px;
font-size: 18pt;
font-weight: 400;
}
.site-version {
position: absolute;
top: 10px;
left: 120px;
color: #f00;
}
.site-logo {
position: absolute;
left: 36px;
width: 64px;
height: 64px;
background-image: url("./images/logo.png");
background-size: 64px;
}
@media not all and (min-width: 576px) {
.header {
padding-left: 88px;
}
.site-logo {
left: 12px;
}
.site-name {
display: none;
}
.site-version {
display: none;
}
}
.account-dropdown-header {
position: absolute;
right: 0;
top: 0;
width: 64px;
height: 64px;
font-size: 18px;
line-height: 72px;
display: flex;
justify-content: center;
}
.account-dropdown-header::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: transparent;
transition: all 0.3s;
content: "";
}
.account-dropdown-header:hover::after {
background: rgba(255, 255, 255, 0.1);
}
.error-traceback code {
display: block;
margin: 0;
@@ -222,18 +174,25 @@ body {
height: 1px;
}
/* Scroll headroom above the output so short output sits at the bottom; it is
scroll content, not box padding, so it does not block dragging smaller. */
.instance-console code .console-spacer {
height: 100%;
}
.instance-console code {
display: block;
margin: 0;
background: #000;
border-color: #252525;
/* Drag-resizable (the native handle needs the scroll overflow); height is set
imperatively in LogConsole, border-box keeps padding out of offsetHeight. */
box-sizing: border-box;
height: 300px;
min-height: 100px;
resize: vertical;
overflow-y: scroll;
white-space: pre-wrap;
/* Make sure there's at least one pixel of scroll space, this */
/* is necessary for the view to stick on the scroll anchor. */
padding-top: 300px;
}
.instance-console .factorio-time {
+175
View File
@@ -0,0 +1,175 @@
/**
* Pure helpers for storing antd Table state (sort / filters / pagination) in
* the URL query string. Kept free of React and antd runtime imports so the
* serialisation can be unit tested in isolation.
*
* Every parameter is namespaced with the table's name so the URL is
* self-describing and two tables could coexist on one page without colliding:
* <ns>.sort `<field>.<asc|desc>`, e.g. user.sort=name.desc
* <ns>.page 1-based page number
* <ns>.size rows per page
* <ns>.<column> filter value for that column (e.g. user.name, user.admin)
*
* System keys (`sort`, `page`, `size`) are reserved, so filterable columns must
* not use those names. `prefix` below is the namespace with its trailing dot,
* e.g. "user.". Multi-value filters use a repeated parameter
* (`user.roles=1&user.roles=2`) so URLSearchParams handles encoding once.
*/
export type SortOrder = "ascend" | "descend";
export interface TableSortState {
/** Column key the table is sorted by, or undefined for no/default sort. */
columnKey?: string;
/** Sort direction, or undefined when unsorted. */
order?: SortOrder;
}
export interface TablePageState {
page: number;
pageSize: number;
}
/** A single filter value as produced by antd (React.Key is string | number | bigint). */
export type FilterValue = string | number | bigint | boolean;
/**
* Maps a single column's antd filter value to/from one or more query
* parameters. Lets a column that bundles several controls (e.g. the user
* filter) persist as separate readable params instead of one opaque blob.
* `prefix` is the table namespace with its trailing dot, e.g. "user.".
*/
export interface FilterCodec {
/** Read this column's `filteredValue` from the query, or null when inactive. */
decode(params: URLSearchParams, prefix: string): string[] | null;
/** Write this column's antd filter value into `params` (mutating). */
encode(params: URLSearchParams, value: ReadonlyArray<FilterValue> | null, prefix: string): void;
}
/** Suffixes reserved for table-level state; any other `<prefix><x>` is a column filter. */
const RESERVED_SUFFIXES = new Set(["sort", "page", "size"]);
/** Convert a URL sort direction token to the antd sort order. */
function orderFromToken(token: string): SortOrder {
return token === "desc" ? "descend" : "ascend";
}
/** Convert an antd sort order to a URL direction token. */
function tokenFromOrder(order: SortOrder): string {
return order === "descend" ? "desc" : "asc";
}
/**
* Read the sort state from the query, falling back to the supplied defaults
* when no sort parameter is present. The value is `<field>.<asc|desc>`; only the
* first `<prefix>sort` parameter is used (the format allows repeats so
* multi-column sort can be added later without changing the URL shape).
*/
export function parseSort(
params: URLSearchParams,
prefix: string,
defaultKey?: string,
defaultOrder: SortOrder = "ascend",
): TableSortState {
const raw = params.getAll(`${prefix}sort`)[0];
if (!raw) {
return { columnKey: defaultKey, order: defaultKey ? defaultOrder : undefined };
}
const dot = raw.lastIndexOf(".");
const columnKey = dot === -1 ? raw : raw.slice(0, dot);
const order = dot === -1 ? defaultOrder : orderFromToken(raw.slice(dot + 1));
return { columnKey, order };
}
/** Read pagination state from the query, tolerating missing/garbage values. */
export function parsePagination(
params: URLSearchParams,
prefix: string,
defaultPageSize: number,
defaultPage = 1,
): TablePageState {
const page = Number.parseInt(params.get(`${prefix}page`) ?? "", 10);
const pageSize = Number.parseInt(params.get(`${prefix}size`) ?? "", 10);
return {
page: Number.isInteger(page) && page >= 1 ? page : defaultPage,
pageSize: Number.isInteger(pageSize) && pageSize >= 1 ? pageSize : defaultPageSize,
};
}
/** Collect every namespaced column-filter parameter into a map of column key -> values. */
export function parseFilters(params: URLSearchParams, prefix: string): Record<string, string[]> {
const filters: Record<string, string[]> = {};
for (const key of params.keys()) {
if (key.startsWith(prefix)) {
const column = key.slice(prefix.length);
if (!RESERVED_SUFFIXES.has(column) && !(column in filters)) {
filters[column] = params.getAll(key);
}
}
}
return filters;
}
/**
* Write the sort state into `params` (mutating it). The parameters are omitted
* when the sort matches the table default or is cleared, keeping default URLs
* clean.
*/
export function applySort(
params: URLSearchParams,
prefix: string,
sort: TableSortState,
defaultKey?: string,
defaultOrder: SortOrder = "ascend",
): void {
const { columnKey, order } = sort;
const isDefault = columnKey === defaultKey && (order ?? defaultOrder) === defaultOrder;
params.delete(`${prefix}sort`);
if (columnKey && order && !isDefault) {
params.set(`${prefix}sort`, `${columnKey}.${tokenFromOrder(order)}`);
}
}
/**
* Write the filter state into `params` (mutating it). `filters` is the object
* antd hands to onChange: every filterable column key mapped to its current
* value array, or null when inactive.
*/
export function applyFilters(
params: URLSearchParams,
prefix: string,
filters: Record<string, ReadonlyArray<FilterValue> | null>,
): void {
for (const [key, values] of Object.entries(filters)) {
const param = prefix + key;
params.delete(param);
if (values) {
for (const value of values) {
params.append(param, String(value));
}
}
}
}
/**
* Write pagination into `params` (mutating it). Defaults (page 1 / default
* page size) are omitted to keep URLs clean.
*/
export function applyPagination(
params: URLSearchParams,
prefix: string,
page: number,
pageSize: number,
defaultPageSize: number,
): void {
if (page > 1) {
params.set(`${prefix}page`, String(page));
} else {
params.delete(`${prefix}page`);
}
if (pageSize !== defaultPageSize) {
params.set(`${prefix}size`, String(pageSize));
} else {
params.delete(`${prefix}size`);
}
}
@@ -0,0 +1,66 @@
import React, { useRef } from "react";
import { Input, InputRef } from "antd";
import { SearchOutlined } from "@ant-design/icons";
import type { FilterDropdownProps } from "antd/es/table/interface";
import type { TableQueryState } from "./useTableQueryState";
/**
* Column props for a free-text search filter rendered in the column header
* dropdown (the same style as the Users table). Spread the result onto a column
* whose `filteredValue` is `tableState.filteredValue(columnKey)`.
*
* Filtering is live: the table updates as you type via {@link TableQueryState.setFilter},
* and the value is only written to the URL when the dropdown closes (via
* {@link TableQueryState.commitFilter}).
*
* @param tableState The table's query state.
* @param columnKey Key of the column this search filters (matches its filteredValue key).
* @param getText Returns the searchable text for a row.
* @param placeholder Placeholder shown in the search box.
*/
export default function useColumnSearch<T>(
tableState: TableQueryState<T>,
columnKey: string,
getText: (record: T) => string,
placeholder = "Search",
) {
const searchInput = useRef<InputRef>(null);
return {
filterIcon: (filtered: boolean) => (
<SearchOutlined style={{ color: filtered ? "#1677ff" : undefined }} />
),
filterDropdown: ({ selectedKeys, setSelectedKeys, close }: FilterDropdownProps) => (
<div style={{ padding: 8, width: 240 }} onKeyDown={e => e.stopPropagation()}>
<Input.Search
ref={searchInput}
placeholder={placeholder}
value={selectedKeys[0] as string | undefined}
allowClear
onChange={e => {
const values = e.target.value ? [e.target.value] : [];
setSelectedKeys(values);
tableState.setFilter(columnKey, values.length ? values : null);
}}
onPressEnter={e => {
close();
tableState.commitFilter(columnKey);
}}
/>
</div>
),
filterDropdownProps: {
onOpenChange: (open: boolean) => {
if (open) {
setTimeout(() => searchInput.current?.select(), 100);
} else {
tableState.commitFilter(columnKey);
}
},
},
onFilter: (value: React.Key | boolean, record: T) => (
getText(record).toLowerCase().includes(String(value).toLowerCase())
),
};
}
@@ -0,0 +1,33 @@
import { useState } from "react";
/**
* Persist a JSON-serialisable value in localStorage. Behaves like useState, but
* seeds the initial value from localStorage and writes changes back. The write
* is deferred to a later task so it never blocks the state update / re-render.
*/
export default function useLocalStorage<T>(key: string, defaultValue: T): [T, (value: T) => void] {
const [value, setValue] = useState<T>(() => {
const stored = localStorage.getItem(key);
if (stored === null) {
return defaultValue;
}
try {
return JSON.parse(stored) as T;
} catch {
return defaultValue;
}
});
function set(next: T) {
setValue(next);
setTimeout(() => {
try {
localStorage.setItem(key, JSON.stringify(next));
} catch {
// Ignore storage errors (e.g. quota exceeded or storage disabled).
}
}, 0);
}
return [value, set];
}
@@ -0,0 +1,52 @@
import type React from "react";
import { useNavigate } from "react-router-dom";
/** Resolve an in-app path to a full path including the router basename. */
function resolvePath(to: string): string {
const base = webRoot.replace(/\/$/, "");
return base + to;
}
function isInsideLink(target: EventTarget | null): boolean {
return target instanceof HTMLElement && target.closest("a") !== null;
}
/**
* Navigation handlers for a whole table row that also support "open in new
* tab": left-click navigates in place, middle-click or ctrl/cmd-click opens the
* target in a new tab. Spread the result into the object returned from antd's
* `onRow`, e.g. `onRow={record => rowNav(`/hosts/${record.id}/view`)}`. Clicks
* that land on a real link inside the row are ignored so the anchor handles
* them (and shows the native context menu / new-tab affordances itself).
*/
export default function useRowNavigation() {
const navigate = useNavigate();
return function rowNavigation(to: string): React.HTMLAttributes<HTMLElement> {
return {
style: { cursor: "pointer" },
onClick: (event) => {
if (event.defaultPrevented || isInsideLink(event.target)) {
return;
}
if (event.ctrlKey || event.metaKey || event.shiftKey) {
window.open(resolvePath(to), "_blank", "noopener");
} else {
navigate(to);
}
},
onAuxClick: (event) => {
// Middle click opens the row in a new tab.
if (event.button === 1 && !isInsideLink(event.target)) {
event.preventDefault();
window.open(resolvePath(to), "_blank", "noopener");
}
},
onMouseDown: (event) => {
// Suppress the middle-click autoscroll cursor.
if (event.button === 1) {
event.preventDefault();
}
},
};
};
}
@@ -0,0 +1,150 @@
import { useEffect, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom";
import type { TablePaginationConfig } from "antd";
import type { FilterValue, SorterResult } from "antd/es/table/interface";
import {
applyFilters, applyPagination, applySort,
parseFilters, parsePagination, parseSort,
type FilterCodec, type SortOrder,
} from "./tableQuery";
export interface TableQueryStateOptions {
/** Table name used to namespace every query parameter, e.g. "user" -> `user.sort`, `user.name`. */
namespace: string;
/** Column key sorted by default (no `sort` param present). Omit for no default sort. */
defaultSortKey?: string;
/** Direction of the default sort. Default "ascend". */
defaultSortOrder?: SortOrder;
/** Pagination config to persist pagination; only `defaultPageSize` is read (omit for `pagination={false}`). */
pagination?: false | TablePaginationConfig;
/** Per-column-key codecs to persist a bundled filter value as separate readable params. */
filterCodecs?: Record<string, FilterCodec>;
}
export interface TableQueryState<T> {
/** Spread onto `<Table onChange={...}>` to persist sort/filter/pagination (pushes history). */
onChange: (
pagination: TablePaginationConfig,
filters: Record<string, FilterValue | null>,
sorter: SorterResult<T> | SorterResult<T>[],
) => void;
/** Controlled `sortOrder` for the column with this key (null unless it's the active sort). */
sortOrder: (columnKey: string) => SortOrder | null;
/** Controlled `filteredValue` for the column with this key (null when no filter is active). */
filteredValue: (columnKey: string) => string[] | null;
/** Set a column's live filter — filters the table immediately without writing the URL. */
setFilter: (columnKey: string, values: string[] | null) => void;
/** Commit a column's filter to the URL: pass `values` to write now (e.g. clear), omit to commit the override. */
commitFilter: (columnKey: string, values?: string[] | null) => void;
/** Controlled pagination ({@link current}/{@link pageSize}), or `false` when disabled. */
pagination: false | TablePaginationConfig;
}
/**
* Persist an antd Table's sort, column filters and pagination into the URL
* query string, so reload, link-sharing and browser back/forward restore the
* same view (clusterio issue #927).
*/
export default function useTableQueryState<T>(options: TableQueryStateOptions): TableQueryState<T> {
const { defaultSortKey, defaultSortOrder = "ascend" } = options;
const prefix = `${options.namespace}.`;
const codecs = options.filterCodecs ?? {};
const paginationOptions = options.pagination && typeof options.pagination === "object"
? options.pagination
: undefined;
const paginationEnabled = paginationOptions !== undefined;
const defaultPageSize = paginationOptions?.defaultPageSize ?? 10;
const [params, setParams] = useSearchParams();
// Live filter overrides let the table filter as the user types without writing the
// URL on every keystroke; they are committed to the URL when the filter dropdown
// closes (see commitFilter) and cleared whenever the URL changes (commit, back/forward).
const [liveFilters, setLiveFilters] = useState<Record<string, string[] | null>>({});
// A ref mirrors the overrides so commitFilter always reads the latest values even within
// the same event that set them (e.g. clearing then closing), instead of a stale closure.
const liveFiltersRef = useRef<Record<string, string[] | null>>(liveFilters);
const paramsString = params.toString();
useEffect(() => {
liveFiltersRef.current = {};
setLiveFilters({});
}, [paramsString]);
const sort = parseSort(params, prefix, defaultSortKey, defaultSortOrder);
const filters = parseFilters(params, prefix);
const page = paginationEnabled ? parsePagination(params, prefix, defaultPageSize) : undefined;
return {
onChange(pagination, tableFilters, sorter) {
// Only single-column sort is persisted; antd passes an array when several
// columns are sorted, so take the first (primary) sorter.
const single = Array.isArray(sorter) ? sorter[0] : sorter;
const order = single && single.order ? single.order : undefined;
const columnKey = order ? String(single.columnKey ?? single.field ?? "") : undefined;
const next = new URLSearchParams(params);
applySort(next, prefix, { columnKey, order }, defaultSortKey, defaultSortOrder);
const plainFilters: Record<string, FilterValue | null> = {};
for (const [key, values] of Object.entries(tableFilters)) {
const codec = codecs[key];
if (codec) {
codec.encode(next, values, prefix);
} else {
plainFilters[key] = values;
}
}
applyFilters(next, prefix, plainFilters);
if (paginationEnabled) {
applyPagination(
next, prefix, pagination.current ?? 1, pagination.pageSize ?? defaultPageSize, defaultPageSize
);
}
setParams(next);
},
sortOrder(columnKey) {
return columnKey === sort.columnKey ? (sort.order ?? null) : null;
},
filteredValue(columnKey) {
if (columnKey in liveFilters) {
return liveFilters[columnKey];
}
const codec = codecs[columnKey];
return codec ? codec.decode(params, prefix) : (filters[columnKey] ?? null);
},
setFilter(columnKey, values) {
const nextLive = { ...liveFiltersRef.current, [columnKey]: values };
liveFiltersRef.current = nextLive;
setLiveFilters(nextLive);
},
commitFilter(columnKey, values) {
// Explicit values (e.g. from the clear button) are written immediately; otherwise
// commit the current live override, if any (e.g. when the dropdown closes).
let effective = values;
if (effective === undefined && columnKey in liveFiltersRef.current) {
effective = liveFiltersRef.current[columnKey];
}
if (effective === undefined) {
return;
}
const next = new URLSearchParams(params);
const codec = codecs[columnKey];
if (codec) {
codec.encode(next, effective, prefix);
} else {
applyFilters(next, prefix, { [columnKey]: effective });
}
// Avoid pushing a duplicate history entry when nothing actually changed.
if (next.toString() !== paramsString) {
setParams(next);
}
},
pagination: paginationEnabled && page
? { current: page.page, pageSize: page.pageSize }
: false,
};
}
+7
View File
@@ -72,6 +72,9 @@ export class Control extends lib.Link {
/** Cache for factorio versions to avoid repeat calls to the controller */
factorioVersions = new lib.ValueCache(this.requestFactorioVersions.bind(this));
/** Cache for the latest factorio release channels to avoid repeat calls to the controller */
latestReleases = new lib.ValueCache(this.requestLatestReleases.bind(this));
declare connector: ControlConnector;
constructor(
@@ -112,6 +115,10 @@ export class Control extends lib.Link {
return this.send(new lib.FactorioVersionsRequest());
}
requestLatestReleases() {
return this.send(new lib.LatestReleasesRequest());
}
async handleAccountUpdateEvent(event: lib.AccountUpdateEvent) {
this.accountRoles = event.roles??null;
this.emitAccountUpdate();
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-global_chat",
"version": "2.0.0-alpha.26",
"version": "2.0.0-alpha.27",
"description": "Clusterio plugin forwarding between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -20,7 +20,7 @@
"url": "https://github.com/clusterio/clusterio/issues"
},
"engines": {
"node": ">=20"
"node": ">=22"
},
"peerDependencies": {
"@clusterio/web_ui": "workspace:^",
+160 -18
View File
@@ -8,6 +8,8 @@ local v2_logistic_api = compat.version_ge("2.0.0")
local v2_storage_api = compat.version_ge("2.0.0")
local v2_remote_controller = compat.version_ge("2.0.0")
local recipe_notifications_api = compat.version_ge("2.0.67")
local v2_0_quick_bar_api = compat.version_ge("2.0.0")
local v2_1_quick_bar_api = compat.version_ge("2.1.0")
function serialize.serialize_inventories(source, inventories)
local serialized = {}
@@ -194,6 +196,159 @@ function serialize.deserialize_personal_logistic_slots(player, serialized)
end
end
-- name is a custom type where quality is "normal" and comparator is "=" (most common case)
--- @alias QuickBarSlotEncoded.name string
--- @alias QuickBarSlotEncoded.filter { t: "f", n: string, q: string, c: string }
--- @alias QuickBarSlotEncoded QuickBarSlotEncoded.name | QuickBarSlotEncoded.filter
--- @param filter ItemFilter
--- @return QuickBarSlotEncoded
local function serialize_filter(filter)
if filter.quality == "normal" and filter.comparator == "=" then
return filter.name
end
return {
t = "f",
n = filter.name,
q = filter.quality,
c = filter.comparator,
}
end
--- @param slot QuickBarSlot | ItemFilter | LuaItemPrototype
--- @return QuickBarSlotEncoded | nil
function serialize.serialize_quick_bar_slot(slot)
if v2_1_quick_bar_api then
-- 2.1 has a dedicated type for quick bar slots
--- @cast slot QuickBarSlot
if slot.type == "filter" then
return serialize_filter(slot.filter)
end
print("Warning: Unsupported quick bar slot type '" .. slot.type .. "'")
return nil
end
if v2_0_quick_bar_api then
-- 2.0 gives us an item filter which supports quality
--- @cast slot ItemFilter
return serialize_filter(slot)
end
-- pre 2.0 quality did not exist
--- @cast slot LuaItemPrototype
return slot.name
end
--- @param entry string | QuickBarSlotEncoded
--- @return QuickBarSlot | ItemWithQualityID | nil
function serialize.deserialize_quick_bar_slot(entry)
if v2_1_quick_bar_api then
-- Return a quick bar slot
if type(entry) == "string" then
return {
type = "filter",
filter = {
name = entry,
quality = "normal",
comparator = "=",
},
}
end
if entry.t == "f" then
return {
type = "filter",
filter = {
name = entry.n,
quality = entry.q,
comparator = entry.c,
},
}
end
print("Warning: Unsupported serialized quick bar slot type '" .. tostring(entry.t) .. "'")
return nil
end
if v2_0_quick_bar_api then
-- Return a ItemIDAndQualityIDPair (member of ItemWithQualityID)
if type(entry) == "string" then
return {
name = entry,
quality = "normal",
}
end
-- entry.t == "f" is only remaining case for 2.0
return {
name = entry.n,
quality = entry.q,
}
end
-- Return a string (member of ItemPrototypeIdentification)
-- Pre 2.0 this was the only method of encoding used
return entry
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
local slot
if v2_1_quick_bar_api then
local page = math.floor((i - 1) / width) + 1
local page_slot = ((i - 1) % width) + 1
slot = player.get_quick_bar_slot(page, page_slot)
else
--- @diagnostic disable-next-line: missing-parameter
slot = player.get_quick_bar_slot(i)
end
if slot ~= nil then
local entry = serialize.serialize_quick_bar_slot(slot)
if entry ~= nil then
serialized = serialized or {}
serialized[tostring(i)] = entry
end
end
end
return serialized
end
--- @param player LuaPlayer
--- @param serialized table<string, string | QuickBarSlotEncoded>?
function serialize.deserialize_quick_bar(player, serialized)
if not serialized then
return
end
local width = v2_1_quick_bar_api and player.quick_bar_width
for i = 1, 100 do
local entry = serialized[tostring(i)]
if entry ~= nil then
local slot = serialize.deserialize_quick_bar_slot(entry)
if slot ~= nil then
if v2_1_quick_bar_api then
local page = math.floor((i - 1) / width) + 1
local page_slot = ((i - 1) % width) + 1
player.set_quick_bar_slot(page, page_slot, slot)
else
--- @diagnostic disable-next-line: param-type-mismatch
player.set_quick_bar_slot(i, slot)
end
end
end
end
end
-- Crafting queue is a table with the following fields:
-- crafting_queue
-- ingredients
@@ -439,6 +594,7 @@ end
--- @field character table<string, any>?
--- @field inventories table<string, table>?
--- @field hotbar table<string, string>?
--- @field quick_bar table<string, QuickBarSlotEncoded>?
--- @field personal_logistic_slots table?
--- @field crafting_queue table?
--- @field recipe_notifications string?
@@ -494,16 +650,8 @@ function serialize.serialize_player(player, failed_deserialization)
serialized.inventories = serialize.serialize_inventories(player, { main = defines.inventory.god_main })
end
-- Serialize hotbar
for i = 1, 100 do
local slot = player.get_quick_bar_slot(i)
if slot ~= nil and slot.name ~= nil then
if not serialized.hotbar then
serialized.hotbar = {}
end
serialized.hotbar[tostring(i)] = slot.name --[[@as string]]
end
end
-- Serialize quick bar
serialized.quick_bar = serialize.serialize_quick_bar(player)
-- Serialize crafting queue
if player.character then
@@ -615,14 +763,8 @@ function serialize.deserialize_player(player, serialized)
serialize.deserialize_inventories(player, serialized.inventories, { main = defines.inventory.god_main })
end
-- Deserialize hotbar
if serialized.hotbar then
for i = 1, 100 do
if serialized.hotbar[tostring(i)] ~= nil then
player.set_quick_bar_slot(i, serialized.hotbar[tostring(i)])
end
end
end
-- Deserialize quick bar (named hotbar in old data)
serialize.deserialize_quick_bar(player, serialized.quick_bar or serialized.hotbar)
-- Deserialize crafting queue
if player.character and serialized.crafting_queue then
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-inventory_sync",
"version": "2.0.0-alpha.26",
"version": "2.0.0-alpha.27",
"description": "Clusterio plugin forwarding between Factorio servers",
"author": "Danielv <danielv@danielv.no>",
"homepage": "https://github.com/clusterio/factorioClusterio#readme",
@@ -20,7 +20,7 @@
"url": "https://github.com/clusterio/factorioClusterio/issues"
},
"engines": {
"node": ">=20"
"node": ">=22"
},
"peerDependencies": {
"@clusterio/controller": "workspace:^",
+27 -5
View File
@@ -3,11 +3,12 @@ import express, { type Request, type Response } from "express";
import util from "util";
import jwt from "jsonwebtoken";
import { Static } from "@sinclair/typebox";
import * as lib from "@clusterio/lib";
import { BaseControllerPlugin } from "@clusterio/controller";
const { basicType } = lib;
import { FetchPlayerCodeRequest, SetVerifyCodeRequest } from "./messages";
import { FetchPlayerCodeRequest, PlayerAuthServer, SetVerifyCodeRequest } from "./messages";
async function generateCode(length: number): Promise<string> {
@@ -46,12 +47,33 @@ export class ControllerPlugin extends BaseControllerPlugin {
}, 60e3).unref();
this.controller.app.get("/api/player_auth/servers", (req: Request, res: Response) => {
let servers: string[] = [];
for (let instance of this.controller.instances.values()) {
if (instance.status === "running" && instance.config.get("player_auth.load_plugin")) {
servers.push(instance.config.get("factorio.settings")["name"] as string || "unnamed server");
const servers: Static<typeof PlayerAuthServer.jsonSchema>[] = [];
for (const instance of this.controller.instances.values()) {
const pluginLoaded = instance.config.get("player_auth.load_plugin");
const assignedHost = instance.config.get("instance.assigned_host");
if (instance.status !== "running" || !pluginLoaded || assignedHost === null) {
continue;
}
const host = this.controller.hosts.get(assignedHost);
if (!host) {
continue;
}
const address = instance.gamePort !== undefined
? `${host.publicAddress}:${instance.gamePort}`
: host.publicAddress;
const settings = instance.config.get("factorio.settings");
const includeAddress = this.controller.config.get("player_auth.show_connect_address");
servers.push({
name: settings["name"] as string || "unnamed server",
factorioVersion: instance.factorioVersion,
address: (includeAddress && host.publicAddress !== "") ? address : undefined,
});
}
res.send(servers);
});
+7
View File
@@ -8,6 +8,7 @@ declare module "@clusterio/lib" {
export interface ControllerConfigFields {
"player_auth.code_length": number;
"player_auth.code_timeout": number;
"player_auth.show_connect_address": boolean;
}
}
@@ -31,6 +32,12 @@ export const plugin: lib.PluginDeclaration = {
type: "number",
initialValue: 120,
},
"player_auth.show_connect_address": {
title: "Show Connect Address",
description: "Show server connect addresses to unauthenticated users.",
type: "boolean",
initialValue: false,
},
},
features: [
+31
View File
@@ -1,5 +1,36 @@
import { Type, Static } from "@sinclair/typebox";
export class PlayerAuthServer {
declare ["constructor"]: typeof PlayerAuthServer;
static jsonSchema = Type.Object({
name: Type.String(),
address: Type.Optional(Type.String()),
factorioVersion: Type.Optional(Type.String()),
});
constructor(
public name: string,
public address?: string,
public factorioVersion?: string,
) {}
toJSON(): Static<typeof PlayerAuthServer.jsonSchema> {
const json = { name: this.name } as Static<typeof PlayerAuthServer.jsonSchema>;
if (this.address !== undefined) {
json.address = this.address;
}
if (this.factorioVersion !== undefined) {
json.factorioVersion = this.factorioVersion;
}
return json;
}
static fromJSON(json: Static<typeof PlayerAuthServer.jsonSchema>) {
return new this(json.name, json.address, json.factorioVersion);
}
}
class FetchPlayerCodeResponse {
constructor(
public playerCode: string,
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-player_auth",
"version": "2.0.0-alpha.26",
"version": "2.0.0-alpha.27",
"description": "Clusterio plugin authenticating logged in players to the web interface",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -20,7 +20,7 @@
"url": "https://github.com/clusterio/clusterio/issues"
},
"engines": {
"node": ">=20"
"node": ">=22"
},
"peerDependencies": {
"@clusterio/controller": "workspace:^",
+73 -13
View File
@@ -7,9 +7,11 @@ const mock = require("../../../test/mock");
const controller = require("../dist/node/controller");
const instance = require("../dist/node/instance");
const info = require("../dist/node/index").plugin;
const { FetchPlayerCodeRequest, SetVerifyCodeRequest } = require("../dist/node/messages");
const { FetchPlayerCodeRequest, SetVerifyCodeRequest, PlayerAuthServer } = require("../dist/node/messages");
const { testRoundTripJsonSerialisable, testMatrix } = require("../../../test/common");
const lib = require("@clusterio/lib");
function postJSON(url, body) {
return fetch(url, {
method: "POST",
@@ -19,6 +21,44 @@ function postJSON(url, body) {
}
describe("player_auth", function() {
describe("messages.js", function() {
describe("PlayerAuthServer", function() {
it("should be round trip json serialisable", function() {
testRoundTripJsonSerialisable(PlayerAuthServer, testMatrix(
["server1", "server2"], // name
[undefined, "127.0.0.1", "10.0.0.1:1234"], // address
[undefined, "1.1.0", "2.0.0"], // version
));
});
});
describe("FetchPlayerCodeResponse", function() {
it("should be round trip json serialisable", function() {
testRoundTripJsonSerialisable(FetchPlayerCodeRequest.Response, testMatrix(
["abc123", "player-code"], // playerCode
["http://localhost", "https://example.com"], // controllerUrl
));
});
});
describe("FetchPlayerCodeRequest", function() {
it("should be round trip json serialisable", function() {
testRoundTripJsonSerialisable(FetchPlayerCodeRequest, testMatrix(
["player1", "player2"], // player
));
});
});
describe("SetVerifyCodeRequest", function() {
it("should be round trip json serialisable", function() {
testRoundTripJsonSerialisable(SetVerifyCodeRequest, testMatrix(
["player1", "player2"], // player
["code123", "verify456"], // verifyCode
));
});
});
});
describe("controller.js", function() {
describe("generateCode()", function() {
it("should generate a code of the given length", async function() {
@@ -50,6 +90,7 @@ describe("player_auth", function() {
controllerPlugin = await mock.createControllerPlugin(controller.ControllerPlugin, info);
controllerPlugin.controller.mockConfigEntries.set("player_auth.code_length", 10);
controllerPlugin.controller.mockConfigEntries.set("player_auth.code_timeout", 1);
controllerPlugin.controller.mockConfigEntries.set("player_auth.show_connect_address", true);
controllerUrl = await controllerPlugin.controller.startServer();
});
after(async function() {
@@ -60,33 +101,52 @@ describe("player_auth", function() {
describe("/api/player_auth/servers", function() {
it("should return a list of running servers with player_auth loaded", async function() {
function addInstance(id, status, load, name) {
function addInstance(id, status, load, name, version, gamePort, host=1) {
controllerPlugin.controller.instances.records.set(id, {
config: {
get(field) {
if (field === "player_auth.load_plugin") {
return load;
} else if (field === "factorio.settings") {
}
if (field === "factorio.settings") {
return { name };
} else if (field === "instance.id") {
}
if (field === "instance.id") {
return id;
}
if (field === "instance.assigned_host") {
return host;
}
throw new Error(`field ${field} not implemented`);
},
},
status,
gamePort,
factorioVersion: version,
});
}
addInstance(1, "running", true, "running loaded");
addInstance(2, "stopped", true, "stopped loaded");
addInstance(3, "running", false, "running unloaded");
addInstance(4, "stopped", false, "stopped unloaded");
addInstance(5, "running", true, undefined);
addInstance(1, "running", true, "running loaded", "1.1.1", 34197);
addInstance(2, "stopped", true, "stopped loaded", "1.1.2", 34197);
addInstance(3, "running", false, "running unloaded", "1.1.3", 34197);
addInstance(4, "stopped", false, "stopped unloaded", "1.1.4", 34197);
addInstance(5, "running", true, undefined, "1.1.5", 34197);
addInstance(6, "running", true, "no port", "1.1.6", undefined);
addInstance(7, "running", true, "no host", "1.1.7", 34197, null);
addInstance(8, "running", true, "invalid host", "1.1.8", 34197, 2);
addInstance(9, "running", true, "no version", undefined, 34197);
controllerPlugin.controller.hosts.set(1, {
publicAddress: "127.0.0.1",
});
const result = await fetch(`${controllerUrl}/api/player_auth/servers`);
assert.deepEqual(await result.json(), ["running loaded", "unnamed server"]);
for (let id of [1, 2, 3, 4, 5]) {
controllerPlugin.controller.instances.records.delete(id);
}
assert.deepEqual(await result.json(), [
{ name: "running loaded", address: "127.0.0.1:34197", factorioVersion: "1.1.1" },
{ name: "unnamed server", address: "127.0.0.1:34197", factorioVersion: "1.1.5" },
{ name: "no port", address: "127.0.0.1", factorioVersion: "1.1.6" },
{ name: "no version", address: "127.0.0.1:34197" },
]);
});
});
+149 -48
View File
@@ -1,8 +1,9 @@
import React, { useEffect, useState } from "react";
import { BaseWebPlugin, notifyErrorHandler } from "@clusterio/web_ui";
import { Button, Form, Input, Spin, Typography } from "antd";
import { Alert, Button, Form, Input, Modal, Space, Spin, Typography } from "antd";
import { PlayerAuthServer } from "../messages";
import "./style.css";
const { Paragraph, Text } = Typography;
@@ -12,60 +13,95 @@ interface LoginFormProps {
}
function LoginForm(props: LoginFormProps) {
let [servers, setServers] = useState<string[] | null>(null);
const [servers, setServers] = useState<PlayerAuthServer[] | null>(null);
let [playerCode, setPlayerCode] = useState<string | null>(null);
let [playerCodeError, setPlayerCodeError] = useState<string | null>(null);
let [verifyCode, setVerifyCode] = useState<string | undefined>();
let [verifyToken, setVerifyToken] = useState<string | null>(null);
const [connectServer, setConnectServer] = useState<{ name: string, address: string } | null>(null);
useEffect(() => {
(async () => {
let response = await fetch(`${webRoot}api/player_auth/servers`);
if (response.ok) {
setServers(await response.json());
} else {
setServers([]);
let cancelled = false;
async function loadServers() {
try {
const response = await fetch(`${webRoot}api/player_auth/servers`);
if (!response.ok) {
throw new Error(`Bad server response: ${response.status}`);
}
const data = await response.json();
if (!cancelled) {
setServers(data);
}
} catch (err) {
if (!cancelled && err instanceof Error) {
setServers([]);
notifyErrorHandler("Error fetching servers")(err);
}
}
})();
}
loadServers();
const interval = setInterval(loadServers, 15 * 1000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, []);
useEffect(() => {
if (verifyToken) {
let checkLoop = setInterval(() => {
(async () => {
let response = await fetch(`${webRoot}api/player_auth/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
verify_token: verifyToken,
verify_code: verifyCode,
player_code: playerCode,
}),
});
if (!response.ok) {
throw new Error(`Bad server response: ${response.status}`);
}
let json = await response.json();
if (json.error) {
// While there are other possibilites the most likely
// cause of an error response here is that the code expired.
setPlayerCodeError("Code expired");
setVerifyToken(null);
clearInterval(checkLoop);
return;
}
if (json.verified) {
props.setToken(json.token);
}
})().catch(err => {
clearInterval(checkLoop);
notifyErrorHandler("Error verifying code")(err);
});
}, 2000);
return () => { clearInterval(checkLoop); };
if (!verifyToken) {
return undefined;
}
return undefined;
let cancelled = false;
async function verify() {
try {
const response = await fetch(`${webRoot}api/player_auth/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
verify_token: verifyToken,
verify_code: verifyCode,
player_code: playerCode,
}),
});
if (!response.ok) {
throw new Error(`Bad server response: ${response.status}`);
}
const json = await response.json();
if (cancelled) {
return;
}
if (json.error) {
setPlayerCodeError("Code expired");
setVerifyToken(null);
clearInterval(interval);
return;
}
if (json.verified) {
props.setToken(json.token);
}
} catch (err) {
if (!cancelled && err instanceof Error) {
clearInterval(interval);
notifyErrorHandler("Error verifying code")(err);
}
}
}
verify();
const interval = setInterval(verify, 2 * 1000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [verifyToken]);
if (!servers) {
@@ -74,12 +110,47 @@ function LoginForm(props: LoginFormProps) {
if (servers.length === 0) {
return <Paragraph>
There are no servers in the cluster currently running that can complete
the login via Factorio. Please try again later.
There are no running servers available for login via Factorio at the moment.
Please try again later.
</Paragraph>;
}
return <>
<Modal
open={connectServer !== null}
title={`Connect to ${connectServer?.name ?? ""}`}
okText="Connect"
cancelText="Back"
onCancel={() => setConnectServer(null)}
onOk={() => {
if (!connectServer) {
return;
}
window.location.href = `steam://run/427520//--mp-connect=${connectServer.address}`;
setConnectServer(null);
}}
>
<Space direction="vertical" style={{ width: "100%" }}>
<Paragraph>
This button launches Factorio through Steam. It only works if
you own and have installed the Steam version of Factorio, and
Factorio is not already running.
</Paragraph>
<Paragraph>
If you are using the standalone version, or Factorio is already
running, use the following address with <b>Multiplayer → Connect to address</b>:
</Paragraph>
<Typography.Paragraph
copyable={{ text: connectServer?.address ?? "" }}
style={{ marginBottom: 0 }}
>
{connectServer?.address}
</Typography.Paragraph>
</Space>
</Modal>
<Paragraph>Login using your Factorio account is a 3 step process:</Paragraph>
<style>
</style>
@@ -89,9 +160,39 @@ function LoginForm(props: LoginFormProps) {
Start Factorio, join one of the following multiplayer servers
and type <Text keyboard>/web-login</Text> into the chat:
</Paragraph>
<div style={{ maxHeight: 160, overflowY: "auto", paddingLeft: 16 }}>
<ul>
{servers.map(text => <li key={text}>{text}</li>)}
<div style={{ maxHeight: 160, overflowY: "auto", paddingLeft: 16, padding: "8px 0" }}>
<ul style={{ margin: 0, paddingLeft: 16 }}>
{servers.map(server => (
<li key={server.name} style={{ marginBottom: 6 }}>
<div style={{
display: "grid",
gridTemplateColumns: "1fr auto",
alignItems: "center",
gap: 8,
}}>
<span>
{server.name}
{server.factorioVersion && (
<Text type="secondary"> ({server.factorioVersion})</Text>
)}
</span>
{server.address && (
<Button
size="small"
onClick={() => {
setConnectServer({
name: server.name,
address: server.address!,
});
}}
>
Connect
</Button>
)}
</div>
</li>
))}
</ul>
</div>
</li>
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-research_sync",
"version": "2.0.0-alpha.26",
"version": "2.0.0-alpha.27",
"description": "Clusterio plugin for synchronizing research between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -20,7 +20,7 @@
"url": "https://github.com/clusterio/clusterio/issues"
},
"engines": {
"node": ">=20"
"node": ">=22"
},
"peerDependencies": {
"@clusterio/controller": "workspace:^",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-statistics_exporter",
"version": "2.0.0-alpha.26",
"version": "2.0.0-alpha.27",
"description": "Clusterio plugin for gathering statistics from Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -20,7 +20,7 @@
"url": "https://github.com/clusterio/clusterio/issues"
},
"engines": {
"node": ">=20"
"node": ">=22"
},
"peerDependencies": {
"@clusterio/web_ui": "workspace:^",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-subspace_storage",
"version": "2.0.0-alpha.26",
"version": "2.0.0-alpha.27",
"description": "Clusterio plugin for sharing storage between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -20,7 +20,7 @@
"url": "https://github.com/clusterio/clusterio/issues"
},
"engines": {
"node": ">=20"
"node": ">=22"
},
"peerDependencies": {
"@clusterio/controller": "workspace:^",
+32 -27
View File
@@ -1,16 +1,15 @@
import { useContext, useEffect, useState } from "react";
import { Input, Table, Typography } from "antd";
import { Table } from "antd";
import {
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
useExportLocale, useExportPrototypeMetadata, useDefaultModPack, FactorioIcon,
useTableQueryState, useColumnSearch,
} from "@clusterio/web_ui";
import { GetStorageRequest, Item, SetStorageSubscriptionRequest, UpdateStorageEvent } from "../messages";
import "./style.css";
const { Paragraph } = Typography;
function useStorage(control: Control) {
let plugin = control.plugins.get("subspace_storage") as WebPlugin;
@@ -37,8 +36,12 @@ function StoragePage() {
let itemMetadata = prototypes?.get("item");
let fluidMetadata = prototypes?.get("fluid");
let storage = useStorage(control);
type ItemFilter = ([name, item]: [string, Item]) => boolean;
let [filter, setFilter] = useState<null | ItemFilter>(null);
const tableState = useTableQueryState<[string, Item]>({
namespace: "storage", defaultSortKey: "quantity", defaultSortOrder: "descend", pagination: false,
});
const resourceSearch = useColumnSearch<[string, Item]>(
tableState, "resource", item => getLocaleName(item[1].name), "Search"
);
function getLocaleName(itemName: string) {
let localeName = itemName;
@@ -65,33 +68,34 @@ function StoragePage() {
let numberFormat = new Intl.NumberFormat("en-US");
// Build the regex the resource search uses: word-boundary anchored, spaces match any gap.
function matchesResource(value: string | number | bigint | boolean, item: [string, Item]) {
let search = String(value).trim();
if (!search) {
return true;
}
search = search.replace(/(^| )(\w)/g, "$1\\b$2").replace(/ +/g, ".*");
let filterExpr;
try {
filterExpr = new RegExp(search, "i");
} catch {
return true;
}
return filterExpr.test(getLocaleName(item[1].name)) || filterExpr.test(item[1].name);
}
return <PageLayout nav={[{ name: "Storage" }]}>
<PageHeader title="Storage" />
<Paragraph>
<Input
placeholder="Search"
onChange={(event) => {
let search = event.target.value.trim();
if (!search) {
setFilter(null);
return;
}
search = search.replace(/(^| )(\w)/g, "$1\\b$2");
search = search.replace(/ +/g, ".*");
let filterExpr = new RegExp(search, "i");
setFilter(() => ((item: [string, Item]) => {
let name = getLocaleName(item[1].name);
return filterExpr.test(name) || filterExpr.test(item[1].name);
}));
}}
/>
</Paragraph>
<Table
className="subspace-storage-storage"
columns={[
{
title: "Resource",
key: "resource",
...resourceSearch,
onFilter: matchesResource,
filteredValue: tableState.filteredValue("resource"),
sortOrder: tableState.sortOrder("resource"),
sorter: (a, b) => {
let aName = getLocaleName(a[1].name);
let bName = getLocaleName(b[1].name);
@@ -119,14 +123,15 @@ function StoragePage() {
title: "Quantity",
key: "quantity",
align: "right",
defaultSortOrder: "descend",
sorter: (a, b) => a[1].count - b[1].count,
sortOrder: tableState.sortOrder("quantity"),
render: (_, item) => numberFormat.format(item[1].count),
},
]}
dataSource={filter ? storage.filter(filter) : storage}
dataSource={storage}
rowKey={item => item[0]}
pagination={false}
pagination={tableState.pagination}
onChange={tableState.onChange}
/>
</PageLayout>;
}
+16 -2
View File
@@ -62,7 +62,11 @@ describe("host/server", function() {
let installDir = path.join("test", "file", "factorio", "0.1.1");
await assert.rejects(
hostServer._findVersion(installDir, "0.1.2"),
new Error("Unable to find Factorio version 0.1.2")
new Error(
`Unable to find Factorio version 0.1.2: ${installDir} is a direct (single-version) ` +
`install of 0.1.1. Use a versioned layout, where ${installDir} contains a subdirectory ` +
"per version, to run other versions or download them automatically."
)
);
});
});
@@ -229,6 +233,16 @@ describe("host/server", function() {
});
});
describe(".setTargetVersion()", function() {
it("should override the target version before init", function() {
const fresh = new hostServer.FactorioServer(
path.join("test", "file", "factorio"), writePath, {}
);
fresh.setTargetVersion("0.1.5");
assert.equal(fresh._targetVersion, "0.1.5");
});
});
describe(".init()", function() {
it("should not throw on first call", async function() {
await server.init();
@@ -433,7 +447,7 @@ describe("host/server", function() {
assert.equal(fetchCalledWith, null);
assert.ok(logLine !== null);
assert.ok(logLine.endsWith("but must be manually downloaded"));
assert.ok(logLine.endsWith("(automatic downloads are only supported on Linux)."));
});
it("should do attempt to download on linux", async function() {
let logLine = null;
+31 -2
View File
@@ -704,6 +704,28 @@ describe("Integration of Clusterio", function() {
await execCtl("instance config set 44 factorio.version latest");
}
});
it("should resolve a release channel target when starting", async function() {
slowTest(this);
try {
// "experimental" is resolved to a concrete version through the
// controller's latest-releases data before the server starts.
// Whether that version is installed (or downloadable) depends on
// the host's Factorio install - CI uses a fixed direct install -
// so the start may succeed or fail, but the channel must resolve
// and the instance must reach a terminal state rather than hang.
await execCtl("instance config set 44 factorio.version experimental");
await execCtl(`instance ${cmd} test`).catch(() => {});
const status = (await getInstances()).get(44).status;
assert(
["running", "stopped"].includes(status),
`instance stuck in unexpected state '${status}'`
);
} finally {
await execCtl("instance stop 44").catch(() => {});
await execCtl("instance config set 44 factorio.version latest");
}
});
it("should not leave the instance in the stopping state if it fails", async function() {
slowTest(this);
try {
@@ -1128,8 +1150,15 @@ describe("Integration of Clusterio", function() {
await execCtl("instance start 44");
const instance = (await getInstances()).get(44);
const isV2 = lib.integerPartialVersion(instance.factorioVersion) > lib.integerPartialVersion("2.0");
const exchangeString = (isV2 ? testStrings.modified_v2 : testStrings.modified).replace(/[\n\r]+/g, "");
const args = `base/freeplay --seed 1234 --map-exchange-string "${exchangeString}"`;
const isV2_1 = lib.integerPartialVersion(instance.factorioVersion) > lib.integerPartialVersion("2.1");
let exchangeString = testStrings.modified;
if (isV2_1) {
exchangeString = testStrings.modified_v2_1;
} else if (isV2) {
exchangeString = testStrings.modified_v2;
}
// eslint-disable-next-line max-len
const args = `base/freeplay --seed 1234 --map-exchange-string "${exchangeString.replace(/[\n\r]+/g, "")}"`;
await execCtl("instance stop 44");
await execCtl(`instance load-scenario test ${args}`);
+10 -3
View File
@@ -363,7 +363,11 @@ before(async function() {
controllerProcess = await spawnNode("controller:", "../../packages/controller run", /Started controller/);
haveFactorioInstall = (await _listFactorioVersions(factorioDir)).versions.size > 0;
const factorioVersions = (await _listFactorioVersions(factorioDir)).versions;
const latestFactorioVersion = [...factorioVersions.values()]
.sort((a, b) => lib.integerFullVersion(b) - lib.integerFullVersion(a))[0];
haveFactorioInstall = factorioVersions.size > 0;
const relativeFactorioDir = path.isAbsolute(factorioDir) ? factorioDir : path.join("..", "..", factorioDir);
await execCtlProcess("host create-config --id 4 --name host --generate-token");
await execHost(`config set host.factorio_directory ${relativeFactorioDir}`);
@@ -382,8 +386,11 @@ before(async function() {
const testPack = lib.ModPack.fromJSON({});
testPack.id = 12;
testPack.name = "subspace_storage-pack";
testPack.factorioVersion = "2.0.0";
testPack.mods.set("clusterio_lib", { name: "clusterio_lib", enabled: true, version: "2.0.20" });
testPack.factorioVersion = latestFactorioVersion;
const [major, minor] = lib.normaliseMajorMinorVersion(latestFactorioVersion).split(".");
testPack.mods.set("clusterio_lib", { name: "clusterio_lib", enabled: true, version: `2.0.${major}${minor}` });
await control.sendTo("controller", new lib.ModPackCreateRequest(testPack));
await control.sendTo(
"controller",
+1 -1
View File
@@ -358,7 +358,7 @@ describe("lib/ModStore", function () {
await assert.rejects(
modStore.downloadMods(modsToDownload, username, token, factorioVersion),
/Fetch: https:\/\/mods\.factorio\.com\/api\/mods.* returned 503 Service Unavailable/
/Mod portal request to https:\/\/mods\.factorio\.com\/api\/mods.* failed: 503 Service Unavailable/
);
assert.equal(modStore.files.size, 0, "No mods should be added on API error");
});
+22
View File
@@ -176,6 +176,28 @@ describe("lib/config/classes", function() {
});
});
describe("get filepath", function() {
it("returns the current filepath for the config", function() {
const testInstance = new TestConfig("local", undefined, "foo/bar");
assert.equal(testInstance.filepath, "foo/bar");
});
});
describe("set filepath", function() {
it("sets the current filepath for the config", function() {
const testInstance = new TestConfig("local");
assert.equal(testInstance.filepath, undefined);
testInstance.filepath = "foo/bar";
assert.equal(testInstance.filepath, "foo/bar");
});
it("sets the dirty flag", function() {
const testInstance = new TestConfig("local");
assert.equal(testInstance.dirty, false);
testInstance.filepath = "foo/bar";
assert.equal(testInstance.dirty, true);
});
});
describe(".toJSON()", function() {
it("should serialize a basic config", function() {
const testInstance = new TestConfig("local");
+1 -1
View File
@@ -203,7 +203,7 @@ describe("lib/config/definitions/validators", function() {
// invalid case
assert.throws(
() => config.set("factorio.version", "invalidValue"),
/Value must be be 'latest', or match X.Y, or match X.Y.Z/
/Value must be 'latest', a release channel, or match X.Y or X.Y.Z/
);
});
+55 -6
View File
@@ -217,23 +217,29 @@ describe("lib/data/ModInfo", function() {
const dependency = new ModDependency("! my-mod");
assert.equal(dependency.isSatisfied(mods), false);
});
it("should pass for optional / hidden being missing", function() {
it("should pass for optional / hidden / recommended being missing", function() {
const dependencyOptional = new ModDependency("? not-present");
assert.equal(dependencyOptional.isSatisfied(mods), true);
const dependencyHidden = new ModDependency("(?) not-present");
assert.equal(dependencyHidden.isSatisfied(mods), true);
const dependencyRecommended = new ModDependency("+ not-present");
assert.equal(dependencyRecommended.isSatisfied(mods), true);
});
it("should pass for optional / hidden being present", function() {
it("should pass for optional / hidden / recommended being present", function() {
const dependencyOptional = new ModDependency("? my-mod");
assert.equal(dependencyOptional.isSatisfied(mods), true);
const dependencyHidden = new ModDependency("(?) my-mod");
assert.equal(dependencyHidden.isSatisfied(mods), true);
const dependencyRecommended = new ModDependency("+ my-mod");
assert.equal(dependencyRecommended.isSatisfied(mods), true);
});
it("should fail for optional / hidden being present but wrong version", function() {
it("should fail for optional / hidden / recommended being present but wrong version", function() {
const dependencyOptional = new ModDependency("? my-mod > 2.0.0");
assert.equal(dependencyOptional.isSatisfied(mods), false);
const dependencyHidden = new ModDependency("(?) my-mod > 2.0.0");
assert.equal(dependencyHidden.isSatisfied(mods), false);
const dependencyRecommended = new ModDependency("+ my-mod > 2.0.0");
assert.equal(dependencyRecommended.isSatisfied(mods), false);
});
it("should pass for unordered / required being present", function() {
const dependencyOptional = new ModDependency("~ my-mod");
@@ -258,19 +264,29 @@ describe("lib/data/ModInfo", function() {
it("should return true iff the type is incompatible", function() {
const dependencyIncompatible = new ModDependency("! my-mod");
assert.equal(dependencyIncompatible.incompatible, true);
for (const test of ["my-mod", "? my-mod", "(?) my-mod", "~ my-mod"]) {
for (const test of ["my-mod", "? my-mod", "(?) my-mod", "~ my-mod", "+ my-mod"]) {
const dependency = new ModDependency(test);
assert.equal(dependency.incompatible, false, test);
}
});
});
describe("recommended", function() {
it("should return true iff the type is recommended", function() {
const dependencyIncompatible = new ModDependency("+ my-mod");
assert.equal(dependencyIncompatible.recommended, true);
for (const test of ["my-mod", "? my-mod", "(?) my-mod", "~ my-mod", "! my-mod"]) {
const dependency = new ModDependency(test);
assert.equal(dependency.recommended, false, test);
}
});
});
describe("required", function() {
it("should return true iff the dependency is required", function() {
for (const test of ["my-mod", "~ my-mod"]) {
const dependency = new ModDependency(test);
assert.equal(dependency.required, true, test);
}
for (const test of ["! my-mod", "? my-mod", "(?) my-mod"]) {
for (const test of ["! my-mod", "? my-mod", "(?) my-mod", "+ my-mod"]) {
const dependency = new ModDependency(test);
assert.equal(dependency.required, false, test);
}
@@ -282,7 +298,7 @@ describe("lib/data/ModInfo", function() {
const dependency = new ModDependency(test);
assert.equal(dependency.optional, true, test);
}
for (const test of ["! my-mod", "my-mod", "~ my-mod"]) {
for (const test of ["! my-mod", "my-mod", "~ my-mod", "+ my-mod"]) {
const dependency = new ModDependency(test);
assert.equal(dependency.optional, false, test);
}
@@ -341,6 +357,39 @@ describe("lib/data/ModInfo", function() {
dependencies: ["base", ""],
}), /my-mod/); // check the mod name is included in the error message
});
it("should keep the raw mod version but normalise it for comparison", function() {
const mod = ModInfo.fromJSON({
name: "my-mod",
version: " 1.2.3-extra",
factorio_version: "1.1.0",
});
// The version is kept verbatim so it still matches the mod's file name,
// but is read leniently the way the game does when compared.
assert.equal(mod.version, " 1.2.3-extra");
assert.equal(mod.integerVersion, ModInfo.fromJSON({ version: "1.2.3" }).integerVersion);
// factorio_version is not part of any file name, so it is reduced to major.minor.
assert.equal(mod.factorioVersion, "1.1");
});
it("should keep leading zeros in the version so the file name matches", function() {
// Mirrors the mods.factorio.com/mod/leading-zeros case: the version has
// a zero-padded component that the file name keeps verbatim.
const mod = ModInfo.fromJSON({ name: "leading-zeros", version: "1.01.0" });
assert.equal(mod.version, "1.01.0");
assert.equal(mod.filename, "leading-zeros_1.01.0.zip");
assert.equal(mod.integerVersion, ModInfo.fromJSON({ version: "1.1.0" }).integerVersion);
});
it("should throw for an unparseable mod version", function() {
assert.throws(
() => ModInfo.fromJSON({ name: "my-mod", version: "not-a-version" }),
/Invalid mod version/
);
});
it("should throw for an unparseable factorio_version", function() {
assert.throws(
() => ModInfo.fromJSON({ name: "my-mod", factorio_version: "not-a-version" }),
/Invalid factorio_version/
);
});
it("should sort integer mod versions lexicographically", function() {
let unsortedVersions = ["1.0.0", "1.1.0", "0.1.0", "3.0.0", "1.2.0", "0.3.1", "0.3.3", "2.1.1", "0.0.1"];
let sortedVersions = ["0.0.1", "0.1.0", "0.3.1", "0.3.3", "1.0.0", "1.1.0", "1.2.0", "2.1.1", "3.0.0"];
+41 -24
View File
@@ -17,36 +17,53 @@ describe("lib/data/version", function() {
}
});
});
describe("isApiVersion()", function() {
it("should correctly validate input strings", function() {
const valid = ["0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0"];
for (const test of valid) {
assert.equal(lib.isApiVersion(test), true, test);
}
const invalid = ["0.12", "0.19", "1.2", "2.1", "1.0.0", "1.0.0.0", "0", "", "latest"];
describe("normaliseSourceVersion()", function() {
it("should normalise lenient version strings the way the game reads them", function() {
// Major and minor required, patch optional and defaulting to 0.
assert.equal(lib.normaliseSourceVersion("1.1"), "1.1.0");
assert.equal(lib.normaliseSourceVersion("1.1.50"), "1.1.50");
assert.equal(lib.normaliseSourceVersion("0.18"), "0.18.0");
// Leading whitespace and zeros are dropped.
assert.equal(lib.normaliseSourceVersion(" 1.1"), "1.1.0");
assert.equal(lib.normaliseSourceVersion("01.02.03"), "1.2.3");
assert.equal(lib.normaliseSourceVersion("1. 1. 50"), "1.1.50");
// Anything after the patch (or a non-numeric patch) is ignored.
assert.equal(lib.normaliseSourceVersion("1.1.50-beta"), "1.1.50");
assert.equal(lib.normaliseSourceVersion("1.1.x"), "1.1.0");
assert.equal(lib.normaliseSourceVersion("1.1.50.60"), "1.1.50");
});
it("should return undefined when no major.minor can be read", function() {
const invalid = ["1", "", "latest", "abc", ".1.1", "x.y"];
for (const test of invalid) {
assert.equal(lib.isApiVersion(test), false, test);
assert.equal(lib.normaliseSourceVersion(test), undefined, test);
}
});
});
describe("normaliseApiVersion()", function() {
it("should normalise accepted versions", function() {
assert.equal(lib.normaliseApiVersion("0.13"), "0.13");
assert.equal(lib.normaliseApiVersion("0.13.0"), "0.13");
assert.equal(lib.normaliseApiVersion("0.13.1"), "0.13");
assert.equal(lib.normaliseApiVersion("1.1"), "1.1");
assert.equal(lib.normaliseApiVersion("1.1.0"), "1.1");
assert.equal(lib.normaliseApiVersion("1.1.1"), "1.1");
assert.equal(lib.normaliseApiVersion("2.0"), "2.0");
assert.equal(lib.normaliseApiVersion("2.0.0"), "2.0");
assert.equal(lib.normaliseApiVersion("2.0.1"), "2.0");
assert.equal(lib.normaliseApiVersion("1.0.0.0"), "1.0");
describe("normaliseMajorMinorVersion()", function() {
it("should return the major.minor", function() {
assert.equal(lib.normaliseMajorMinorVersion("1.1"), "1.1");
assert.equal(lib.normaliseMajorMinorVersion("1.1.50"), "1.1");
assert.equal(lib.normaliseMajorMinorVersion("0.18.0"), "0.18");
});
it("should throw for invalid versions", function() {
const invalid = ["0.12", "0", "", "latest"];
for (const test of invalid) {
assert.throws(() => lib.normaliseApiVersion(test), undefined, test);
});
describe("isMajorMinorVersion()", function() {
it("should correctly validate input strings", function() {
const valid = ["1.1", "0.18", "2.0", "10.20"];
const invalid = ["1", "1.1.0", "1.1.50", "latest", "1.", ".1", ""];
for (const version of valid) {
assert(lib.isMajorMinorVersion(version), `${version} should be valid`);
}
for (const version of invalid) {
assert(!lib.isMajorMinorVersion(version), `${version} should be invalid`);
}
});
});
describe("integerMajorMinorVersion()", function() {
it("should sort versions lexicographically", function() {
let unsortedVersions = ["1.0", "1.1", "0.1", "3.0", "1.2", "0.3", "2.1"];
let sortedVersions = ["0.1", "0.3", "1.0", "1.1", "1.2", "2.1", "3.0"];
unsortedVersions.sort((a, b) => lib.integerMajorMinorVersion(a) - lib.integerMajorMinorVersion(b));
assert.deepEqual(unsortedVersions, sortedVersions);
});
});
describe("isFullVersion()", function() {
+88
View File
@@ -0,0 +1,88 @@
const assert = require("assert").strict;
const { fetchLatestReleases, resolveReleaseChannel } = require("@clusterio/lib");
const { slowTest } = require("../../integration");
describe("LatestReleases", function() {
describe("fetchLatestReleases", function() {
const originalFetch = global.fetch;
beforeEach(() => {
global.fetch = undefined;
});
afterEach(() => {
global.fetch = originalFetch;
});
const sample = {
experimental: { alpha: "2.1.8", demo: "2.0.77", expansion: "2.1.8", headless: "2.1.8" },
stable: { alpha: "2.0.77", demo: "2.0.77", expansion: "2.0.77", headless: "2.0.77" },
};
function mockFetch(json) {
global.fetch = async () => ({
ok: true,
status: 200,
statusText: "OK",
json: async () => json,
});
}
it("fetches and returns the releases json", async function() {
mockFetch(sample);
const releases = await fetchLatestReleases();
assert.deepEqual(releases, sample);
});
it("throws on HTTP error status", async function() {
global.fetch = async () => ({
ok: false,
status: 500,
statusText: "Server Error",
json: async () => ({}),
});
await assert.rejects(fetchLatestReleases(), /HTTP 500 Server Error/);
});
it("throws on network error", async function() {
global.fetch = async () => {
throw new Error("Network Error");
};
await assert.rejects(fetchLatestReleases(), /Network Error/);
});
it("fetches from the live api", async function() {
slowTest(this);
global.fetch = originalFetch;
const releases = await fetchLatestReleases();
assert.ok(releases.stable, "expected a stable channel");
assert.ok(releases.experimental, "expected an experimental channel");
assert.equal(typeof releases.stable.headless, "string");
});
});
describe("resolveReleaseChannel", function() {
const releases = {
experimental: { alpha: "2.1.8", demo: "2.0.77", headless: "2.1.8" },
stable: { alpha: "2.0.77", headless: "2.0.77" },
// A channel without a headless build, to exercise the fallback.
oddball: { alpha: "1.2.3" },
};
it("returns the headless build version by default", function() {
assert.equal(resolveReleaseChannel(releases, "stable"), "2.0.77");
assert.equal(resolveReleaseChannel(releases, "experimental"), "2.1.8");
});
it("returns the requested build version", function() {
assert.equal(resolveReleaseChannel(releases, "experimental", "demo"), "2.0.77");
});
it("falls back to the first build when headless is missing", function() {
assert.equal(resolveReleaseChannel(releases, "oddball"), "1.2.3");
});
it("returns undefined for an unknown channel", function() {
assert.equal(resolveReleaseChannel(releases, "nightly"), undefined);
});
});
});
+10
View File
@@ -59,6 +59,16 @@ describe("lib/factorio/exchange_string", function() {
});
});
it("should parse post v2.1 strings", function() {
assert.deepEqual(lib.readMapExchangeString(testStrings.modified_v2_1), {
version: [2, 1, 9, 3],
unknown: 0,
map_gen_settings: testSettings.modified_v2_1_map_gen_settings,
map_settings: testSettings.modified_v2_1_map_settings,
checksum: 3180296799,
});
});
it("should handle malformed strings", function() {
assert.throws(
() => lib.readMapExchangeString("<<blah>>"),
+150
View File
@@ -467,6 +467,154 @@ const modified_v2_map_settings = {
},
};
const modified_v2_1_map_gen_settings = {
"autoplace_controls": {
"coal": { "frequency": 1, "size": 1, "richness": 1 },
"copper-ore": { "frequency": 0.5, "size": 1, "richness": 1 },
"crude-oil": { "frequency": 0.5, "size": 1, "richness": 4 },
"enemy-base": { "frequency": 2, "size": 1.5, "richness": 1 },
"iron-ore": { "frequency": 1.5, "size": 1, "richness": 1 },
"nauvis_cliff": { "frequency": 1, "size": 1, "richness": 1 },
"rocks": { "frequency": 1, "size": 1, "richness": 1 },
"starting_area_moisture": { "frequency": 1, "size": 1, "richness": 1 },
"stone": { "frequency": 2, "size": 1, "richness": 1 },
"trees": { "frequency": 1.3333333730697632, "size": 0.3333333432674408, "richness": 1 },
"uranium-ore": { "frequency": 1, "size": 1.3333333730697632, "richness": 1 },
"water": { "frequency": 0.5, "size": 0.3333333432674408, "richness": 1 },
},
"autoplace_settings": {},
"default_enable_all_autoplace_controls": true,
"seed": 3378377395,
"width": 2000000,
"height": 2000000,
"area_to_generate_at_start": {
"left_top": { "x": -224, "y": -224 },
"right_bottom": { "x": 224, "y": 224 },
"orientation": { "x": 0, "y": -32767 },
},
"starting_area": 0.3333333432674408,
"peaceful_mode": false,
"no_enemies_mode": false,
"starting_points": [
{ "x": 0, "y": 0 },
],
"property_expression_names": {
"control:aux:bias": "-0.200000",
"control:aux:frequency": "2.000000",
"control:moisture:bias": "-0.200000",
"control:moisture:frequency": "0.333333",
"elevation": "elevation_lakes",
},
"cliff_settings": {
"name": "cliff",
"control": "",
"cliff_elevation_0": 10,
"cliff_elevation_interval": 40,
"richness": 1,
"cliff_smoothing": 1,
},
"territory_settings": null,
};
const modified_v2_1_map_settings = {
"pollution": {
"enabled": true,
"diffusion_ratio": 0.02,
"min_to_diffuse": 15,
"ageing": 1.5,
"expected_max_per_chunk": 150,
"min_to_show_per_chunk": 50,
"min_pollution_to_damage_trees": 3785,
"pollution_with_max_forest_damage": 150,
"pollution_per_tree_damage": 50,
"pollution_restored_per_tree_damage": 3714,
"max_pollution_to_restore_trees": 20,
"enemy_attack_pollution_consumption_modifier": 1,
},
"enemy_evolution": {
"enabled": true,
"time_factor": 0.00002,
"destroy_factor": 0.002,
"pollution_factor": 0.000012,
},
"enemy_expansion": {
"enabled": true,
"max_expansion_distance": 13,
"min_expansion_distance": 3,
"friendly_base_influence_radius": 6,
"enemy_building_influence_radius": 3,
"building_coefficient": 0.5,
"other_base_coefficient": 3,
"neighbouring_chunk_coefficient": 0.5,
"neighbouring_base_chunk_coefficient": 0.5,
"max_colliding_tiles_coefficient": 0.8,
"settler_group_min_size": 5,
"settler_group_max_size": 20,
"evolution_group_size_factor": 4,
"min_expansion_cooldown": 169200,
"max_expansion_cooldown": 414000,
},
"unit_group": {
"min_group_gathering_time": 3600,
"max_group_gathering_time": 36000,
"max_wait_time_for_late_members": 7200,
"max_group_radius": 30,
"min_group_radius": 5,
"max_member_speedup_when_behind": 1.4,
"max_member_slowdown_when_ahead": 0.6,
"max_group_slowdown_factor": 0.3,
"max_group_member_fallback_factor": 3,
"member_disown_distance": 10,
"tick_tolerance_when_member_arrives": 60,
"max_gathering_unit_groups": 30,
"max_unit_group_size": 200,
},
"path_finder": {
"fwd2bwd_ratio": 5,
"goal_pressure_ratio": 2,
"use_path_cache": true,
"max_steps_worked_per_tick": 1000,
"max_work_done_per_tick": 8000,
"short_cache_size": 5,
"long_cache_size": 25,
"short_cache_min_cacheable_distance": 10,
"short_cache_min_algo_steps_to_cache": 50,
"long_cache_min_cacheable_distance": 30,
"cache_max_connect_to_cache_steps_multiplier": 100,
"cache_accept_path_start_distance_ratio": 0.2,
"cache_accept_path_end_distance_ratio": 0.15,
"negative_cache_accept_path_start_distance_ratio": 0.3,
"negative_cache_accept_path_end_distance_ratio": 0.3,
"cache_path_start_distance_rating_multiplier": 10,
"cache_path_end_distance_rating_multiplier": 20,
"stale_enemy_with_same_destination_collision_penalty": 30,
"ignore_moving_enemy_collision_distance": 5,
"enemy_with_different_destination_collision_penalty": 30,
"general_entity_collision_penalty": 10,
"general_entity_subsequent_collision_penalty": 3,
"extended_collision_penalty": 3,
"max_clients_to_accept_any_new_request": 10,
"max_clients_to_accept_short_new_request": 100,
"direct_distance_to_consider_short_request": 100,
"short_request_max_steps": 1000,
"short_request_ratio": 0.5,
"min_steps_to_check_path_find_termination": 2000,
"start_to_goal_cost_multiplier_to_terminate_path_find": 2000,
"overload_levels": [0, 100, 500],
"overload_multipliers": [2, 3, 4],
"negative_path_cache_delay_interval": 20,
},
"max_failed_behavior_count": 3,
"difficulty_settings": {
"technology_price_multiplier": 12,
"spoil_time_modifier": 1,
},
"asteroids": {
"spawning_rate": 1,
"max_ray_portals_expanded_per_tick": 100,
},
};
const modified_space_age_map_gen_settings = {
autoplace_controls: {
"aquilo_crude_oil": { frequency: 1, size: 1, richness: 1 },
@@ -553,5 +701,7 @@ module.exports = {
modified_map_settings,
modified_v2_map_gen_settings,
modified_v2_map_settings,
modified_v2_1_map_gen_settings,
modified_v2_1_map_settings,
modified_space_age_map_gen_settings,
};
+17
View File
@@ -49,6 +49,22 @@ Ln6IET6h2alhiMgvQ3SGxnDDDd32daT/yCD5fQ8R3WMEyeN/PENaNmy
E5utYOI24z/cT+oQf48FdgozaFZg6qX+1KqW+GYyy4qNZgFNY/0IAHj
RI5Q/27QvL<<<`;
const modified_v2_1 = `
>>>eNp1U89rE0EUftM0NYlVc4iCIjWHHkRICNFTkezoRTx4ELxKnGwm
unSz087uVlsP5uDBg1CQXvSiVyt6EY9CwIu9if4DlXqwt1aK9CDEeZm
dyZrgg/f49nu/37BTQCAPGYDZaVcwH6DnGC24YmmJy4qQHAAsnXdl3O
YV4fkplhZ4wLurlRYLMZgC9IeunCdFoCv0bYXZgMUrXth0fa/TSXfMS
uEuhmnmVBgxGXnB3SaTnDW7wgujGKulksJIBEnThIkk5+GbzU1HaQOZ
o7FkgRd39SQ95HXkfRZxiXuYSCDvf93e6j2eA9TBIygPBqgKbas4VCA
9HU0UmUi26IogksJfYPGDhZbHwnylVq3XUE6mXR3Jl2MeuKu5erX2r9
ssN5Z+ZsI/qlGrXhxKnvt8hUWeCE5Y1PTZIg+zyZGhfEXpVXs4QsjD0
rtr39c2HKJXqNIEHCZMv2WY6wl49pb+zzVtXHDJgD1HNdm/t/Pkw+FB
g/x5vf/1RuuOQ279KC6HF3YbynkMj5lBM2ORzt02U+XoOKPAi+couw7
JYkZplFakZG9jCkjt5gyQ4nHFv3qqTPmsjWiYeiVKOkP57RB9xB0Dvk
00n6fkMnaZQ/MZTXbUFKhaRYN1Sug54z09ClH5dUjP0MYvvcaWafsp1
X9skPmJ46b3GGNscC4FCtiwbc3PTPrUX46YL/qSDp8BMOqAJI8C+u/S
peyl8e7GC3Ce2le3AIs019Y//gU3lOV1<<<`;
const modified_space_age = `
>>>eNp1VEFoE0EUnUkb27TWBomFSqkBexAhMVZPRbJbL+LBg+BV1slm
kgzd7LYzs9HYgz304EEQpJf2Yq9W9CIevBW8WFAQPXmr1IOCh1aL9CD
@@ -74,5 +90,6 @@ module.exports = {
default: default_,
modified,
modified_v2,
modified_v2_1,
modified_space_age,
};
+23 -1
View File
@@ -792,7 +792,10 @@ describe("lib/subscriptions", function() {
});
describe("class EventSubscriber", function() {
let mockControl, eventSubscriber;
/** @type {MockControl} */
let mockControl;
/** @type {lib.EventSubscriber} */
let eventSubscriber;
beforeEach(function() {
mockControl = new MockControl(new MockConnector(
@@ -1120,6 +1123,25 @@ describe("lib/subscriptions", function() {
}
assert.equal(logged, undefined);
});
it("should expose errors through the snapshot", async function() {
eventSubscriber.control.sendTo = function() {
throw new lib.PermissionError("Permission denied");
};
const unsubscribe = eventSubscriber.subscribe(() => {});
await new Promise(r => setImmediate(r));
const [,, error1] = eventSubscriber.getSnapshot();
assert.ok(error1 instanceof lib.PermissionError);
assert.equal(error1.message, "Permission denied");
assert.equal(error1.code, "PermissionError");
eventSubscriber.control.sendTo = function() {};
unsubscribe();
await new Promise(r => setImmediate(r));
const [,, error2] = eventSubscriber.getSnapshot();
assert.deepEqual(error2, null);
});
});
});
+48
View File
@@ -1,5 +1,8 @@
"use strict";
const assert = require("assert").strict;
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const lib = require("@clusterio/lib");
const { Controller, ControlConnection } = require("@clusterio/controller");
@@ -416,5 +419,50 @@ describe("messages/controller", function() {
assert.deepEqual(versions3, versions);
assert.equal(callCount, 2);
});
it("round trips LatestReleasesRequest", function() {
testRoundTripJsonSerialisable(lib.LatestReleasesRequest, [[], [0], [60000]]);
});
it("caches the latest releases and persists them for offline use", async function() {
const releases = {
stable: { headless: "2.0.77" },
experimental: { headless: "2.1.8" },
};
const databaseDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "clusterio-test-"));
controller.config.set("controller.database_directory", databaseDirectory);
const cachePath = path.join(databaseDirectory, "latest-releases.json");
const originalFetch = global.fetch;
try {
let callCount = 0;
global.fetch = async () => {
callCount += 1;
return { ok: true, status: 200, statusText: "OK", json: async () => releases };
};
// First call fetches from the api and persists the result to disk
const fetched = await controlConnection.handleLatestReleasesRequest(
new lib.LatestReleasesRequest(0)
);
assert.deepEqual(fetched, releases);
assert.equal(callCount, 1);
assert.deepEqual(JSON.parse(await fs.readFile(cachePath, "utf8")), releases);
// When the api is unavailable the persisted copy is served instead
global.fetch = async () => { throw new Error("Network Error"); };
const cached = await controlConnection.handleLatestReleasesRequest(
new lib.LatestReleasesRequest(0)
);
assert.deepEqual(cached, releases);
// With no persisted copy and an unavailable api an empty object is returned
await fs.rm(cachePath);
const empty = await controlConnection.handleLatestReleasesRequest(
new lib.LatestReleasesRequest(0)
);
assert.deepEqual(empty, {});
} finally {
global.fetch = originalFetch;
await fs.rm(databaseDirectory, { recursive: true, force: true });
}
});
});
});
+62 -4
View File
@@ -187,14 +187,16 @@ describe("messages/mod", function() {
});
});
describe("handle", function() {
const factorioVersions = ["1.0", "1.1", "2.0"];
const factorioVersions = ["1.0", "1.1", "2.0", "2.1"];
it("resolves dependencies", async function() {
for (const factorioVersion of factorioVersions) {
setPortalModRelease("root", "1.0.0", factorioVersion, ["foo", "bar < 2.0.0", "? baz"]);
setPortalModRelease("root", "1.0.0", factorioVersion, ["foo", "bar < 2.0.0", "? baz", "+ qux"]);
setPortalModRelease("foo", "1.0.0", factorioVersion, ["foo-dep"]);
setPortalModRelease("foo-dep", "1.0.0", factorioVersion, []);
setPortalModRelease("bar", "1.0.0", factorioVersion, []);
setPortalModRelease("baz", "1.0.0", factorioVersion, []);
setPortalModRelease("qux", "1.0.0", factorioVersion, ["qux-dep"]);
setPortalModRelease("qux-dep", "1.0.0", factorioVersion, []);
setPortalModRelease("root-2", "1.0.0", factorioVersion, ["foo", "bar-2"]);
setPortalModRelease("bar-2", "1.0.0", factorioVersion, []);
@@ -213,7 +215,7 @@ describe("messages/mod", function() {
const depIds = new Set(result.dependencies.map(mod => mod.id));
assert.deepEqual(depIds, new Set([
"root_1.0.0", "foo_1.0.0", "foo-dep_1.0.0", "bar_1.0.0",
"root-2_1.0.0", "bar-2_1.0.0",
"root-2_1.0.0", "bar-2_1.0.0", "qux_1.0.0", "qux-dep_1.0.0",
]));
}
});
@@ -307,6 +309,24 @@ describe("messages/mod", function() {
assert.deepEqual(depIds, new Set(["root_1.0.0", "foo_1.0.0"]));
}
});
it("ignores missing optional dependencies", async function() {
for (const factorioVersion of factorioVersions) {
setPortalModRelease("root", "1.0.0", factorioVersion, ["? foo"]);
const result = await controlConnection.handleModDependencyResolveRequest(
new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion),
);
assert.deepEqual(result.errors, new Map([
["base", "notFound"],
]));
const depIds = new Set(result.dependencies.map(mod => mod.id));
assert.deepEqual(depIds, new Set([
"root_1.0.0",
]));
}
});
it("does not ignore optional dependencies when required by another", async function() {
for (const factorioVersion of factorioVersions) {
setPortalModRelease("root", "1.0.0", factorioVersion, ["foo", "? baz"]);
@@ -325,6 +345,44 @@ describe("messages/mod", function() {
assert.deepEqual(depIds, new Set(["root_1.0.0", "foo_1.0.0", "baz_1.0.0"]));
}
});
it("ignores missing recommended dependencies", async function() {
for (const factorioVersion of factorioVersions) {
setPortalModRelease("root", "1.0.0", factorioVersion, ["+ foo"]);
const result = await controlConnection.handleModDependencyResolveRequest(
new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion),
);
assert.deepEqual(result.errors, new Map([
["base", "notFound"],
]));
const depIds = new Set(result.dependencies.map(mod => mod.id));
assert.deepEqual(depIds, new Set([
"root_1.0.0",
]));
}
});
it("does not ignore recommended dependencies when required by another", async function() {
for (const factorioVersion of factorioVersions) {
setPortalModRelease("root", "1.0.0", factorioVersion, ["+ baz", "foo"]);
setPortalModRelease("foo", "1.0.0", factorioVersion, ["baz"]);
setPortalModRelease("baz", "1.0.0", factorioVersion, []);
const result = await controlConnection.handleModDependencyResolveRequest(
new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion),
);
assert.deepEqual(result.errors, new Map([
["base", "notFound"],
]));
const depIds = new Set(result.dependencies.map(mod => mod.id));
assert.deepEqual(depIds, new Set([
"root_1.0.0", "foo_1.0.0", "baz_1.0.0",
]));
}
});
it("prefers the locally installed version if matching", async function() {
controller.modStore.addMod(lib.ModInfo.fromJSON({ name: "foo", version: "1.0.0" }));
for (const factorioVersion of factorioVersions) {
@@ -426,7 +484,7 @@ describe("messages/mod", function() {
"pyalienlife", "pyalienlifegraphics", "pyalienlifegraphics2", "pyalienlifegraphics3",
"pycoalprocessing", "pycoalprocessinggraphics", "pyfusionenergy", "pyfusionenergygraphics",
"pypetroleumhandling", "pypetroleumhandlinggraphics", "pyrawores", "pyraworesgraphics",
"pyhightech", "pyhightechgraphics", "pyindustry", "pyindustrygraphics",
"pyhightech", "pyhightechgraphics", "pyindustry", "pyindustrygraphics", "enable-all-feature-flags",
]));
});
});