Merge pull request #827 from Cooldude2606/feature/startedAtMs

Feature/started at ms
This commit is contained in:
Daniel authored and GitHub committed 2026-02-02 22:26:17 +01:00
commit 12b52a854e
18 files changed
+182 -39

No files matched your search

+9 -1
View File
@@ -96,6 +96,7 @@ export default class Controller {
devMiddleware: any | null = null;
startedAtMs = Date.now();
autosaveInterval?: ReturnType<typeof setInterval>;
systemMetricsInterval?: ReturnType<typeof setInterval>;
@@ -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;
});
}
@@ -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);
+4
View File
@@ -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"),
);
+1
View File
@@ -291,6 +291,7 @@ async function startHost() {
});
await hostConnector.connect();
host.startedAtMs = Date.now();
logger.info("Started host");
}
+3 -1
View File
@@ -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() {
@@ -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,
);
@@ -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,
);
+10 -2
View File
@@ -134,7 +134,12 @@ function minZip<T>(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,
);
}
@@ -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() {
<Descriptions.Item label="Node.js">{system?.node}</Descriptions.Item>
<Descriptions.Item label="OS Kernel">{system?.kernel}</Descriptions.Item>
<Descriptions.Item label="Machine">{system?.machine}</Descriptions.Item>
<Descriptions.Item label="System Uptime">
<MetricRelativeDate timeMs={system?.systemStartedAtMs}/>
</Descriptions.Item>
<Descriptions.Item label="Process Uptime">
<MetricRelativeDate timeMs={system?.processStartedAtMs}/>
</Descriptions.Item>
<Descriptions.Item label="Hostname" span={2}>{system?.hostname}</Descriptions.Item>
<Descriptions.Item label="CPU Model" span={2}>{system?.cpuModel}</Descriptions.Item>
<Descriptions.Item label="CPU Usage"><MetricCpuRatio system={system} /></Descriptions.Item>
@@ -0,0 +1,17 @@
import { Tag } from "antd";
import React from "react";
import { CheckCircleOutlined, CloseCircleOutlined } from "@ant-design/icons";
const statusOptions = {
connected: <Tag color="#389e0d" icon={<CheckCircleOutlined/>} >Connected</Tag>,
disconnected: <Tag color="#cf1322" icon={<CloseCircleOutlined/>} >Disconnected</Tag>,
};
type HostStatusTagProps = {
connected: boolean;
};
export default function HostStatusTag(props: HostStatusTagProps) {
return props.connected ? statusOptions.connected : statusOptions.disconnected;
}
+12 -10
View File
@@ -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 <PageLayout nav={nav}>
<PageHeader
title={host.name || String(hostId)}
status={<HostStatusTag connected={host["connected"]}/>}
extra={hostButtons}
/>
<Descriptions bordered size="small" column={{ xs: 1, md: 2, lg: 2, xl: 2, xxl: 2 }}>
<Descriptions.Item label="Name">{host["name"]}</Descriptions.Item>
<Descriptions.Item label="Connected">
<Tag color={host["connected"] ? "#389e0d" : "#cf1322"}>
{host["connected"] ? "Connected" : "Disconnected"}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Version">{host.version}</Descriptions.Item>
<Descriptions.Item label="Node.js">{system?.node}</Descriptions.Item>
<Descriptions.Item label="OS Kernel">{system?.kernel}</Descriptions.Item>
<Descriptions.Item label="Machine">{system?.machine}</Descriptions.Item>
<Descriptions.Item label="Hostname" span={2}>{system?.hostname}</Descriptions.Item>
<Descriptions.Item label="Connected From" span={2}>{host.remoteAddress}</Descriptions.Item>
<Descriptions.Item label="System Uptime">
<MetricRelativeDate timeMs={system?.systemStartedAtMs}/>
</Descriptions.Item>
<Descriptions.Item label="Process Uptime">
<MetricRelativeDate timeMs={system?.processStartedAtMs}/>
</Descriptions.Item>
<Descriptions.Item label="Hostname">{system?.hostname}</Descriptions.Item>
<Descriptions.Item label="Connected From">{host.remoteAddress}</Descriptions.Item>
<Descriptions.Item label="CPU Model" span={2}>{system?.cpuModel}</Descriptions.Item>
<Descriptions.Item label="CPU Usage"><MetricCpuRatio system={system} /></Descriptions.Item>
<Descriptions.Item label="Cores"><MetricCpuUsed system={system} /></Descriptions.Item>
+3 -6
View File
@@ -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) => <>
<Tag
color={host.connected ? "#389e0d" : "#cf1322"}
>
{host.connected ? "Connected" : "Disconnected"}
</Tag>
<HostStatusTag connected={host["connected"]}/>
<RestartRequired system={systems.get(host.id)}/>
</>,
sorter: (a, b) => Number(a.connected) - Number(b.connected),
@@ -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: <Tag color="#8c8c8c" icon={<QuestionCircleOutlined />}>Unknown</Tag>,
unassigned: <Tag color="#eb2f96" icon={<ClockCircleOutlined />}>Unassigned</Tag>,
stopped: <Tag color="#cf1322" icon={<StopOutlined />}>Stopped</Tag>,
starting: <Tag color="#ad8b00" icon={<LoadingOutlined />}>Starting</Tag>,
running: <Tag color="#389e0d" icon={<CheckCircleOutlined />}>Running</Tag>,
stopping: <Tag color="#d48806" icon={<LoadingOutlined />}>Stopping</Tag>,
creating_save: <Tag color="#096dd9" icon={<SaveOutlined />}>Creating Save</Tag>,
exporting_data: <Tag color="#08979c" icon={<ExportOutlined />}>Exporting Data</Tag>,
deleted: <Tag color="#262626" icon={<DeleteOutlined />}>Deleted</Tag>,
};
type InstanceStatusTag = {
status: keyof typeof statusColors;
status: keyof typeof statusOptions;
};
export default function InstanceStatusTag(props: InstanceStatusTag) {
return <Tag color={statusColors[props.status]}>
{props.status.replace("_", " ")}
</Tag>;
return statusOptions[props.status];
}
@@ -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<MenuProps>["items"][number];
const { Title } = Typography;
@@ -41,6 +42,7 @@ function InstanceDescription(props: InstanceDescriptionProps) {
return <Descriptions
bordered
size="small"
column={{ xs: 1, md: 3, lg: 3, xl: 3, xxl: 3 }}
>
<Descriptions.Item label="Host">
{!assigned
@@ -64,7 +66,9 @@ function InstanceDescription(props: InstanceDescriptionProps) {
<Descriptions.Item label="Version">
{instance.factorioVersion ?? "unknown"}
</Descriptions.Item>
<Descriptions.Item label="Status"><InstanceStatusTag status={instance.status!} /></Descriptions.Item>
<Descriptions.Item label="Last Start Time">
<MetricRelativeDate timeMs={instance.startedAtMs}/>
</Descriptions.Item>
</Descriptions>;
}
@@ -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: <DeleteOutlined />,
@@ -208,6 +212,7 @@ export default function InstanceViewPage() {
return <PageLayout nav={nav}>
<PageHeader
title={instance.name ?? ""}
status={<InstanceStatusTag status={instance.status} />}
extra={<InstanceButtons instance={instance} />}
/>
<InstanceDescription host={host} instance={instance} />
@@ -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 {
<Space>
<h2 className="page-header-title">{props.title}</h2>
{props.subTitle && <Text type="secondary">{props.subTitle}</Text>}
{props.status ?? null}
</Space>
{props.extra && <div className="page-header-extra">{props.extra}</div>}
</div>;
@@ -63,3 +63,52 @@ export function RestartRequired(props: { system?: lib.SystemInfo }) {
<ReloadOutlined style={{ color: "yellow" }}/>
</Tooltip>;
}
/**
* 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())})`;
}
+1 -1
View File
@@ -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";
+21 -1
View File
@@ -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);