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
Cooldude2606 eb77ca566c 2.0.0-alpha.26 2026-06-23 00:39:47 +01: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 9e727f9a13 Merge pull request #917 from Cooldude2606/feature/user-status-filter
Enhance user filters and improve UI components
2026-06-13 15:18:22 +01:00
Cooldude2606 d35e8caa2b Merge pull request #914 from Cooldude2606/feature/view-changelog
Add viewable changelog
2026-06-12 16:14:19 +01:00
Cooldude2606 b9c073e26f Fix typo in ci publish workflow 2026-06-12 16:08:37 +01:00
Cooldude2606 b160fecac4 Make icons local to package 2026-06-12 16:04:39 +01:00
Cooldude2606 dab727f90f Fix import in AboutModal 2026-06-10 22:39:26 +01:00
Cooldude2606 0e36df8ae3 Add AboutModal 2026-06-10 22:35:00 +01:00
Cooldude2606 f7d3516c24 Remove internal div from changelog 2026-06-10 20:18:05 +01:00
Cooldude2606 e6665d0803 Fix incorrect use of path.parse within create template (#919) 2026-06-10 20:01:53 +01:00
Cooldude2606 6fd4654c21 Merge pull request #913 from Cooldude2606/refactor/subscriptions-2
Refactor subscription handling and add new filter classes
2026-06-02 22:51:13 +01:00
Cooldude2606 b5c99fad2a Fix incorrect doc comment 2026-06-02 22:50:41 +01:00
Cooldude2606 f588f7f8d4 Guard against div0 in cpuUsage (#918) 2026-06-02 22:49:45 +01:00
Cooldude2606 4fe83cbcbc Add duration filter to first seen column 2026-06-01 23:12:47 +01:00
Cooldude2606 069a050f39 Unify column for instance and no instance 2026-06-01 23:04:57 +01:00
Cooldude2606 577615803b Add loading={!synced} to table and inputs 2026-06-01 22:45:04 +01:00
Cooldude2606 680ade5975 Add instance filter to users page 2026-06-01 22:44:13 +01:00
Cooldude2606 6914645c19 Make Username reusable and fix active icon issue 2026-06-01 21:58:27 +01:00
Cooldude2606 1d0b6b9a62 Add user status filters 2026-06-01 21:47:33 +01: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
Cooldude2606 dd80d49989 Add viewable changelog 2026-05-31 03:52:42 +01:00
Cooldude2606 e3d5f434a4 Better toString and test coverage 2026-05-29 20:14:23 +01:00
Cooldude2606 11b8a0ff97 Fix bad snapshot initialisation 2026-05-29 19:15:27 +01:00
Cooldude2606 e54b292fce Fix tests relating to permissions 2026-05-29 18:38:07 +01:00
Cooldude2606 41222343e3 Add tests 2026-05-29 17:45:14 +01:00
Cooldude2606 4676f1c820 Add filteredHandler to EventSubscriber 2026-05-29 14:09:19 +01:00
Cooldude2606 e49b82842d Add ValueSubscriber and MapSubscriber 2026-05-28 01:12:05 +01:00
Cooldude2606 e0a39e5630 Add option for events to define get filters 2026-05-28 01:11:38 +01:00
Cooldude2606 21b050b220 Call subscriptionUpdate for all update cases 2026-05-28 00:38:15 +01:00
Cooldude2606 9bed1223fd Add option to replace filters 2026-05-28 00:33:45 +01:00
Cooldude2606 4735f0725d Add SubscriptionFilters class 2026-05-28 00:25:29 +01:00
Cooldude2606 1f27617a27 Use addressIndex and a map for subscriptions 2026-05-27 23:35:31 +01:00
Cooldude2606 ee460d677d Rename libAddress.index to requestIndex and add addressIndex 2026-05-27 23:23:29 +01:00
Cooldude2606 f2ca465ec6 Merge pull request #909 from Cooldude2606/fix/small-things
Multiple small changes
2026-05-14 13:08:06 +01:00
Cooldude2606 40e6961711 Fix conflict between defaultFilteredValue and responsive 2026-05-14 12:51:04 +01:00
Cooldude2606 1bb51988ac Fix popconfirm on instance kill button 2026-04-25 23:32:30 +01:00
Cooldude2606 2c30fa4a94 Add user section to changelog script 2026-04-25 22:38:23 +01:00
Cooldude2606 a22186ca01 Add a newline after each section title in change log 2026-04-25 22:19:49 +01:00
Cooldude2606 746b11ff65 Add scrollable div to player auth server list 2026-04-25 22:18:32 +01:00
Cooldude2606 de51741835 Use workspace:^ rather than workspace:* 2026-04-25 22:06:36 +01:00
Cooldude2606 a633c42904 Fix default filter for online users table 2026-04-25 22:03:02 +01:00
Cooldude2606 7522424517 2.0.0-alpha.25 2026-04-24 17:41:16 +01:00
Cooldude2606 57f15d7ba4 Merge pull request #894 from Cooldude2606/fix/892
Fix accessing main inventory when in map view
2026-04-24 11:30:56 +01:00
Cooldude2606 1af672aa1d Always exit remote view on sync 2026-04-23 17:25:13 +01:00
Cooldude2606 41f8f493aa Fix nil table key error in factorio 1.1 2026-04-23 00:19:20 +01:00
Cooldude2606 8be95c140a Fix edge cases with physical location and controller 2026-04-22 22:37:42 +01:00
Cooldude2606 add0088704 Switch to xpcall for better trace backs 2026-04-22 00:49:45 +01:00
Cooldude2606 037f06808e Support remote controller type within deserialize 2026-04-22 00:45:31 +01:00
Daniel 01456818a8 Merge pull request #897 from Cooldude2606/feature/custom-version-input
Allow custom versions to be selected as a target
2026-04-20 22:26:11 +02:00
Cooldude2606 5c3182fb56 Merge pull request #902 from Cooldude2606/feature/improved-role-page
Improved RoleViewPage
2026-04-19 22:52:19 +01:00
Cooldude2606 f515b2db80 Disable the select all and clear buttons using canUpdate 2026-04-19 22:47:05 +01:00
Daniel eb2c2b5aec Merge pull request #901 from Cooldude2606/fix/instance-config-update
Add missing instanceConfigUpdated to InstanceConfigSetRequest
2026-04-19 22:48:22 +02:00
Cooldude2606 6f181f357b Change colour of groups to match input colour 2026-04-19 16:53:27 +01:00
Cooldude2606 1bcc152368 Improved RoleViewPage
Add groups, search, better description format, and default / dangerous icons.
2026-04-19 03:28:09 +01:00
Cooldude2606 26df9d2caa Add missing instanceConfigUpdated to InstanceConfigSetRequest 2026-04-18 22:05:05 +01:00
Cooldude2606 45adb1e88c Merge pull request #896 from Cooldude2606/feature/ctl-double-interrupt
Add double interrupt logic to ctl to force exit
2026-04-15 22:22:58 +01:00
Cooldude2606 6f5b8ce68d Update log statement to fatal 2026-04-15 22:15:14 +01:00
Cooldude2606 16a249d5bf Have version selectors share a common base component 2026-04-14 23:32:16 +01:00
Cooldude2606 a52e4320ec Improve UX on keyboard 2026-04-14 22:50:39 +01:00
Cooldude2606 e8cabb7196 Standardise shutdown signal guarding 2026-04-14 22:10:01 +01:00
Cooldude2606 7e82ad89ff Change config errors to be subclasses of RequestError (#895)
This prevents them from being logged when raised during an event handler.
2026-04-14 21:38:11 +01:00
Cooldude2606 2219b58000 Fix two missed cases of get_main_inventory 2026-04-14 21:31:26 +01:00
Cooldude2606 3667b4733a Fix webui error on setting host config (#893) 2026-04-14 21:21:00 +01:00
Hornwitser 490e9c0e5f Remove download headless Factorio in installer (#890)
This feature was made obsolete by hosts being able to download a
configured target version on instance startup in #831.
2026-04-13 12:27:20 +02:00
Cooldude2606 7e87201c24 Allow custom versions to be selected as a target
Add a custom version input field which can be used to enter any version number.
2026-04-12 23:18:13 +01:00
Cooldude2606 e9653c13eb Add double interrupt logic to ctl to force exit 2026-04-12 22:37:50 +01:00
Cooldude2606 255cc4c325 Fix accessing main inventory when in map view 2026-04-12 22:15:24 +01:00
129 changed files with 6082 additions and 2139 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
+110
View File
@@ -31,6 +31,116 @@ 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
- Added filter state to `EventSubscriber` allowing plugings to more easilly filter events. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Added `replace` subscription action allowing filter state to be fully replaced with a single request. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Added `ValueSubscriber` and `MapSubscriber` as subclasses of `EventSubscriber`. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Added changelog viewer accessible from the version number in the web interface. [#907](<https://github.com/clusterio/clusterio/issues/907>)
- Added about popup to user menu containing support links, build, clusterio, and debug info. [#914](<https://github.com/clusterio/clusterio/pull/914>)
- Added user status filtering (admin, whitelisted, banned) to the users table. [#905](<https://github.com/clusterio/clusterio/issues/905>)
- Added instance selector to users page to scope user stats by instance. [#905](<https://github.com/clusterio/clusterio/issues/905>)
### Fixes
- Online user table on instance page now correctly defaults to online only filter. [#909](<https://github.com/clusterio/clusterio/pull/909>)
- Fixed popup not opening when pressing kill within the instance control button. [#859](<https://github.com/clusterio/clusterio/issues/859>)
- Fix division by zero in CPU usage calculation. [#916](<https://github.com/clusterio/clusterio/issues/916>)
- Fixed permissions list not including `grantByDefault` in the json representation. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Fixed subscription resources being `synced = false` if the remote data was empty. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Fix incorrect use of `path.parse` within create template for plugins. [#919](<https://github.com/clusterio/clusterio/pull/919>)
- Fixed active filter icon behaviour in user search using empty string. [#917](<https://github.com/clusterio/clusterio/pull/917>)
### Changes
- Player auth now uses a scroll pane for listing instance names. [#909](<https://github.com/clusterio/clusterio/pull/909>)
- `subscriptionUpdate` now fires for all subscription requests rather than just added subscriptions. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Unified instance and global user table columns for consistent behaviour. [#917](<https://github.com/clusterio/clusterio/pull/917>)
- Improved loading states for tables and inputs using synced state. [#917](<https://github.com/clusterio/clusterio/pull/917>)
- Refactored username rendering into a reusable component. [#917](<https://github.com/clusterio/clusterio/pull/917>)
### Breaking Changes
- Renamed `lib.Address.index` to `requestIndex` and added `addressIndex`. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- `EventSubscriber` no longer includes a values map, use `MapSubscriber` instead. [#913](<https://github.com/clusterio/clusterio/pull/913>)
### Meta
- Version dependencies now use `^` which enables updates to be applied one package at a time. [#803](<https://github.com/clusterio/clusterio/issues/803>)
- Updated change log creation to include new lines and user contributions. [#909](<https://github.com/clusterio/clusterio/pull/909>)
Many thanks to the following for contributing to this release:
[@Cooldude2606](<https://github.com/Cooldude2606>)
## Version 2.0.0-alpha.25
### Features
- Added double interrupt logic to ctl to force immediate exit. [#887](<https://github.com/clusterio/clusterio/issues/887>)
- Allow selection of factorio versions not listed on the Wube website for instances and mod packs. [#867](<https://github.com/clusterio/clusterio/issues/867>)
### Fixes
- Fix the webui being unable to set host config values and that no error was displayed. [#891](<https://github.com/clusterio/clusterio/issues/891>)
- Fix instance config not updating on changes being applied. [#901](<https://github.com/clusterio/clusterio/pull/901>)
- Incorrect acccessing of player inventory during crafting queue serialisation when in remote view. [#892](<https://github.com/clusterio/clusterio/issues/892>)
- Use physical location and controller for syncing player to prevent teleporting with remote view. [#894](<https://github.com/clusterio/clusterio/pull/894>)
- Fixed player exiting space platform hub on sync and other remote view edge cases. [#894](<https://github.com/clusterio/clusterio/pull/894>)
### Changes
- Config validation errors are no longer logged when raised during an request handler. [#888](<https://github.com/clusterio/clusterio/issues/888>)
- Updated role permission editor to use grouped layout with search and improved formatting. [#902](<https://github.com/clusterio/clusterio/pull/902>)
### Breaking Changes
- Removed obsolete functionality to download headless server in the installer. [#890](<https://github.com/clusterio/clusterio/pull/890>)
Many thanks to the following for contributing to this release:
[@Cooldude2606](<https://github.com/Cooldude2606>)
[@Daniel](<https://github.com/Daniel>)
[@Hornwitser](<https://github.com/Hornwitser>)
## Version 2.0.0-alpha.24
### Features
+6 -6
View File
@@ -26,7 +26,7 @@
]
},
"engines": {
"node": ">=20"
"node": ">=22"
},
"workspaces": [
"packages/*",
@@ -35,11 +35,11 @@
"external_plugins/*/*"
],
"devDependencies": {
"@clusterio/controller": "workspace:*",
"@clusterio/ctl": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@clusterio/controller": "workspace:^",
"@clusterio/ctl": "workspace:^",
"@clusterio/host": "workspace:^",
"@clusterio/lib": "workspace:^",
"@clusterio/web_ui": "workspace:^",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.32.0",
"@stylistic/eslint-plugin": "^5.2.2",
+3 -28
View File
@@ -18,7 +18,6 @@ import path from "path";
import fs from "node:fs/promises";
import crypto from "crypto";
import setBlocking from "set-blocking";
import yargs from "yargs";
import util from "util";
import winston from "winston";
@@ -446,33 +445,9 @@ async function startup() {
process.exit(1);
}
let secondSigint = false;
process.on("SIGINT", () => {
if (secondSigint) {
setBlocking(true);
logger.fatal("Caught second interrupt, terminating immediately");
process.exit(1);
}
secondSigint = true;
logger.info("Caught interrupt signal, shutting down");
controller.stop();
});
let secondSigterm = false;
process.on("SIGTERM", () => {
if (secondSigterm) {
setBlocking(true);
logger.fatal("Caught second termination, terminating immediately");
process.exit(1);
}
secondSigterm = true;
logger.info("Caught termination signal, shutting down");
controller.stop();
});
// Handle interrupts
process.on("SIGINT", lib.createShutdownGuard(logger, "interrupt", controller.stop.bind(controller)));
process.on("SIGTERM", lib.createShutdownGuard(logger, "termination", controller.stop.bind(controller)));
process.on("SIGHUP", () => {
logger.info("Terminal closed, shutting down");
controller.stop();
+4 -6
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio controller",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.24",
"version": "2.0.0-alpha.27",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --mode production",
@@ -17,10 +17,10 @@
"factorio"
],
"engines": {
"node": ">=20"
"node": ">=22"
},
"dependencies": {
"@clusterio/lib": "workspace:*",
"@clusterio/lib": "workspace:^",
"@sinclair/typebox": "catalog:",
"busboy": "^1.6.0",
"compression": "^1.8.0",
@@ -28,14 +28,13 @@
"finalhandler": "^1.3.1",
"jsonwebtoken": "catalog:",
"jszip": "catalog:",
"set-blocking": "catalog:",
"winston": "catalog:",
"winston-daily-rotate-file": "catalog:",
"ws": "catalog:",
"yargs": "^17.7.2"
},
"devDependencies": {
"@clusterio/web_ui": "workspace:*",
"@clusterio/web_ui": "workspace:^",
"@swc/core": "catalog:",
"@types/busboy": "^1.5.0",
"@types/compression": "^1.7.2",
@@ -43,7 +42,6 @@
"@types/finalhandler": "catalog:",
"@types/jsonwebtoken": "catalog:",
"@types/node": "catalog:",
"@types/set-blocking": "catalog:",
"@types/ws": "catalog:",
"@types/yargs": "^17.0.33",
"react": "catalog:",
+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);
}
}
+30 -11
View File
@@ -330,6 +330,7 @@ export default class ControlConnection extends BaseConnection {
}
instance.config.commitStaging();
await this._controller.instanceConfigUpdated(instance);
} finally {
instance.config.revertStaging();
}
@@ -674,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>();
@@ -706,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) {
@@ -738,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
@@ -756,6 +774,7 @@ export default class ControlConnection extends BaseConnection {
if (!versionRange.valid) {
candidateReleases.delete(mod.name);
errors.set(mod.name, "unsatisfiable");
continue;
}
}
@@ -803,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);
}
}
+54 -3
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;
});
@@ -875,6 +900,9 @@ export default class Controller {
}
async handleSystemInfoSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const systems = [...this.systems.values()].filter(
metric => metric.updatedAtMs > request.lastRequestTimeMs,
);
@@ -897,6 +925,9 @@ export default class Controller {
}
async handleHostSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const hosts = [...this.hosts.values()].filter(
host => host.updatedAtMs > request.lastRequestTimeMs,
).map(host => host.toHostDetails());
@@ -944,6 +975,9 @@ export default class Controller {
}
async handleInstanceDetailsSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const instances = [...this.instances.values()].filter(
instance => instance.updatedAtMs > request.lastRequestTimeMs,
).map(instance => instance.toInstanceDetails());
@@ -955,7 +989,12 @@ export default class Controller {
}
async handleInstanceSaveDetailsSubscription(request: lib.SubscriptionRequest) {
const saves = [...this.saves.values()].filter(save => save.updatedAtMs > request.lastRequestTimeMs);
if (request.action === "unsubscribe") {
return null;
}
const saves = [...this.saves.values()].filter(
save => save.updatedAtMs > request.lastRequestTimeMs
);
return saves.length ? new lib.InstanceSaveDetailsUpdatesEvent(saves) : null;
}
@@ -966,6 +1005,9 @@ export default class Controller {
}
async handleModPackSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const modPacks = [...this.modPacks.values()].filter(
modPack => modPack.updatedAtMs > request.lastRequestTimeMs,
);
@@ -979,6 +1021,9 @@ export default class Controller {
}
async handleModSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const mods = [...this.modStore.files.values()].filter(
mod => mod.updatedAtMs > request.lastRequestTimeMs,
);
@@ -990,6 +1035,9 @@ export default class Controller {
}
async handleUserSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const users = [...this.users.records.values()].filter(
user => user.updatedAtMs > request.lastRequestTimeMs,
);
@@ -1037,6 +1085,9 @@ export default class Controller {
}
async handleRoleSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const roles = [...this.roles.values()].filter(
role => role.updatedAtMs > request.lastRequestTimeMs,
);
-82
View File
@@ -14,7 +14,6 @@ const { levels, logger, setLogLevel } = require("./logging");
const { copyPluginTemplates } = require("./template");
let dev = false;
const scriptExt = process.platform === "win32" ? ".cmd" : "";
const finished = util.promisify(stream.finished);
const { escapeArg } = require("./escape_arg");
// I hate this, there is a bug with rl.close on windows that was meant to have been fixed in Node14.4
@@ -660,22 +659,6 @@ async function inquirerMissingArgs(args) {
foundLocations.push(location);
}
}
if (process.platform === "linux") {
if (args.hasOwnProperty("downloadHeadless")) { answers.downloadHeadless = args.downloadHeadless; }
answers = await inquirer.prompt([
{
type: "confirm",
name: "downloadHeadless",
message: "(Linux only) Automatically download latest factorio release?",
default: true,
},
], answers);
if (!answers.factorioDir && answers.downloadHeadless) {
answers.factorioDir = "factorio";
}
}
answers = await inquirer.prompt([
{
@@ -767,54 +750,6 @@ async function inquirerMissingArgs(args) {
return answers;
}
async function downloadLinuxServer() {
let res = await fetch("https://factorio.com/get-download/stable/headless/linux64");
// get the filename of the latest factorio archive from redirected url
const url = new URL(res.url); // The res is redirects, need final url
const filename = path.posix.basename(url.pathname);
const match = filename.match(/(?<=factorio_headless_x64_|factorio-headless_linux_)\d+\.\d+\.\d+(?=\.tar\.xz)/);
if (!match || !match.length) {
throw Error(`Unable to extract version from filename: ${filename}`);
}
const version = match[0];
const tmpDir = "temp/create-temp/";
const archivePath = tmpDir + filename;
const tmpArchivePath = `${archivePath}.tmp`;
const factorioDir = `factorio/${version}/`;
const tmpFactorioDir = tmpDir + version;
if (await pathExists(factorioDir)) {
logger.warn(`setting downloadDir to ${factorioDir}, but not downloading because already existing`);
} else {
await fs.rm(tmpDir, { force: true, recursive: true, maxRetries: 10 });
await fs.mkdir(tmpDir, { recursive: true });
logger.info("Downloading latest Factorio server release. This may take a while.");
const writeStream = (await fs.open(tmpArchivePath, "w")).createWriteStream();
stream.Readable.fromWeb(res.body).pipe(writeStream);
await finished(writeStream);
await fs.rename(tmpArchivePath, archivePath);
try {
await fs.mkdir(tmpFactorioDir, { recursive: true });
await execFile("tar", [
"xf", archivePath, "-C", tmpFactorioDir, "--strip-components", "1",
]);
} catch (e) {
logger.error("error executing command- do you have 'xz-utils' installed?");
throw e;
}
await fs.mkdir("factorio", { recursive: true });
await fs.rename(tmpFactorioDir, factorioDir);
await fs.unlink(archivePath);
await fs.rm(tmpDir);
}
}
async function main() {
let args = yargs
.option("log-level", {
@@ -837,10 +772,6 @@ async function main() {
.option("http-port", {
nargs: 1, describe: "HTTP port to listen on [standalone/controller]", type: "number",
})
.option("download-headless", {
describe: "Download the latest headless release [standalone/controller] [linux only]",
type: "boolean", nargs: 0,
})
.option("host-name", {
nargs: 1, describe: "Host name [host]", type: "string",
})
@@ -879,12 +810,6 @@ async function main() {
.option("allow-install-as-root", {
nargs: 0, describe: "(Linux only) Allow installing as root (not recommended)", type: "boolean",
})
.option("download-headless", {
nargs: 0,
describe: "(Linux only) Automatically download and unpack the latest factorio release. " +
"Can be set to false using --no-download-headless.",
type: "boolean",
})
;
}
@@ -911,13 +836,6 @@ async function main() {
return;
}
if (answers.downloadHeadless) {
if (answers.factorioDir !== "factorio") {
throw new InstallError("--download-headless option requires --factorio-dir to be set to factorio");
}
await downloadLinuxServer();
}
if (!dev) {
await installClusterio(answers.mode, answers.plugins);
}
+2 -2
View File
@@ -3,7 +3,7 @@
"description": "Installer for clusterio",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.24",
"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:",
+1 -1
View File
@@ -62,7 +62,7 @@ async function copyTemplateFile(src, dst, properties) {
});
// Create folder for output file
const dir = path.parse(dst);
const { dir } = path.parse(dst);
if (dir) {
await fs.mkdir(dir, { recursive: true });
}
+3 -10
View File
@@ -8,7 +8,6 @@ import fs from "node:fs/promises";
import yargs, { type Argv } from "yargs";
import path from "path";
import { version } from "./package.json";
import setBlocking from "set-blocking";
import { strict as assert } from "assert";
// Reduce startup time by lazy compiling schemas.
@@ -311,15 +310,9 @@ async function startControl() {
throw err;
}
process.on("SIGINT", () => {
logger.info("Caught interrupt signal, closing connection");
control.shutdown().catch(err => {
setBlocking(true);
logger.error(err.stack);
process.exit(1);
});
});
// Handle interrupts
process.on("SIGINT", lib.createShutdownGuard(logger, "interrupt", control.shutdown.bind(control)));
process.on("SIGTERM", lib.createShutdownGuard(logger, "termination", control.shutdown.bind(control)));
try {
const targetCommand = selectTargetCommand(args, rootCommands);
+3 -5
View File
@@ -3,7 +3,7 @@
"description": "Clusterio command line management tool",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.24",
"version": "2.0.0-alpha.27",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
@@ -17,12 +17,11 @@
"factorio"
],
"engines": {
"node": ">=20"
"node": ">=22"
},
"dependencies": {
"@clusterio/lib": "workspace:*",
"@clusterio/lib": "workspace:^",
"as-table": "^1.0.55",
"set-blocking": "catalog:",
"winston": "catalog:",
"yargs": "^17.7.2"
},
@@ -31,7 +30,6 @@
},
"devDependencies": {
"@types/node": "catalog:",
"@types/set-blocking": "catalog:",
"@types/yargs": "^17.0.33",
"typescript": "catalog:"
}
+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)) {
+2 -27
View File
@@ -15,7 +15,6 @@
import fs from "node:fs/promises";
import path from "path";
import yargs from "yargs";
import setBlocking from "set-blocking";
import { version } from "./package.json";
import winston from "winston";
import "winston-daily-rotate-file";
@@ -239,32 +238,8 @@ async function startHost() {
);
// Handle interrupts
let secondSigint = false;
process.on("SIGINT", () => {
if (secondSigint) {
setBlocking(true);
logger.fatal("Caught second interrupt, terminating immediately");
process.exit(1);
}
secondSigint = true;
logger.info("Caught interrupt signal, shutting down");
host!.shutdown();
});
let secondSigterm = false;
process.on("SIGTERM", () => {
if (secondSigterm) {
setBlocking(true);
logger.fatal("Caught second termination, terminating immediately");
process.exit(1);
}
secondSigterm = true;
logger.info("Caught termination signal, shutting down");
host!.shutdown();
});
process.on("SIGINT", lib.createShutdownGuard(logger, "interrupt", host.shutdown.bind(host)));
process.on("SIGTERM", lib.createShutdownGuard(logger, "termination", host.shutdown.bind(host)));
process.on("SIGHUP", () => {
logger.info("Terminal closed, shutting down");
host!.shutdown();
@@ -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)
+3 -3
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio host server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.24",
"version": "2.0.0-alpha.27",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
@@ -18,10 +18,10 @@
"factorio"
],
"engines": {
"node": ">=20"
"node": ">=22"
},
"dependencies": {
"@clusterio/lib": "workspace:*",
"@clusterio/lib": "workspace:^",
"@sinclair/typebox": "catalog:",
"jimp": "^1.6.0",
"jszip": "catalog:",
+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.24",
"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>[] = [];
+14 -4
View File
@@ -9,6 +9,7 @@ import * as libSchema from "../schema";
import { StringEnum } from "../data/composites";
import { safeOutputFile } from "../file_ops";
import * as validators from "./validators";
import { RequestError } from "../errors";
const ConfigLocation = StringEnum(["controller", "host", "control"]);
export type ConfigLocation = Static<typeof ConfigLocation>;
@@ -21,7 +22,7 @@ export type ConfigLocation = Static<typeof ConfigLocation>;
*
* @extends Error
*/
export class InvalidAccess extends Error { };
export class InvalidAccess extends RequestError { };
/**
* Invalid Value exception
@@ -31,7 +32,7 @@ export class InvalidAccess extends Error { };
*
* @extends Error
*/
export class InvalidValue extends Error { };
export class InvalidValue extends RequestError { };
/**
* Invalid Field exception
@@ -41,7 +42,7 @@ export class InvalidValue extends Error { };
*
* @extends Error
*/
export class InvalidField extends Error { };
export class InvalidField extends RequestError { };
const OldConfigGroupSchema = Type.Object({
@@ -250,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");
@@ -272,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 },
]);
}
+3 -2
View File
@@ -8,16 +8,17 @@ export default class Permission {
public name: string,
public title: string,
public description: string,
public grantByDefault?: boolean,
public grantByDefault: boolean = false,
) { }
static jsonSchema = Type.Object({
name: Type.String(),
title: Type.String(),
description: Type.String(),
grantByDefault: Type.Optional(Type.Boolean()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.name, json.title, json.description);
return new this(json.name, json.title, json.description, Boolean(json.grantByDefault));
}
}
+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);
}
+10 -2
View File
@@ -102,8 +102,16 @@ export class Address {
* Returns a string suitable for indexing the address in a Map
* @returns unique string for this address
*/
index() {
return `${this.type}:${this.id}:${this.requestId}`;
requestIndex() {
return `${this.type}:${this.id}:${this.requestId}` as const;
}
/**
* Returns a string suitable for indexing the address in a Map
* @returns unique string for this address, excluding required id
*/
addressIndex() {
return `${this.type}:${this.id}` as const;
}
/**
+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),
+29
View File
@@ -3,6 +3,9 @@
* @module lib/helpers
*/
import type { Logger } from "./logging";
import setBlocking from "set-blocking";
/**
* Return a string describing the type of the value passed
*
@@ -51,6 +54,32 @@ export async function timeout<T>(promise: Promise<T>, limitMs: number, timeoutRe
}
}
/**
* Catch process signals, force exit on second signal
*/
export function createShutdownGuard(logger: Logger, signalName: string, callback: () => Promise<unknown>): () => void {
let called = false;
return () => {
if (called) {
setBlocking(true);
logger.fatal(`Caught second ${signalName}, terminating immediately`);
process.exit(1);
}
called = true;
logger.info(`Caught ${signalName}, shutting down`);
callback().catch(err => {
setBlocking(true);
logger.fatal("Error during shutdown, terminating immediately");
logger.fatal(err.stack);
process.exit(1);
});
};
}
/**
* Helper to serialise and merge waiting calls to an async callback.
*
+2 -2
View File
@@ -219,7 +219,7 @@ export class Link {
if (!message.dst.addressedTo(this.connector.src)) {
if (message.type === "response" || message.type === "responseError") {
this._forwardedRequests.delete(message.dst.index());
this._forwardedRequests.delete(message.dst.requestIndex());
}
this._routeMessage(message, entry);
return;
@@ -566,7 +566,7 @@ export class Link {
src: message.src,
dst: message.dst,
};
this._forwardedRequests.set(message.src.index(), pending);
this._forwardedRequests.set(message.src.requestIndex(), pending);
this.connector.forward(message);
}
+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",
});
+401 -153
View File
@@ -1,13 +1,144 @@
import { Type, Static } from "@sinclair/typebox";
import { Link, Event, EventClass, RequestHandler, WebSocketBaseConnector } from "./link";
import { Address, MessageRequest, IUser, plainJson, JsonBoolean } from "./data";
import { Address, MessageRequest, IUser, JsonBoolean, StringEnum, AccountDetails } from "./data";
import isDeepStrictEqual from "./is_deep_strict_equal";
import { RequestError, SessionLost } from "./errors";
import { RequestError } from "./errors";
import { logger } from "./logging";
export type SubscriptionRequestHandler<T> = RequestHandler<SubscriptionRequest, Event<T> | null>;
export type EventSubscriberCallback<T> = (event: T | null, synced: boolean) => void
/**
* Represents a set of string subscription filters.
* When isAll() is true, all strings match this filter.
* When isEmpty() is true, no strings match this filter.
* Otherwise, literal string matching is performed against a set.
*/
export class SubscriptionFilters extends Set<string> {
private constructor(
private _all: boolean,
_filters: Iterable<string> = [],
) {
super(_filters);
}
static jsonSchema = Type.Array(Type.String());
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(false, json);
}
toJSON() {
return [...this];
}
/** Creates a subscription filter accepting all strings */
static all() {
return new this(true);
}
isAll() {
return this._all;
}
/** Creates a subscription filter rejecting all strings */
static empty() {
return new this(false);
}
isEmpty() {
return !this._all && this.size === 0;
}
/**
* Creates a subscription filter from undefined, string, or array of strings.
* Strings work as expected, undefined will produce a filter accepting all values.
* @param filters Shorthand representation
*/
static fromShorthand(filters?: string | string[]) {
if (filters === undefined) {
return this.all();
}
if (Array.isArray(filters)) {
return new this(false, new Set(filters));
}
return new this(false, new Set([filters]));
}
toString() {
if (this._all) {
return "[SubscriptionFilters All]";
}
if (this.size === 0) {
return "[SubscriptionFilters Empty]";
}
return `[SubscriptionFilters Set<${this.size}>]`;
}
/** Returns true if this filter accepts the given string */
accepts(value: string) {
return this._all || this.has(value);
}
/** Returns true if this filter extends the other */
extends(other: this) {
return this._all || (!other._all && [...other.values()].every(value => this.has(value)));
}
/** Returns true if there is overlap between two filters */
intersects(other: this) {
if (this._all) {
return !other.isEmpty();
}
if (other._all) {
return !this.isEmpty();
}
for (const filter of other) {
if (this.has(filter)) {
return true;
}
}
return false;
}
/** Add filters from a filter into this one. */
union(other: this) {
if (this._all) {
return; // Already all
}
if (other._all) {
this._all = true;
this.clear();
return;
}
for (const filter of other) {
this.add(filter);
}
}
/**
* Remove filters in a filter from this one.
* You cannot subtract filters from a filter accepting all.
* Subtracting a filter accepting all produces an empty filter.
*/
subtract(other: this) {
if (other._all) {
this._all = false;
this.clear();
return;
}
if (this._all) {
return; // Cannot subtract from all
}
for (const filter of other) {
this.delete(filter);
}
}
}
const SubscriptionActionSchema = StringEnum(["subscribe", "unsubscribe", "replace"]);
type SubscriptionAction = Static<typeof SubscriptionActionSchema>;
/**
* A subscription request sent by a subscriber, this updates what events the subscriber will be sent
* The permission for this request copies the permission from the event being subscribed to
@@ -20,7 +151,7 @@ export class SubscriptionRequest {
static dst = "controller" as const;
static permission(user: IUser, message: MessageRequest) {
if (typeof message.data === "object" && message.data !== null) {
const data = message.data as Static<typeof SubscriptionRequest.jsonSchema>;
const data = message.data as Static<typeof this.jsonSchema>;
const entry = Link._eventsByName.get(data[0]);
if (entry && entry.Event.permission) {
if (typeof entry.Event.permission === "string") {
@@ -34,55 +165,86 @@ export class SubscriptionRequest {
/** Indicates if updates have been sent */
static Response = JsonBoolean;
public filters: SubscriptionFilters;
public action: SubscriptionAction;
/** @deprecated Use SubscriptionFilters and SubscriptionAction instead */
constructor(
eventName: string,
subscribe: boolean,
lastRequestTimeMs: number | undefined,
filters?: string | string[],
);
constructor(
eventName: string,
action: SubscriptionAction,
lastRequestTimeMs: number | undefined,
filters?: SubscriptionFilters,
);
constructor(
public eventName: string,
public subscribe: boolean,
action: boolean | SubscriptionAction,
public lastRequestTimeMs: number = 0,
public filters: string | string[] | undefined = undefined,
filters: undefined | string | string[] | SubscriptionFilters,
) {
if (!Link._eventsByName.has(eventName)) {
throw new Error(`Unregistered Event class ${eventName}`);
}
if (filters instanceof SubscriptionFilters) { // Easier migration for plugins
this.filters = filters;
} else {
this.filters = SubscriptionFilters.fromShorthand(filters);
}
if (typeof action === "boolean") { // Easier migration for plugins
this.action = action ? "subscribe" : "unsubscribe";
} else {
this.action = action;
}
}
static jsonSchema = Type.Union([
Type.Tuple([
Type.String(),
Type.Boolean(),
SubscriptionActionSchema,
Type.Number(),
]),
Type.Tuple([
Type.String(),
Type.Boolean(),
SubscriptionActionSchema,
Type.Number(),
Type.Union([Type.String(), Type.Array(Type.String())]),
SubscriptionFilters.jsonSchema,
]),
]);
toJSON() {
if (this.filters !== undefined) {
return [this.eventName, this.subscribe, this.lastRequestTimeMs, this.filters];
if (!this.filters.isAll()) {
return [this.eventName, this.action, this.lastRequestTimeMs, this.filters.toJSON()];
}
return [this.eventName, this.subscribe, this.lastRequestTimeMs];
return [this.eventName, this.action, this.lastRequestTimeMs];
}
static fromJSON(json: Static<typeof SubscriptionRequest.jsonSchema>): SubscriptionRequest {
const [eventName, subscribe, lastRequestTimeMs, filters] = json;
return new this(eventName, subscribe, lastRequestTimeMs, filters);
static fromJSON(json: Static<typeof this.jsonSchema>) {
const [eventName, action, lastRequestTimeMs, filters] = json;
return new this(
eventName,
action,
lastRequestTimeMs,
filters ? SubscriptionFilters.fromJSON(filters) : SubscriptionFilters.all()
);
}
}
type Subscriber = {
link: Link,
dst: Address,
/** Optional filters this subscriber is interested in. Undefined means all events */
filters?: string[],
filters: SubscriptionFilters,
}
type EventData = {
subscriptionUpdate?: SubscriptionRequestHandler<unknown>,
subscriptions: Array<Subscriber>,
subscriptions: Map<ReturnType<Address["addressIndex"]>, Subscriber>,
};
/**
@@ -92,13 +254,6 @@ type EventData = {
export class SubscriptionController {
_events = new Map<string, EventData>();
private static normalizeFilters(value?: string | string[]): string[] | undefined {
if (value === undefined) {
return undefined;
}
return Array.isArray(value) ? value : [value];
}
/**
* Allow clients to subscribe to an event by telling the subscription controller to accept them
* Has an optional subscription update handler which is called when a client subscribes
@@ -116,7 +271,7 @@ export class SubscriptionController {
}
this._events.set(entry.name, {
subscriptionUpdate: subscriptionUpdate,
subscriptions: [],
subscriptions: new Map(),
});
}
@@ -124,7 +279,7 @@ export class SubscriptionController {
* Broadcast an event to all subscribers of that event
* @param event - Event to broadcast.
*/
broadcast<T>(event: Event<T>, filter?: string | string[]) {
broadcast<T extends Event<T>>(event: T & { filters?: string | string[] }, filters?: string | string[]) {
const entry = Link._eventsByClass.get(event.constructor);
if (!entry) {
throw new Error(`Unregistered Event class ${event.constructor.name}`);
@@ -133,24 +288,17 @@ export class SubscriptionController {
if (!eventData) {
throw new Error(`Event ${entry.name} is not a registered as subscribable`);
}
const toRemove: Subscriber[] = [];
const filtersToMatch = SubscriptionController.normalizeFilters(filter);
for (const subscriber of eventData.subscriptions) {
const broadcastFilters = SubscriptionFilters.fromShorthand(filters ?? event.filters);
for (const [addressIndex, subscriber] of eventData.subscriptions) {
if ((subscriber.link.connector as WebSocketBaseConnector).closing) {
toRemove.push(subscriber);
eventData.subscriptions.delete(addressIndex);
continue;
}
if (
filtersToMatch === undefined
|| subscriber.filters === undefined
|| subscriber.filters.some(f => filtersToMatch.includes(f))
) {
if (subscriber.filters.intersects(broadcastFilters)) {
subscriber.link.sendTo(subscriber.dst, event);
}
}
eventData.subscriptions = eventData.subscriptions.filter(subscriber => !toRemove.includes(subscriber));
}
/**
@@ -159,10 +307,9 @@ export class SubscriptionController {
* @param address - Address to stop sending events to.
*/
unsubscribeAddress(address: Address) {
for (let eventData of this._events.values()) {
eventData.subscriptions = eventData.subscriptions.filter(
subscriber => !subscriber.dst.addressedTo(address)
);
const addressIndex = address.addressIndex();
for (const eventData of this._events.values()) {
eventData.subscriptions.delete(addressIndex);
}
}
@@ -172,69 +319,76 @@ export class SubscriptionController {
* @param link - Link to stop sending events to.
*/
unsubscribeLink(link: Link) {
for (let eventData of this._events.values()) {
eventData.subscriptions = eventData.subscriptions.filter(
subscriber => subscriber.link !== link
);
for (const eventData of this._events.values()) {
for (const [addressIndex, subscriber] of eventData.subscriptions) {
if (subscriber.link === link) {
eventData.subscriptions.delete(addressIndex);
}
}
}
}
/**
* Handle incoming subscription requests on a link
* @param link - Link message was received on
* @param event - incomming event.
* @param request - incoming request.
* @param src - Source address of incomming request.
* @param dst - destination address of incomming request.
*/
async handleRequest(link: Link, event: SubscriptionRequest, src: Address, dst: Address) {
if (!Link._eventsByName.has(event.eventName)) {
throw new Error(`Event ${event.eventName} is not a registered event`);
async handleRequest(link: Link, request: SubscriptionRequest, src: Address, dst: Address) {
if (!Link._eventsByName.has(request.eventName)) {
throw new Error(`Event ${request.eventName} is not a registered event`);
}
const eventData = this._events.get(event.eventName);
const eventData = this._events.get(request.eventName);
if (!eventData) {
throw new Error(`Event ${event.eventName} is not a registered as subscribable`);
throw new Error(`Event ${request.eventName} is not a registered as subscribable`);
}
if (event.subscribe === false) {
// Unsubscribe logic
const subscriber = eventData.subscriptions.find(sub => sub.dst.addressedTo(src));
if (!subscriber) {
return false;
}
if (event.filters === undefined || subscriber.filters === undefined) {
// Remove entire subscription for this address
eventData.subscriptions = eventData.subscriptions.filter(sub => !sub.dst.addressedTo(src));
} else {
const filtersToRemove = Array.isArray(event.filters) ? event.filters : [event.filters];
subscriber.filters = subscriber.filters.filter(f => !filtersToRemove.includes(f));
if (subscriber.filters.length === 0) {
// No filters left, remove subscription
eventData.subscriptions = eventData.subscriptions.filter(sub => sub !== subscriber);
const addressIndex = src.addressIndex();
const subscriber = eventData.subscriptions.get(addressIndex);
switch (request.action) {
case "unsubscribe":
if (!subscriber) {
return false;
}
}
} else {
// Subscribe logic
let subscriber = eventData.subscriptions.find(sub => sub.dst.addressedTo(src));
const newFilters = SubscriptionController.normalizeFilters(event.filters);
if (!subscriber) {
eventData.subscriptions.push({ link: link, dst: src, filters: newFilters });
} else if (subscriber.filters === undefined || newFilters === undefined) {
// Either was already subscribed to all or new request wants all
subscriber.filters = undefined;
} else {
for (const f of newFilters) {
if (!subscriber.filters.includes(f)) {
subscriber.filters.push(f);
subscriber.filters.subtract(request.filters);
if (subscriber.filters.isEmpty()) {
eventData.subscriptions.delete(addressIndex);
}
break;
case "subscribe":
if (!subscriber) {
eventData.subscriptions.set(addressIndex, { link: link, dst: src, filters: request.filters });
} else {
subscriber.filters.union(request.filters);
}
break;
case "replace":
if (request.filters.isEmpty()) {
if (!subscriber) {
return false;
}
eventData.subscriptions.delete(addressIndex);
} else if (!subscriber) {
eventData.subscriptions.set(addressIndex, { link: link, dst: src, filters: request.filters });
} else {
subscriber.filters = request.filters;
}
}
if (eventData.subscriptionUpdate) {
const eventReplay = await eventData.subscriptionUpdate(event, src, dst);
if (eventReplay) {
link.sendTo(src, eventReplay);
return true;
}
break;
default:
throw new Error(`unreachable case: ${String(request.action)}`);
}
if (eventData.subscriptionUpdate) {
const eventReplay = await eventData.subscriptionUpdate(request, src, dst);
if (eventReplay) {
link.sendTo(src, eventReplay);
return true;
}
}
return false;
}
}
@@ -245,36 +399,29 @@ export interface SubscribableValue {
isDeleted: boolean,
}
export type EventSubscribable<T, V extends SubscribableValue> = Event<T> & Partial<{
updates: V[],
}>
/**
* Component for subscribing to and tracking updates of a remote resource
* Multiple handlers can be subscribed at the same time
*/
export class EventSubscriber<
T extends EventSubscribable<T, V>,
K extends string | number = NonNullable<T["updates"]>[number]["id"],
V extends SubscribableValue = NonNullable<T["updates"]>[number],
> {
export class EventSubscriber<E, S = null> {
/** The time at which an event was last received, is less than 0 when there have been no events */
lastResponseTimeMs = -1;
/** Values of the subscribed resource */
values = new Map<K, V>();
lastUpdatedMs = -1;
/** True if this subscriber is currently synced with the source */
synced = false;
/** True if this subscriber is expecting to receive updates from the source */
_syncing = 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 */
_snapshot: readonly [ReadonlyMap<K, Readonly<V>>, boolean] = [new Map<K, V>(), false];
_snapshotLastUpdatedMs = -1;
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 */
_callbacks = new Array<EventSubscriberCallback<T>>();
private _callbacks = new Array<EventSubscriberCallback<E>>();
/** Filters applied to the filtered handler, if empty the filtered handler will not be called */
private _filters = SubscriptionFilters.empty();
constructor(
private Event: EventClass<T>,
protected Event: EventClass<E>,
public control: Link,
protected filteredHandler?: EventSubscriberCallback<E>,
) {
control.handle(Event, this._handleEvent.bind(this));
// The events below exist only on websocket connectors, we can't early return because of test mocking
@@ -294,12 +441,10 @@ export class EventSubscriber<
*/
handleConnectionEvent(event: "connect" | "drop" | "resume" | "close") {
if (event === "connect" || event === "resume") {
this._updateSubscription();
this._sendRequest("replace", this._callbacks.length > 0 ? SubscriptionFilters.all() : this._filters);
} else if (this.synced) {
this.synced = false;
for (let callback of this._callbacks) {
callback(null, false);
}
this._notify(null);
}
}
@@ -308,33 +453,35 @@ export class EventSubscriber<
* @param event - event from subscribed resource
* @internal
*/
async _handleEvent(event: T) {
// We support the automatic maintaining of a map for events with the updates property
const updates = event.updates ?? [];
for (const value of updates) {
this.lastResponseTimeMs = Math.max(this.lastResponseTimeMs, value.updatedAtMs);
// Warn about updates which changes content but don't update updatedAtMs
const existing = this.values.get(value.id as K);
if (existing && existing.updatedAtMs === value.updatedAtMs && !isDeepStrictEqual(existing, value)) {
logger.warn(
`${this.Event.name} contains update for value with id ${value.id} that has an identical ` +
"updatedAtMs timestamp but differing content"
);
}
if (value.isDeleted) {
this.values.delete(value.id as K);
} else {
this.values.set(value.id as K, value);
}
}
// Event handling logic
if (this._syncing && !this.synced) {
this._snapshotLastUpdatedMs = -1;
this.synced = true;
private async _handleEvent(event: E) {
const eventTimeMs = this.getLastUpdatedTimeMs(event);
if (eventTimeMs > this.lastUpdatedMs) {
this.lastUpdatedMs = eventTimeMs;
}
this.synced = this._hasSubscriptions();
this.processEvent(event);
this._notify(event);
}
private _notify(event: E | null) {
for (const callback of this._callbacks) {
callback(event, this.synced);
}
if (this.filteredHandler && !this._filters.isEmpty()) {
this.filteredHandler(event, this.synced);
}
}
protected getLastUpdatedTimeMs(event: E) {
return Date.now();
}
protected processEvent(event: E) {
// Noop for EventSubscriber
}
protected makeSnapshot(): S {
return null as S;
}
/**
@@ -344,10 +491,10 @@ export class EventSubscriber<
* synced property changes, in which case the event will be null.
* @returns function that will unsubscribe from notifications
*/
subscribe(handler: EventSubscriberCallback<T>) {
subscribe(handler: EventSubscriberCallback<E>) {
this._callbacks.push(handler);
if (this._callbacks.length === 1) {
this._updateSubscription();
this._sendRequest("subscribe", SubscriptionFilters.all());
}
return () => {
// During a page transition the components currently rendered
@@ -368,52 +515,153 @@ export class EventSubscriber<
}
this._callbacks.splice(index, 1);
if (this._callbacks.length === 0) {
this._updateSubscription();
this._sendRequest("replace", this._filters);
}
});
};
}
/** Returns true if all filters are present */
hasFilters(filters: string | string[] | SubscriptionFilters) {
if (!(filters instanceof SubscriptionFilters)) {
filters = SubscriptionFilters.fromShorthand(filters);
}
return this._filters.extends(filters);
}
/** Add filters to the filtered handler */
addFilters(filters: string | string[] | SubscriptionFilters) {
if (!(filters instanceof SubscriptionFilters)) {
filters = SubscriptionFilters.fromShorthand(filters);
}
this._filters.union(filters);
this._sendRequest("subscribe", filters);
}
/** Remove filters from the filtered handler */
removeFilters(filters: string | string[] | SubscriptionFilters) {
if (!(filters instanceof SubscriptionFilters)) {
filters = SubscriptionFilters.fromShorthand(filters);
}
this._filters.subtract(filters);
this._sendRequest("unsubscribe", filters);
}
/** Clear all filters from the filtered handler, it will no longer be called */
clearFilters() {
this._filters = SubscriptionFilters.empty();
if (!this._hasSubscriptions()) {
this._sendRequest("unsubscribe", SubscriptionFilters.all());
}
}
/**
* Obtain a snapshot of the current state of the tracked resource
* @returns tuple of values map snapshot and synced property.
*/
getSnapshot() {
if (this._snapshotLastUpdatedMs !== this.lastResponseTimeMs) {
this._snapshotLastUpdatedMs = this.lastResponseTimeMs;
this._snapshot = [new Map(this.values), 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.error];
}
return this._snapshot!;
return this._snapshot;
}
/** Returns true if there are any active subscription callbacks */
private _hasSubscriptions() {
return this._callbacks.length > 0 || !this._filters.isEmpty();
}
/**
* Update the subscription with the controller based on current handler counts
*/
async _updateSubscription() {
private async _sendRequest(action: SubscriptionAction, filters: SubscriptionFilters) {
if (!this.control.connector.valid) {
return;
}
const entry = Link._eventsByClass.get(this.Event)!;
const entry = Link._eventsByClass.get(this.Event)!;
try {
this._syncing = this._callbacks.length > 0;
const updatesSent = await this.control.sendTo("controller", new SubscriptionRequest(
entry.name,
this._syncing,
this.lastResponseTimeMs
entry.name, action, this.lastUpdatedMs, filters
));
this.error = null;
if (!updatesSent) {
this.synced = this._syncing;
for (const callback of this._callbacks) {
callback(null, this.synced);
}
this.synced = this._hasSubscriptions();
this._notify(null);
}
} catch (err: any) {
if (!(err instanceof RequestError) || err.code !== "SessionLost") {
logger.error(
`Unexpected error updating ${entry.name} subscription:\n${err.stack}`
);
logger.error(`Unexpected error updating ${entry.name} subscription:\n${err.stack}`);
}
if (err instanceof Error) {
this.error = err;
}
}
}
}
export class ValueSubscriber<
E extends { value: V },
V extends SubscribableValue = E["value"],
> extends EventSubscriber<E, Readonly<V> | null> {
/** Current value of the subscribed resource */
value: V | null = null;
protected override getLastUpdatedTimeMs(event: E) {
return event.value.updatedAtMs;
}
protected override processEvent(event: E) {
this.value = event.value;
}
protected override makeSnapshot() {
return this.value;
}
}
export class MapSubscriber<
E extends { updates: V[] },
K extends string | number = E["updates"][number]["id"],
V extends SubscribableValue = E["updates"][number],
> extends EventSubscriber<E, ReadonlyMap<K, Readonly<V>>> {
/** Current value of the subscribed resource */
values = new Map<K, V>();
protected override getLastUpdatedTimeMs(event: E) {
let maxTime = this.lastUpdatedMs;
for (const update of event.updates) {
if (update.updatedAtMs > maxTime) {
maxTime = update.updatedAtMs;
}
}
return maxTime;
}
protected override processEvent(event: E) {
for (const value of event.updates) {
// Warn about updates which changes content but don't update updatedAtMs
const existing = this.values.get(value.id as K);
if (existing && existing.updatedAtMs === value.updatedAtMs && !isDeepStrictEqual(existing, value)) {
logger.warn(
`${this.Event.name} contains update for value with id ${value.id} that has an identical ` +
"updatedAtMs timestamp but differing content"
);
}
if (value.isDeleted) {
this.values.delete(value.id as K);
} else {
this.values.set(value.id as K, value);
}
}
}
protected override makeSnapshot() {
return new Map(this.values);
}
}
+3 -1
View File
@@ -132,7 +132,9 @@ export async function gatherSystemInfo(
const currentIdleCpuMs = cpus.map(({ times }) => times.idle);
const deltaTotalCpuMs = minZip(previousTotalCpuMs, currentTotalCpuMs).map(([prev, curr]) => curr - prev);
const deltaIdleCpuMs = minZip(previousIdleCpuMs, currentIdleCpuMs).map(([prev, curr]) => curr - prev);
const cpuUsage = minZip(deltaTotalCpuMs, deltaIdleCpuMs).map(([total, idle]) => (total - idle) / total);
const cpuUsage = minZip(deltaTotalCpuMs, deltaIdleCpuMs).map(
([total, idle]) => (total <= 0 ? 0 : (total - idle) / total)
);
previousTotalCpuMs = currentTotalCpuMs;
previousIdleCpuMs = currentIdleCpuMs;
const stats = await fs.statfs(".");
+10
View File
@@ -3,6 +3,16 @@ declare module "*.png" {
export default value;
}
declare module "*.svg" {
const value: string;
export default value;
}
declare module "*.md" {
const value: string;
export default value;
}
declare const webRoot: string;
declare const staticRoot: string;
+3 -2
View File
@@ -3,7 +3,7 @@
"description": "Clusterio web interface implementation",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.24",
"version": "2.0.0-alpha.27",
"browser": "src/index.ts",
"main": "dist/browser/src/index.js",
"scripts": {
@@ -19,7 +19,7 @@
},
"dependencies": {
"@ant-design/icons": "^5.6.1",
"@clusterio/lib": "workspace:*",
"@clusterio/lib": "workspace:^",
"@sinclair/typebox": "^0.30.4",
"@swc/core": "^1.13.3",
"antd": "^5.24.2",
@@ -37,6 +37,7 @@
"rc-field-form": "^1.37.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "catalog:",
"react-router-dom": "^6.29.0",
"stream-browserify": "^3.0.0",
"style-loader": "^3.3.3",
+2 -2
View File
@@ -9,8 +9,7 @@ import App from "./components/App";
import BaseWebPlugin, { InputComponent } from "./BaseWebPlugin";
import InputRole from "./components/InputRole";
import InputModPack from "./components/InputModPack";
import InputTargetVersion from "./components/InputTargetVersion";
import InputPartialVersion from "./components/InputPartialVersion";
import { InputTargetVersion, InputPartialVersion, InputFullVersion } from "./components/InputVersion";
import { Control, ControlConnector } from "./util/websocket";
const { ConsoleTransport, WebConsoleFormat, logger } = lib;
@@ -111,6 +110,7 @@ async function loadPlugins(pluginInfos: lib.PluginWebpackEnvInfo[], control: Con
function inputComponentsFromPlugins(plugins: Map<string, BaseWebPlugin>) {
const inputComponents: Record<string, InputComponent> = {
"full_version": InputFullVersion,
"partial_version": InputPartialVersion,
"target_version": InputTargetVersion,
"mod_pack": InputModPack,
@@ -0,0 +1,189 @@
import React, { useContext, useEffect, useMemo, useState } from "react";
import { Modal, Button, Space, Typography, message, Row, Col, Card } from "antd";
import { ReloadOutlined, CopyOutlined, InfoCircleOutlined } from "@ant-design/icons";
import { PluginWebApi } from "@clusterio/lib";
import webUiPackage from "../../package.json";
import DiscordLogo from "../images/discord.svg";
import GitHubLogo from "../images/github.svg";
import ControlContext from "./ControlContext";
import { useAccount } from "../model/account";
import { useHosts } from "../model/host";
import { useInstances } from "../model/instance";
import { useUsers } from "../model/user";
const { Text } = Typography;
function formatOS(os?: string) {
switch (os) {
case "win32": return "Windows";
case "darwin": return "macOS";
case "linux": return "Linux";
default: return os;
}
}
export default function AboutModal({ open, onClose, onOpenChangelog }: any) {
const control = useContext(ControlContext);
const account = useAccount();
const [hosts] = useHosts();
const [instances] = useInstances();
const [users] = useUsers();
const [pluginList, setPluginList] = useState<PluginWebApi[]>([]);
useEffect(() => {
(async () => {
let response = await fetch(`${webRoot}api/plugins`);
if (response.ok) {
setPluginList(await response.json());
}
})();
}, [webRoot]);
const browserInfo = useMemo(() => ({
userAgent: navigator.userAgent,
platform: navigator.platform,
}), []);
const debugText = useMemo(() => {
const permissions = new Set(
account.roles.flatMap(role => role.permissions)
);
const date = new Date();
const lines: string[] = [
"Clusterio Debug Info",
`- Timestamp: ${date.toISOString()}`,
`- Local Time: ${date.toString()}`,
"",
"Build",
`- Version: ${webUiPackage.version}`,
`- Build Date: ${process.env.BUILD_DATE}`,
`- Commit Hash: ${process.env.COMMIT_HASH}`,
`- Environment: ${process.env.BUILD_ENV}`,
`- System: ${process.env.BUILD_OS}`,
"",
"Runtime",
`- Platform: ${browserInfo.platform}`,
`- User Agent: ${browserInfo.userAgent}`,
`- Screen: ${window.screen.width}x${window.screen.height}`,
`- Viewport: ${window.innerWidth}x${window.innerHeight}`,
"",
"Cluster",
`- Url: ${new URL(webRoot, window.location.origin)}`,
`- Hosts: ${hosts.size}`,
`- Instances: ${instances.size}`,
`- Plugins: ${pluginList.length}`,
`- Users: ${users.size}`,
"",
"Account",
`- Name: ${account.name}`,
`- Roles: ${account.roles.map(r => r.name).sort().join(", ") || "None"}`,
`- Permissions: ${permissions.size}`,
"",
"Permission List",
...[...permissions].sort().map(p => `- ${p}`),
"",
];
lines.push("Plugin List");
for (let plugin of pluginList) {
lines.push(`- ${plugin.name}`);
lines.push(` Version: ${plugin.version ?? "unknown"}`);
lines.push(` Loaded: ${plugin.loaded ? "yes" : "no"}`);
lines.push(` Controller: ${plugin.enabled ? "enabled" : "disabled"}`);
lines.push(` Web: ${control.plugins.get(plugin.name) ? "enabled" : "disabled"}`);
if (plugin.npmPackage) {
lines.push(` Package: ${plugin.npmPackage}`);
}
}
return lines.join("\n");
}, [account, hosts, instances, users, pluginList, browserInfo]);
async function copyDebug() {
await navigator.clipboard.writeText(debugText);
message.success("Copied debug info");
}
function forceRefresh() {
window.location.reload();
}
return <Modal open={open} onCancel={onClose} footer={null} title="About Clusterio" width={800}>
<Space direction="vertical" style={{ width: "100%" }} size="large">
<Row gutter={[24, 16]}>
<Col xs={24} md={12}>
<Card title="Build Info">
<Space direction="vertical">
<Text>Version: {`${webUiPackage.version} (${process.env.COMMIT_HASH})`}</Text>
<Text>Date: {new Date(process.env.BUILD_DATE ?? "").toLocaleString()}</Text>
<Text>Environment: {process.env.BUILD_ENV}</Text>
<Text>System: {formatOS(process.env.BUILD_OS)}</Text>
</Space>
</Card>
</Col>
<Col xs={24} md={12}>
<Card title="Cluster Info">
<Space direction="vertical">
<Text>Hosts: {hosts.size}</Text>
<Text>Instances: {instances.size}</Text>
<Text>Plugins: {pluginList.length}</Text>
<Text>Users: {users.size}</Text>
</Space>
</Card>
</Col>
</Row>
<Row>
<Col span={24}>
<Space style={{ width: "100%", justifyContent: "space-between" }} wrap>
<Space wrap>
<Button icon={<InfoCircleOutlined />} onClick={onOpenChangelog}>
Changelog
</Button>
<Button
target="_blank"
href="https://github.com/clusterio/clusterio/issues"
icon={<span style={{ display: "flex", alignItems: "center" }}>
<img src={GitHubLogo} style={{ width: 16 }} />
</span>}
>
GitHub Issues
</Button>
<Button
target="_blank"
href="https://discord.gg/mzAsgnm"
icon={<span style={{ display: "flex", alignItems: "center" }}>
<img src={DiscordLogo} style={{ width: 16}} />
</span>}
>
Support Server
</Button>
</Space>
<Space wrap>
<Button icon={<ReloadOutlined />} onClick={forceRefresh}>
Reload
</Button>
<Button icon={<CopyOutlined />} onClick={copyDebug} type="primary">
Copy Debug
</Button>
</Space>
</Space>
</Col>
</Row>
</Space>
</Modal>;
}
@@ -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
@@ -9,7 +9,7 @@ import * as lib from "@clusterio/lib";
import ControlContext from "./ControlContext";
import { InputComponent, InputComponentProps } from "../BaseWebPlugin";
import type { Control } from "../util/websocket";
import { notifyErrorHandler } from "../util/notify";
import notify from "../util/notify";
const { Title } = Typography;
@@ -183,7 +183,7 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
await props.setConfig(fields);
setChangedFields(new Set());
} catch (err: any) {
notifyErrorHandler(err.message);
notify(err.message, "error");
}
await updateConfig();
@@ -0,0 +1,26 @@
import React from "react";
import { Modal } from "antd";
import ReactMarkdown from "react-markdown";
import changelog from "../../../../CHANGELOG.md";
const cleanedChangelog = changelog
.replace(/<!--[\s\S]*?-->/g, "") // remove comments
.replace(/^#\s+Changelog\s*\n?/i, ""); // remove top-level title
/**
* Displays a modal element which contains the change log
*/
export default function ChangeLogModal({ open, onClose }: any) {
return <Modal
title="Changelog"
open={open}
onCancel={onClose}
footer={null}
width={800}
>
<ReactMarkdown>
{cleanedChangelog}
</ReactMarkdown>
</Modal>;
}
@@ -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">
+5 -2
View File
@@ -2,6 +2,9 @@ import React from "react";
import { Button, Card, Flex, Image, Typography } from "antd";
import example from "../images/console-error-edge.png";
import DiscordLogo from "../images/discord.svg";
import GitHubLogo from "../images/github.svg";
import PageLayout from "./PageLayout";
import type { ErrorProps } from "./App";
@@ -38,12 +41,12 @@ export default function ErrorPage(props: ErrorProps & { throw?: boolean }) {
<SupportLinkCard
name="Github Issues"
href="https://github.com/clusterio/clusterio/issues"
image="https://github.githubassets.com/favicons/favicon-dark.svg"
image={GitHubLogo}
/>
<SupportLinkCard
name="Support Server"
href="https://discord.gg/mzAsgnm"
image="https://cdn.prod.website-files.com/6257adef93867e50d84d30e2/66e3d7f4ef6498ac018f2c55_Symbol.svg"
image={DiscordLogo}
/>
</Flex>
<br/>
@@ -14,7 +14,7 @@ export default function HostConfigTree(props: { id: number, available: boolean }
}
async function setConfig(fields: Record<string, string | Record<string, string>>) {
await control.send(new lib.HostConfigSetRequest(fields));
await control.sendTo({ hostId: props.id }, new lib.HostConfigSetRequest(fields));
}
return <BaseConfigTree
+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 }}>
@@ -0,0 +1,42 @@
import { Select } from "antd";
import { InputComponentProps } from "../BaseWebPlugin";
import { useInstances } from "../model/instance";
interface BaseInputInstanceProps extends InputComponentProps {
includeAll?: boolean;
}
function BaseInputInstance(props: BaseInputInstanceProps) {
const [instances, synced] = useInstances();
const options = [
...(props.includeAll ? [{ label: "All instances", value: null }] : []),
...[...instances.values()].map(instance => ({
label: instance.name ?? `Instance ${instance.id}`,
value: instance.id,
})),
];
return (
<Select
showSearch
optionFilterProp="label"
style={{ minWidth: 175 }}
onChange={value => props.onChange(value ?? null)}
value={props.value}
options={options}
allowClear={props.fieldDefinition.optional}
disabled={!synced || props.disabled}
loading={!synced}
/>
);
}
export function InputInstanceWithAll(props: InputComponentProps) {
return <BaseInputInstance {...props} includeAll />;
}
export function InputInstance(props: InputComponentProps) {
return <BaseInputInstance {...props} />;
}
@@ -5,7 +5,7 @@ import { useModPacks } from "../model/mod_pack";
import { InputComponentProps } from "../BaseWebPlugin";
export default function InputModPack(props: InputComponentProps) {
const [modPacks] = useModPacks();
const [modPacks, synced] = useModPacks();
return <Select
showSearch
optionFilterProp="label"
@@ -17,6 +17,7 @@ export default function InputModPack(props: InputComponentProps) {
value: modPack.id,
}))}
allowClear={props.fieldDefinition.optional}
disabled={props.disabled}
disabled={!synced || props.disabled}
loading={!synced}
/>;
}
@@ -1,68 +0,0 @@
import React, { useContext, useEffect, useState } from "react";
import { TreeSelect } from "antd";
import * as lib from "@clusterio/lib";
import { InputComponentProps } from "../BaseWebPlugin";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
const defaultFieldDefinition: lib.FieldDefinition = { type: "string", optional: false };
export default function InputPartialVersion (
props: Omit<InputComponentProps, "fieldDefinition"> & {
fieldDefinition?: lib.FieldDefinition;
className?: string;
}
) {
const fieldDefinition = props.fieldDefinition || defaultFieldDefinition;
const [versions, setVersions] = useState<readonly lib.PartialVersion[]>(lib.ApiVersions);
const control = useContext(ControlContext);
const account = useAccount();
useEffect(() => {
(async () => {
if (account.hasPermission("core.external.get_factorio_versions")) {
const res = await control.factorioVersions.get(5 * 60 * 1000);
setVersions(res.map(v => v.version));
}
})();
}, [control]);
// Split the versions into groups based on major minor
const groups = new Map<lib.PartialVersion, lib.PartialVersion[]>();
for (const version of versions) {
const [major, minor] = version.split(".");
const key = `${major}.${minor}` as lib.PartialVersion;
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key)!.push(version);
}
// Construct the tree to be disabled
const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => (
{
title: majorMinor,
value: majorMinor,
key: majorMinor,
children: patchVersions.map((v) => ({
title: v,
value: v,
key: v,
})),
}
));
return <TreeSelect
showSearch
style={{ minWidth: 175 }}
onChange={(value) => props.onChange(value ?? null)}
value={props.value}
treeData={tree}
allowClear={fieldDefinition.optional}
className={props.className}
disabled={props.disabled}
/>;
}
+3 -2
View File
@@ -4,7 +4,7 @@ import { InputComponentProps } from "../BaseWebPlugin";
import { useRoles } from "../model/roles";
export default function InputRole(props: InputComponentProps) {
const [roles] = useRoles();
const [roles, synced] = useRoles();
return <Select
showSearch
@@ -17,6 +17,7 @@ export default function InputRole(props: InputComponentProps) {
value: role.id,
}))}
allowClear={props.fieldDefinition.optional}
disabled={props.disabled}
disabled={!synced || props.disabled}
loading={!synced}
/>;
}
@@ -1,71 +0,0 @@
import React, { useContext, useEffect, useState } from "react";
import { TreeSelect } from "antd";
import * as lib from "@clusterio/lib";
import { InputComponentProps } from "../BaseWebPlugin";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
const defaultFieldDefinition: lib.FieldDefinition = { type: "string", optional: false };
export default function InputTargetVersion (
props: Omit<InputComponentProps, "fieldDefinition"> & {
fieldDefinition?: lib.FieldDefinition;
className?: string;
}
) {
const fieldDefinition = props.fieldDefinition || defaultFieldDefinition;
const [versions, setVersions] = useState<readonly lib.TargetVersion[]>(lib.ApiVersions);
const control = useContext(ControlContext);
const account = useAccount();
useEffect(() => {
(async () => {
if (account.hasPermission("core.external.get_factorio_versions")) {
const res = await control.factorioVersions.get(5 * 60 * 1000);
setVersions(res.map(v => v.version));
}
})();
}, [control]);
// Split the versions into groups based on major minor
const groups = new Map<lib.TargetVersion, lib.TargetVersion[]>();
for (const version of versions) {
const [major, minor] = version.split(".");
const key = `${major}.${minor}` as lib.PartialVersion;
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key)!.push(version);
}
// Construct the tree to be disabled
const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => (
{
title: majorMinor,
value: majorMinor,
key: majorMinor,
children: patchVersions.map((v) => ({
title: v,
value: v,
key: v,
})),
}
));
// Add "latest" option for TargetVersion
tree.unshift({ title: "latest", value: "latest", key: "latest", children: [] });
return <TreeSelect
showSearch
style={{ minWidth: 175 }}
onChange={(value) => props.onChange(value ?? null)}
value={props.value}
treeData={tree}
allowClear={fieldDefinition.optional}
className={props.className}
disabled={props.disabled}
/>;
}
@@ -0,0 +1,206 @@
import React, { useContext, useEffect, useState, useRef } from "react";
import { Button, Divider, Input, InputRef, RefSelectProps, TreeSelect } from "antd";
import * as lib from "@clusterio/lib";
import { InputComponentProps } from "../BaseWebPlugin";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
const defaultFieldDefinition: lib.FieldDefinition = { type: "string", optional: false };
function InputVersion<
Version extends lib.TargetVersion,
IncludeLatest extends (Version extends "latest" ? boolean : false),
> (
props: Omit<InputComponentProps, "fieldDefinition"> & {
fieldDefinition?: lib.FieldDefinition;
className?: string;
version: {
isValid: (v: string) => v is Version;
includeLatest: IncludeLatest;
};
}
) {
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);
const selectRef = useRef<RefSelectProps>(null);
const control = useContext(ControlContext);
const account = useAccount();
useEffect(() => {
(async () => {
if (account.hasPermission("core.external.get_factorio_versions")) {
const res = await control.factorioVersions.get(5 * 60 * 1000);
setVersions(res.map(v => v.version));
}
})();
}, [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[]>();
for (const version of versions) {
const [major, minor] = version.split(".");
const key = `${major}.${minor}`;
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key)!.push(version);
}
// Construct the tree to be disabled
const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => (
{
title: majorMinor,
value: majorMinor,
key: majorMinor,
// 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,
})),
}
));
// Add "latest" and release channel options for TargetVersion
if (props.version.includeLatest) {
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
showSearch
ref={selectRef}
style={{ minWidth: 175 }}
onChange={(value) => props.onChange(value ?? null)}
value={props.value}
treeData={tree}
allowClear={fieldDefinition.optional}
className={props.className}
disabled={props.disabled}
open={open}
onOpenChange={newOpen => {
setOpen(newOpen);
if (newOpen && typeof props.value === "string") {
setCustomVersion(props.value);
}
}}
onKeyDown={e => {
if (open && e.key === "Tab") {
e.preventDefault();
inputRef.current?.focus();
}
}}
popupRender={(menu) => {
const isValid = props.version.isValid(customVersion);
const submit = () => {
if (customVersion && isValid) {
setTimeout(() => selectRef.current?.focus(), 0);
props.onChange(customVersion);
setOpen(false);
}
};
return (
<>
{menu}
<Divider style={{ margin: "8px 0" }} />
<div style={{ display: "flex", gap: 8, padding: "0 8px 4px" }}>
<Input
ref={inputRef}
style={{ flex: 1 }}
placeholder="Custom X.Y.Z"
value={customVersion}
status={customVersion && !isValid ? "error" : undefined}
onChange={(e) => setCustomVersion(e.target.value)}
onPressEnter={submit}
/>
<Button
type="primary"
disabled={!customVersion || !isValid}
onClick={submit}
>
Use
</Button>
</div>
</>
);
}}
/>;
}
export function InputTargetVersion (
props: Omit<InputComponentProps, "fieldDefinition"> & {
fieldDefinition?: lib.FieldDefinition;
className?: string;
}
) {
return <InputVersion
{...props}
version={{
isValid: lib.isTargetVersion,
includeLatest: true,
}}
/>;
}
export function InputPartialVersion (
props: Omit<InputComponentProps, "fieldDefinition"> & {
fieldDefinition?: lib.FieldDefinition;
className?: string;
}
) {
return <InputVersion
{...props}
version={{
isValid: lib.isPartialVersion,
includeLatest: false,
}}
/>;
}
export function InputFullVersion (
props: Omit<InputComponentProps, "fieldDefinition"> & {
fieldDefinition?: lib.FieldDefinition;
className?: string;
}
) {
return <InputVersion
{...props}
version={{
isValid: lib.isFullVersion,
includeLatest: false,
}}
/>;
}
@@ -21,6 +21,7 @@ export default function InstanceControlButton(props: InstanceControlButtonProps)
const account = useAccount();
const control = useContext(ControlContext);
const [switching, setSwitching] = useState<boolean>(false);
const [popconfirm, setPopconfirm] = useState<string | null>(null);
const instanceId = props.instance.id;
const instanceStatus = props.instance.status;
@@ -88,35 +89,43 @@ export default function InstanceControlButton(props: InstanceControlButtonProps)
if (account.hasPermission("core.instance.kill")) {
actions.push({
key: "kill",
label: "Kill",
danger: true,
disabled: instanceStatus === "stopped",
label: (<Popconfirm
title={<>
Killing this instance may leave you save and player data corrupted.
Are you sure you want to kill the instance?
</>}
placement="bottomRight"
okText="Kill"
okButtonProps={{ danger: true }}
onConfirm={() => {
control.sendTo(
{ instanceId },
new lib.InstanceKillRequest(),
).catch(
notifyErrorHandler("Error killing instance")
).finally(actionFinish);
}}
>
Kill
</Popconfirm>),
onClick: () => setPopconfirm("kill"),
});
}
return <VariableDropdownButton
{...props}
type="primary"
actions={actions}
loading={switching}
disabled={!(showStart || showStop)}
/>;
return <div style={{ position: "relative", display: "inline-block" }}>
<VariableDropdownButton
{...props}
type="primary"
actions={actions}
loading={switching}
disabled={!(showStart || showStop)}
/>
<Popconfirm
title={<>
Killing this instance may leave you save and player data corrupted.
Are you sure you want to kill the instance?
</>}
open={popconfirm === "kill"}
placement="bottomRight"
okText="Kill"
okButtonProps={{ danger: true }}
onCancel={() => setPopconfirm(null)}
onConfirm={() => {
control.sendTo(
{ instanceId },
new lib.InstanceKillRequest(),
).catch(
notifyErrorHandler("Error killing instance")
).finally(actionFinish);
setPopconfirm(null);
}}
>
{/* Used to position the popup under the button */}
<span style={{ position: "absolute", right: 0, top: "100%", width: 1, height: 1 }} />
</Popconfirm>
</div>;
}
+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} />
@@ -26,7 +26,7 @@ import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import SectionHeader from "./SectionHeader";
import ModDetails from "./ModDetails";
import InputPartialVersion from "./InputPartialVersion";
import { InputPartialVersion } from "./InputVersion";
import Link from "./Link";
const { logger } = lib;
@@ -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>
+45 -17
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,10 +17,14 @@ 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";
import InputPartialVersion from "./InputPartialVersion";
import { InputPartialVersion } from "./InputVersion";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
@@ -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>;
}
@@ -8,6 +8,7 @@ import BaseWebPlugin from "../BaseWebPlugin";
type PluginExtraProps = {
component: string;
search?: string;
instance?: lib.InstanceDetails;
user?: lib.UserDetails;
host?: lib.HostDetails;
+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>;
}
+271 -71
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useContext, useState } from "react";
import React, { useEffect, useContext, useState, useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Button, Checkbox, Form, Input, Popconfirm, Space, Spin } from "antd";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import { Button, Checkbox, Input, Popconfirm, Space, Spin, Tooltip } from "antd";
import { ExclamationCircleOutlined, StarOutlined, DeleteOutlined} from "@ant-design/icons";
import * as lib from "@clusterio/lib";
@@ -13,20 +13,165 @@ import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import { notifyErrorHandler } from "../util/notify";
/** Splits a permission name into parts, removing the last part */
function getGroupName(name: string) {
const parts = name.split(".");
return parts.length <= 2
? parts.slice(0, 2).join(".")
: parts.slice(0, parts.length - 1).join(".");
}
/** Converts a group name into Title Case with _ and . removed */
function formatGroupTitle(name: string) {
return name
.replace(/\./g, " / ")
.replace(/_/g, " ")
.split(" ")
.filter(Boolean)
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
}
/** Check if a permission is dangerous, in the future we might have metadata for this */
function isDangerous(permission: lib.Permission) {
return ["delete", "kill", "stop", "revoke", "update", "core.admin"]
.some(word => permission.name.includes(word));
}
/** A single permission item inside a group; includes a checkbox, name, and description */
function PermissionItem({
permission, baseline, checked, disabled, onChange,
}: {
permission: lib.Permission;
checked: boolean;
baseline: boolean;
disabled: boolean;
onChange: (value: boolean) => void;
}) {
const modified = checked !== baseline;
return (
<div style={{
marginBottom: 8,
padding: modified ? "4px 6px" : undefined,
background: modified ? "#2a1912" : undefined,
borderRadius: 4,
}}>
<div style={{ display: "flex", alignItems: "center" }}>
<Checkbox checked={checked} disabled={disabled} onChange={e => onChange(e.target.checked)}>
<Space size={6}>
<span>{permission.title}</span>
{permission.grantByDefault && (
<Tooltip title="Granted by default">
<StarOutlined style={{ color: "#faad14" }} />
</Tooltip>
)}
{isDangerous(permission) && (
<Tooltip title="Dangerous permission">
<ExclamationCircleOutlined style={{ color: "#ff4d4f" }} />
</Tooltip>
)}
</Space>
</Checkbox>
</div>
<div style={{ marginLeft: 24, color: "#888", fontSize: 12 }}>
{permission.description}
</div>
</div>
);
}
/** A group of permissions; includes a title, clear and select all buttons, and the permission items */
function PermissionGroup({
groupName, permissions, canUpdate, baselineState, permissionState, setPermissionState,
}: {
groupName: string;
permissions: lib.Permission[];
canUpdate: boolean;
baselineState: Record<string, boolean>;
permissionState: Record<string, boolean>;
setPermissionState: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
setEdited?: (value: boolean) => void;
}) {
function setGroup(value: boolean) {
setPermissionState((prev: any) => {
const next = { ...prev };
for (let p of permissions) {
next[p.name] = value;
}
return next;
});
}
return (
<div style={{ border: "1px solid #424242", padding: 12, borderRadius: 6 }}>
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<strong>{formatGroupTitle(groupName)}</strong>
<Space>
<Button size="small" disabled={!canUpdate} onClick={() => setGroup(true)}>Select all</Button>
<Button size="small" disabled={!canUpdate} onClick={() => setGroup(false)}>Clear</Button>
</Space>
</Space>
<div style={{ marginTop: 8 }}>
{permissions.map((p: any) => (
<PermissionItem
key={p.name}
permission={p}
checked={permissionState[p.name]}
baseline={baselineState[p.name]}
disabled={!canUpdate}
onChange={v => setPermissionState((prev: any) => ({ ...prev, [p.name]: v }))}
/>
))}
</div>
</div>
);
}
/** The role page view */
export default function RoleViewPage() {
let params = useParams();
let roleId = Number(params.id);
const params = useParams();
const roleId = Number(params.id);
let navigate = useNavigate();
let account = useAccount();
let control = useContext(ControlContext);
const navigate = useNavigate();
const account = useAccount();
const control = useContext(ControlContext);
const [role, synced] = useRole(roleId);
let [edited, setEdited] = useState(false);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [permissionState, setPermissionState] = useState<Record<string, boolean>>({});
const [baselineState, setBaselineState] = useState<Record<string, boolean>>({});
const [search, setSearch] = useState("");
let nav = [{ name: "Roles", path: "/roles" }, { name: role?.name ?? String(roleId) }];
const allPermissions = useMemo(() => [...lib.permissions.values()], []);
const canUpdate = Boolean(account.hasPermission("core.role.update"));
useEffect(() => {
if (!role) {
return;
}
const base = Object.fromEntries(
allPermissions.map(p => [p.name, role.permissions.has(p.name)])
);
setName(role.name);
setDescription(role.description);
setPermissionState(base);
setBaselineState(base);
}, [role, allPermissions]);
// If no role then display either the loading screen or the not found screen
if (!role) {
const nav = [{ name: "Roles", path: "/roles" }, { name: String(roleId) }];
if (!synced) {
return <PageLayout nav={nav}>
<PageHeader title={String(roleId)} />
@@ -40,81 +185,136 @@ export default function RoleViewPage() {
</PageLayout>;
}
// TODO: Update displayed perms when role perms change and have not been edited locally
let initialValues = {
name: role["name"],
description: role["description"],
permissions: {
...Object.fromEntries([...lib.permissions.values()].map(perm => [
perm.name, [...role.permissions].includes(perm.name),
])),
},
};
// Apply the filter by searching all permission fields
const filtered = allPermissions.filter(p => {
const q = search.toLowerCase();
return p.name.toLowerCase().includes(q)
|| p.title.toLowerCase().includes(q)
|| p.description.toLowerCase().includes(q);
});
let canUpdate = account.hasPermission("core.role.update");
return <PageLayout nav={nav}>
<Form
initialValues={initialValues}
onValuesChange={() => { setEdited(true); }}
onFinish={values => {
let newPermissions = [];
for (let [name, value] of Object.entries(values.permissions)) {
if (value) { newPermissions.push(name); }
}
// Organise the permissions into groups
const groups = new Map<string, typeof filtered>();
for (let p of filtered) {
const g = getGroupName(p.name);
if (!groups.has(g)) {
groups.set(g, []);
}
groups.get(g)!.push(p);
}
control.send(
new lib.RoleUpdateRequest(roleId, values.name || "", values.description || "", newPermissions)
).then(() => {
setEdited(false);
}).catch(notifyErrorHandler("Error applying changes"));
}}
>
// Sort permissions alphabetically within their groups
const sortedGroups = [...groups.entries()].sort(([a], [b]) => a.localeCompare(b));
for (let [, permissions] of sortedGroups) {
permissions.sort((a, b) => a.title.localeCompare(b.title));
}
// Check if any changes have been made to the permissions or role metadata
const permissionChanged = Object.keys(permissionState).some(k => permissionState[k] !== baselineState[k]);
const metaChanged = name !== role.name || description !== role.description;
const edited = permissionChanged || metaChanged;
/** Applies all pending changes */
function applyChanges() {
const newPermissions = Object.entries(permissionState).filter(([, v]) => v).map(([k]) => k);
control.send(new lib.RoleUpdateRequest(roleId, name || "", description || "", newPermissions))
.then(() => {
setBaselineState(permissionState);
})
.catch(notifyErrorHandler("Error applying changes"));
}
/** Revert any pending changes */
function revertChanges() {
setPermissionState(baselineState);
if (role) {
setName(role.name);
setDescription(role.description);
}
}
return (
<PageLayout nav={[{ name: "Roles", path: "/roles" }, { name: role.name }]}>
<PageHeader
title={role.name}
extra={<Space wrap>
{canUpdate && <Button type={edited ? "primary" : "default"} htmlType="submit">Apply</Button>}
{account.hasPermission("core.role.delete") && <Popconfirm
title="Delete role?"
placement="bottomRight"
okText="Delete"
okButtonProps={{ danger: true }}
onConfirm={() => {
control.send(
new lib.RoleDeleteRequest(roleId)
).then(() => {
navigate("/roles");
}).catch(notifyErrorHandler("Error deleting role"));
control.send(new lib.RoleDeleteRequest(roleId))
.then(() => navigate("/roles"))
.catch(notifyErrorHandler("Error deleting role"));
}}
>
<Button danger >
<DeleteOutlined />
</Button>
<Button danger><DeleteOutlined /></Button>
</Popconfirm>}
</Space>}
/>
<Form.Item name="name" label="Name">
<Input disabled={!canUpdate}/>
</Form.Item>
<Form.Item name="description" label="Description">
<Input disabled={!canUpdate}/>
</Form.Item>
{edited && (
<div style={{
position: "fixed",
bottom: 24,
left: "50%",
transform: "translateX(-50%)",
background: "#2a1912",
borderRadius: 8,
boxShadow: "0 8px 24px rgba(0,0,0,0.12)",
padding: "10px 14px",
display: "flex",
alignItems: "center",
gap: 12,
zIndex: 1000,
}}>
<span style={{ color: "#FFF" }}>
You have unsaved changes
</span>
<Space>
<Button onClick={revertChanges}>
Revert
</Button>
<Button type="primary" onClick={applyChanges}>
Apply
</Button>
</Space>
</div>
)}
<div style={{ marginBottom: 16, display: "flex", alignItems: "center", gap: 12 }}>
<label style={{ width: 110, flexShrink: 0 }}>Name</label>
<Input value={name} disabled={!canUpdate} onChange={e => setName(e.target.value)} />
</div>
<div style={{ marginBottom: 16, display: "flex", alignItems: "center", gap: 12 }}>
<label style={{ width: 110, flexShrink: 0 }}>Description</label>
<Input value={description} disabled={!canUpdate} onChange={e => setDescription(e.target.value)} />
</div>
<h3>Permissions</h3>
{[...lib.permissions.values()].map(({name, title, description}) => (
<Form.Item
name={["permissions", name]}
key={name}
label={title}
tooltip={description}
valuePropName="checked"
labelCol={{ sm: { span: 22, push: 2 }}}
wrapperCol={{ sm: { span: 2, pull: 22 }}}
labelAlign="left"
colon={false}
>
<Checkbox disabled={!canUpdate} />
</Form.Item>
))}
</Form>
<PluginExtra component="RoleViewPage" role={role} />
</PageLayout>;
<Input.Search placeholder="Search permissions" allowClear
onChange={e => setSearch(e.target.value)} style={{ marginBottom: 16 }} />
<Space direction="vertical" style={{ width: "100%" }}>
{sortedGroups.map(([groupName, permissions]) => (
<PermissionGroup
key={groupName}
groupName={groupName}
permissions={permissions}
permissionState={permissionState}
baselineState={baselineState}
setPermissionState={setPermissionState}
canUpdate={canUpdate}
/>
))}
</Space>
<PluginExtra component="RoleViewPage" role={role} search={search}/>
</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,
+142 -47
View File
@@ -1,22 +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 from "@ant-design/icons/UserOutlined";
import webUiPackage from "../../package.json";
import { Button, Drawer, Dropdown, Flex, Grid, Layout, Menu, MenuProps, Tooltip, Typography } from "antd";
import { UserOutlined, DownloadOutlined, MenuOutlined } from "@ant-design/icons";
import { useAccount } from "../model/account";
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 { DraggingContext } from "../model/is_dragging";
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";
import { DownloadOutlined } from "@ant-design/icons";
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) {
@@ -39,10 +52,20 @@ 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);
const [dragging, setDragging] = useState(0);
const [aboutOpen, setAboutOpen] = useState(false);
const [wasAboutOpen, setWasAboutOpen] = useState(false);
const [changeLogOpen, setChangeLogOpen] = useState(false);
function SetSidebar(props: { path: string | null }) {
useEffect(() => {
@@ -61,18 +84,27 @@ export default function SiteLayout() {
"control.controller_url": (new URL(webRoot, document.location.href)).href,
});
saveJson("config-control.json", config.toJSON());
} else if (key === "about") {
setAboutOpen(true);
} else if (key === "logOut") {
account.logOut();
}
},
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 />} />
</Tooltip>,
key: "ctlConfig",
},
{ label: "About", key: "about" },
{ type: "divider" },
{ label: "Log out", danger: true, key: "logOut" },
],
@@ -101,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) {
@@ -117,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)}
@@ -141,44 +205,75 @@ export default function SiteLayout() {
}
}}
>
<AboutModal
open={aboutOpen}
onClose={() => {
setAboutOpen(false);
setWasAboutOpen(false);
}}
onOpenChangelog={() => {
setChangeLogOpen(true);
setWasAboutOpen(aboutOpen);
setAboutOpen(false);
}}
/>
<ChangeLogModal
open={changeLogOpen}
onClose={() => {
setChangeLogOpen(false);
setAboutOpen(wasAboutOpen);
}}
/>
<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}
</Typography.Text>
</Tooltip>
</Flex>
{accountMenu}
</Header>
<DraggingContext.Provider value={dragging > 0}>
<Header className="header">
<div className="site-logo" />
<span className="site-name">Clusterio</span>
<span className="site-version">{webUiPackage.version}</span>
<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>
<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>;
@@ -0,0 +1,221 @@
import React, { useRef } from "react";
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 {
search?: string;
admin?: boolean;
whitelisted?: boolean;
banned?: boolean;
}
function encodeFilter(value: UserFilter): string {
return JSON.stringify(value);
}
function decodeFilter(value: string): UserFilter {
try {
return JSON.parse(value);
} catch {
return {};
}
}
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"; }
return "any";
}
function segmentedToTriState(value: string): boolean | undefined {
if (value === "yes") { return true; }
if (value === "no") { return false; }
return undefined;
}
function matchesTriState(value: boolean, filter?: boolean) {
return filter === undefined || value === filter;
}
export function onFilterUser(
value: boolean | React.Key,
record: UserDetails
) {
const filter = decodeFilter(String(value));
// Name filter
if (filter.search) {
if (!record.name.toLowerCase().includes(filter.search.toLowerCase())) {
return false;
}
}
// Status filter
return matchesTriState(record.isAdmin, filter.admin)
&& matchesTriState(record.isWhitelisted, filter.whitelisted)
&& matchesTriState(record.isBanned, filter.banned);
}
export function Username({
user,
withStatus = false,
} : {
user: UserDetails,
withStatus: boolean,
}) {
return (
<Space>
{user.name}
{withStatus && (
<span>
{user.isAdmin && <Tag color="gold">Admin</Tag>}
{user.isWhitelisted && <Tag>Whitelisted</Tag>}
{user.isBanned && <Tag color="red">Banned</Tag>}
</span>
)}
</Space>
);
}
export function useUserFilter(
tableState: TableQueryState<UserDetails>,
columnKey: string,
withStatus?: boolean,
) {
const searchInput = useRef<InputRef>(null);
function filterDropdown({
selectedKeys,
setSelectedKeys,
close,
}: FilterDropdownProps) {
const filter: UserFilter = 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 });
const values = encoded === "{}" ? null : [encoded];
setSelectedKeys(values ?? []);
tableState.setFilter(columnKey, values);
}
return (
<div style={{ padding: 8, width: 240 }} onKeyDown={(e) => e.stopPropagation()}>
<Space direction="vertical" style={{ width: "100%" }}>
<Input.Search
ref={searchInput}
placeholder="Search username"
value={filter.search}
allowClear
onPressEnter={e => {
close();
tableState.commitFilter(columnKey);
}}
onChange={(e) => update({ search: e.target.value !== "" ? e.target.value : undefined })}
/>
{withStatus &&<>
<Space size={8} align="center">
<Segmented
size="small"
value={triStateToSegmented(filter.admin)}
onChange={value => update({ admin: segmentedToTriState(value) })}
options={[
{ icon: <CloseOutlined />, value: "no", className: "seg-no" },
{ icon: <MinusOutlined />, value: "any" },
{ icon: <CheckOutlined />, value: "yes", className: "seg-yes" },
]}
/>
<Text>Admin</Text>
</Space>
<Space size={8} align="center">
<Segmented
size="small"
value={triStateToSegmented(filter.whitelisted)}
onChange={value => update({ whitelisted: segmentedToTriState(value) })}
options={[
{ icon: <CloseOutlined />, value: "no", className: "seg-no" },
{ icon: <MinusOutlined />, value: "any" },
{ icon: <CheckOutlined />, value: "yes", className: "seg-yes" },
]}
/>
<Text>Whitelist</Text>
</Space>
<Space size={8} align="center">
<Segmented
size="small"
value={triStateToSegmented(filter.banned)}
onChange={value => update({ banned: segmentedToTriState(value) })}
options={[
{ icon: <CloseOutlined />, value: "no", className: "seg-no" },
{ icon: <MinusOutlined />, value: "any" },
{ icon: <CheckOutlined />, value: "yes", className: "seg-yes" },
]}
/>
<Text>Banned</Text>
</Space>
</>}
</Space>
</div>
);
}
const filterDropdownProps = {
onOpenChange: (open: boolean) => {
if (open) {
setTimeout(() => searchInput.current?.select(), 100);
} else {
tableState.commitFilter(columnKey);
}
},
};
return { filterDropdown, filterDropdownProps };
}
+15 -6
View File
@@ -19,6 +19,7 @@ import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import UsersTable from "./UsersTable";
import { InputInstanceWithAll } from "./InputInstance";
function CreateUserButton() {
let control = useContext(ControlContext);
@@ -49,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">
@@ -344,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">
@@ -366,18 +367,26 @@ function BulkUserActionButton() {
}
export default function UsersPage() {
let account = useAccount();
const account = useAccount();
const [instanceId, setInstanceId] = useState<number | null>(null);
return <PageLayout nav={[{ name: "Users" }]}>
<PageHeader
title="Users"
extra={<Space>
{account.hasPermission("core.user.create") ? <CreateUserButton /> : undefined}
{account.hasAllPermission("core.instance.list", "core.instance.subscribe")
&& <InputInstanceWithAll
value={instanceId}
onChange={value => setInstanceId(value as number)}
fieldDefinition={{ optional: true } as any}
disabled={false}
/>}
{account.hasPermission("core.user.create") && <CreateUserButton />}
{account.hasAnyPermission("core.user.bulk_import", "core.user.bulk_export")
? <BulkUserActionButton /> : undefined}
&& <BulkUserActionButton />}
</Space>}
/>
<UsersTable />
<UsersTable instanceId={instanceId ?? undefined}/>
<PluginExtra component="UsersPage" />
</PageLayout>;
}
+140 -196
View File
@@ -1,113 +1,123 @@
import React, { useRef } from "react";
import { Table, Tag, Space, Input, InputRef } from "antd";
import type { FilterDropdownProps } from "antd/es/table/interface";
import React from "react";
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";
import { useRoles } from "../model/roles";
import { formatDuration } from "../util/time_format";
import {
formatFirstSeen,
formatLastSeen,
sortFirstSeen,
sortLastSeen,
useUsers,
calculateFirstSeen, formatFirstSeen, sortFirstSeen,
calculateLastSeen, formatLastSeen, sortLastSeen,
useUsers, getUserStats, isUserOnline,
} from "../model/user";
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, instance-specific columns (Play Time, Join Count, First Seen) are shown.
* – If omitted, global player statistics columns (Online Time, First/Last Seen) are shown.
*/
/** Optional instance id. If provided, stats will be filtered to that instance only */
instanceId?: number;
/** 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) {
const [roles] = useRoles();
const navigate = useNavigate();
const searchInput = useRef<InputRef>(null);
export default function UsersTable(
{ instanceId, onlyOnline = false, pagination, size }: UsersTableProps
) {
const [roles, rolesSynced] = useRoles();
const [users, usersSynced] = useUsers();
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 [users] = useUsers();
const {filterDropdown, filterDropdownProps} = useUserFilter(tableState, "name", true);
const data = [...users.values()];
// Determine online predicate based on instanceId
const isUserOnline = (user: lib.UserDetails) => {
if (instanceId !== undefined) {
return user.instances && user.instances.has(instanceId);
}
return user.instances && user.instances.size > 0;
};
// Prepare filters for roles column
const roleFilters = [...roles.values()].map(role => ({ text: role.name, value: role.id }));
const durationFilters = [
{ text: "Online", value: "online" },
{ text: "24h", value: "24h" },
{ text: "7d", value: "7d" },
{ text: "30d", value: "30d" },
{ text: "Anytime", value: "any" },
];
function onFilterDuration(
value: string,
record: lib.UserDetails,
calculateDuration: (user: lib.UserDetails, instanceId?: number) => number | undefined,
) {
const online = isUserOnline(record, instanceId);
if (value === "online") {
return online;
}
// Online players should appear in all buckets
if (online) {
return true;
}
const ts = calculateDuration(record, instanceId);
if (!ts) { return false; }
const diff = Date.now() - ts;
switch (value) {
case "24h":
return diff <= 24 * 60 * 60 * 1000;
case "7d":
return diff <= 7 * 24 * 60 * 60 * 1000;
case "30d":
return diff <= 30 * 24 * 60 * 60 * 1000;
case "any":
return true;
default:
return false;
}
}
const columns: any[] = [
{
title: "Name",
key: "name",
className: "table-link-cell",
render: (_: any, user: lib.UserDetails) => (
<Space>
{user.name}
<span>
{user.isAdmin && <Tag color="gold">Admin</Tag>}
{user.isWhitelisted && <Tag>Whitelisted</Tag>}
{user.isBanned && <Tag color="red">Banned</Tag>}
</span>
</Space>
<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 }} />
),
onFilter: (value: string | number | boolean, record: lib.UserDetails) => record.name
.toLowerCase()
.includes((value as string).toLowerCase()),
filterDropdownProps: {
onOpenChange: (open: boolean) => open && setTimeout(() => searchInput.current?.select(), 100),
},
filterDropdown: ({ selectedKeys, setSelectedKeys, confirm, clearFilters }: {
selectedKeys: string[],
setSelectedKeys: (keys: string[]) => void,
confirm: FilterDropdownProps["confirm"],
clearFilters: FilterDropdownProps["clearFilters"],
}) => (
<div style={{ padding: 4 }} onKeyDown={(e) => e.stopPropagation()}>
<Input.Search
allowClear
ref={searchInput}
placeholder={"Search username"}
value={selectedKeys[0]}
onChange={(e) => setSelectedKeys([e.target.value])}
onSearch={() => confirm({ closeDropdown: false })}
onClear={() => {
clearFilters?.({ closeDropdown: false });
confirm({ closeDropdown: true });
}}
/>
</div>
),
onFilter: onFilterUser,
filterDropdownProps,
filterDropdown,
},
{
title: "Roles",
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) => (
@@ -117,149 +127,83 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
))
),
},
];
// Helper to get last seen timestamp
function getLastSeenTimestamp(user: lib.UserDetails): number | undefined {
const stats = instanceId !== undefined ? user.instanceStats.get(instanceId) : user.playerStats;
if (!stats) { return undefined; }
if (stats.lastLeaveAt && stats.lastLeaveAt.getTime() > (stats.lastJoinAt?.getTime() ?? 0)) {
return stats.lastLeaveAt.getTime();
}
if (stats.lastJoinAt) {
return stats.lastJoinAt.getTime();
}
return undefined;
}
if (instanceId !== undefined) {
columns.push(
{
title: "Play Time",
key: "playTime",
render: (_: any, user: lib.UserDetails) => {
const instanceStats = user.instanceStats.get(instanceId);
return instanceStats?.onlineTimeMs ? formatDuration(instanceStats.onlineTimeMs) : "-";
},
sorter: (a: lib.UserDetails, b: lib.UserDetails) => {
const statsA = a.instanceStats.get(instanceId);
const statsB = b.instanceStats.get(instanceId);
return (statsA?.onlineTimeMs ?? 0) - (statsB?.onlineTimeMs ?? 0);
},
},
{
title: "Join Count",
key: "joinCount",
render: (_: any, user: lib.UserDetails) => {
const instanceStats = user.instanceStats.get(instanceId);
return instanceStats?.joinCount ?? 0;
},
sorter: (a: lib.UserDetails, b: lib.UserDetails) => {
const statsA = a.instanceStats.get(instanceId);
const statsB = b.instanceStats.get(instanceId);
return (statsA?.joinCount ?? 0) - (statsB?.joinCount ?? 0);
},
},
{
title: "First Seen",
key: "firstSeen",
render: (_: any, user: lib.UserDetails) => formatFirstSeen(user, instanceId),
sorter: (a: lib.UserDetails, b: lib.UserDetails) => {
const statsA = a.instanceStats.get(instanceId);
const statsB = b.instanceStats.get(instanceId);
const firstSeenA = statsA?.firstJoinAt?.getTime() ?? 0;
const firstSeenB = statsB?.firstJoinAt?.getTime() ?? 0;
return firstSeenA - firstSeenB;
},
},
);
} else {
columns.push(
{
title: "Online time",
key: "onlineTime",
render: (_: any, user: lib.UserDetails) => (user.playerStats?.onlineTimeMs
? formatDuration(user.playerStats.onlineTimeMs)
: null),
// eslint-disable-next-line max-len
sorter: (a: lib.UserDetails, b: lib.UserDetails) => (a.playerStats?.onlineTimeMs ?? 0) - (b.playerStats?.onlineTimeMs ?? 0),
responsive: ["lg"],
},
{
title: "First seen",
key: "firstSeen",
render: (_: any, user: lib.UserDetails) => formatFirstSeen(user),
sorter: (a: lib.UserDetails, b: lib.UserDetails) => sortFirstSeen(a, b),
},
);
}
columns.push(
{
title: "Last seen",
title: "Online Time",
key: "onlineTime",
render: (_: any, user: lib.UserDetails) => {
const userStats = getUserStats(user, instanceId);
return userStats?.onlineTimeMs ? formatDuration(userStats.onlineTimeMs) : "-";
},
sorter: (a: lib.UserDetails, b: lib.UserDetails) => {
const statsA = getUserStats(a, instanceId);
const statsB = getUserStats(b, instanceId);
return (statsA?.onlineTimeMs ?? 0) - (statsB?.onlineTimeMs ?? 0);
},
sortOrder: tableState.sortOrder("onlineTime"),
},
{
title: "Join Count",
key: "joinCount",
render: (_: any, user: lib.UserDetails) => (
getUserStats(user, instanceId)?.joinCount ?? 0
),
sorter: (a: lib.UserDetails, b: lib.UserDetails) => {
const statsA = getUserStats(a, instanceId);
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,
defaultFilteredValue: onlyOnline ? ["online"] : undefined,
filters: [
{ text: "Online", value: "online" },
{ text: "24h", value: "24h" },
{ text: "7d", value: "7d" },
{ text: "30d", value: "30d" },
],
onFilter: (value: string | number | boolean, record: lib.UserDetails) => {
const online = isUserOnline(record);
if (value === "online") {
return online;
}
// Online players should appear in all buckets
if (online) {
return true;
}
const ts = getLastSeenTimestamp(record);
if (!ts) { return false; }
const diff = Date.now() - ts;
switch (value) {
case "24h":
return diff <= 24 * 60 * 60 * 1000;
case "7d":
return diff <= 7 * 24 * 60 * 60 * 1000;
case "30d":
return diff <= 30 * 24 * 60 * 60 * 1000;
default:
return false;
}
},
filters: durationFilters,
// 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: ["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" }}
onRow={(user) => ({
onClick: (event) => {
if ((event.target as HTMLElement).closest("a")) {
return;
}
navigate(`/users/${user.name}/view`);
},
})}
loading={!usersSynced || !rolesSynced}
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})`;
}
+3
View File
@@ -0,0 +1,3 @@
<svg width="64" height="48" viewBox="0 0 64 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M40.575 0C39.9562 1.09866 39.4006 2.2352 38.8954 3.397C34.0967 2.67719 29.2096 2.67719 24.3982 3.397C23.9057 2.2352 23.3374 1.09866 22.7186 0C18.2104 0.770324 13.8157 2.12155 9.64839 4.02841C1.38951 16.2652 -0.845688 28.1863 0.265599 39.9432C5.10222 43.517 10.5197 46.2447 16.2909 47.9874C17.5916 46.2447 18.7407 44.3883 19.7257 42.4562C17.8568 41.7616 16.0509 40.8903 14.3208 39.88C14.7755 39.5517 15.2175 39.2107 15.6468 38.8824C25.7873 43.6559 37.5316 43.6559 47.6847 38.8824C48.1141 39.236 48.5561 39.577 49.0107 39.88C47.2806 40.9029 45.4748 41.7616 43.5931 42.4688C44.5781 44.4009 45.7273 46.2573 47.028 48C52.7991 46.2573 58.2167 43.5422 63.0533 39.9684C64.3666 26.3299 60.8055 14.5099 53.6452 4.04104C49.4905 2.13418 45.0959 0.782952 40.5876 0.0252565L40.575 0ZM21.1401 32.7072C18.0209 32.7072 15.4321 29.8785 15.4321 26.3804C15.4321 22.8824 17.9199 20.041 21.1275 20.041C24.3351 20.041 26.886 22.895 26.8354 26.3804C26.7849 29.8658 24.3224 32.7072 21.1401 32.7072ZM42.1788 32.7072C39.047 32.7072 36.4834 29.8785 36.4834 26.3804C36.4834 22.8824 38.9712 20.041 42.1788 20.041C45.3864 20.041 47.9246 22.895 47.8741 26.3804C47.8236 29.8658 45.3611 32.7072 42.1788 32.7072Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 0C7.16 0 0 7.16 0 16C0 23.08 4.58 29.06 10.94 31.18C11.74 31.32 12.04 30.84 12.04 30.42C12.04 30.04 12.02 28.78 12.02 27.44C8 28.18 6.96 26.46 6.64 25.56C6.46 25.1 5.68 23.68 5 23.3C4.44 23 3.64 22.26 4.98 22.24C6.24 22.22 7.14 23.4 7.44 23.88C8.88 26.3 11.18 25.62 12.1 25.2C12.24 24.16 12.66 23.46 13.12 23.06C9.56 22.66 5.84 21.28 5.84 15.16C5.84 13.42 6.46 11.98 7.48 10.86C7.32 10.46 6.76 8.82 7.64 6.62C7.64 6.62 8.98 6.2 12.04 8.26C13.32 7.9 14.68 7.72 16.04 7.72C17.4 7.72 18.76 7.9 20.04 8.26C23.1 6.18 24.44 6.62 24.44 6.62C25.32 8.82 24.76 10.46 24.6 10.86C25.62 11.98 26.24 13.4 26.24 15.16C26.24 21.3 22.5 22.66 18.94 23.06C19.52 23.56 20.02 24.52 20.02 26.02C20.02 28.16 20 29.88 20 30.42C20 30.84 20.3 31.34 21.1 31.18C27.42 29.06 32 23.06 32 16C32 7.16 24.84 0 16 0V0Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 958 B

+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";
+12 -5
View File
@@ -4,20 +4,27 @@ import ControlContext from "../components/ControlContext";
import * as lib from "@clusterio/lib";
function getInstanceStats(user: lib.UserDetails, instanceId?: number) {
export function getUserStats(user: lib.UserDetails, instanceId?: number) {
if (instanceId === undefined) {
return user.playerStats;
}
return user.instanceStats.get(instanceId);
}
function calculateFirstSeen(user: lib.UserDetails, instanceId?: number) {
const stats = getInstanceStats(user, instanceId);
export function isUserOnline(user: lib.UserDetails, instanceId?: number) {
if (instanceId === undefined) {
return user.instances && user.instances.size > 0;
}
return user.instances && user.instances.has(instanceId);
}
export function calculateFirstSeen(user: lib.UserDetails, instanceId?: number) {
const stats = getUserStats(user, instanceId);
return stats?.firstJoinAt?.getTime();
}
function calculateLastSeen(user: lib.UserDetails, instanceId?: number) {
const stats = getInstanceStats(user, instanceId);
export function calculateLastSeen(user: lib.UserDetails, instanceId?: number) {
const stats = getUserStats(user, instanceId);
if (!stats) {
return undefined;
}
+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",
+69 -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 {
@@ -409,3 +368,30 @@ tr.ant-table-row.deleted {
border-radius: 8px;
max-width: 384px;
}
/**
* Tri-state segmented control:
* represents include, exclude, or no filter
*/
.seg-yes {
color: #389e0d;
}
.seg-no {
color: #cf1322;
}
.ant-segmented-item-selected.seg-yes {
background-color: #389e0d !important;
color: white !important;
}
.ant-segmented-item-selected.seg-no {
background-color: #cf1322 !important;
color: white !important;
}
.seg-yes:hover {
background-color: #306317;
}
.seg-no:hover {
background-color: #7a2e2f;
}
+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,
};
}
+15 -8
View File
@@ -60,18 +60,21 @@ export class Control extends lib.Link {
logHandlers: Map<lib.LogFilter, logHandler[]> = new Map();
/** Updates handled by the subscription service */
systems = new lib.EventSubscriber(lib.SystemInfoUpdateEvent, this);
hosts = new lib.EventSubscriber(lib.HostUpdatesEvent, this);
instances = new lib.EventSubscriber(lib.InstanceDetailsUpdatesEvent, this);
saves = new lib.EventSubscriber(lib.InstanceSaveDetailsUpdatesEvent, this);
modPacks = new lib.EventSubscriber(lib.ModPackUpdatesEvent, this);
mods = new lib.EventSubscriber(lib.ModUpdatesEvent, this);
users = new lib.EventSubscriber(lib.UserUpdatesEvent, this);
roles = new lib.EventSubscriber(lib.RoleUpdatesEvent, this);
systems = new lib.MapSubscriber(lib.SystemInfoUpdateEvent, this);
hosts = new lib.MapSubscriber(lib.HostUpdatesEvent, this);
instances = new lib.MapSubscriber(lib.InstanceDetailsUpdatesEvent, this);
saves = new lib.MapSubscriber(lib.InstanceSaveDetailsUpdatesEvent, this);
modPacks = new lib.MapSubscriber(lib.ModPackUpdatesEvent, this);
mods = new lib.MapSubscriber(lib.ModUpdatesEvent, this);
users = new lib.MapSubscriber(lib.UserUpdatesEvent, this);
roles = new lib.MapSubscriber(lib.RoleUpdatesEvent, this);
/** 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();
+23
View File
@@ -1,9 +1,20 @@
"use strict";
const os = require("os");
const { execSync } = require("child_process");
const webpack = require("webpack");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const { WebpackManifestPlugin } = require("webpack-manifest-plugin");
const TerserPlugin = require("terser-webpack-plugin");
function getCommitHash() {
try {
return execSync("git rev-parse --short HEAD").toString().trim();
} catch {
return "unknown";
}
}
module.exports = (env = {}, argv = {}) => ({
mode: argv.mode ?? "development",
devtool: argv.mode === "production" ? "source-map" : "eval-source-map",
@@ -29,6 +40,10 @@ module.exports = (env = {}, argv = {}) => ({
new webpack.DefinePlugin({
"process.env.APP_ENV": JSON.stringify("browser"),
"process.env.COMMIT_HASH": JSON.stringify(getCommitHash()),
"process.env.BUILD_DATE": JSON.stringify(new Date().toISOString()),
"process.env.BUILD_ENV": JSON.stringify(process.env.NODE_ENV ?? "dev"),
"process.env.BUILD_OS": JSON.stringify(os.platform()),
}),
// Make sure ant-design icons use the ES variant
@@ -114,6 +129,14 @@ module.exports = (env = {}, argv = {}) => ({
test: /\.png$/,
type: "asset/resource",
},
{
test: /\.svg$/,
type: "asset/resource",
},
{
test: /\.md$/,
type: "asset/source",
},
],
},
resolve: {
+10 -10
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-global_chat",
"version": "2.0.0-alpha.24",
"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,22 +20,22 @@
"url": "https://github.com/clusterio/clusterio/issues"
},
"engines": {
"node": ">=20"
"node": ">=22"
},
"peerDependencies": {
"@clusterio/web_ui": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/ctl": "workspace:*",
"@clusterio/lib": "workspace:*"
"@clusterio/web_ui": "workspace:^",
"@clusterio/host": "workspace:^",
"@clusterio/ctl": "workspace:^",
"@clusterio/lib": "workspace:^"
},
"dependencies": {
"@sinclair/typebox": "catalog:"
},
"devDependencies": {
"@clusterio/web_ui": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/ctl": "workspace:*",
"@clusterio/lib": "workspace:*",
"@clusterio/web_ui": "workspace:^",
"@clusterio/host": "workspace:^",
"@clusterio/ctl": "workspace:^",
"@clusterio/lib": "workspace:^",
"@types/node": "catalog:",
"@types/react": "catalog:",
"typescript": "catalog:",
@@ -16,6 +16,8 @@ local get_script_data = require("modules/inventory_sync/get_script_data")
local progress_dialog = require("modules/inventory_sync/gui/progress_dialog")
local dialog_failed_download = require("modules/inventory_sync/gui/dialog_failed_download")
local v2_remote_controller = compat.version_ge("2.0.0")
-- Returns true if the player is currently in a cutscene
local function is_in_cutscene(player)
return player.controller_type == defines.controllers.cutscene
@@ -116,9 +118,9 @@ function inventory_sync.deserialize_player(player, finished_record)
-- Transfer items from stashed inventory
if stashed_corpse then
local main = player.get_main_inventory()
local stash = stashed_corpse.get_inventory(defines.inventory.character_corpse)
if main then
if player.character then
local main = player.character.get_main_inventory()
for i = 1, #stash do
-- Try transferring a stack
local source = stash[i]
@@ -312,7 +314,7 @@ function inventory_sync.sync_player(acquire_response)
end
function inventory_sync.finish_download(player, finished_record)
local status, result = pcall(inventory_sync.deserialize_player, player, finished_record)
local status, result = xpcall(inventory_sync.deserialize_player, debug.traceback, player, finished_record)
if not status then
log("ERROR: Deserializing player " .. player.name .. " failed: " .. result)
player.print("ERROR: Deserializing player data failed: " .. result)
@@ -382,7 +384,7 @@ inventory_sync.events[defines.events.on_pre_player_left_game] = function(event)
end
player_record.generation = player_record.generation + 1
local status, result = pcall(inventory_sync.serialize_player, player, player_record)
local status, result = xpcall(inventory_sync.serialize_player, debug.traceback, player, player_record)
if not status then
log("ERROR: Serializing player " .. player.name .. " failed: " .. result)
player.print("ERROR: Serializing player data failed: " .. result)
@@ -440,8 +442,13 @@ function inventory_sync.initiate_inventory_download(player, player_record, gener
-- player data is downloading
if player_record.sync then
-- Store original position to teleport back to
record.surface = player.surface
record.position = player.position
if v2_remote_controller then
record.surface = player.physical_surface
record.position = player.physical_position
else
record.surface = player.surface
record.position = player.position
end
if player.driving then
record.vehicle = player.vehicle
end
@@ -1,9 +1,18 @@
local compat = require("modules/clusterio/compat")
local can_enter_vehicle = {
[defines.controllers.character] = true,
[defines.controllers.god] = true,
[defines.controllers.editor] = true,
}
if defines.controllers.remote then
-- Does not exist in 1.1, so we need this nil check
can_enter_vehicle[defines.controllers.remote] = true
end
local v2_surface_platform = compat.version_ge("2.0.0")
--- @param player LuaPlayer
--- @param record table
return function(player, record)
@@ -20,6 +29,8 @@ return function(player, record)
player.teleport(safe_position, player.surface)
end
end
elseif v2_surface_platform and record.surface and record.surface.platform and can_enter_vehicle[player.controller_type] then
player.enter_space_platform(record.surface.platform)
elseif record.surface and record.position then
player.teleport(record.position, record.surface)
end
+227 -54
View File
@@ -6,7 +6,10 @@ local serialize = {}
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 = {}
@@ -193,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
@@ -203,7 +359,7 @@ function serialize.serialize_crafting_queue(player)
player.character_inventory_slots_bonus = player.character_inventory_slots_bonus + 1000
-- Save current items
local inventory = player.get_main_inventory()
local inventory = player.character.get_main_inventory()
local old_items = inventory.get_contents()
local crafting_queue_progress = player.crafting_queue_progress
@@ -317,7 +473,7 @@ function serialize.serialize_crafting_queue(player)
end
function serialize.deserialize_crafting_queue(player, serialized)
local inventory = player.get_main_inventory()
local inventory = player.character.get_main_inventory()
-- Give player some more inventory space to avoid duplicating items
player.character_inventory_slots_bonus = player.character_inventory_slots_bonus + 1000
@@ -438,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?
@@ -460,6 +617,11 @@ function serialize.serialize_player(player, failed_deserialization)
ticks_to_respawn = player.ticks_to_respawn,
}
-- In 2.0 we want to sync the physical controller to ignore remote view
if v2_remote_controller then
serialized.controller = controller_to_name[player.physical_controller_type]
end
-- For the waiting to respawn state the inventory logistic requests and filters are hidden on the player
if player.controller_type == defines.controllers.ghost and player.ticks_to_respawn then
player.ticks_to_respawn = nil -- Respawn now
@@ -482,20 +644,14 @@ function serialize.serialize_player(player, failed_deserialization)
end
-- Serialize non-character inventories
if player.controller_type == defines.controllers.god then
if player.controller_type == defines.controllers.god
or v2_remote_controller and player.physical_controller_type == defines.controllers.god
then
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
@@ -510,6 +666,26 @@ function serialize.serialize_player(player, failed_deserialization)
return serialized
end
--- Ensure a player has a character, works from any controller type
--- @param player LuaPlayer
--- @return LuaEntity
local function ensure_character(player)
-- Do nothing if the player has a valid character
local character = player.character
if character and character.valid then
return character
end
-- Switch to god controller if create_character would fail
if player.controller_type ~= defines.controllers.god then
player.set_controller{ type = defines.controllers.god }
end
-- Create and return the character
assert(player.create_character(), "Failed to create character")
return player.character
end
--- @class FailedDeserializationPlayerData
--- @field recipe_notifications string[]?
@@ -519,46 +695,49 @@ end
function serialize.deserialize_player(player, serialized)
local failed_deserialization = {}
if player.controller_type ~= defines.controllers[serialized.controller] or serialized.controller == "ghost" then
-- If targeting the character or ghost controller then create a character
if serialized.controller == "character" or serialized.controller == "ghost" then
if player.controller_type == defines.controllers.ghost or player.controller_type == defines.controllers.spectator then
player.set_controller({ type = defines.controllers.god })
end
if player.controller_type == defines.controllers.god then
player.create_character()
local target_controller = defines.controllers[serialized.controller]
if player.controller_type ~= target_controller or serialized.controller == "ghost" then
if serialized.controller == "character" then
-- Create a character but do not destroy an existing one
ensure_character(player)
if player.controller_type ~= target_controller then
player.set_controller{ type = target_controller }
end
-- The ghost state stores hidden logistic and filters which are only accessible in the character controller
if serialized.controller == "ghost" then
elseif serialized.controller == "ghost" then
-- Ghost state stores hidden logistic and filters which are only accessible in the character controller
local character = ensure_character(player)
if serialized.personal_logistic_slots then
serialize.deserialize_personal_logistic_slots(player, serialized.personal_logistic_slots)
end
if serialized.inventories then
serialize.deserialize_inventories(player, serialized.inventories, character_inventories)
local character = player.character
if serialized.ticks_to_respawn then
player.ticks_to_respawn = serialized.ticks_to_respawn
else
-- We have to set ticks to respawn to save the hidden state into the player but we
-- can't unset tick_to_respawn by setting it back to nil as that triggers a respawn.
player.ticks_to_respawn = 0
player.set_controller({ type = defines.controllers.god })
player.set_controller({ type = defines.controllers.ghost })
end
if character and character.valid then
character.destroy()
end
end
if serialized.ticks_to_respawn then
player.ticks_to_respawn = serialized.ticks_to_respawn
else
-- We have to set ticks to respawn to save the hidden state into the player but we
-- can't unset tick_to_respawn by setting it back to nil as that triggers a respawn.
player.ticks_to_respawn = 0
player.set_controller{ type = defines.controllers.god }
player.set_controller{ type = defines.controllers.ghost }
end
if character and character.valid then
character.destroy()
end
elseif serialized.controller ~= "remote" then
-- All other controllers should not have a character
local character = player.character
if character and character.valid then
character.destroy()
end
if player.controller_type ~= target_controller then
player.set_controller{ type = target_controller }
end
else
-- Targeting the god or spectator controller, if coming from the
-- character controller then destroy the character
if player.controller_type == defines.controllers.character then
player.character.destroy()
end
-- Switching to god or spectator is a matter of setting the controller
if player.controller_type ~= defines.controllers[serialized.controller] then
player.set_controller({ type = defines.controllers[serialized.controller] })
end
error("Remote should not be 'serialized.controller'")
end
end
@@ -584,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
+10 -10
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-inventory_sync",
"version": "2.0.0-alpha.24",
"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,22 +20,22 @@
"url": "https://github.com/clusterio/factorioClusterio/issues"
},
"engines": {
"node": ">=20"
"node": ">=22"
},
"peerDependencies": {
"@clusterio/controller": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*",
"@clusterio/web_ui": "workspace:*"
"@clusterio/controller": "workspace:^",
"@clusterio/host": "workspace:^",
"@clusterio/lib": "workspace:^",
"@clusterio/web_ui": "workspace:^"
},
"dependencies": {
"@sinclair/typebox": "catalog:"
},
"devDependencies": {
"@clusterio/controller": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@clusterio/controller": "workspace:^",
"@clusterio/host": "workspace:^",
"@clusterio/lib": "workspace:^",
"@clusterio/web_ui": "workspace:^",
"@types/node": "catalog:",
"@types/react": "catalog:",
"antd": "catalog:",
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more