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..2a433aa3 --- /dev/null +++ b/packages/web_ui/src/components/OverviewPage.tsx @@ -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 + + + ; +} + +/** 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(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 + } 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}`} /> + + } + {canSystems && <> + + + ( + + )} /> + + + + 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} /> + + } + {canSaves && + + } + + {canLogs && <> + Recent errors + + } + + ; +} 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})`; } 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",