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] 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",