perf(plugin-logger): permission control & log preview (#1491)

Co-authored-by: wjh <wwwjh0710@163.com>
Reviewed-on: daoyoucloud/tachybase#1491
Co-authored-by: TomyJan <TomyJan6@gmail.com>
Co-committed-by: TomyJan <TomyJan6@gmail.com>
This commit is contained in:
TomyJan 2024-08-29 15:37:11 +08:00 committed by bai.zixv
parent f87250fd6e
commit 009ba601ef
5 changed files with 147 additions and 37 deletions

View File

@ -1,5 +1,5 @@
import React, { useCallback, useMemo } from 'react';
import { useAPIClient, useRequest } from '@tachybase/client';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { CodeMirror, css, useAPIClient, useRequest } from '@tachybase/client';
import { FileOutlined, FolderOutlined } from '@ant-design/icons';
import { useMemoizedFn } from 'ahooks';
@ -46,12 +46,28 @@ export const LogsDownloader = React.memo((props) => {
const [searchValue, setSearchValue] = React.useState('');
const [autoExpandParent, setAutoExpandParent] = React.useState(true);
const [checkedKeys, setCheckedKeys] = React.useState<string[]>([]);
const [checkedName, setCheckedName] = useState();
const { data } = useRequest(() =>
api
.resource('logger')
.list()
.then((res) => res.data?.data),
);
const { data: previewData, run } = useRequest(
{
url: 'logger:preview',
method: 'post',
data: { file: checkedName },
},
{
manual: true,
},
);
useEffect(() => {
if (checkedName) {
run();
}
}, [checkedName]);
const data2tree = useCallback(
(data: Log[], parent: string): DataNode[] =>
data.map((log: Log, index: number) => {
@ -186,31 +202,62 @@ export const LogsDownloader = React.memo((props) => {
<Alert message={''} description={<Tips />} type="info" showIcon />
<Input.Search style={{ marginTop: 16, width: '450px' }} placeholder={t('Search')} onChange={onSearch} />
<div
style={{
maxHeight: '400px',
width: '450px',
overflow: 'auto',
border: '1px solid',
marginTop: '6px',
marginBottom: '10px',
borderColor: token.colorBorder,
}}
className={css`
display: flex;
`}
>
{tree.length ? (
<Tree
checkable
showIcon
showLine
checkedKeys={checkedKeys}
expandedKeys={expandedKeys}
autoExpandParent={autoExpandParent}
onExpand={onExpand}
onCheck={(keys: any) => setCheckedKeys(keys)}
treeData={tree}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
<div
style={{
maxHeight: '400px',
width: '450px',
overflow: 'auto',
border: '1px solid',
marginTop: '6px',
marginBottom: '10px',
borderColor: token.colorBorder,
}}
>
{tree.length ? (
<Tree
checkable
showIcon
showLine
checkedKeys={checkedKeys}
expandedKeys={expandedKeys}
autoExpandParent={autoExpandParent}
onExpand={onExpand}
onCheck={(keys: any) => setCheckedKeys(keys)}
treeData={tree}
selectable={true}
onSelect={(selectedKeys, e: { selected: boolean; selectedNodes; node; event }) => {
if (!e.node.children) {
const name = getTreeNodeName(tree, selectedKeys[0], 0, '');
if (name) {
setCheckedName(name);
} else {
setCheckedName(undefined);
}
}
}}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</div>
<div
style={{
maxHeight: '400px',
overflow: 'auto',
border: '1px solid',
marginTop: '6px',
marginBottom: '10px',
borderColor: token.colorBorder,
width: '1005px',
marginLeft: '10px',
}}
>
{checkedName ? <CodeMirror value={previewData?.data} width="1000px" height="398px" /> : null}
</div>
</div>
<Button type="primary" onClick={Download}>
{t('Download')} (.tar.gz)
@ -219,3 +266,17 @@ export const LogsDownloader = React.memo((props) => {
);
});
LogsDownloader.displayName = 'LogsDownloader';
const getTreeNodeName = (tree, currSelectedKeys, currKey, path) => {
const selectedKeys = currSelectedKeys.split('-');
const key = selectedKeys[currKey];
let currPath = path;
if (currKey > 0) {
currPath += `/${tree[key].title}`;
}
if (currKey === selectedKeys.length - 1) {
return currPath;
}
if (!tree[key].children) return;
return getTreeNodeName(tree[key].children, currSelectedKeys, currKey + 1, currPath);
};

View File

@ -8,4 +8,6 @@ export default {
'Application, database, plugins and other system logs, the error level logs will be sent to',
'SQL execution logs, printed by Sequelize when the db logging is enabled':
'SQL execution logs, printed by Sequelize when the db logging is enabled',
'App not found': 'App not found',
'Permission denied': 'Permission denied',
};

View File

@ -7,5 +7,7 @@ export default {
"Application, database, plugins and other system logs, the error level logs will be sent to":
"응용 프로그램, 데이터베이스, 플러그인 및 기타 시스템 로그, 오류 수준 로그가 전송됩니다:",
"SQL execution logs, printed by Sequelize when the db logging is enabled":
"데이터베이스 로깅이 활성화되어 있을 때 Sequelize에서 인쇄하는 SQL 실행 로그"
"데이터베이스 로깅이 활성화되어 있을 때 Sequelize에서 인쇄하는 SQL 실행 로그",
"App not found": "앱을 찾을 수 없습니다",
"Permission denied": "권한이 거부되었습니다"
};

View File

@ -8,4 +8,6 @@ export default {
'应用、数据库、插件和其他系统日志,错误级别日志将会打印到',
'SQL execution logs, printed by Sequelize when the db logging is enabled':
'SQL 执行日志, 数据库日志启用时Sequelize 打印的 SQL 执行日志',
'App not found': '应用不存在',
'Permission denied': '无权限',
};

View File

@ -1,4 +1,4 @@
import { readdir } from 'fs/promises';
import { readdir, readFile } from 'fs/promises';
import { join } from 'path';
import stream from 'stream';
import zlib from 'zlib';
@ -38,23 +38,35 @@ export default {
name: 'logger',
actions: {
list: async (ctx: Context, next: Next) => {
const appName = ctx.app.name;
if (!appName) {
ctx.throw(400, ctx.t('App not found'));
}
const path = getLoggerFilePath();
const readDir = async (path: string) => {
const readDir = async (path: string, hasPermission: boolean) => {
const fileTree = [];
try {
const files = await readdir(path, { withFileTypes: true });
for (const file of files) {
if (file.isDirectory()) {
const subFiles = await readDir(join(path, file.name));
let hasSubDirPermission = hasPermission;
if (hasSubDirPermission === undefined) {
hasSubDirPermission = appName === 'main' || file.name === appName;
}
const subFiles = await readDir(join(path, file.name), hasSubDirPermission);
if (!subFiles.length) {
continue;
}
fileTree.push({
name: file.name,
files: subFiles,
});
if (hasSubDirPermission) {
fileTree.push({
name: file.name,
files: subFiles,
});
}
} else if (file.name.endsWith('.log')) {
fileTree.push(file.name);
if (hasPermission || appName === 'main' || file.name.startsWith(appName + '.')) {
fileTree.push(file.name);
}
}
}
return fileTree;
@ -63,11 +75,15 @@ export default {
return [];
}
};
const files = await readDir(path);
const files = await readDir(path, undefined);
ctx.body = files;
await next();
},
download: async (ctx: Context, next: Next) => {
const appName = ctx.app.name;
if (!appName) {
ctx.throw(400, ctx.t('App not found'));
}
let { files = [] } = ctx.action.params.values || {};
const invalid = files.some((file: string) => !file.endsWith('.log'));
if (invalid) {
@ -75,7 +91,10 @@ export default {
}
files = files.map((file: string) => {
if (file.startsWith('/')) {
return file.slice(1);
file = file.slice(1);
}
if (appName !== 'main' && !file.startsWith(appName)) {
ctx.throw(401, ctx.t('Permission denied'));
}
return file;
});
@ -88,5 +107,29 @@ export default {
}
await next();
},
preview: async (ctx: Context, next: Next) => {
const appName = ctx.app.name;
if (!appName) {
ctx.throw(400, ctx.t('App not found'));
}
let { file } = ctx.action.params.values || {};
if (!file || !file.endsWith('.log')) {
ctx.throw(400, ctx.t('Invalid file type: ') + file);
}
if (file.startsWith('/')) {
file = file.slice(1);
}
if (appName !== 'main' && !file.startsWith(appName)) {
ctx.throw(401, ctx.t('Permission denied'));
}
try {
const path = getLoggerFilePath();
ctx.body = await readFile(join(path, file), { encoding: 'utf8' });
} catch (err) {
ctx.log.error(`preview error: ${err.message}`, { file, err: err.stack });
ctx.throw(500, ctx.t('Preview logs failed.'));
}
await next();
},
},
};