diff --git a/.env.example b/.env.example index 7a39ba96d8..758deb8a35 100644 --- a/.env.example +++ b/.env.example @@ -20,13 +20,13 @@ API_BASE_URL= # console | file | dailyRotateFile LOGGER_TRANSPORT= LOGGER_BASE_PATH=storage/logs -# error | warn | info | debug +# error | warn | info | debug | trace LOGGER_LEVEL= # If LOGGER_TRANSPORT is dailyRotateFile and using days, add 'd' as the suffix. LOGGER_MAX_FILES= # add 'k', 'm', 'g' as the suffix. LOGGER_MAX_SIZE= -# json | splitter, split by '|' character +# console | json | logfmt | delimiter LOGGER_FORMAT= ################# DATABASE ################# diff --git a/packages/core/client/src/locale/en_US.json b/packages/core/client/src/locale/en_US.json index 11a70b294e..2e28cf9347 100644 --- a/packages/core/client/src/locale/en_US.json +++ b/packages/core/client/src/locale/en_US.json @@ -764,6 +764,7 @@ "App error": "App error", "Feedback": "Feedback", "Try again": "Try again", + "Download logs": "Download logs", "Data template": "Data template", "Duplicate": "Duplicate", "Duplicating": "Duplicating", diff --git a/packages/core/client/src/locale/zh-CN.json b/packages/core/client/src/locale/zh-CN.json index 24e2b81155..6322e8048e 100644 --- a/packages/core/client/src/locale/zh-CN.json +++ b/packages/core/client/src/locale/zh-CN.json @@ -795,6 +795,7 @@ "Render Failed": "渲染失败", "Feedback": "反馈问题", "Try again": "重试一下", + "Download logs": "下载日志", "Download": "下载", "Click or drag file to this area to upload": "点击或拖拽文件到此区域上传", "Support for a single or bulk upload.": "支持单个或批量上传", diff --git a/packages/core/client/src/schema-component/antd/error-fallback/ErrorFallback.tsx b/packages/core/client/src/schema-component/antd/error-fallback/ErrorFallback.tsx index 0e0af62082..17a3986208 100644 --- a/packages/core/client/src/schema-component/antd/error-fallback/ErrorFallback.tsx +++ b/packages/core/client/src/schema-component/antd/error-fallback/ErrorFallback.tsx @@ -9,17 +9,57 @@ import { Button, Result, Typography } from 'antd'; import React, { FC } from 'react'; -import { FallbackProps, useErrorBoundary } from 'react-error-boundary'; +import { FallbackProps } from 'react-error-boundary'; import { Trans, useTranslation } from 'react-i18next'; import { ErrorFallbackModal } from './ErrorFallbackModal'; +import { useAPIClient } from '../../../api-client'; +import { useFieldSchema } from '@formily/react'; +import { useLocation } from 'react-router-dom'; const { Paragraph, Text, Link } = Typography; +export const useDownloadLogs = (error: any, data: Record = {}) => { + const location = useLocation(); + const [loading, setLoading] = React.useState(false); + const api = useAPIClient(); + return { + loading, + download: async () => { + setLoading(true); + try { + const res = await api.request({ + url: 'logger:collect', + method: 'post', + responseType: 'blob', + data: { + error: { + message: error.message, + stack: error.stack, + }, + location, + ...data, + }, + }); + const url = window.URL.createObjectURL(new Blob([res.data], { type: 'application/gzip' })); + const link = document.createElement('a'); + link.setAttribute('href', url); + link.setAttribute('download', 'logs.tar.gz'); + link.click(); + link.remove(); + } catch (err) { + console.log(err); + } + setLoading(false); + }, + }; +}; + export const ErrorFallback: FC & { Modal: FC; } = ({ error }) => { - const { resetBoundary } = useErrorBoundary(); + const schema = useFieldSchema(); const { t } = useTranslation(); + const { loading, download } = useDownloadLogs(error, { schema }); const subTitle = ( @@ -41,8 +81,8 @@ export const ErrorFallback: FC & { , - , ]} > diff --git a/packages/core/client/src/schema-component/antd/error-fallback/__tests__/components/basic.tsx b/packages/core/client/src/schema-component/antd/error-fallback/__tests__/components/basic.tsx deleted file mode 100644 index 458d7fa36b..0000000000 --- a/packages/core/client/src/schema-component/antd/error-fallback/__tests__/components/basic.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; -import { ErrorBoundary } from 'react-error-boundary'; -import { ErrorFallback } from '../../ErrorFallback'; - -const App = () => { - throw new Error('error message'); -}; - -export default () => { - return ( - - - - ); -}; diff --git a/packages/core/client/src/schema-component/antd/error-fallback/__tests__/components/modal.tsx b/packages/core/client/src/schema-component/antd/error-fallback/__tests__/components/modal.tsx deleted file mode 100644 index 34147acb51..0000000000 --- a/packages/core/client/src/schema-component/antd/error-fallback/__tests__/components/modal.tsx +++ /dev/null @@ -1,24 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -import React from 'react'; -import { ErrorBoundary } from 'react-error-boundary'; -import { ErrorFallback } from '../../ErrorFallback'; - -const App = () => { - throw new Error('error message'); -}; - -export default () => { - return ( - - - - ); -}; diff --git a/packages/core/client/src/schema-component/antd/error-fallback/__tests__/error-fallback.test.tsx b/packages/core/client/src/schema-component/antd/error-fallback/__tests__/error-fallback.test.tsx index fd6ef47b78..e088e1a9f4 100644 --- a/packages/core/client/src/schema-component/antd/error-fallback/__tests__/error-fallback.test.tsx +++ b/packages/core/client/src/schema-component/antd/error-fallback/__tests__/error-fallback.test.tsx @@ -7,19 +7,19 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { render, screen, userEvent, waitFor } from '@nocobase/test/client'; +import { renderApp, screen, userEvent, waitFor } from '@nocobase/test/client'; import React from 'react'; -import App1 from './components/basic'; -import InlineApp from './components/modal'; +import App1 from '../demos/basic'; +import ModalApp from '../demos/modal'; describe('ErrorFallback', () => { - it('should render correctly', () => { - render(); + it('should render correctly', async () => { + await renderApp(); expect(screen.getByText(/render failed/i)).toBeInTheDocument(); expect(screen.getByText(/this is likely a nocobase internals bug\. please open an issue at/i)).toBeInTheDocument(); expect(screen.getByRole('link', { name: /feedback/i })).toBeInTheDocument(); - expect(screen.getByText(/try again/i)).toBeInTheDocument(); + expect(screen.getByText(/download logs/i)).toBeInTheDocument(); expect(screen.getByText(/error: error message/i)).toBeInTheDocument(); // 底部复制按钮 @@ -27,7 +27,7 @@ describe('ErrorFallback', () => { }); it('should render inline correctly', async () => { - render(); + await renderApp(); expect(screen.getByText(/Error: error message/i)).toBeInTheDocument(); expect(document.querySelector('.ant-typography-copy')).toBeInTheDocument(); diff --git a/packages/core/client/src/schema-component/antd/error-fallback/demos/basic.tsx b/packages/core/client/src/schema-component/antd/error-fallback/demos/basic.tsx new file mode 100644 index 0000000000..1a2451f72f --- /dev/null +++ b/packages/core/client/src/schema-component/antd/error-fallback/demos/basic.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; +import { ErrorFallback } from '../ErrorFallback'; +import { Plugin } from '@nocobase/client'; +import { mockApp } from '@nocobase/client/demo-utils'; + +const App = () => { + throw new Error('error message'); +}; + +const Demo = () => { + return ( + + + + ); +}; + +class DemoPlugin extends Plugin { + async load() { + this.app.router.add('root', { path: '/', Component: Demo }); + } +} + +const app = mockApp({ plugins: [DemoPlugin] }); + +export default app.getRootComponent(); diff --git a/packages/core/client/src/schema-component/antd/error-fallback/demos/modal.tsx b/packages/core/client/src/schema-component/antd/error-fallback/demos/modal.tsx new file mode 100644 index 0000000000..5b1ceb26d5 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/error-fallback/demos/modal.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; +import { ErrorFallback } from '../ErrorFallback'; +import { mockApp } from '@nocobase/client/demo-utils'; +import { Plugin } from '@nocobase/client'; + +const App = () => { + throw new Error('error message'); +}; + +const Demo = () => { + return ( + + + + ); +}; + +class DemoPlugin extends Plugin { + async load() { + this.app.router.add('root', { path: '/', Component: Demo }); + } +} + +const app = mockApp({ plugins: [DemoPlugin] }); + +export default app.getRootComponent(); diff --git a/packages/core/client/src/schema-component/antd/error-fallback/demos/new-demos/basic.tsx b/packages/core/client/src/schema-component/antd/error-fallback/demos/new-demos/basic.tsx deleted file mode 100644 index 0b7181ad6b..0000000000 --- a/packages/core/client/src/schema-component/antd/error-fallback/demos/new-demos/basic.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react'; -import { ErrorBoundary } from 'react-error-boundary'; -import { ErrorFallback } from '@nocobase/client'; -import { Button } from 'antd'; - -const App = () => { - const [showError, setShowError] = React.useState(false); - - if (showError) { - throw new Error('error message'); - } - - return ( - - ); -}; - -export default () => { - return ( - - - - ); -}; diff --git a/packages/core/client/src/schema-component/antd/error-fallback/demos/new-demos/modal.tsx b/packages/core/client/src/schema-component/antd/error-fallback/demos/new-demos/modal.tsx deleted file mode 100644 index c4f01b6dfe..0000000000 --- a/packages/core/client/src/schema-component/antd/error-fallback/demos/new-demos/modal.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react'; -import { ErrorBoundary } from 'react-error-boundary'; -import { ErrorFallback } from '@nocobase/client'; -import { Button } from 'antd'; - -const App = () => { - const [showError, setShowError] = React.useState(false); - - if (showError) { - throw new Error('error message'); - } - - return ( - - ); -}; - -export default () => { - return ( - - - - ); -}; diff --git a/packages/core/client/src/schema-component/antd/error-fallback/index.en-US.md b/packages/core/client/src/schema-component/antd/error-fallback/index.en-US.md index e1809572df..373e858a7f 100644 --- a/packages/core/client/src/schema-component/antd/error-fallback/index.en-US.md +++ b/packages/core/client/src/schema-component/antd/error-fallback/index.en-US.md @@ -6,8 +6,8 @@ Based on the [react-error-boundary](https://github.com/bvaughn/react-error-bound ## Basic - + ## Modal - + diff --git a/packages/core/client/src/schema-component/antd/error-fallback/index.md b/packages/core/client/src/schema-component/antd/error-fallback/index.md index fa9cbfafaf..0a5cc9e316 100644 --- a/packages/core/client/src/schema-component/antd/error-fallback/index.md +++ b/packages/core/client/src/schema-component/antd/error-fallback/index.md @@ -8,8 +8,8 @@ ## Basic - + ## Modal - + diff --git a/packages/core/database/src/database.ts b/packages/core/database/src/database.ts index 48b19b28c8..364c612245 100644 --- a/packages/core/database/src/database.ts +++ b/packages/core/database/src/database.ts @@ -238,9 +238,15 @@ export class Database extends EventEmitter implements AsyncEmitter { } this.options = opts; - this.logger.debug(`create database instance: ${safeJsonStringify(this.options)}`, { - databaseInstanceId: this.instanceId, - }); + this.logger.debug( + `create database instance: ${safeJsonStringify( + // remove sensitive information + lodash.omit(this.options, ['storage', 'host', 'password']), + )}`, + { + databaseInstanceId: this.instanceId, + }, + ); const sequelizeOptions = this.sequelizeOptions(this.options); this.sequelize = new Sequelize(sequelizeOptions); diff --git a/packages/plugins/@nocobase/plugin-logger/src/server/resourcer/logger.ts b/packages/plugins/@nocobase/plugin-logger/src/server/resourcer/logger.ts index 221f594d7f..b65945c2f6 100644 --- a/packages/plugins/@nocobase/plugin-logger/src/server/resourcer/logger.ts +++ b/packages/plugins/@nocobase/plugin-logger/src/server/resourcer/logger.ts @@ -9,11 +9,48 @@ import { Context, Next } from '@nocobase/actions'; import { getLoggerFilePath } from '@nocobase/logger'; -import { readdir } from 'fs/promises'; +import { readdir, stat } from 'fs/promises'; import { join } from 'path'; import stream from 'stream'; import { pack } from 'tar-fs'; import zlib from 'zlib'; +import lodash from 'lodash'; + +const envVars = [ + 'APP_ENV', + 'APP_PORT', + 'API_BASE_PATH', + 'API_BASE_URL', + 'DB_DIALECT', + 'DB_TABLE_PREFIX', + 'DB_UNDERSCORED', + 'DB_TIMEZONE', + 'DB_LOGGING', + 'LOGGER_TRANSPORT', + 'LOGGER_LEVEL', +]; + +const getLastestLogs = async (path: string) => { + const files = await readdir(path); + const prefixes = ['request', 'sql', 'system', 'system_error']; + const logs = files.filter((file) => file.endsWith('.log') && prefixes.some((prefix) => file.startsWith(prefix))); + if (!logs.length) { + return []; + } + const mtime = async (file: string) => { + const info = await stat(join(path, file)); + return [file, info.mtime]; + }; + const logsWithTime = await Promise.all(logs.map(mtime)); + const getLatestLog = (prefix: string) => { + const logs = logsWithTime.filter((file) => (file[0] as string).startsWith(prefix)); + if (!logs.length) { + return null; + } + return logs.reduce((a, b) => (a[1] > b[1] ? a : b))[0] as string; + }; + return prefixes.map(getLatestLog).filter((file) => file); +}; const tarFiles = (path: string, files: string[]): Promise => { return new Promise((resolve, reject) => { @@ -97,5 +134,29 @@ export default { } await next(); }, + collect: async (ctx: Context, next: Next) => { + const { error, ...info } = ctx.action.params.values || {}; + const { message, ...e } = error || {}; + ctx.log.error({ message: `Diagnosis, frontend error, ${message}`, ...e }); + ctx.log.error(`Diagnostic information`, info); + ctx.log.error('Diagnosis, environment variables', lodash.pick(process.env, envVars)); + + const path = getLoggerFilePath(ctx.app.name || 'main'); + const files = await getLastestLogs(path); + if (!files.length) { + ctx.throw( + 404, + ctx.t('No log files found. Please check the LOGGER_TRANSPORTS environment variable configuration.'), + ); + } + try { + ctx.attachment('logs.tar.gz'); + ctx.body = await tarFiles(path, files); + } catch (err) { + ctx.log.error(`download error: ${err.message}`, { files, err: err.stack }); + ctx.throw(500, ctx.t('Download logs failed.')); + } + await next(); + }, }, };