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:
parent
f87250fd6e
commit
009ba601ef
@ -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);
|
||||
};
|
||||
|
@ -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',
|
||||
};
|
||||
|
@ -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": "권한이 거부되었습니다"
|
||||
};
|
||||
|
@ -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': '无权限',
|
||||
};
|
||||
|
@ -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();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user