mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-15 07:24:52 +09:00
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:
1 parent
81fb223d5e
commit
f3a62e585f
8 files changed
+113
-27
No files matched your search
+1
-1
@@ -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,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;
|
||||
|
||||
@@ -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"} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user