mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-12 15:04:52 +09:00
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>
This commit is contained in:
commit
9c627f4c16
4 files changed
+164
-2
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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})`;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user