From 25e20eea8f89c5978cdb1bfa6b923a6d74dcaa75 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Tue, 30 Jun 2026 23:14:37 +0200 Subject: [PATCH] feat: add frontend and backend log export in options --- __init__.py | 98 ++++++++++++++++++++++++ web/css/resolver-main.css | 7 ++ web/log_system/logger.js | 13 ++-- web/resolver/views/options_methods.js | 103 ++++++++++++++++++++++++++ 4 files changed, 215 insertions(+), 6 deletions(-) diff --git a/__init__.py b/__init__.py index b3cc45f..517207f 100644 --- a/__init__.py +++ b/__init__.py @@ -3935,6 +3935,104 @@ class ModelResolverExtension: # ==================== SETTINGS (server-side persistence) ==================== + def _flush_backend_log_handlers(): + for handler in getattr(backend_log_controller, "file_handlers", {}).values(): + try: + handler.flush() + except Exception: + pass + + def _backend_log_sort_key(path): + name = os.path.basename(path) + rotation = 0 + if ".log." in name: + base_name, rotation_text = name.rsplit(".log.", 1) + try: + rotation = int(rotation_text) + except ValueError: + rotation = 999 + elif name.endswith(".log"): + base_name = name[:-4] + else: + base_name = name + return (base_name.lower(), rotation) + + def _collect_backend_log_files(log_dir): + if not log_dir or not os.path.isdir(log_dir): + return [] + + files = [] + for entry in os.listdir(log_dir): + name = str(entry or "") + if not name.startswith("azlogs_"): + continue + if not (name.endswith(".log") or ".log." in name): + continue + + path = os.path.abspath(os.path.join(log_dir, name)) + try: + if os.path.commonpath([log_dir, path]) != log_dir: + continue + except ValueError: + continue + if os.path.isfile(path): + files.append(path) + return sorted(files, key=_backend_log_sort_key) + + def _build_backend_log_export(): + _flush_backend_log_handlers() + raw_log_dir = str(backend_log_controller.config.get("log_dir") or "") + log_dir = os.path.abspath(raw_log_dir) if raw_log_dir else "" + exported_at = time.strftime("%Y-%m-%d %H:%M:%S") + lines = [ + "Model Resolver Backend Logs", + f"Exported: {exported_at}", + f"Log directory: {log_dir or 'not configured'}", + f"File logging: {bool(backend_log_controller.config.get('log_to_file'))}", + "", + ] + + log_files = _collect_backend_log_files(log_dir) + if not log_files: + lines.append("No backend log files found.") + lines.append("") + return "\n".join(lines) + + for path in log_files: + name = os.path.basename(path) + try: + stat = os.stat(path) + modified_at = time.strftime( + "%Y-%m-%d %H:%M:%S", + time.localtime(stat.st_mtime), + ) + lines.append( + f"===== {name} ({stat.st_size} bytes, modified {modified_at}) =====" + ) + with open(path, "r", encoding="utf-8", errors="replace") as log_file: + lines.append(log_file.read().rstrip()) + except OSError as exc: + lines.append(f"===== {name} =====") + lines.append(f"Could not read log file: {exc}") + lines.append("") + + return "\n".join(lines) + + @routes.get("/model_resolver/logs/backend/export") + @json_api_endpoint("backend log export") + async def export_backend_logs_route(request): + """Download Model Resolver backend logs as a text file.""" + content = await asyncio.to_thread(_build_backend_log_export) + filename = f"model_resolver_backend_logs_{time.strftime('%Y%m%d_%H%M%S')}.txt" + return web.Response( + text=content, + content_type="text/plain", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "no-store", + }, + ) + def _log_level_setting(value, default: str = BACKEND_DEFAULT_LOG_LEVEL) -> LogLevel: normalized = str(value or default or "INFO").strip().upper() if hasattr(LogLevel, normalized): diff --git a/web/css/resolver-main.css b/web/css/resolver-main.css index 9f26f99..7685df8 100644 --- a/web/css/resolver-main.css +++ b/web/css/resolver-main.css @@ -6259,6 +6259,13 @@ a.mr-image-preview-resource-name:hover { height: 14px; } +.mr-options-export-status { + display: inline-flex; + align-items: center; + min-height: 32px; + margin-top: 0; +} + #model-resolver-modal .mr-options-shell .mr-btn.mr-btn-primary, #model-resolver-modal .mr-options-shell .mr-btn.mr-btn-primary:hover:not(:disabled) { color: #101010; diff --git a/web/log_system/logger.js b/web/log_system/logger.js index 1c0d78f..326881f 100644 --- a/web/log_system/logger.js +++ b/web/log_system/logger.js @@ -221,11 +221,11 @@ class Logger { callsite, time: new Date() }; + this.logs.push(logData); + if (this.logs.length > this.config.maxStoredLogs) { + this.logs.shift(); + } if (this.config.saveToStorage) { - this.logs.push(logData); - if (this.logs.length > this.config.maxStoredLogs) { - this.logs.shift(); - } this.saveLogs(); } this.printToConsole(logData); @@ -662,13 +662,13 @@ class Logger { exportLogs(format = 'json') { if (this.logs.length === 0) { console.warn('No logs to export'); - return; + return false; } let content; let mimeType; let extension; if (format === 'json') { - content = JSON.stringify(this.logs, null, 2); + content = JSON.stringify(this.logs.map((log) => this.serializeLogEntry(log)), null, 2); mimeType = 'application/json'; extension = 'json'; } @@ -690,6 +690,7 @@ class Logger { a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); + return true; } /** * Log at DEBUG level diff --git a/web/resolver/views/options_methods.js b/web/resolver/views/options_methods.js index 998ee4e..76d55ba 100644 --- a/web/resolver/views/options_methods.js +++ b/web/resolver/views/options_methods.js @@ -638,6 +638,10 @@ export const optionsMethods = { ${renderLogLevelOptions(tokens.frontend_log_level)} +
+ + Ready to export frontend logs. +