Add log filters to web ui (#746)

* Add max level select to logs

Closes: #745

* Add log filter for server actions

Closes: #744

* Respond to comments

Change name of component, clean up onChange anon function
This commit is contained in:
Cooldude2606 authored and GitHub committed 2025-04-10 16:04:05 +01:00
1 parent 81fb223d5e
commit f3a62e585f
8 files changed
+113 -27

No files matched your search

+1 -1
View File
@@ -191,7 +191,7 @@ module.exports = {
"multiline-comment-style": ["error", "separate-lines"],
"multiline-ternary": "off",
"new-cap": ["error", {
"capIsNewExceptions": ["StringEnum"],
"capIsNewExceptions": ["StringEnum", "StringKey"],
"capIsNewExceptionPattern": "^Type\\.",
}],
"new-parens": "error",
+4
View File
@@ -4,6 +4,10 @@ export function StringEnum<T extends string[]>(values: [...T]) {
return Type.Unsafe<T[number]>({ type: "string", enum: values });
}
export function StringKey<T extends string>(object: Record<T, any>) {
return Type.Unsafe<keyof typeof object>({ type: "string", enum: Object.keys(object) });
}
export interface JSONDeserialisable<T> {
fromJSON(json: unknown): T;
jsonSchema: object;
+3 -3
View File
@@ -1,5 +1,5 @@
import { Type, Static } from "@sinclair/typebox";
import { JsonString, StringEnum, plainJson } from "./composites";
import { JsonString, StringEnum, StringKey, plainJson } from "./composites";
import { levels } from "../logging";
import { ControllerConfig, HostConfig } from "../config";
@@ -217,12 +217,12 @@ export class LogMessageEvent {
static dst = ["controller", "control"] as const;
constructor(
public info: { level: string, message: string },
public info: { level: keyof typeof levels, message: string },
) { }
static jsonSchema = Type.Object({
"info": Type.Object({
"level": Type.String(),
"level": StringKey(levels),
"message": Type.String(),
}),
});
@@ -1,10 +1,10 @@
import React, {useContext} from "react";
import { Button, Descriptions, Popconfirm, Space, Typography } from "antd";
import React, {useContext, useState} from "react";
import { Button, Descriptions, Flex, Popconfirm, Space, Typography } from "antd";
import * as lib from "@clusterio/lib";
import PluginExtra from "./PluginExtra";
import LogConsole from "./LogConsole";
import LogConsole, { SelectMaxLogLevel } from "./LogConsole";
import {
MetricCpuRatio, MetricCpuUsed, MetricMemoryRatio, MetricMemoryUsed,
MetricDiskUsed, MetricDiskRatio,
@@ -26,6 +26,7 @@ export default function ControllerPage() {
const control = useContext(ControlContext);
const [systems] = useSystems();
const system = systems.get("controller");
const [maxLevel, setMaxLevel] = useState<keyof typeof lib.levels>("info");
return <PageLayout nav={[{ name: "Controller" }]}>
<PageHeader
@@ -82,8 +83,15 @@ export default function ControllerPage() {
<Descriptions.Item label="Disk"><MetricDiskUsed system={system} /></Descriptions.Item>
</Descriptions>
{account.hasPermission("core.log.follow") && <>
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
<LogConsole controller={true} />
<Flex justify="space-between" align="baseline">
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
<SelectMaxLogLevel
value={maxLevel}
onChange={setMaxLevel}
hidden={["server"]}
/>
</Flex>
<LogConsole controller={true} maxLevel={maxLevel}/>
</>}
{account.hasPermission("core.controller.get_config") && <ControllerConfigTree />}
<PluginExtra component="ControllerPage" />
@@ -1,5 +1,5 @@
import React, { useContext } from "react";
import { Descriptions, Spin, Tag, Typography, Button, Space, Modal, Popconfirm } from "antd";
import React, { useContext, useState } from "react";
import { Descriptions, Spin, Tag, Typography, Button, Space, Modal, Popconfirm, Flex } from "antd";
import { useParams } from "react-router-dom";
import * as lib from "@clusterio/lib";
@@ -7,7 +7,7 @@ import notify, { notifyErrorHandler } from "../util/notify";
import ControlContext from "./ControlContext";
import HostConfigTree from "./HostConfigTree";
import InstanceList from "./InstanceList";
import LogConsole from "./LogConsole";
import LogConsole, { SelectMaxLogLevel } from "./LogConsole";
import { useAccount } from "../model/account";
import { useInstances } from "../model/instance";
import { useHost } from "../model/host";
@@ -32,6 +32,7 @@ export default function HostViewPage() {
const [systems] = useSystems();
const system = systems.get(hostId);
const [host, synced] = useHost(hostId);
const [maxLevel, setMaxLevel] = useState<keyof typeof lib.levels>("info");
const hostInstances = new Map([...instances].filter(([_id, instance]) => instance.assignedHost === hostId));
let nav = [{ name: "Hosts", path: "/hosts" }, { name: host?.name ?? String(hostId) }];
@@ -141,8 +142,15 @@ export default function HostViewPage() {
<InstanceList instances={hostInstances} size="small" hideAssignedHost />
</>}
{account.hasPermission("core.log.follow") && <>
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
<LogConsole hosts={[hostId]} />
<Flex justify="space-between" align="baseline">
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
<SelectMaxLogLevel
value={maxLevel}
onChange={setMaxLevel}
hidden={["server", "http"]}
/>
</Flex>
<LogConsole hosts={[hostId]} maxLevel={maxLevel}/>
</>}
{account.hasPermission("core.host.get_config") && <HostConfigTree id={hostId} available={host.connected} />}
<PluginExtra component="HostViewPage" host={host} />
@@ -1,6 +1,6 @@
import React, { useContext, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Alert, Button, Descriptions, Dropdown, MenuProps, Modal, Space, Spin, Typography } from "antd";
import { Alert, Button, Descriptions, Dropdown, Flex, MenuProps, Modal, Space, Spin, Switch, Typography } from "antd";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import DownOutlined from "@ant-design/icons/DownOutlined";
@@ -12,7 +12,7 @@ import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import InstanceConfigTree from "./InstanceConfigTree";
import LogConsole from "./LogConsole";
import LogConsole, { SelectMaxLogLevel } from "./LogConsole";
import InstanceRcon from "./InstanceRcon";
import AssignInstanceModal from "./AssignInstanceModal";
import StartStopInstanceButton from "./StartStopInstanceButton";
@@ -185,6 +185,8 @@ function InstanceButtons(props: { instance: lib.InstanceDetails }) {
export default function InstanceViewPage() {
let params = useParams();
let instanceId = Number(params.id);
const [maxLevel, setMaxLevel] = useState<keyof typeof lib.levels>("server");
const [actionsOnly, setActionsOnly] = useState<boolean>(true);
let navigate = useNavigate();
@@ -229,9 +231,30 @@ export default function InstanceViewPage() {
}
{
account.hasAnyPermission("core.log.follow", "core.instance.send_rcon")
&& <Title level={5} style={{ marginTop: 16 }}>Console</Title>
&& <Flex justify="space-between" align="baseline">
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
{
account.hasPermission("core.log.follow")
&& <Flex align="center" gap="middle">
<Switch
checkedChildren="Chat"
unCheckedChildren="Log"
checked={actionsOnly}
onChange={setActionsOnly}
/>
<SelectMaxLogLevel
value={maxLevel}
onChange={setMaxLevel}
hidden={["http"]}
/>
</Flex>
}
</Flex>
}
{
account.hasPermission("core.log.follow")
&& <LogConsole instances={[instanceId]} maxLevel={maxLevel} actionsOnly={actionsOnly}/>
}
{account.hasPermission("core.log.follow") && <LogConsole instances={[instanceId]} />}
{
account.hasPermission("core.instance.send_rcon")
&& <InstanceRcon id={instanceId} disabled={instance.status !== "running"} />
+51 -8
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useContext, useRef, useState, ReactElement } from "react";
import { Typography } from "antd";
import { Select, Typography } from "antd";
import * as lib from "@clusterio/lib";
@@ -51,10 +51,11 @@ function formatParsedOutput(parsed: Parsed, key: number) {
}
type Info = {
level: string;
level: keyof typeof lib.levels;
message: string;
parsed?: Parsed;
};
function formatLog(info: Info, key: number): ReactElement {
if (info.level === "server" && info.parsed) {
return formatParsedOutput(info.parsed, key);
@@ -68,7 +69,30 @@ type LogConsoleProps = {
controller?: boolean;
hosts?: number[];
instances?: number[];
maxLevel?: keyof typeof lib.levels;
actionsOnly?: boolean;
};
export function SelectMaxLogLevel(props: {
value: keyof typeof lib.levels,
hidden?: (keyof typeof lib.levels)[],
onChange: (option: keyof typeof lib.levels) => void
}) {
return <Select
showSearch
style={{ width: 100 }}
defaultValue={props.value}
onChange={props.onChange}
options={(Object.entries(lib.levels) as [keyof typeof lib.levels, number][])
.filter(([level]) => !props.hidden || !props.hidden.includes(level))
.map(([level, index]) => ({
value: level, label: level.charAt(0).toUpperCase() + level.slice(1), index: index,
}))
.sort((a, b) => a.index - b.index)
}
/>;
}
export default function LogConsole(props: LogConsoleProps) {
let account = useAccount();
let control = useContext(ControlContext);
@@ -82,6 +106,7 @@ export default function LogConsole(props: LogConsoleProps) {
controller: props.controller || false,
hostIds: props.hosts || [],
instanceIds: props.instances || [],
maxLevel: props.maxLevel || undefined,
};
// Scroll view to the anchor so it sticks to the bottom
@@ -95,11 +120,19 @@ export default function LogConsole(props: LogConsoleProps) {
logFilter.controller,
logFilter.hostIds,
logFilter.instanceIds,
undefined,
logFilter.maxLevel,
400,
"desc",
)).then(result => {
setPastLines(result.log.map((info, index) => formatLog(info as Info, -index - 1)).reverse());
setPastLines(result.log
.filter(info => (
!props.actionsOnly
|| (info as Info).level !== "server"
|| (info as Info).parsed?.type === "action"
))
.map((info, index) => formatLog(info as Info, -index - 1))
.reverse()
);
}).catch(err => {
setPastLines([<span key={0}>{`Error loading log: ${err.message}`}<br/></span>]);
});
@@ -108,16 +141,26 @@ export default function LogConsole(props: LogConsoleProps) {
}
function logHandler(info: Info) {
setLines(currentLines => currentLines.concat(
[formatLog(info, currentLines.length)]
));
if (!props.actionsOnly || info.level !== "server" || info.parsed?.type === "action") {
setLines(currentLines => currentLines.concat(
[formatLog(info, currentLines.length)]
));
}
}
setLines([]);
control.onLog(logFilter, logHandler);
return () => {
control.offLog(logFilter, logHandler);
};
}, [props.all, props.controller, (props.hosts || []).join(), (props.instances || []).join()]);
}, [
props.all,
props.controller,
(props.hosts || []).join(),
(props.instances || []).join(),
props.maxLevel,
props.actionsOnly,
]);
return <>
<Paragraph code className="instance-console">
+1 -1
View File
@@ -11,7 +11,7 @@ type saveListHandler = (saveListEvent: lib.SaveDetails) => void;
type modPackHandler = (modPack: lib.ModPack) => void;
type modInfoHandler = (modInfo: lib.ModInfo) => void;
type userHandler = (rawUser: lib.User) => void;
type logHandler = (info: { level:string, message:string }) => void;
type logHandler = (info: { level: keyof typeof lib.levels, message: string }) => void;
/**
* Connector for control connection to controller