From 79c6bd1ac43409d32f80cabb5fb94bc194f86700 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 25 Jun 2026 01:21:17 +0000 Subject: [PATCH 1/3] 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) --- packages/lib/src/config/definitions.ts | 7 + .../web_ui/src/components/OverviewPage.tsx | 133 ++++++++++++++++++ packages/web_ui/src/pages.tsx | 6 + 3 files changed, 146 insertions(+) create mode 100644 packages/web_ui/src/components/OverviewPage.tsx diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index b8257ecd..b693de9c 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -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 { 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.", diff --git a/packages/web_ui/src/components/OverviewPage.tsx b/packages/web_ui/src/components/OverviewPage.tsx new file mode 100644 index 00000000..4ee2aad1 --- /dev/null +++ b/packages/web_ui/src/components/OverviewPage.tsx @@ -0,0 +1,133 @@ +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 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 + + + ; +} + +/** 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 [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 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 colProps = { xs: 24, sm: 12, lg: 8 }; + + return + } href={grafanaUrl} target="_blank" rel="noreferrer"> + Grafana + + : 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 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 2/3] 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 118154a43aac12840e2eb7a93c846f20db2a01ef Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:20:48 +0100 Subject: [PATCH 3/3] Change order of cards and fix permission check --- .../web_ui/src/components/OverviewPage.tsx | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/packages/web_ui/src/components/OverviewPage.tsx b/packages/web_ui/src/components/OverviewPage.tsx index 82b02de7..2a433aa3 100644 --- a/packages/web_ui/src/components/OverviewPage.tsx +++ b/packages/web_ui/src/components/OverviewPage.tsx @@ -46,10 +46,11 @@ export default function OverviewPage() { 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 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"); @@ -109,11 +110,14 @@ export default function OverviewPage() { suffix={`/ ${userList.length}`} /> } - {canSaves && - - } - {canHosts && <> + {canSystems && <> + + + ( + + )} /> + + s.cpuUsed))} total={sum(hostSystems.map(s => s.cpuCapacity))} format={formatCores} unit="cores" /> @@ -126,14 +130,11 @@ export default function OverviewPage() { s.diskUsed))} total={sum(hostSystems.map(s => s.diskCapacity))} format={lib.formatBytes} /> - - - ( - - )} /> - - } + {canSaves && + + } {canLogs && <> Recent errors