diff --git a/application/Espo/Controllers/AppLogRecord.php b/application/Espo/Controllers/AppLogRecord.php new file mode 100644 index 0000000000..5f813e1c34 --- /dev/null +++ b/application/Espo/Controllers/AppLogRecord.php @@ -0,0 +1,64 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Controllers; + +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Controllers\Record; +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use stdClass; + +class AppLogRecord extends Record +{ + protected function checkAccess(): bool + { + return $this->user->isAdmin(); + } + + public function postActionCreate(Request $request, Response $response): stdClass + { + throw new Forbidden(); + } + + public function putActionUpdate(Request $request, Response $response): stdClass + { + throw new Forbidden(); + } + + public function postActionCreateLink(Request $request): bool + { + throw new Forbidden(); + } + + public function deleteActionRemoveLink(Request $request): bool + { + throw new Forbidden(); + } +} diff --git a/application/Espo/Core/Log/Handler/DatabaseHandler.php b/application/Espo/Core/Log/Handler/DatabaseHandler.php new file mode 100644 index 0000000000..d56449e53c --- /dev/null +++ b/application/Espo/Core/Log/Handler/DatabaseHandler.php @@ -0,0 +1,120 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Log\Handler; + +use Espo\Core\ORM\EntityManagerProxy; +use Espo\Entities\AppLogRecord; +use Monolog\Handler\HandlerInterface; +use Monolog\Level; +use Monolog\LogRecord; +use Throwable; + +class DatabaseHandler implements HandlerInterface +{ + public function __construct( + private Level $level, + private EntityManagerProxy $entityManager + ) {} + + public function isHandling(LogRecord $record): bool + { + return $record->level->value >= $this->level->value; + } + + public function handle(LogRecord $record): bool + { + if (!$this->isHandling($record)) { + return false; + } + + try { + $logRecord = $this->entityManager->getRDBRepositoryByClass(AppLogRecord::class)->getNew(); + + $level = ucfirst($record->level->toPsrLogLevel()); + $message = $this->interpolate($record, $record->message); + + $logRecord + ->setLevel($level) + ->setMessage($message); + + $this->setException($record, $logRecord); + + $this->entityManager->saveEntity($logRecord); + } + catch (Throwable) { + // Nowhere to log. + + return false; + } + + return true; + } + + private function interpolate(LogRecord $record, string $line): string + { + $replace = []; + + foreach ($record->context as $key => $val) { + if (!is_array($val) && (!is_object($val) || method_exists($val, '__toString'))) { + $replace['{' . $key . '}'] = $val; + } + } + + return strtr($line, $replace); + } + + /** + * @param LogRecord[] $records + */ + public function handleBatch(array $records): void + { + foreach ($records as $record) { + $this->handle($record); + } + } + + public function close(): void + {} + + private function setException(LogRecord $record, AppLogRecord $logRecord): void + { + $exception = $record->context['exception'] ?? null; + + if (!$exception instanceof Throwable) { + return; + } + + $logRecord + ->setExceptionClass(get_class($exception)) + ->setFile($exception->getFile()) + ->setLine($exception->getLine()) + ->setCode($exception->getCode()); + } +} diff --git a/application/Espo/Core/Log/LogLoader.php b/application/Espo/Core/Log/LogLoader.php index 7330ff9fa1..d609febea6 100644 --- a/application/Espo/Core/Log/LogLoader.php +++ b/application/Espo/Core/Log/LogLoader.php @@ -29,8 +29,10 @@ namespace Espo\Core\Log; +use Espo\Core\Log\Handler\DatabaseHandler; use Espo\Core\Log\Handler\EspoFileHandler; use Espo\Core\Log\Handler\EspoRotatingFileHandler; +use Espo\Core\ORM\EntityManagerProxy; use Espo\Core\Utils\Config; use Espo\Core\Utils\Log; @@ -47,7 +49,8 @@ class LogLoader public function __construct( private readonly Config $config, - private readonly HandlerListLoader $handlerListLoader + private readonly HandlerListLoader $handlerListLoader, + private readonly EntityManagerProxy $entityManagerProxy ) {} public function load(): Log @@ -65,6 +68,10 @@ class LogLoader $handlerList = [$this->createDefaultHandler()]; } + if ($this->config->get('loggerDatabase')) { + $handlerList[] = $this->createDatabaseHandler(); + } + foreach ($handlerList as $handler) { $log->pushHandler($handler); } @@ -105,4 +112,14 @@ class LogLoader { return (bool) $this->config->get('logger.printTrace'); } + + private function createDatabaseHandler(): HandlerInterface + { + $rawLevel = $this->config->get('logger.level') ?? + self::DEFAULT_LEVEL; + + $level = Logger::toMonologLevel($rawLevel); + + return new DatabaseHandler($level, $this->entityManagerProxy); + } } diff --git a/application/Espo/Entities/AppLogRecord.php b/application/Espo/Entities/AppLogRecord.php new file mode 100644 index 0000000000..bae6bada64 --- /dev/null +++ b/application/Espo/Entities/AppLogRecord.php @@ -0,0 +1,91 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Entities; + +use Espo\Core\ORM\Entity; + +class AppLogRecord extends Entity +{ + public const ENTITY_TYPE = 'AppLogRecord'; + + public function setMessage(string $message): self + { + $this->set('message', $message); + + return $this; + } + + public function setLevel(string $level): self + { + $this->set('level', $level); + + return $this; + } + + public function setCode(?int $code): self + { + $this->set('code', $code); + + return $this; + } + + public function setExceptionClass(?string $exceptionClass): self + { + $len = $this->getAttributeParam('exceptionClass', 'len'); + + if ($exceptionClass && strlen($exceptionClass) > $len) { + $exceptionClass = substr($exceptionClass, $len); + } + + $this->set('exceptionClass', $exceptionClass); + + return $this; + } + + public function setFile(?string $file): self + { + $len = $this->getAttributeParam('file', 'len'); + + if ($file && strlen($file) > $len) { + $file = substr($file, $len); + } + + $this->set('file', $file); + + return $this; + } + + public function setLine(?int $code): self + { + $this->set('line', $code); + + return $this; + } +} diff --git a/application/Espo/Resources/defaults/config.php b/application/Espo/Resources/defaults/config.php index 00d679ef0d..897d663316 100644 --- a/application/Espo/Resources/defaults/config.php +++ b/application/Espo/Resources/defaults/config.php @@ -295,5 +295,6 @@ return [ 'listPagination' => true, 'starsLimit' => 500, 'quickSearchFullTextAppendWildcard' => false, + 'loggerDatabase' => false, 'isInstalled' => false, ]; diff --git a/application/Espo/Resources/defaults/systemConfig.php b/application/Espo/Resources/defaults/systemConfig.php index bce77e79ba..373fbd367e 100644 --- a/application/Espo/Resources/defaults/systemConfig.php +++ b/application/Espo/Resources/defaults/systemConfig.php @@ -53,6 +53,7 @@ return [ 'database', 'crud', 'logger', + 'loggerDatabase', 'isInstalled', 'systemUser', 'defaultPermissions', diff --git a/application/Espo/Resources/i18n/en_US/Admin.json b/application/Espo/Resources/i18n/en_US/Admin.json index 1b9a416d12..1c9225732d 100644 --- a/application/Espo/Resources/i18n/en_US/Admin.json +++ b/application/Espo/Resources/i18n/en_US/Admin.json @@ -37,6 +37,7 @@ "User Interface": "User Interface", "Auth Tokens": "Auth Tokens", "Auth Log": "Auth Log", + "App Log": "App Log", "Authentication": "Authentication", "Currency": "Currency", "Integrations": "Integrations", @@ -293,6 +294,7 @@ "labelManager": "Customize application labels.", "templateManager": "Customize message templates.", "authLog": "Login history.", + "appLog": "Application log.", "leadCapture": "API entry points for Web-to-Lead.", "attachments": "All file attachments stored in the system.", "systemRequirements": "System Requirements for EspoCRM.", diff --git a/application/Espo/Resources/i18n/en_US/AppLogRecord.json b/application/Espo/Resources/i18n/en_US/AppLogRecord.json new file mode 100644 index 0000000000..8013998a2c --- /dev/null +++ b/application/Espo/Resources/i18n/en_US/AppLogRecord.json @@ -0,0 +1,10 @@ +{ + "fields": { + "message": "Message", + "code": "Code", + "level": "Level", + "exceptionClass": "Exception Class", + "file": "File", + "line": "Line" + } +} diff --git a/application/Espo/Resources/i18n/en_US/Global.json b/application/Espo/Resources/i18n/en_US/Global.json index 569798285d..b4970b445a 100644 --- a/application/Espo/Resources/i18n/en_US/Global.json +++ b/application/Espo/Resources/i18n/en_US/Global.json @@ -47,6 +47,7 @@ "Preferences": "Preferences", "EmailAddress": "Email Address", "PhoneNumber": "Phone Number", + "AppLogRecord": "App Log Record", "AuthLogRecord": "Auth Log Record", "AuthFailLogRecord": "Auth Fail Log Record", "LeadCapture": "Lead Capture Entry Point", @@ -100,6 +101,7 @@ "AuthToken": "Auth Tokens", "UniqueId": "Unique IDs", "LastViewed": "Last Viewed", + "AppLogRecord": "App Log", "AuthLogRecord": "Auth Log", "AuthFailLogRecord": "Auth Fail Log", "LeadCapture": "Lead Capture", diff --git a/application/Espo/Resources/layouts/AppLogRecord/detail.json b/application/Espo/Resources/layouts/AppLogRecord/detail.json new file mode 100644 index 0000000000..7ad79dcea3 --- /dev/null +++ b/application/Espo/Resources/layouts/AppLogRecord/detail.json @@ -0,0 +1,11 @@ +[ + { + "rows": [ + [{"name": "level"}, {"name": "code"}], + [{"name": "message"}], + [{"name": "exceptionClass"}], + [{"name": "file"}], + [{"name": "line"}, false] + ] + } +] diff --git a/application/Espo/Resources/layouts/AppLogRecord/detailSmall.json b/application/Espo/Resources/layouts/AppLogRecord/detailSmall.json new file mode 100644 index 0000000000..c1c7a160dd --- /dev/null +++ b/application/Espo/Resources/layouts/AppLogRecord/detailSmall.json @@ -0,0 +1,11 @@ +[ + { + "rows": [ + [{"name":"level"}, {"name": "code"}], + [{"name": "message"}], + [{"name": "exceptionClass"}], + [{"name": "file"}], + [{"name": "line"}, false] + ] + } +] diff --git a/application/Espo/Resources/layouts/AppLogRecord/filters.json b/application/Espo/Resources/layouts/AppLogRecord/filters.json new file mode 100644 index 0000000000..fd80cf2dc9 --- /dev/null +++ b/application/Espo/Resources/layouts/AppLogRecord/filters.json @@ -0,0 +1,8 @@ +[ + "createdAt", + "level", + "code", + "exceptionClass", + "file", + "line" +] diff --git a/application/Espo/Resources/layouts/AppLogRecord/list.json b/application/Espo/Resources/layouts/AppLogRecord/list.json new file mode 100644 index 0000000000..2aa72cc31a --- /dev/null +++ b/application/Espo/Resources/layouts/AppLogRecord/list.json @@ -0,0 +1,16 @@ +[ + {"name": "level", "widthPx": 100}, + {"name": "code", "widthPx": 80}, + {"name": "message"}, + {"name": "createdAt", "width": 13}, + { + "name": "exceptionClass", + "width": 20, + "hidden": true + }, + { + "name": "file", + "width": 20, + "hidden": true + } +] diff --git a/application/Espo/Resources/metadata/app/adminPanel.json b/application/Espo/Resources/metadata/app/adminPanel.json index 2bfeb93d34..34eebf13b2 100644 --- a/application/Espo/Resources/metadata/app/adminPanel.json +++ b/application/Espo/Resources/metadata/app/adminPanel.json @@ -331,6 +331,12 @@ "label": "Phone Numbers", "iconClass": "fas fa-phone", "description": "phoneNumbers" + }, + { + "url": "#Admin/appLog", + "label": "App Log", + "iconClass": "fas fa-list", + "description": "appLog" } ], "order": 25 diff --git a/application/Espo/Resources/metadata/clientDefs/AppLogRecord.json b/application/Espo/Resources/metadata/clientDefs/AppLogRecord.json new file mode 100644 index 0000000000..72860f838c --- /dev/null +++ b/application/Espo/Resources/metadata/clientDefs/AppLogRecord.json @@ -0,0 +1,5 @@ +{ + "controller": "controllers/record", + "createDisabled": true, + "editDisabled": true +} diff --git a/application/Espo/Resources/metadata/entityDefs/AppLogRecord.json b/application/Espo/Resources/metadata/entityDefs/AppLogRecord.json new file mode 100644 index 0000000000..5cb6de4a02 --- /dev/null +++ b/application/Espo/Resources/metadata/entityDefs/AppLogRecord.json @@ -0,0 +1,59 @@ +{ + "fields": { + "number": { + "type": "autoincrement", + "dbType": "bigint" + }, + "createdAt": { + "type": "datetime", + "readOnly": true + }, + "message": { + "type": "text", + "readOnly": true, + "orderDisabled": true + }, + "level": { + "type": "enum", + "options": [ + "Debug", + "Info", + "Notice", + "Warning", + "Error", + "Critical", + "Alert", + "Emergency" + ], + "maxLength": 9, + "readOnly": true, + "index": true + }, + "code": { + "type": "int", + "readOnly": true + }, + "exceptionClass": { + "type": "varchar", + "maxLength": 512, + "readOnly": true + }, + "file": { + "type": "varchar", + "maxLength": 512, + "readOnly": true + }, + "line": { + "type": "int", + "readOnly": true + } + }, + "links": {}, + "collection": { + "orderBy": "number", + "order": "desc", + "textFilterFields": ["message"] + }, + "indexes": {}, + "hooksDisabled": true +} diff --git a/application/Espo/Resources/metadata/scopes/AppLogRecord.json b/application/Espo/Resources/metadata/scopes/AppLogRecord.json new file mode 100644 index 0000000000..a5280171da --- /dev/null +++ b/application/Espo/Resources/metadata/scopes/AppLogRecord.json @@ -0,0 +1,7 @@ +{ + "entity": true, + "layouts": false, + "tab": false, + "acl": false, + "customizable": false +} diff --git a/client/src/controllers/admin.js b/client/src/controllers/admin.js index a675dee0eb..8ec31097b4 100644 --- a/client/src/controllers/admin.js +++ b/client/src/controllers/admin.js @@ -412,6 +412,29 @@ class AdminController extends Controller { }); } + // noinspection JSUnusedGlobalSymbols + actionAppLog() { + this.collectionFactory.create('AppLogRecord', collection => { + const searchManager = new SearchManager( + collection, + 'list', + this.getStorage(), + this.getDateTime() + ); + + searchManager.loadStored(); + + collection.where = searchManager.getWhere(); + collection.maxSize = this.getConfig().get('recordsPerPage') || collection.maxSize; + + this.main('views/list', { + scope: 'AppLogRecord', + collection: collection, + searchManager: searchManager + }); + }); + } + // noinspection JSUnusedGlobalSymbols actionIntegrations(options) { const integration = options.name || null;