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 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);
};

View File

@ -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',
}; };

View File

@ -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": "권한이 거부되었습니다"
}; };

View File

@ -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': '无权限',
}; };

View File

@ -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();
},
}, },
}; };