diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index 7c366a73..a353e456 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -96,6 +96,7 @@ export default class Controller { devMiddleware: any | null = null; + startedAtMs = Date.now(); autosaveInterval?: ReturnType; systemMetricsInterval?: ReturnType; @@ -397,6 +398,7 @@ export default class Controller { this.onSystemMetricsIntervalChanged(); logger.info("Started controller"); + this.startedAtMs = Date.now(); this._state = "running"; } @@ -553,7 +555,7 @@ export default class Controller { requests.push(hostConnection.send(new lib.SystemInfoRequest())); } const restartRequired = await this.checkRestartRequired(); - requests.push(lib.gatherSystemInfo("controller", this.canRestart, restartRequired)); + requests.push(lib.gatherSystemInfo("controller", this.canRestart, restartRequired, this.startedAtMs)); const newMetrics = await Promise.all(requests); for (const metric of newMetrics) { this.systems.set(metric); @@ -663,6 +665,12 @@ export default class Controller { if (!json.restartRequired) { // Added in 2.0.0.alpha.21 json.restartRequired = false; } + if (json.systemStartedAtMs === undefined) { // Added in 2.0.0.alpha.23 + json.systemStartedAtMs = Date.now(); + } + if (json.processStartedAtMs === undefined) { // Added in 2.0.0.alpha.23 + json.processStartedAtMs = Date.now(); + } return json; }); } diff --git a/packages/controller/src/HostConnection.ts b/packages/controller/src/HostConnection.ts index ac23de34..c50c5f3d 100644 --- a/packages/controller/src/HostConnection.ts +++ b/packages/controller/src/HostConnection.ts @@ -207,6 +207,11 @@ export default class HostConnection extends BaseConnection { let prev = instance.status; instance.status = request.status; + // If the instance has transitioned to running then updated the started at time + if (request.status === "running" && prev === "starting") { + instance.startedAtMs = Date.now(); + } + instance.gamePort = request.gamePort; instance.factorioVersion = request.factorioVersion; this._controller.instances.set(instance); @@ -246,6 +251,9 @@ export default class HostConnection extends BaseConnection { controllerInstance.status = instanceData.status; controllerInstance.gamePort = instanceData.gamePort; controllerInstance.factorioVersion = instanceData.factorioVersion; + if (instanceData.status === "running" && prev === "starting") { + controllerInstance.startedAtMs = Date.now(); + } instanceUpdates.push(controllerInstance); logger.verbose(`Instance ${instanceConfig.get("instance.name")} State: ${instanceData.status}`); await lib.invokeHook( @@ -261,6 +269,7 @@ export default class HostConnection extends BaseConnection { instanceData.status, instanceData.gamePort, instanceData.factorioVersion, + 0, Date.now(), ); instanceUpdates.push(newInstance); diff --git a/packages/controller/src/InstanceInfo.ts b/packages/controller/src/InstanceInfo.ts index 05d63596..836ad472 100644 --- a/packages/controller/src/InstanceInfo.ts +++ b/packages/controller/src/InstanceInfo.ts @@ -12,6 +12,7 @@ export default class InstanceInfo { public status: lib.InstanceStatus, public gamePort?: number, public factorioVersion?: lib.TargetVersion, + public startedAtMs = 0, public updatedAtMs = 0, ) { this.config = config; @@ -22,6 +23,7 @@ export default class InstanceInfo { "config": lib.InstanceConfig.jsonSchema, "status": lib.InstanceStatus, "gamePort": Type.Optional(Type.Number()), + "startedAtMs": Type.Optional(Type.Number()), "updatedAtMs": Type.Optional(Type.Number()), "factorioVersion": Type.Optional(lib.TargetVersionSchema), }); @@ -32,6 +34,7 @@ export default class InstanceInfo { json.status, json.gamePort, json.factorioVersion, + json.startedAtMs, json.updatedAtMs, ); } @@ -44,6 +47,7 @@ export default class InstanceInfo { this.gamePort, this.status, this.factorioVersion, + this.startedAtMs, this.updatedAtMs, this.config.get("instance.exclude_from_start_all"), ); diff --git a/packages/host/host.ts b/packages/host/host.ts index c3f539a5..19127713 100755 --- a/packages/host/host.ts +++ b/packages/host/host.ts @@ -291,6 +291,7 @@ async function startHost() { }); await hostConnector.connect(); + host.startedAtMs = Date.now(); logger.info("Started host"); } diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts index f3061f29..b3bc909b 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -278,6 +278,8 @@ export default class Host extends lib.Link { _disconnecting = false; _shuttingDown = false; + startedAtMs = Date.now(); + static instanceConfigWarning = { "_warning": "Changes to this file will be overwritten by the controller's copy.", }; @@ -807,7 +809,7 @@ export default class Host extends lib.Link { async handleSystemInfoRequest() { const restartRequired = await this.checkRestartRequired(); - return lib.gatherSystemInfo(this.config.get("host.id"), this.canRestart, restartRequired); + return lib.gatherSystemInfo(this.config.get("host.id"), this.canRestart, restartRequired, this.startedAtMs); } async checkRestartRequired() { diff --git a/packages/lib/src/data/messages_controller.ts b/packages/lib/src/data/messages_controller.ts index aa4232a5..53c48e74 100644 --- a/packages/lib/src/data/messages_controller.ts +++ b/packages/lib/src/data/messages_controller.ts @@ -260,6 +260,8 @@ export class SystemInfo { public diskAvailable: number, public canRestart: boolean, public restartRequired: boolean, + public systemStartedAtMs: number, + public processStartedAtMs: number, /** Millisecond Unix timestamp this entry was last updated at */ public updatedAtMs: number, public isDeleted: boolean, @@ -279,6 +281,8 @@ export class SystemInfo { "diskAvailable": Type.Number(), "canRestart": Type.Boolean(), "restartRequired": Type.Boolean(), + "systemStartedAtMs": Type.Number(), + "processStartedAtMs": Type.Number(), "updatedAtMs": Type.Number(), "isDeleted": Type.Boolean(), }); @@ -298,6 +302,8 @@ export class SystemInfo { json.diskAvailable, json.canRestart, json.restartRequired, + json.systemStartedAtMs, + json.processStartedAtMs, json.updatedAtMs, json.isDeleted, ); diff --git a/packages/lib/src/data/messages_instance.ts b/packages/lib/src/data/messages_instance.ts index a0a251d2..91d8c0b4 100644 --- a/packages/lib/src/data/messages_instance.ts +++ b/packages/lib/src/data/messages_instance.ts @@ -44,6 +44,7 @@ export class InstanceDetails { public gamePort: number | undefined, public status: InstanceStatus, public factorioVersion: TargetVersion | undefined, + public startedAtMs = 0, /** Millisecond Unix timestamp this entry was last updated at */ public updatedAtMs = 0, public excludeFromStartAll = false, @@ -59,6 +60,7 @@ export class InstanceDetails { "creating_save", "exporting_data", "deleted", ]), "factorioVersion": Type.Optional(TargetVersionSchema), + "startedAtMs": Type.Optional(Type.Number()), "updatedAtMs": Type.Optional(Type.Number()), "excludeFromStartAll": Type.Optional(Type.Boolean()), }); @@ -71,6 +73,7 @@ export class InstanceDetails { json.gamePort, json.status, json.factorioVersion, + json.startedAtMs, json.updatedAtMs, json.excludeFromStartAll, ); diff --git a/packages/lib/src/system_collectors.ts b/packages/lib/src/system_collectors.ts index 6822e474..436c57de 100644 --- a/packages/lib/src/system_collectors.ts +++ b/packages/lib/src/system_collectors.ts @@ -134,7 +134,12 @@ function minZip(a: T[], b: T[]): [T, T][] { let previousTotalCpuMs: number[] = []; let previousIdleCpuMs: number[] = []; -export async function gatherSystemInfo(id: SystemInfo["id"], canRestart: boolean, restartRequired: boolean) { +export async function gatherSystemInfo( + id: SystemInfo["id"], + canRestart: boolean, + restartRequired: boolean, + startedAtMs: number +) { const cpus = os.cpus(); const currentTotalCpuMs = cpus.map(({ times }) => times.user + times.idle + times.irq + times.nice + times.sys); const currentIdleCpuMs = cpus.map(({ times }) => times.idle); @@ -152,6 +157,7 @@ export async function gatherSystemInfo(id: SystemInfo["id"], canRestart: boolean diskAvailable = stats.bavail * stats.bsize; } + const now = Date.now(); return new SystemInfo( id, os.hostname(), @@ -166,7 +172,9 @@ export async function gatherSystemInfo(id: SystemInfo["id"], canRestart: boolean diskAvailable, canRestart, restartRequired, - Date.now(), + now - (os.uptime() * 1000), + startedAtMs, + now, false, ); } diff --git a/packages/web_ui/src/components/ControllerPage.tsx b/packages/web_ui/src/components/ControllerPage.tsx index 4a65b415..1c74cbc9 100644 --- a/packages/web_ui/src/components/ControllerPage.tsx +++ b/packages/web_ui/src/components/ControllerPage.tsx @@ -8,7 +8,7 @@ import PluginExtra from "./PluginExtra"; import LogConsole, { SelectMaxLogLevel } from "./LogConsole"; import { MetricCpuRatio, MetricCpuUsed, MetricMemoryRatio, MetricMemoryUsed, - MetricDiskUsed, MetricDiskRatio, + MetricDiskUsed, MetricDiskRatio, MetricRelativeDate, } from "./system_metrics"; import { useAccount } from "../model/account"; import { useSystems } from "../model/system"; @@ -101,6 +101,12 @@ export default function ControllerPage() { {system?.node} {system?.kernel} {system?.machine} + + + + + + {system?.hostname} {system?.cpuModel} diff --git a/packages/web_ui/src/components/HostStatusTag.tsx b/packages/web_ui/src/components/HostStatusTag.tsx new file mode 100644 index 00000000..eebf61a3 --- /dev/null +++ b/packages/web_ui/src/components/HostStatusTag.tsx @@ -0,0 +1,17 @@ +import { Tag } from "antd"; +import React from "react"; + +import { CheckCircleOutlined, CloseCircleOutlined } from "@ant-design/icons"; + +const statusOptions = { + connected: } >Connected, + disconnected: } >Disconnected, +}; + +type HostStatusTagProps = { + connected: boolean; +}; + +export default function HostStatusTag(props: HostStatusTagProps) { + return props.connected ? statusOptions.connected : statusOptions.disconnected; +} diff --git a/packages/web_ui/src/components/HostViewPage.tsx b/packages/web_ui/src/components/HostViewPage.tsx index f7eddcf8..c55eddd8 100644 --- a/packages/web_ui/src/components/HostViewPage.tsx +++ b/packages/web_ui/src/components/HostViewPage.tsx @@ -1,5 +1,5 @@ import React, { useContext, useState } from "react"; -import { Descriptions, Spin, Tag, Typography, Button, Space, Modal, Popconfirm, Flex, Tooltip } from "antd"; +import { Descriptions, Spin, Typography, Button, Space, Popconfirm, Flex, Tooltip } from "antd"; import { ExclamationCircleOutlined } from "@ant-design/icons"; import { useParams } from "react-router-dom"; @@ -7,6 +7,7 @@ import * as lib from "@clusterio/lib"; import notify, { notifyErrorHandler } from "../util/notify"; import ControlContext from "./ControlContext"; import HostConfigTree from "./HostConfigTree"; +import HostStatusTag from "./HostStatusTag"; import InstanceList from "./InstanceList"; import LogConsole, { SelectMaxLogLevel } from "./LogConsole"; import { useAccount } from "../model/account"; @@ -17,7 +18,7 @@ import PageLayout from "./PageLayout"; import PluginExtra from "./PluginExtra"; import { MetricCpuRatio, MetricCpuUsed, MetricMemoryRatio, MetricMemoryUsed, - MetricDiskUsed, MetricDiskRatio, + MetricDiskUsed, MetricDiskRatio, MetricRelativeDate, } from "./system_metrics"; import { formatTimestamp } from "../util/time_format"; import { useSystems } from "../model/system"; @@ -134,21 +135,22 @@ export default function HostViewPage() { return } extra={hostButtons} /> - {host["name"]} - - - {host["connected"] ? "Connected" : "Disconnected"} - - {host.version} {system?.node} {system?.kernel} {system?.machine} - {system?.hostname} - {host.remoteAddress} + + + + + + + {system?.hostname} + {host.remoteAddress} {system?.cpuModel} diff --git a/packages/web_ui/src/components/HostsPage.tsx b/packages/web_ui/src/components/HostsPage.tsx index e6212271..010b56fe 100644 --- a/packages/web_ui/src/components/HostsPage.tsx +++ b/packages/web_ui/src/components/HostsPage.tsx @@ -1,6 +1,6 @@ import React, { useContext, useRef, useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; -import { Button, Flex, Form, InputNumber, Modal, Progress, Space, Switch, Table, Tag, Tooltip, Typography } from "antd"; +import { Button, Flex, Form, InputNumber, Modal, Space, Switch, Table, Tooltip, Typography } from "antd"; import { ExclamationCircleOutlined, CopyOutlined } from "@ant-design/icons"; import * as lib from "@clusterio/lib"; @@ -11,6 +11,7 @@ import ControlContext from "./ControlContext"; import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; import PluginExtra from "./PluginExtra"; +import HostStatusTag from "./HostStatusTag"; import { MetricCpuRatio, MetricCpuUsed, MetricMemoryRatio, MetricMemoryUsed, MetricDiskUsed, MetricDiskRatio, @@ -287,11 +288,7 @@ export default function HostsPage() { title: "Connected", key: "connected", render: (_, host) => <> - - {host.connected ? "Connected" : "Disconnected"} - + , sorter: (a, b) => Number(a.connected) - Number(b.connected), diff --git a/packages/web_ui/src/components/InstanceStatusTag.tsx b/packages/web_ui/src/components/InstanceStatusTag.tsx index 0ade6ff4..718db2a4 100644 --- a/packages/web_ui/src/components/InstanceStatusTag.tsx +++ b/packages/web_ui/src/components/InstanceStatusTag.tsx @@ -1,23 +1,27 @@ import { Tag } from "antd"; import React from "react"; -export const statusColors = { - unknown: "#8c8c8c", - unassigned: "#eb2f96", - stopped: "#cf1322", - starting: "#ad8b00", - running: "#389e0d", - stopping: "#d48806", - creating_save: "#096dd9", - exporting_data: "#08979c", - deleted: "#262626", +import { + QuestionCircleOutlined, ClockCircleOutlined, StopOutlined, LoadingOutlined, + CheckCircleOutlined, SaveOutlined, ExportOutlined, DeleteOutlined, +} from "@ant-design/icons"; + +export const statusOptions = { + unknown: }>Unknown, + unassigned: }>Unassigned, + stopped: }>Stopped, + starting: }>Starting, + running: }>Running, + stopping: }>Stopping, + creating_save: }>Creating Save, + exporting_data: }>Exporting Data, + deleted: }>Deleted, }; type InstanceStatusTag = { - status: keyof typeof statusColors; + status: keyof typeof statusOptions; }; + export default function InstanceStatusTag(props: InstanceStatusTag) { - return - {props.status.replace("_", " ")} - ; + return statusOptions[props.status]; } diff --git a/packages/web_ui/src/components/InstanceViewPage.tsx b/packages/web_ui/src/components/InstanceViewPage.tsx index 69b5055c..06cb11b7 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 { MetricRelativeDate } from "./system_metrics"; type MenuItem = Required["items"][number]; const { Title } = Typography; @@ -41,6 +42,7 @@ function InstanceDescription(props: InstanceDescriptionProps) { return {!assigned @@ -64,7 +66,9 @@ function InstanceDescription(props: InstanceDescriptionProps) { {instance.factorioVersion ?? "unknown"} - + + + ; } @@ -97,7 +101,7 @@ function InstanceButtons(props: { instance: lib.InstanceDetails }) { instanceButtonMenuItems.push({ type: "divider" }); } instanceButtonMenuItems.push({ - disabled: !["unknown", "unassigned", "stopped"].includes(instance.status!), + disabled: !["unknown", "unassigned", "stopped"].includes(instance.status), danger: true, key: "delete", icon: , @@ -208,6 +212,7 @@ export default function InstanceViewPage() { return } extra={} /> diff --git a/packages/web_ui/src/components/PageHeader.tsx b/packages/web_ui/src/components/PageHeader.tsx index 4c78bf31..04a99d40 100644 --- a/packages/web_ui/src/components/PageHeader.tsx +++ b/packages/web_ui/src/components/PageHeader.tsx @@ -5,6 +5,7 @@ const { Text } = Typography; type PageHeaderProps = { title: string | React.ReactElement; subTitle?: string | React.ReactElement; + status?: React.JSX.Element; extra?: React.JSX.Element; }; @@ -13,6 +14,7 @@ export default function PageHeader(props: PageHeaderProps): React.JSX.Element {

{props.title}

{props.subTitle && {props.subTitle}} + {props.status ?? null}
{props.extra &&
{props.extra}
} ; diff --git a/packages/web_ui/src/components/system_metrics.tsx b/packages/web_ui/src/components/system_metrics.tsx index 34e503da..9c051d12 100644 --- a/packages/web_ui/src/components/system_metrics.tsx +++ b/packages/web_ui/src/components/system_metrics.tsx @@ -63,3 +63,52 @@ export function RestartRequired(props: { system?: lib.SystemInfo }) { ; } + +/** + * Returns a human-readable duration between two dates. + * + * The output includes up to two most significant time units and omits + * leading zero units. Supported units are weeks, days, hours, minutes, + * and seconds. + * + * Examples: "1w 2d", "2d 3h", "1h 2m", "45s" + * + * @param date1 - The starting date. + * @param date2 - The ending date. + * @returns A duration string (e.g. "2d 3h"). + */ +function humanAbsTimeDiff(date1: number, date2 = 0) { + let diffMs = Math.abs(date2 - date1); + const totalSeconds = Math.floor(diffMs / 1000); + + const weeks = Math.floor(totalSeconds / 604800); // 7 * 24 * 60 * 60 + const days = Math.floor((totalSeconds % 604800) / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (weeks > 0) { + return days > 0 ? `${weeks}w ${days}d` : `${weeks}w`; + } + + if (days > 0) { + return hours > 0 ? `${days}d ${hours}h` : `${days}d`; + } + + if (hours > 0) { + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; + } + + if (minutes > 0) { + return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; + } + + return `${seconds}s`; +} + +export function MetricRelativeDate(props: { timeMs?: number }) { + if (!props.timeMs || props.timeMs === 0) { + return "N/A"; + } + return `${new Date(props.timeMs).toLocaleString()} (${humanAbsTimeDiff(props.timeMs, Date.now())})`; +} diff --git a/packages/web_ui/src/index.ts b/packages/web_ui/src/index.ts index 748e201f..0421bd5b 100644 --- a/packages/web_ui/src/index.ts +++ b/packages/web_ui/src/index.ts @@ -21,7 +21,7 @@ export { default as LogConsole } from "./components/LogConsole"; export { default as AssignInstanceModal } from "./components/AssignInstanceModal"; export { default as InstanceList } from "./components/InstanceList"; export { default as InstanceRcon } from "./components/InstanceRcon"; -export { default as InstanceStatusTag, statusColors } from "./components/InstanceStatusTag"; +export { default as InstanceStatusTag } from "./components/InstanceStatusTag"; export { default as InstanceControlButton, InstanceControlButtonPermissions } from "./components/InstanceControlButton"; export { default as PageHeader } from "./components/PageHeader"; diff --git a/test/controller/Controller.js b/test/controller/Controller.js index d9e68be2..f2f6a3a5 100644 --- a/test/controller/Controller.js +++ b/test/controller/Controller.js @@ -315,9 +315,29 @@ describe("controller/src/Controller", function() { assert.equal(result[1].restartRequired, true); assert.equal(result[2].restartRequired, false); }); + it("migrates 'systemStartedAtMs' from undefined to Date.now", function() { // Alpha 23 + const result = Controller.migrateSystems([ + {}, { systemStartedAtMs: 0 }, { systemStartedAtMs: 500 }, + ]); + const now = Date.now(); + const r0 = result[0].systemStartedAtMs; + assert.ok(r0 > now - 100 && r0 <= now); + assert.equal(result[1].systemStartedAtMs, 0); + assert.equal(result[2].systemStartedAtMs, 500); + }); + it("migrates 'processStartedAtMs' from undefined to Date.now", function() { // Alpha 23 + const result = Controller.migrateSystems([ + {}, { processStartedAtMs: 0 }, { processStartedAtMs: 500 }, + ]); + const now = Date.now(); + const r0 = result[0].processStartedAtMs; + assert.ok(r0 > now - 100 && r0 <= now); + assert.equal(result[1].processStartedAtMs, 0); + assert.equal(result[2].processStartedAtMs, 500); + }); it("does nothing for upto date data", function() { const systemInfo = new SystemInfo( - 1, "name", "n", "k", "m", "cm", "cr", "mc", "ma", "dc", "da", false, false, 0, false + 1, "name", "n", "k", "m", "cm", "cr", "mc", "ma", "dc", "da", false, false, 0, 0, 0, false ); const jsonString = JSON.stringify(systemInfo); const systemInfoJson = JSON.parse(jsonString);