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 React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useAPIClient, useRequest } from '@tachybase/client';
|
import { CodeMirror, css, useAPIClient, useRequest } from '@tachybase/client';
|
||||||
|
|
||||||
import { FileOutlined, FolderOutlined } from '@ant-design/icons';
|
import { FileOutlined, FolderOutlined } from '@ant-design/icons';
|
||||||
import { useMemoizedFn } from 'ahooks';
|
import { useMemoizedFn } from 'ahooks';
|
||||||
@ -46,12 +46,28 @@ export const LogsDownloader = React.memo((props) => {
|
|||||||
const [searchValue, setSearchValue] = React.useState('');
|
const [searchValue, setSearchValue] = React.useState('');
|
||||||
const [autoExpandParent, setAutoExpandParent] = React.useState(true);
|
const [autoExpandParent, setAutoExpandParent] = React.useState(true);
|
||||||
const [checkedKeys, setCheckedKeys] = React.useState<string[]>([]);
|
const [checkedKeys, setCheckedKeys] = React.useState<string[]>([]);
|
||||||
|
const [checkedName, setCheckedName] = useState();
|
||||||
const { data } = useRequest(() =>
|
const { data } = useRequest(() =>
|
||||||
api
|
api
|
||||||
.resource('logger')
|
.resource('logger')
|
||||||
.list()
|
.list()
|
||||||
.then((res) => res.data?.data),
|
.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(
|
const data2tree = useCallback(
|
||||||
(data: Log[], parent: string): DataNode[] =>
|
(data: Log[], parent: string): DataNode[] =>
|
||||||
data.map((log: Log, index: number) => {
|
data.map((log: Log, index: number) => {
|
||||||
@ -186,31 +202,62 @@ export const LogsDownloader = React.memo((props) => {
|
|||||||
<Alert message={''} description={<Tips />} type="info" showIcon />
|
<Alert message={''} description={<Tips />} type="info" showIcon />
|
||||||
<Input.Search style={{ marginTop: 16, width: '450px' }} placeholder={t('Search')} onChange={onSearch} />
|
<Input.Search style={{ marginTop: 16, width: '450px' }} placeholder={t('Search')} onChange={onSearch} />
|
||||||
<div
|
<div
|
||||||
style={{
|
className={css`
|
||||||
maxHeight: '400px',
|
display: flex;
|
||||||
width: '450px',
|
`}
|
||||||
overflow: 'auto',
|
|
||||||
border: '1px solid',
|
|
||||||
marginTop: '6px',
|
|
||||||
marginBottom: '10px',
|
|
||||||
borderColor: token.colorBorder,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{tree.length ? (
|
<div
|
||||||
<Tree
|
style={{
|
||||||
checkable
|
maxHeight: '400px',
|
||||||
showIcon
|
width: '450px',
|
||||||
showLine
|
overflow: 'auto',
|
||||||
checkedKeys={checkedKeys}
|
border: '1px solid',
|
||||||
expandedKeys={expandedKeys}
|
marginTop: '6px',
|
||||||
autoExpandParent={autoExpandParent}
|
marginBottom: '10px',
|
||||||
onExpand={onExpand}
|
borderColor: token.colorBorder,
|
||||||
onCheck={(keys: any) => setCheckedKeys(keys)}
|
}}
|
||||||
treeData={tree}
|
>
|
||||||
/>
|
{tree.length ? (
|
||||||
) : (
|
<Tree
|
||||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
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>
|
</div>
|
||||||
<Button type="primary" onClick={Download}>
|
<Button type="primary" onClick={Download}>
|
||||||
{t('Download')} (.tar.gz)
|
{t('Download')} (.tar.gz)
|
||||||
@ -219,3 +266,17 @@ export const LogsDownloader = React.memo((props) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
LogsDownloader.displayName = 'LogsDownloader';
|
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',
|
'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':
|
||||||
'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":
|
"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":
|
||||||
"데이터베이스 로깅이 활성화되어 있을 때 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 execution logs, printed by Sequelize when the db logging is enabled':
|
||||||
'SQL 执行日志, 数据库日志启用时,Sequelize 打印的 SQL 执行日志',
|
'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 { join } from 'path';
|
||||||
import stream from 'stream';
|
import stream from 'stream';
|
||||||
import zlib from 'zlib';
|
import zlib from 'zlib';
|
||||||
@ -38,23 +38,35 @@ export default {
|
|||||||
name: 'logger',
|
name: 'logger',
|
||||||
actions: {
|
actions: {
|
||||||
list: async (ctx: Context, next: Next) => {
|
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 path = getLoggerFilePath();
|
||||||
const readDir = async (path: string) => {
|
const readDir = async (path: string, hasPermission: boolean) => {
|
||||||
const fileTree = [];
|
const fileTree = [];
|
||||||
try {
|
try {
|
||||||
const files = await readdir(path, { withFileTypes: true });
|
const files = await readdir(path, { withFileTypes: true });
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file.isDirectory()) {
|
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) {
|
if (!subFiles.length) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
fileTree.push({
|
if (hasSubDirPermission) {
|
||||||
name: file.name,
|
fileTree.push({
|
||||||
files: subFiles,
|
name: file.name,
|
||||||
});
|
files: subFiles,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else if (file.name.endsWith('.log')) {
|
} else if (file.name.endsWith('.log')) {
|
||||||
fileTree.push(file.name);
|
if (hasPermission || appName === 'main' || file.name.startsWith(appName + '.')) {
|
||||||
|
fileTree.push(file.name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return fileTree;
|
return fileTree;
|
||||||
@ -63,11 +75,15 @@ export default {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const files = await readDir(path);
|
const files = await readDir(path, undefined);
|
||||||
ctx.body = files;
|
ctx.body = files;
|
||||||
await next();
|
await next();
|
||||||
},
|
},
|
||||||
download: async (ctx: Context, next: 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 || {};
|
let { files = [] } = ctx.action.params.values || {};
|
||||||
const invalid = files.some((file: string) => !file.endsWith('.log'));
|
const invalid = files.some((file: string) => !file.endsWith('.log'));
|
||||||
if (invalid) {
|
if (invalid) {
|
||||||
@ -75,7 +91,10 @@ export default {
|
|||||||
}
|
}
|
||||||
files = files.map((file: string) => {
|
files = files.map((file: string) => {
|
||||||
if (file.startsWith('/')) {
|
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;
|
return file;
|
||||||
});
|
});
|
||||||
@ -88,5 +107,29 @@ export default {
|
|||||||
}
|
}
|
||||||
await next();
|
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