system log + frontend update

This commit is contained in:
Roman 2024-03-25 18:37:08 +01:00
parent 716d623db4
commit 5da644acce
11 changed files with 4172 additions and 2943 deletions

@ -17,10 +17,14 @@ namespace Core\API\Logs {
use Core\API\LogsAPI; use Core\API\LogsAPI;
use Core\API\Parameter\Parameter; use Core\API\Parameter\Parameter;
use Core\API\Parameter\StringType; use Core\API\Parameter\StringType;
use Core\API\Traits\Pagination;
use Core\Driver\Logger\Logger; use Core\Driver\Logger\Logger;
use Core\Driver\SQL\Column\Column; use Core\Driver\SQL\Column\Column;
use Core\Driver\SQL\Condition\Compare; use Core\Driver\SQL\Condition\Compare;
use Core\Driver\SQL\Condition\CondAnd;
use Core\Driver\SQL\Condition\CondIn; use Core\Driver\SQL\Condition\CondIn;
use Core\Driver\SQL\Condition\CondLike;
use Core\Driver\SQL\Condition\CondOr;
use Core\Driver\SQL\Query\Insert; use Core\Driver\SQL\Query\Insert;
use Core\Objects\Context; use Core\Objects\Context;
use Core\Objects\DatabaseEntity\Group; use Core\Objects\DatabaseEntity\Group;
@ -28,11 +32,15 @@ namespace Core\API\Logs {
class Get extends LogsAPI { class Get extends LogsAPI {
use Pagination;
public function __construct(Context $context, bool $externalCall = false) { public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, [ $params = self::getPaginationParameters(['id', 'timestamp', "module", "severity"],
"since" => new Parameter("since", Parameter::TYPE_DATE_TIME, true), 'timestamp', 'desc');
"severity" => new StringType("severity", 32, true, "debug") $params["since"] = new Parameter("since", Parameter::TYPE_DATE_TIME, true);
]); $params["severity"] = new StringType("severity", 32, true, "debug");
$params["query"] = new StringType("query", 64, true, null);
parent::__construct($context, $externalCall, $params);
$this->csrfTokenRequired = false; $this->csrfTokenRequired = false;
} }
@ -40,6 +48,7 @@ namespace Core\API\Logs {
$since = $this->getParam("since"); $since = $this->getParam("since");
$sql = $this->context->getSQL(); $sql = $this->context->getSQL();
$severity = strtolower(trim($this->getParam("severity"))); $severity = strtolower(trim($this->getParam("severity")));
$query = $this->getParam("query");
$shownLogLevels = Logger::LOG_LEVELS; $shownLogLevels = Logger::LOG_LEVELS;
$logLevel = array_search($severity, Logger::LOG_LEVELS, true); $logLevel = array_search($severity, Logger::LOG_LEVELS, true);
@ -49,19 +58,23 @@ namespace Core\API\Logs {
$shownLogLevels = array_slice(Logger::LOG_LEVELS, $logLevel); $shownLogLevels = array_slice(Logger::LOG_LEVELS, $logLevel);
} }
$condition = new CondIn(new Column("severity"), $shownLogLevels);
$query = SystemLog::createBuilder($sql, false)
->orderBy("timestamp")
->descending();
if ($since !== null) { if ($since !== null) {
$query->where(new Compare("timestamp", $since, ">=")); $condition = new CondAnd($condition, new Compare("timestamp", $since, ">="));
} }
if ($logLevel > 0) { if ($query) {
$query->where(new CondIn(new Column("severity"), $shownLogLevels)); $condition = new CondAnd($condition, new CondOr(
new CondLike(new Column("message"), "%$query%"),
new CondLike(new Column("module"), "%$query%"),
));
} }
if (!$this->initPagination($sql, SystemLog::class, $condition)) {
return false;
}
$query = $this->createPaginationQuery($sql);
$logEntries = SystemLog::findBy($query); $logEntries = SystemLog::findBy($query);
$this->success = $logEntries !== false; $this->success = $logEntries !== false;
$this->lastError = $sql->getLastError(); $this->lastError = $sql->getLastError();
@ -97,7 +110,7 @@ namespace Core\API\Logs {
if ($content && $date) { if ($content && $date) {
// filter log date // filter log date
if ($since !== null && datetimeDiff($date, $since) < 0) { if ($since !== null && datetimeDiff($date, $since) > 0) {
continue; continue;
} }

@ -148,7 +148,6 @@ namespace Core\API\User {
use ImagickException; use ImagickException;
use Core\Objects\Context; use Core\Objects\Context;
use Core\Objects\DatabaseEntity\GpgKey; use Core\Objects\DatabaseEntity\GpgKey;
use Core\Objects\TwoFactor\KeyBasedTwoFactorToken;
use Core\Objects\DatabaseEntity\User; use Core\Objects\DatabaseEntity\User;
class Create extends UserAPI { class Create extends UserAPI {

@ -674,6 +674,8 @@ function onFrontend(array $argv): void {
$moduleName = strtolower($argv[3]); $moduleName = strtolower($argv[3]);
if (!preg_match("/[a-z0-9_-]/", $moduleName)) { if (!preg_match("/[a-z0-9_-]/", $moduleName)) {
_exit("Module name should only be [a-zA-Z0-9_-]"); _exit("Module name should only be [a-zA-Z0-9_-]");
} else if (in_array($moduleName, ["_tmpl", "dist", "shared"])) {
_exit("Invalid module name");
} }
$templatePath = implode(DIRECTORY_SEPARATOR, [$reactRoot, "_tmpl"]); $templatePath = implode(DIRECTORY_SEPARATOR, [$reactRoot, "_tmpl"]);
@ -741,6 +743,8 @@ function onFrontend(array $argv): void {
$moduleName = strtolower($argv[3]); $moduleName = strtolower($argv[3]);
if (!preg_match("/[a-z0-9_-]/", $moduleName)) { if (!preg_match("/[a-z0-9_-]/", $moduleName)) {
_exit("Module name should only be [a-zA-Z0-9_-]"); _exit("Module name should only be [a-zA-Z0-9_-]");
} else if (in_array($moduleName, ["_tmpl", "dist", "shared"])) {
_exit("This module cannot be removed");
} }
$modulePath = implode(DIRECTORY_SEPARATOR, [$reactRoot, $moduleName]); $modulePath = implode(DIRECTORY_SEPARATOR, [$reactRoot, $moduleName]);

@ -12,6 +12,7 @@ import './res/adminlte.min.css';
// views // views
import View404 from "./views/404"; import View404 from "./views/404";
import LogView from "./views/log-view";
const Overview = lazy(() => import('./views/overview')); const Overview = lazy(() => import('./views/overview'));
const UserListView = lazy(() => import('./views/user/user-list')); const UserListView = lazy(() => import('./views/user/user-list'));
const UserEditView = lazy(() => import('./views/user/user-edit')); const UserEditView = lazy(() => import('./views/user/user-edit'));
@ -70,6 +71,7 @@ export default function AdminDashboard(props) {
<Route path={"/admin/user/:userId"} element={<UserEditView {...controlObj} />}/> <Route path={"/admin/user/:userId"} element={<UserEditView {...controlObj} />}/>
<Route path={"/admin/groups"} element={<GroupListView {...controlObj} />}/> <Route path={"/admin/groups"} element={<GroupListView {...controlObj} />}/>
<Route path={"/admin/group/:groupId"} element={<EditGroupView {...controlObj} />}/> <Route path={"/admin/group/:groupId"} element={<EditGroupView {...controlObj} />}/>
<Route path={"/admin/logs"} element={<LogView {...controlObj} />}/>
<Route path={"*"} element={<View404 />} /> <Route path={"*"} element={<View404 />} />
</Routes> </Routes>
</Suspense> </Suspense>

@ -0,0 +1,142 @@
import {useCallback, useContext, useEffect, useState} from "react";
import {LocaleContext} from "shared/locale";
import {Link, useNavigate} from "react-router-dom";
import usePagination from "shared/hooks/pagination";
import {DataColumn, DataTable, DateTimeColumn, NumericColumn, StringColumn} from "shared/elements/data-table";
import dayjs, { Dayjs } from 'dayjs';
import {Chip, TextField} from "@mui/material";
import {DateTimePicker} from "@mui/x-date-pickers";
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import {API_DATETIME_FORMAT_DAYJS} from "shared/constants";
export default function LogView(props) {
//
const LOG_LEVELS = ['debug', 'info', 'warning', 'error', 'severe'];
const api = props.api;
const showDialog = props.showDialog;
const {translate: L, requestModules, currentLocale} = useContext(LocaleContext);
const navigate = useNavigate();
const pagination = usePagination();
const [logEntries, setLogEntries] = useState([]);
// filters
const [logLevel, setLogLevel] = useState(2);
const [timestamp, setTimestamp] = useState(null);
const [query, setQuery] = useState("");
const [forceReload, setForceReload] = useState(0);
useEffect(() => {
requestModules(props.api, ["general"], currentLocale).then(data => {
if (!data.success) {
props.showDialog("Error fetching translations: " + data.msg);
}
});
}, [currentLocale]);
const onFetchLogs = useCallback((page, count, orderBy, sortOrder) => {
api.fetchLogEntries(page, count, orderBy, sortOrder, LOG_LEVELS[logLevel], timestamp?.format(API_DATETIME_FORMAT_DAYJS), query).then((res) => {
if (res.success) {
setLogEntries(res.logs);
pagination.update(res.pagination);
} else {
showDialog(res.msg, "Error fetching log entries");
return null;
}
});
}, [api, showDialog, logLevel, timestamp, query]);
useEffect(() => {
// TODO: wait for user to finish typing before force reloading
setForceReload(forceReload + 1);
}, [query, timestamp, logLevel]);
const messageColumn = (() => {
let column = new DataColumn(L("message"), "message");
column.renderData = (L, entry) => {
return <pre>{entry.message}</pre>
}
return column;
})();
const columnDefinitions = [
new NumericColumn(L("general.id"), "id"),
new StringColumn(L("module"), "module"),
new StringColumn(L("severity"), "severity"),
new DateTimeColumn(L("timestamp"), "timestamp"),
messageColumn,
];
return <>
<div className={"content-header"}>
<div className={"container-fluid"}>
<div className={"row mb-2"}>
<div className={"col-sm-6"}>
<h1 className={"m-0 text-dark"}>System Log</h1>
</div>
<div className={"col-sm-6"}>
<ol className={"breadcrumb float-sm-right"}>
<li className={"breadcrumb-item"}><Link to={"/admin/dashboard"}>Home</Link></li>
<li className="breadcrumb-item active">System Log</li>
</ol>
</div>
</div>
</div>
<div className={"content overflow-auto"}>
<div className={"row p-2"}>
<div className={"col-2"}>
<div className={"form-group"}>
<label>{L("log.severity")}</label>
<select className={"form-control"} value={logLevel} onChange={e => setLogLevel(parseInt(e.target.value))}>
{LOG_LEVELS.map((value, index) =>
<option key={"option-" + value} value={index}>{value}</option>)
}
</select>
</div>
</div>
<div className={"col-4"}>
<div className={"form-group"}>
<label>{L("log.timestamp")}</label>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DateTimePicker className={"form-control"}
label="Select date time to filter..."
value={timestamp ? dayjs(timestamp) : null}
onChange={(newValue) => {console.log(newValue);
setTimestamp(newValue)
}}
slotProps={{ textField: { size: 'small' } }}
/>
</LocalizationProvider>
</div>
</div>
<div className={"col-6"}>
<div className={"form-group"}>
<label>{L("log.query")}</label>
<TextField
className={"form-control"}
placeholder={L("log.search_query") + "…"}
value={query}
onChange={e => setQuery(e.target.value)}
variant={"outlined"}
size={"small"}/>
</div>
</div>
</div>
<div className={"container-fluid"}>
<DataTable
data={logEntries}
pagination={pagination}
className={"table table-striped"}
fetchData={onFetchLogs}
forceReload={forceReload}
defaultSortColumn={3}
defaultSortOrder={"desc"}
placeholder={"No log entries to display"}
columns={columnDefinitions} />
</div>
</div>
</div>
</>
}

@ -32,23 +32,25 @@
"maxParallelRequests": 1 "maxParallelRequests": 1
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.10.5", "@emotion/react": "^11.11.4",
"@emotion/styled": "^11.10.5", "@emotion/styled": "^11.11.0",
"@material-ui/core": "^4.12.4", "@material-ui/core": "^4.12.4",
"@material-ui/icons": "^4.11.3", "@material-ui/icons": "^4.11.3",
"@material-ui/lab": "^4.0.0-alpha.61", "@material-ui/lab": "^4.0.0-alpha.61",
"@mui/icons-material": "^5.11.0", "@mui/icons-material": "^5.11.0",
"@mui/material": "^5.11.3", "@mui/material": "^5.15.14",
"@mui/x-date-pickers": "^7.0.0",
"chart.js": "^4.0.1", "chart.js": "^4.0.1",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"date-fns": "^2.29.3", "date-fns": "^2.29.3",
"dayjs": "^1.11.10",
"material-ui-color-picker": "^3.5.1", "material-ui-color-picker": "^3.5.1",
"mini-css-extract-plugin": "^2.7.1", "mini-css-extract-plugin": "^2.7.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-chartjs-2": "^5.0.1", "react-chartjs-2": "^5.0.1",
"react-collapse": "^5.1.1", "react-collapse": "^5.1.1",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.6.2", "react-router-dom": "^6.22.3",
"sprintf-js": "^1.1.2" "sprintf-js": "^1.1.2"
}, },
"browserslist": { "browserslist": {

@ -360,4 +360,13 @@ export default class API {
async downloadGPG(userId) { async downloadGPG(userId) {
return this.apiCall("user/downloadGPG", { id: userId }, true); return this.apiCall("user/downloadGPG", { id: userId }, true);
} }
/** Log API **/
async fetchLogEntries(pageNum = 1, count = 20, orderBy = 'id', sortOrder = 'asc',
severity = "debug", since = null, query = "") {
return this.apiCall("logs/get", {
page: pageNum, count: count, orderBy: orderBy, sortOrder: sortOrder,
since: since, severity: severity, query: query
});
}
}; };

@ -1,6 +1,10 @@
export const API_DATETIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
export const API_DATE_FORMAT = "yyyy-MM-dd"; export const API_DATE_FORMAT = "yyyy-MM-dd";
export const API_TIME_FORMAT = "HH:mm:ss"; export const API_TIME_FORMAT = "HH:mm:ss";
export const API_DATETIME_FORMAT = API_DATE_FORMAT + " " + API_TIME_FORMAT;
export const API_DATE_FORMAT_DAYJS = "YYYY-MM-DD";
export const API_TIME_FORMAT_DAYJS = "HH:mm:ss";
export const API_DATETIME_FORMAT_DAYJS = API_DATE_FORMAT_DAYJS + " " + API_TIME_FORMAT_DAYJS;
export const USER_GROUP_ADMIN = 1; export const USER_GROUP_ADMIN = 1;
export const USER_GROUP_SUPPORT = 2; export const USER_GROUP_SUPPORT = 2;

@ -16,6 +16,7 @@ export function DataTable(props) {
columns, data, pagination, columns, data, pagination,
fetchData, onClick, onFilter, fetchData, onClick, onFilter,
defaultSortColumn, defaultSortOrder, defaultSortColumn, defaultSortOrder,
forceReload,
title, ...other } = props; title, ...other } = props;
const {translate: L} = useContext(LocaleContext); const {translate: L} = useContext(LocaleContext);
@ -53,10 +54,10 @@ export function DataTable(props) {
} }
}, [pagination?.data?.pageSize, pagination?.data?.current]); }, [pagination?.data?.pageSize, pagination?.data?.current]);
// sorting changed // sorting changed or we forced an update
useEffect(() => { useEffect(() => {
onFetchData(true); onFetchData(true);
}, [sortAscending, sortColumn]); }, [sortAscending, sortColumn, forceReload]);
let headerRow = []; let headerRow = [];
const onChangeSort = useCallback((index, column) => { const onChangeSort = useCallback((index, column) => {

@ -1,5 +1,5 @@
import {format, parse, formatDistance as formatDistanceDateFns } from "date-fns"; import {format, parse, formatDistance as formatDistanceDateFns } from "date-fns";
import {API_DATE_FORMAT, API_DATETIME_FORMAT} from "./constants"; import {API_DATETIME_FORMAT} from "./constants";
function createDownload(name, data) { function createDownload(name, data) {
const url = window.URL.createObjectURL(new Blob([data])); const url = window.URL.createObjectURL(new Blob([data]));

File diff suppressed because it is too large Load Diff