From 262357acef2f5b0d6e7d5ca8d322e43501d8161d Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:16:49 +0000 Subject: [PATCH 01/19] 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) --- packages/web_ui/src/components/SiteLayout.tsx | 119 +++++++++--------- packages/web_ui/src/style.css | 80 +----------- 2 files changed, 65 insertions(+), 134 deletions(-) diff --git a/packages/web_ui/src/components/SiteLayout.tsx b/packages/web_ui/src/components/SiteLayout.tsx index 76338056..d2a9f1f9 100644 --- a/packages/web_ui/src/components/SiteLayout.tsx +++ b/packages/web_ui/src/components/SiteLayout.tsx @@ -1,6 +1,6 @@ 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 { Button, Flex, Layout, Menu, MenuProps, Tooltip, Typography } from "antd"; import { UserOutlined, DownloadOutlined } from "@ant-design/icons"; import ErrorBoundary from "./ErrorBoundary"; @@ -14,12 +14,13 @@ import { saveJson } from "../util/save_file"; import { useAccount } from "../model/account"; import { DraggingContext } from "../model/is_dragging"; import webUiPackage from "../../package.json"; +import logo from "../images/logo.png"; import { ControlConfig } from "@clusterio/lib"; type MenuItem = Required["items"][number]; -const { Header, Sider } = Layout; +const { Sider } = Layout; function isActiveDropzone(element: HTMLElement | null): boolean { if (!element) { @@ -127,6 +128,8 @@ export default function SiteLayout() { } return setDraggingProxy(1)} onDragLeave={() => setDraggingProxy(-1)} @@ -170,70 +173,68 @@ export default function SiteLayout() { }} /> 0}> -
-
- Clusterio - - setChangeLogOpen(true)} - > - {webUiPackage.version} - - - - - - -
- - + + + + Clusterio logo + + Clusterio + + setChangeLogOpen(true)} + > + {webUiPackage.version} + + + + navigate(key)} items={menuItems} /> - - - - {combinedPages.map(({ path, sidebarPath, content }) => - - - {content} - - } - />)} - } /> - - + , + label: account.name, + children: accountMenuProps.items, + }]} + /> + + + + + {combinedPages.map(({ path, sidebarPath, content }) => + + + {content} + + } + />)} + } /> + ; diff --git a/packages/web_ui/src/style.css b/packages/web_ui/src/style.css index b1f53739..9a07fc34 100644 --- a/packages/web_ui/src/style.css +++ b/packages/web_ui/src/style.css @@ -56,13 +56,12 @@ body { } } +/* Keep the sidebar fixed in place while the page content scrolls */ .site-layout-sider { - z-index: 0; -} - -.header { - padding-left: 250px; - padding-right: 64px; + position: sticky; + top: 0; + height: 100vh; + align-self: flex-start; } .page-header { @@ -105,75 +104,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; From eb77ca566ce58dc8c6fcf5efb3a2a014a39af7c1 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:39:47 +0100 Subject: [PATCH 02/19] 2.0.0-alpha.26 --- CHANGELOG.md | 43 ++++++++++++++++++++++++ packages/controller/package.json | 2 +- packages/create/package.json | 2 +- packages/ctl/package.json | 2 +- packages/host/package.json | 2 +- packages/lib/package.json | 2 +- packages/web_ui/package.json | 2 +- plugins/global_chat/package.json | 2 +- plugins/inventory_sync/package.json | 2 +- plugins/player_auth/package.json | 2 +- plugins/research_sync/package.json | 2 +- plugins/statistics_exporter/package.json | 2 +- plugins/subspace_storage/package.json | 2 +- 13 files changed, 55 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43b8c286..4070f286 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,49 @@ Many thanks to the following for contributing to this release: [@username](https://github.com/username) --> +## Version 2.0.0-alpha.26 + +### Features + +- Added filter state to `EventSubscriber` allowing plugings to more easilly filter events. [#913]() +- Added `replace` subscription action allowing filter state to be fully replaced with a single request. [#913]() +- Added `ValueSubscriber` and `MapSubscriber` as subclasses of `EventSubscriber`. [#913]() +- Added changelog viewer accessible from the version number in the web interface. [#907]() +- Added about popup to user menu containing support links, build, clusterio, and debug info. [#914]() +- Added user status filtering (admin, whitelisted, banned) to the users table. [#905]() +- Added instance selector to users page to scope user stats by instance. [#905]() + +### Fixes + +- Online user table on instance page now correctly defaults to online only filter. [#909]() +- Fixed popup not opening when pressing kill within the instance control button. [#859]() +- Fix division by zero in CPU usage calculation. [#916]() +- Fixed permissions list not including `grantByDefault` in the json representation. [#913]() +- Fixed subscription resources being `synced = false` if the remote data was empty. [#913]() +- Fix incorrect use of `path.parse` within create template for plugins. [#919]() +- Fixed active filter icon behaviour in user search using empty string. [#917]() + +### Changes + +- Player auth now uses a scroll pane for listing instance names. [#909]() +- `subscriptionUpdate` now fires for all subscription requests rather than just added subscriptions. [#913]() +- Unified instance and global user table columns for consistent behaviour. [#917]() +- Improved loading states for tables and inputs using synced state. [#917]() +- Refactored username rendering into a reusable component. [#917]() + +### Breaking Changes + +- Renamed `lib.Address.index` to `requestIndex` and added `addressIndex`. [#913]() +- `EventSubscriber` no longer includes a values map, use `MapSubscriber` instead. [#913]() + +### Meta + +- Version dependencies now use `^` which enables updates to be applied one package at a time. [#803]() +- Updated change log creation to include new lines and user contributions. [#909]() + +Many thanks to the following for contributing to this release: +[@Cooldude2606]() + ## Version 2.0.0-alpha.25 ### Features diff --git a/packages/controller/package.json b/packages/controller/package.json index 2bf103c7..6e600ce9 100644 --- a/packages/controller/package.json +++ b/packages/controller/package.json @@ -3,7 +3,7 @@ "description": "Implementation of Clusterio controller", "author": "Hornwitser", "license": "MIT", - "version": "2.0.0-alpha.25", + "version": "2.0.0-alpha.26", "main": "dist/node/index.js", "scripts": { "prepare": "tsc --build && webpack-cli --mode production", diff --git a/packages/create/package.json b/packages/create/package.json index 571e4296..66c34a3a 100644 --- a/packages/create/package.json +++ b/packages/create/package.json @@ -3,7 +3,7 @@ "description": "Installer for clusterio", "author": "Hornwitser", "license": "MIT", - "version": "2.0.0-alpha.25", + "version": "2.0.0-alpha.26", "main": "create.js", "bin": { "clusteriocreate": "./create.js" diff --git a/packages/ctl/package.json b/packages/ctl/package.json index 74ff843b..5474c9e4 100644 --- a/packages/ctl/package.json +++ b/packages/ctl/package.json @@ -3,7 +3,7 @@ "description": "Clusterio command line management tool", "author": "Hornwitser", "license": "MIT", - "version": "2.0.0-alpha.25", + "version": "2.0.0-alpha.26", "main": "dist/node/index.js", "scripts": { "prepare": "tsc --build", diff --git a/packages/host/package.json b/packages/host/package.json index edce1aaf..008c4f8c 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -3,7 +3,7 @@ "description": "Implementation of Clusterio host server", "author": "Hornwitser", "license": "MIT", - "version": "2.0.0-alpha.25", + "version": "2.0.0-alpha.26", "main": "dist/node/index.js", "scripts": { "prepare": "tsc --build", diff --git a/packages/lib/package.json b/packages/lib/package.json index 31ee4de8..a0e67f81 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -1,7 +1,7 @@ { "name": "@clusterio/lib", "description": "Shared library for Clusterio", - "version": "2.0.0-alpha.25", + "version": "2.0.0-alpha.26", "repository": "https://github.com/clusterio/clusterio", "license": "MIT", "scripts": { diff --git a/packages/web_ui/package.json b/packages/web_ui/package.json index 769e221f..66f9eb28 100644 --- a/packages/web_ui/package.json +++ b/packages/web_ui/package.json @@ -3,7 +3,7 @@ "description": "Clusterio web interface implementation", "author": "Hornwitser", "license": "MIT", - "version": "2.0.0-alpha.25", + "version": "2.0.0-alpha.26", "browser": "src/index.ts", "main": "dist/browser/src/index.js", "scripts": { diff --git a/plugins/global_chat/package.json b/plugins/global_chat/package.json index 8eb6babe..ad2ceea8 100644 --- a/plugins/global_chat/package.json +++ b/plugins/global_chat/package.json @@ -1,6 +1,6 @@ { "name": "@clusterio/plugin-global_chat", - "version": "2.0.0-alpha.25", + "version": "2.0.0-alpha.26", "description": "Clusterio plugin forwarding between Factorio servers", "author": "Hornwitser ", "homepage": "https://github.com/clusterio/clusterio#readme", diff --git a/plugins/inventory_sync/package.json b/plugins/inventory_sync/package.json index 143e8301..4834eab0 100644 --- a/plugins/inventory_sync/package.json +++ b/plugins/inventory_sync/package.json @@ -1,6 +1,6 @@ { "name": "@clusterio/plugin-inventory_sync", - "version": "2.0.0-alpha.25", + "version": "2.0.0-alpha.26", "description": "Clusterio plugin forwarding between Factorio servers", "author": "Danielv ", "homepage": "https://github.com/clusterio/factorioClusterio#readme", diff --git a/plugins/player_auth/package.json b/plugins/player_auth/package.json index d9c387ae..fd61f03a 100644 --- a/plugins/player_auth/package.json +++ b/plugins/player_auth/package.json @@ -1,6 +1,6 @@ { "name": "@clusterio/plugin-player_auth", - "version": "2.0.0-alpha.25", + "version": "2.0.0-alpha.26", "description": "Clusterio plugin authenticating logged in players to the web interface", "author": "Hornwitser ", "homepage": "https://github.com/clusterio/clusterio#readme", diff --git a/plugins/research_sync/package.json b/plugins/research_sync/package.json index ee361afe..4687d02f 100644 --- a/plugins/research_sync/package.json +++ b/plugins/research_sync/package.json @@ -1,6 +1,6 @@ { "name": "@clusterio/plugin-research_sync", - "version": "2.0.0-alpha.25", + "version": "2.0.0-alpha.26", "description": "Clusterio plugin for synchronizing research between Factorio servers", "author": "Hornwitser ", "homepage": "https://github.com/clusterio/clusterio#readme", diff --git a/plugins/statistics_exporter/package.json b/plugins/statistics_exporter/package.json index 20020025..fd7d31a6 100644 --- a/plugins/statistics_exporter/package.json +++ b/plugins/statistics_exporter/package.json @@ -1,6 +1,6 @@ { "name": "@clusterio/plugin-statistics_exporter", - "version": "2.0.0-alpha.25", + "version": "2.0.0-alpha.26", "description": "Clusterio plugin for gathering statistics from Factorio servers", "author": "Hornwitser ", "homepage": "https://github.com/clusterio/clusterio#readme", diff --git a/plugins/subspace_storage/package.json b/plugins/subspace_storage/package.json index 148775c8..d54bf1d2 100644 --- a/plugins/subspace_storage/package.json +++ b/plugins/subspace_storage/package.json @@ -1,6 +1,6 @@ { "name": "@clusterio/plugin-subspace_storage", - "version": "2.0.0-alpha.25", + "version": "2.0.0-alpha.26", "description": "Clusterio plugin for sharing storage between Factorio servers", "author": "Hornwitser ", "homepage": "https://github.com/clusterio/clusterio#readme", From ed84a476c6120ae2b3e8dd45549f5aab967e5f21 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:19:36 +0000 Subject: [PATCH 03/19] 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) --- packages/web_ui/src/components/SiteLayout.tsx | 169 +++++++++++------- packages/web_ui/src/style.css | 30 +++- 2 files changed, 125 insertions(+), 74 deletions(-) diff --git a/packages/web_ui/src/components/SiteLayout.tsx b/packages/web_ui/src/components/SiteLayout.tsx index d2a9f1f9..f74b5ab5 100644 --- a/packages/web_ui/src/components/SiteLayout.tsx +++ b/packages/web_ui/src/components/SiteLayout.tsx @@ -1,7 +1,7 @@ import React, { Fragment, useContext, useEffect, useState } from "react"; import { Route, Routes, useNavigate } from "react-router-dom"; -import { Button, Flex, Layout, Menu, MenuProps, Tooltip, Typography } from "antd"; -import { UserOutlined, DownloadOutlined } from "@ant-design/icons"; +import { Button, Drawer, Dropdown, Flex, Grid, Layout, Menu, MenuProps, Tooltip, Typography } from "antd"; +import { UserOutlined, DownloadOutlined, MenuOutlined } from "@ant-design/icons"; import ErrorBoundary from "./ErrorBoundary"; import ErrorPage from "./ErrorPage"; @@ -20,7 +20,7 @@ import { ControlConfig } from "@clusterio/lib"; type MenuItem = Required["items"][number]; -const { Sider } = Layout; +const { Sider, Header } = Layout; function isActiveDropzone(element: HTMLElement | null): boolean { if (!element) { @@ -43,6 +43,13 @@ function isActiveDropzone(element: HTMLElement | null): boolean { export default function SiteLayout() { let navigate = useNavigate(); let [currentSidebarPath, setCurrentSidebarPath] = useState(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); @@ -127,8 +134,38 @@ 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 = navigateAndClose(key)} + items={menuItems} + />; + + let accountMenu = + + ; + return setDraggingProxy(1)} @@ -172,69 +209,69 @@ export default function SiteLayout() { setAboutOpen(wasAboutOpen); }} /> +
+
0}> - - - - Clusterio logo - - Clusterio - - setChangeLogOpen(true)} - > - {webUiPackage.version} - - - - - navigate(key)} - items={menuItems} - /> - , - label: account.name, - children: accountMenuProps.items, - }]} - /> - - - - - {combinedPages.map(({ path, sidebarPath, content }) => - - - {content} - - } - />)} - } /> - + + {isMobile + ? 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} + + : + {sidebarContent} + + } + + + {combinedPages.map(({ path, sidebarPath, content }) => + + + {content} + + } + />)} + } /> + + ; diff --git a/packages/web_ui/src/style.css b/packages/web_ui/src/style.css index 9a07fc34..ef9fcdf4 100644 --- a/packages/web_ui/src/style.css +++ b/packages/web_ui/src/style.css @@ -33,17 +33,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,14 +52,32 @@ body { } } -/* Keep the sidebar fixed in place while the page content scrolls */ -.site-layout-sider { +/* Top bar hosting the menu toggle and branding, sticky above the content. */ +.site-layout-header { position: sticky; top: 0; - height: 100vh; + z-index: 100; + display: flex; + align-items: center; + gap: 16px; + padding-inline: 16px; + background: #001529; +} + +/* 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 { padding: 0 0 16px; } From f04fc685136ba24318e9c24abfe011106f1802ef Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:52:15 +0000 Subject: [PATCH 04/19] Use real links for navigation so they open in new tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-app navigation was done with JS onClick -> navigate() handlers instead of real 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) --- packages/web_ui/src/components/HostsPage.tsx | 14 ++--- .../web_ui/src/components/InstanceList.tsx | 13 +++-- .../src/components/InstanceViewPage.tsx | 7 +-- packages/web_ui/src/components/ModsPage.tsx | 16 +++--- .../web_ui/src/components/PluginsPage.tsx | 18 ++++--- packages/web_ui/src/components/RolesPage.tsx | 13 ++--- packages/web_ui/src/components/SiteLayout.tsx | 18 +++++-- .../web_ui/src/components/UserViewPage.tsx | 13 ++--- packages/web_ui/src/components/UsersTable.tsx | 17 +++--- packages/web_ui/src/index.ts | 2 + packages/web_ui/src/util/useRowNavigation.ts | 52 +++++++++++++++++++ 11 files changed, 123 insertions(+), 60 deletions(-) create mode 100644 packages/web_ui/src/util/useRowNavigation.ts diff --git a/packages/web_ui/src/components/HostsPage.tsx b/packages/web_ui/src/components/HostsPage.tsx index 5b1dfa15..5a9d9986 100644 --- a/packages/web_ui/src/components/HostsPage.tsx +++ b/packages/web_ui/src/components/HostsPage.tsx @@ -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,8 @@ import { import { useHosts } from "../model/host"; import { useSystems } from "../model/system"; import notify, { notifyErrorHandler } from "../util/notify"; +import useRowNavigation from "../util/useRowNavigation"; +import Link from "./Link"; const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; @@ -199,12 +200,12 @@ 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 rowNav = useRowNavigation(); return strcmp(a.name, b.name), + render: (_, host) => + {host.name} + , }, { title: "CPU Model", @@ -306,11 +310,7 @@ export default function HostsPage() { dataSource={[...hosts.values()]} rowKey={host => host.id} pagination={false} - onRow={(record, rowIndex) => ({ - onClick: event => { - navigate(`/hosts/${record.id}/view`); - }, - })} + onRow={record => rowNav(`/hosts/${record.id}/view`)} /> ; diff --git a/packages/web_ui/src/components/ModsPage.tsx b/packages/web_ui/src/components/ModsPage.tsx index a6ece310..8cbfe1a2 100644 --- a/packages/web_ui/src/components/ModsPage.tsx +++ b/packages/web_ui/src/components/ModsPage.tsx @@ -18,6 +18,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 ModDetails from "./ModDetails"; import { Dropzone } from "./Dropzone"; import UploadButton from "./UploadButton"; @@ -507,9 +509,9 @@ function SearchModsButton() { export default function ModsPage() { let account = useAccount(); let control = useContext(ControlContext); - let navigate = useNavigate(); let [mods] = useMods(); let [modPacks] = useModPacks(); + const rowNav = useRowNavigation(); function actions(mod: lib.ModInfo) { return @@ -577,6 +579,12 @@ export default function ModsPage() { dataIndex: "name", defaultSortOrder: "ascend", sorter: (a, b) => strcmp(a.name, b.name), + render: (_, modPack) => + {modPack.name} + , }, { title: "Factorio Version", @@ -592,11 +600,7 @@ export default function ModsPage() { dataSource={[...modPacks.values()]} pagination={false} 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`)} /> diff --git a/packages/web_ui/src/components/PluginsPage.tsx b/packages/web_ui/src/components/PluginsPage.tsx index bea8cdab..38eed49f 100644 --- a/packages/web_ui/src/components/PluginsPage.tsx +++ b/packages/web_ui/src/components/PluginsPage.tsx @@ -1,5 +1,4 @@ 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"; @@ -10,14 +9,16 @@ import notify from "../util/notify"; import ControlContext from "./ControlContext"; import PageLayout from "./PageLayout"; import PageHeader from "./PageHeader"; +import useRowNavigation from "../util/useRowNavigation"; +import Link from "./Link"; const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; export default function PluginsPage() { const control = useContext(ControlContext); - let navigate = useNavigate(); let [pluginList, setPluginList] = useState([]); + const rowNav = useRowNavigation(); useEffect(() => { (async () => { @@ -54,7 +55,12 @@ export default function PluginsPage() { { title: "Name", key: "name", - render: (_, plugin) => (plugin.info ? plugin.info.title : plugin.meta.name), + render: (_, plugin) => + {plugin.info ? plugin.info.title : plugin.meta.name} + , defaultSortOrder: "ascend", sorter: (a, b) => strcmp(a.info ? a.info.title : a.meta.name, b.info ? b.info.title : b.meta.name), }, @@ -86,11 +92,7 @@ export default function PluginsPage() { dataSource={tableContents} rowKey={plugin => plugin.meta.name} pagination={false} - onRow={plugin => ({ - onClick: event => { - navigate(`/plugins/${plugin.meta.name}/view`); - }, - })} + onRow={plugin => rowNav(`/plugins/${plugin.meta.name}/view`)} /> ; } diff --git a/packages/web_ui/src/components/RolesPage.tsx b/packages/web_ui/src/components/RolesPage.tsx index 4e392c27..f1b62fb0 100644 --- a/packages/web_ui/src/components/RolesPage.tsx +++ b/packages/web_ui/src/components/RolesPage.tsx @@ -11,6 +11,8 @@ import { notifyErrorHandler } from "../util/notify"; import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; import PluginExtra from "./PluginExtra"; +import useRowNavigation from "../util/useRowNavigation"; +import Link from "./Link"; const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; @@ -61,8 +63,8 @@ function CreateRoleButton() { export default function RolesPage() { let account = useAccount(); - let navigate = useNavigate(); const [roles] = useRoles(); + const rowNav = useRowNavigation(); return strcmp(a.name, b.name), + render: (_, role) => + {role.name} + , }, { title: "Description", @@ -84,11 +89,7 @@ export default function RolesPage() { dataSource={[...roles.values()]} pagination={false} rowKey={role => role.id} - onRow={(role, rowIndex) => ({ - onClick: event => { - navigate(`/roles/${role.id}/view`); - }, - })} + onRow={role => rowNav(`/roles/${role.id}/view`)} /> ; diff --git a/packages/web_ui/src/components/SiteLayout.tsx b/packages/web_ui/src/components/SiteLayout.tsx index 76338056..7d2a77c4 100644 --- a/packages/web_ui/src/components/SiteLayout.tsx +++ b/packages/web_ui/src/components/SiteLayout.tsx @@ -8,6 +8,7 @@ import ErrorPage from "./ErrorPage"; import ControlContext from "./ControlContext"; import ChangeLogModal from "./ChangeLogModal"; import AboutModal from "./AboutModal"; +import Link from "./Link"; import { pages } from "../pages"; import { saveJson } from "../util/save_file"; @@ -21,6 +22,12 @@ type MenuItem = Required["items"][number]; const { Header, Sider } = 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) { + return e.stopPropagation()}>{label}; +} + function isActiveDropzone(element: HTMLElement | null): boolean { if (!element) { return false; @@ -74,7 +81,12 @@ export default function SiteLayout() { } }, items: [ - { label: account.name, key: "user" }, + { + label: e.stopPropagation()}> + {account.name} + , + key: "user", + }, { label: Ctl Config + : undefined} + /> + + {canHosts && + + h.connected).length} + suffix={`/ ${hostList.length}`} /> + + } + {canInstances && + + i.status === "running").length} + suffix={`/ ${instanceList.length}`} /> + + } + {canUsers && + + isUserOnline(u)).length} + suffix={`/ ${userList.length}`} /> + + } + {canHosts && <> + + s.cpuUsed))} + total={sum(hostSystems.map(s => s.cpuCapacity))} format={formatCores} unit="cores" /> + + + s.memoryUsed))} + total={sum(hostSystems.map(s => s.memoryCapacity))} format={lib.formatBytes} /> + + + s.diskUsed))} + total={sum(hostSystems.map(s => s.diskCapacity))} format={lib.formatBytes} /> + + + + } /> + + + } + + {canLogs && <> + Recent errors + + } + + ; +} diff --git a/packages/web_ui/src/pages.tsx b/packages/web_ui/src/pages.tsx index f2187c45..cd2a7cd2 100644 --- a/packages/web_ui/src/pages.tsx +++ b/packages/web_ui/src/pages.tsx @@ -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: , + }, { path: "/controller", sidebarName: "Controller", From 6feb9c27622f23b2d1d71d5cf37274654f23335f Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 25 Jun 2026 02:07:35 +0000 Subject: [PATCH 07/19] 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) --- .../src/components/InstanceViewPage.tsx | 10 ++---- packages/web_ui/src/util/useLocalStorage.ts | 33 +++++++++++++++++++ 2 files changed, 36 insertions(+), 7 deletions(-) create mode 100644 packages/web_ui/src/util/useLocalStorage.ts diff --git a/packages/web_ui/src/components/InstanceViewPage.tsx b/packages/web_ui/src/components/InstanceViewPage.tsx index 6bfa9759..e83e206d 100644 --- a/packages/web_ui/src/components/InstanceViewPage.tsx +++ b/packages/web_ui/src/components/InstanceViewPage.tsx @@ -25,6 +25,7 @@ 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["items"][number]; @@ -180,9 +181,7 @@ export default function InstanceViewPage() { let params = useParams(); let instanceId = Number(params.id); const [maxLevel, setMaxLevel] = useState("server"); - const [actionsOnly, setActionsOnly] = useState( - () => localStorage.getItem(consoleActionsOnlyKey) !== "false" - ); + const [actionsOnly, setActionsOnly] = useLocalStorage(consoleActionsOnlyKey, true); let navigate = useNavigate(); @@ -239,10 +238,7 @@ export default function InstanceViewPage() { checkedChildren="Chat" unCheckedChildren="Log" checked={actionsOnly} - onChange={value => { - setActionsOnly(value); - localStorage.setItem(consoleActionsOnlyKey, String(value)); - }} + onChange={setActionsOnly} /> (key: string, defaultValue: T): [T, (value: T) => void] { + const [value, setValue] = useState(() => { + 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]; +} From d4394f1190f5c1b9f26f939001fd6b1d8d84a741 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 25 Jun 2026 02:15:53 +0000 Subject: [PATCH 08/19] 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) --- packages/web_ui/src/components/SiteLayout.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/web_ui/src/components/SiteLayout.tsx b/packages/web_ui/src/components/SiteLayout.tsx index f74b5ab5..3ff57a60 100644 --- a/packages/web_ui/src/components/SiteLayout.tsx +++ b/packages/web_ui/src/components/SiteLayout.tsx @@ -8,6 +8,7 @@ import ErrorPage from "./ErrorPage"; import ControlContext from "./ControlContext"; import ChangeLogModal from "./ChangeLogModal"; import AboutModal from "./AboutModal"; +import Link from "./Link"; import { pages } from "../pages"; import { saveJson } from "../util/save_file"; @@ -216,7 +217,9 @@ export default function SiteLayout() { onClick={toggleSidebar} aria-label="Toggle navigation menu" /> - Clusterio logo + + Clusterio logo + Clusterio From 09ec6d6ec58f2341ab10fcdbb69b4fd4c0bf0b27 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 25 Jun 2026 02:40:22 +0000 Subject: [PATCH 09/19] 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) --- packages/web_ui/src/components/OverviewPage.tsx | 17 ++++++++++++++--- .../web_ui/src/components/system_metrics.tsx | 8 ++++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/web_ui/src/components/OverviewPage.tsx b/packages/web_ui/src/components/OverviewPage.tsx index 4ee2aad1..82b02de7 100644 --- a/packages/web_ui/src/components/OverviewPage.tsx +++ b/packages/web_ui/src/components/OverviewPage.tsx @@ -9,6 +9,7 @@ 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"; @@ -42,11 +43,13 @@ export default function OverviewPage() { const [instances] = useInstances(); const [users] = useUsers(); const [systems] = useSystems(); + const [saves] = useSaves(); const [grafanaUrl, setGrafanaUrl] = useState(null); const canHosts = account.hasPermission("core.host.list"); const canInstances = account.hasPermission("core.instance.list"); const canUsers = account.hasPermission("core.user.list"); + const canSaves = account.hasAllPermission("core.instance.save.list", "core.instance.save.subscribe"); const canLogs = account.hasPermission("core.log.follow"); const canConfig = account.hasPermission("core.controller.get_config"); @@ -72,8 +75,11 @@ export default function OverviewPage() { // 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: 8 }; + const colProps = { xs: 24, sm: 12, lg: 6 }; return } + {canSaves && + + } {canHosts && <> s.cpuUsed))} @@ -118,8 +128,9 @@ export default function OverviewPage() { - } /> + ( + + )} /> } diff --git a/packages/web_ui/src/components/system_metrics.tsx b/packages/web_ui/src/components/system_metrics.tsx index 9c051d12..753523c3 100644 --- a/packages/web_ui/src/components/system_metrics.tsx +++ b/packages/web_ui/src/components/system_metrics.tsx @@ -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})`; } From b29f4db52c4722e5ae03a29010e47e42eb32c2a3 Mon Sep 17 00:00:00 2001 From: Bastiaan <17990055+bbassie@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:15:18 +0200 Subject: [PATCH 10/19] 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) --- .../src/components/InstanceViewPage.tsx | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/web_ui/src/components/InstanceViewPage.tsx b/packages/web_ui/src/components/InstanceViewPage.tsx index e83e206d..e3be3237 100644 --- a/packages/web_ui/src/components/InstanceViewPage.tsx +++ b/packages/web_ui/src/components/InstanceViewPage.tsx @@ -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"; @@ -43,10 +46,11 @@ function InstanceDescription(props: InstanceDescriptionProps) { const { host, instance } = props; let assigned = instance.assignedHost !== undefined; + const publicAddress = instancePublicAddress(instance, host); return {!assigned @@ -67,6 +71,22 @@ function InstanceDescription(props: InstanceDescriptionProps) { buttonContent={assigned ? "Reassign" : "Assign"} />} + + {publicAddress + ? + {publicAddress} + diff --git a/packages/web_ui/src/components/NpmButton.tsx b/packages/web_ui/src/components/NpmButton.tsx index aa01c7b4..1a5a83e2 100644 --- a/packages/web_ui/src/components/NpmButton.tsx +++ b/packages/web_ui/src/components/NpmButton.tsx @@ -170,7 +170,7 @@ export function NpmButton(props: { okButtonProps={{disabled: formAction === undefined}} onOk={() => { onOk().catch(notifyErrorHandler(`Error running ${formAction}`)); }} onCancel={() => { setOpen(false); }} - destroyOnClose + destroyOnHidden > diff --git a/packages/web_ui/src/components/RolesPage.tsx b/packages/web_ui/src/components/RolesPage.tsx index a44bd2e1..1d01f847 100644 --- a/packages/web_ui/src/components/RolesPage.tsx +++ b/packages/web_ui/src/components/RolesPage.tsx @@ -47,7 +47,7 @@ function CreateRoleButton() { open={open} onOk={() => { createRole().catch(notifyErrorHandler("Error creating role")); }} onCancel={() => { setOpen(false); }} - destroyOnClose + destroyOnHidden > diff --git a/packages/web_ui/src/components/SavesList.tsx b/packages/web_ui/src/components/SavesList.tsx index 9089335a..d4a84465 100644 --- a/packages/web_ui/src/components/SavesList.tsx +++ b/packages/web_ui/src/components/SavesList.tsx @@ -119,7 +119,7 @@ function TransferModal(props: ModalProps) { open={open} onOk={() => form.submit()} onCancel={() => setOpen(false)} - destroyOnClose + destroyOnHidden > { createUser().catch(notifyErrorHandler("Error creating user")); }} onCancel={() => { setOpen(false); }} - destroyOnClose + destroyOnHidden > @@ -345,7 +345,7 @@ function BulkUserActionButton() { okButtonProps={{ disabled: formAction === undefined }} onOk={() => { onOk().catch(notifyErrorHandler(`Error running ${formAction}`)); }} onCancel={() => { setOpen(false); }} - destroyOnClose + destroyOnHidden >