From 51a55a7d54f57a6ddf4b51ec19661a3337a6ef22 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Fri, 13 Feb 2026 18:02:47 +0000 Subject: [PATCH] Allow user configurable log level 1. Allow users to select log level from settings UI dropdown 2. Fix DB logging config to remove handler when turned off --- .../vue_components/config/ConfigSettings.vue | 27 ++++++++- server/digi_server/app_server.py | 35 ++++++++---- server/digi_server/logger.py | 47 +++++++++++++--- server/digi_server/settings.py | 55 +++++++++++++++++-- 4 files changed, 138 insertions(+), 26 deletions(-) diff --git a/client/src/vue_components/config/ConfigSettings.vue b/client/src/vue_components/config/ConfigSettings.vue index 2fec9230..fbf7f9af 100644 --- a/client/src/vue_components/config/ConfigSettings.vue +++ b/client/src/vue_components/config/ConfigSettings.vue @@ -32,8 +32,18 @@

+ + + + Unknown setting type {{ setting.type }} for setting {{ key }}. + Reset @@ -131,6 +144,16 @@ export default { } return mapping[fieldType]; }, + getChoiceOptions(setting) { + const options = []; + if (setting._nullable) { + options.push({ value: null, text: 'N/A' }); + } + setting.choice_options.forEach((option) => { + options.push({ value: option, text: option }); + }); + return options; + }, validateState(name) { const { $dirty, $error } = this.$v.editSettings[name]; return $dirty ? !$error : null; diff --git a/server/digi_server/app_server.py b/server/digi_server/app_server.py index 8359ac09..9e283908 100644 --- a/server/digi_server/app_server.py +++ b/server/digi_server/app_server.py @@ -17,7 +17,12 @@ from controllers import controllers from controllers.ws_controller import WebSocketController -from digi_server.logger import configure_db_logging, configure_file_logging, get_logger +from digi_server.logger import ( + configure_db_logging, + configure_file_logging, + configure_log_level, + get_logger, +) from digi_server.settings import Settings from models import models from models.cue import CueType @@ -362,23 +367,29 @@ async def _configure_logging(self): log_path = await self.digi_settings.get("log_path") file_size = await self.digi_settings.get("max_log_mb") backups = await self.digi_settings.get("log_backups") + log_level = await self.digi_settings.get("log_level") if log_path: self.app_log_handler = configure_file_logging( - log_path, file_size, backups, self.app_log_handler + log_path=log_path, + max_size_mb=file_size, + log_backups=backups, + handler=self.app_log_handler, ) + configure_log_level(log_level) # Database logging use_db_logging = await self.digi_settings.get("db_log_enabled") - if use_db_logging: - db_log_path = await self.digi_settings.get("db_log_path") - db_file_size = await self.digi_settings.get("db_max_log_mb") - db_backups = await self.digi_settings.get("db_log_backups") - self.db_file_handler = configure_db_logging( - log_path=db_log_path, - max_size_mb=db_file_size, - log_backups=db_backups, - handler=self.db_file_handler, - ) + db_log_path = await self.digi_settings.get("db_log_path") + db_file_size = await self.digi_settings.get("db_max_log_mb") + db_backups = await self.digi_settings.get("db_log_backups") + self.db_file_handler = configure_db_logging( + log_path=db_log_path, + max_size_mb=db_file_size, + log_backups=db_backups, + handler=self.db_file_handler, + log_level=log_level, + enable_db_logging=use_db_logging, + ) def _configure_rbac(self): self._db.register_delete_hook(self.rbac.rbac_db.check_object_deletion) diff --git a/server/digi_server/logger.py b/server/digi_server/logger.py index b9aed4bc..d63a69e6 100644 --- a/server/digi_server/logger.py +++ b/server/digi_server/logger.py @@ -6,6 +6,12 @@ logger = logging.getLogger("DigiScript") +ALL_LOGGERS = [ + logging.getLogger("tornado.access"), + logging.getLogger("tornado.application"), + logging.getLogger("tornado.general"), + logger, +] def get_logger(name: Optional[str] = None): @@ -14,26 +20,40 @@ def get_logger(name: Optional[str] = None): return logger -def configure_file_logging(log_path, max_size_mb=100, log_backups=5, handler=None): +def configure_log_level(log_level=logging.DEBUG): + for _logger in ALL_LOGGERS: + logger.info(f"Setting log level to {log_level} for logger: {_logger.name}") + _logger.setLevel(log_level) + + +def configure_file_logging( + log_path, + max_size_mb=100, + log_backups=5, + handler=None, +): size_bytes = max_size_mb * 1024 * 1024 - app_logger = get_logger() if handler: - app_logger.removeHandler(handler) + for _logger in ALL_LOGGERS: + _logger.removeHandler(handler) file_handler = RotatingFileHandler( log_path, maxBytes=size_bytes, backupCount=log_backups ) file_handler.setFormatter(LogFormatter(color=False)) - app_logger.addHandler(file_handler) - logging.getLogger("tornado.access").addHandler(file_handler) - logging.getLogger("tornado.application").addHandler(file_handler) - logging.getLogger("tornado.general").addHandler(file_handler) + for _logger in ALL_LOGGERS: + _logger.addHandler(file_handler) return file_handler def configure_db_logging( - log_level=logging.DEBUG, log_path=None, max_size_mb=100, log_backups=5, handler=None + log_level=logging.DEBUG, + log_path=None, + max_size_mb=100, + log_backups=5, + handler=None, + enable_db_logging=False, ): size_bytes = max_size_mb * 1024 * 1024 db_logger = logging.getLogger("sqlalchemy.engine") @@ -41,6 +61,11 @@ def configure_db_logging( if handler: db_logger.removeHandler(handler) + if not enable_db_logging: + logger.info(f"Disabling logger: {db_logger.name}") + return None + + logger.info(f"Setting log level to {log_level} for logger: {db_logger.name}") db_logger.setLevel(log_level) file_handler = None if log_path: @@ -75,3 +100,9 @@ def log_to_root(message, *args, **kwargs): setattr(logging, level_name, level_num) setattr(logging.getLoggerClass(), method_name, log_for_level) setattr(logging, method_name, log_to_root) + + +def get_level_names_by_order(): + levels = logging.getLevelNamesMapping() + sorted_levels = sorted(levels.items(), key=lambda x: x[1]) + return [name for name, _ in sorted_levels] diff --git a/server/digi_server/settings.py b/server/digi_server/settings.py index aface3a7..dfe386d4 100644 --- a/server/digi_server/settings.py +++ b/server/digi_server/settings.py @@ -4,11 +4,11 @@ import os import tomllib from pathlib import Path -from typing import TYPE_CHECKING, Dict +from typing import TYPE_CHECKING, Dict, Optional from tornado.locks import Lock -from digi_server.logger import get_logger +from digi_server.logger import get_level_names_by_order, get_logger from utils.file_watcher import IOLoopFileWatcher @@ -35,6 +35,8 @@ def get_version() -> str: class SettingsObject: + ALLOWED_TYPES = [str, bool, int] + def __init__( self, key, @@ -46,13 +48,38 @@ def __init__( display_name: str = "", help_text: str = "", hide_from_ui: bool = False, + choice_options: Optional[list] = None, ): - if val_type not in [str, bool, int]: + if val_type not in self.ALLOWED_TYPES: raise RuntimeError( f"Invalid type {val_type} for {key}. Allowed options are: " - f"[str, int, bool]" + f"{[t.__name__ for t in self.ALLOWED_TYPES]}." + ) + + if default is None and not nullable: + raise RuntimeError( + f"Default value for {key} cannot be None if setting is not nullable." ) + if default is not None and not isinstance(default, val_type): + raise RuntimeError( + f"Default value {default} for {key} is not of type {val_type.__name__}." + ) + + if choice_options is not None: + if len(choice_options) == 0: + raise RuntimeError(f"Choice options for {key} cannot be an empty list.") + + if any(not isinstance(option, val_type) for option in choice_options): + raise RuntimeError( + f"All choice options for {key} must be of type {val_type.__name__}." + ) + + if default not in choice_options: + raise RuntimeError( + f"Default value for {key} must be one of the choice options." + ) + self.key = key self.val_type = val_type self.value = None @@ -64,6 +91,7 @@ def __init__( self.display_name = display_name self.help_text = help_text self.hide_from_ui = hide_from_ui + self.choice_options = choice_options def set_to_default(self): self.value = self.default @@ -81,6 +109,12 @@ def set_value(self, value, spawn_callbacks=True): f"type {self.val_type}" ) + if self.choice_options is not None and value not in self.choice_options: + raise ValueError( + f"Value for {self.key} must be one of the following options: " + f"{self.choice_options}" + ) + changed = False if value != self.value: changed = True @@ -106,6 +140,8 @@ def as_json(self): "display_name": self.display_name, "help_text": self.help_text, "hide_from_ui": self.hide_from_ui, + "choice_options": self.choice_options, + "_nullable": self._nullable, } @@ -161,6 +197,15 @@ def __init__(self, application: DigiScriptServer, settings_path=None): hide_from_ui=True, ) self.define("debug_mode", bool, False, True, display_name="Enable Debug Mode") + self.define( + "log_level", + str, + "DEBUG", + True, + self._application.regen_logging, + display_name="Log Level", + choice_options=get_level_names_by_order(), + ) self.define( "log_path", str, @@ -254,6 +299,7 @@ def define( display_name: str = "", help_text: str = "", hide_from_ui: bool = False, + choice_options: Optional[list] = None, ): self.settings[key] = SettingsObject( key, @@ -265,6 +311,7 @@ def define( display_name, help_text, hide_from_ui, + choice_options, ) def file_deleted(self):