diff --git a/locales/de.json b/locales/de.json index c33f04b3..578124a0 100644 --- a/locales/de.json +++ b/locales/de.json @@ -1806,6 +1806,35 @@ "moveFailed": "Failed to move item: {message}" } }, + "doctor": { + "kicker": "Systemdiagnose", + "title": "Doktor", + "buttonTitle": "Diagnose und häufige Fehlerbehebungen ausführen", + "loading": "Umgebung wird geprüft...", + "footer": "Exportiere ein Diagnosepaket, falls das Problem nach der Reparatur weiterhin besteht.", + "summary": { + "idle": "Führe eine Überprüfung von Einstellungen, Cache-Integrität und UI-Konsistenz durch.", + "ok": "Keine aktiven Probleme wurden in der aktuellen Umgebung gefunden.", + "warning": "{count} Problem(e) wurden gefunden. Die meisten lassen sich direkt über dieses Panel beheben.", + "error": "Bevor die App vollständig fehlerfrei ist, müssen {count} Problem(e) behoben werden." + }, + "status": { + "ok": "Gesund", + "warning": "Handlungsbedarf", + "error": "Aktion erforderlich" + }, + "actions": { + "runAgain": "Erneut ausführen", + "exportBundle": "Paket exportieren" + }, + "toast": { + "loadFailed": "Diagnose konnte nicht geladen werden: {message}", + "repairSuccess": "Cache-Neuaufbau abgeschlossen.", + "repairFailed": "Cache-Neuaufbau fehlgeschlagen: {message}", + "exportSuccess": "Diagnosepaket exportiert.", + "exportFailed": "Export des Diagnosepakets fehlgeschlagen: {message}" + } + }, "banners": { "versionMismatch": { "title": "Anwendungs-Update erkannt", diff --git a/locales/en.json b/locales/en.json index c5f5f3fa..7d0dd984 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1806,6 +1806,35 @@ "moveFailed": "Failed to move item: {message}" } }, + "doctor": { + "kicker": "System diagnostics", + "title": "Doctor", + "buttonTitle": "Run diagnostics and common fixes", + "loading": "Checking environment...", + "footer": "Export a diagnostics bundle if the issue still persists after repair.", + "summary": { + "idle": "Run a health check for settings, cache integrity, and UI consistency.", + "ok": "No active issues were found in the current environment.", + "warning": "{count} issue(s) were found. Most can be fixed directly from this panel.", + "error": "{count} issue(s) need attention before the app is fully healthy." + }, + "status": { + "ok": "Healthy", + "warning": "Needs Attention", + "error": "Action Required" + }, + "actions": { + "runAgain": "Run Again", + "exportBundle": "Export Bundle" + }, + "toast": { + "loadFailed": "Failed to load diagnostics: {message}", + "repairSuccess": "Cache rebuild completed.", + "repairFailed": "Cache rebuild failed: {message}", + "exportSuccess": "Diagnostics bundle exported.", + "exportFailed": "Failed to export diagnostics bundle: {message}" + } + }, "banners": { "versionMismatch": { "title": "Application Update Detected", diff --git a/locales/es.json b/locales/es.json index 93de3cf5..9c0e1c3a 100644 --- a/locales/es.json +++ b/locales/es.json @@ -1806,6 +1806,35 @@ "moveFailed": "Failed to move item: {message}" } }, + "doctor": { + "kicker": "Diagnósticos del sistema", + "title": "Doctor", + "buttonTitle": "Ejecutar diagnósticos y correcciones comunes", + "loading": "Comprobando el entorno...", + "footer": "Exporta un paquete de diagnóstico si el problema persiste después de la reparación.", + "summary": { + "idle": "Ejecuta una comprobación del estado de la configuración, la integridad de la caché y la coherencia de la interfaz.", + "ok": "No se encontraron problemas activos en el entorno actual.", + "warning": "Se encontraron {count} problema(s). La mayoría se puede solucionar directamente desde este panel.", + "error": "Se encontraron {count} problema(s). Deben atenderse antes de que la aplicación esté completamente saludable." + }, + "status": { + "ok": "Saludable", + "warning": "Requiere atención", + "error": "Se requiere acción" + }, + "actions": { + "runAgain": "Ejecutar de nuevo", + "exportBundle": "Exportar paquete" + }, + "toast": { + "loadFailed": "Error al cargar los diagnósticos: {message}", + "repairSuccess": "Reconstrucción de caché completada.", + "repairFailed": "Error al reconstruir la caché: {message}", + "exportSuccess": "Paquete de diagnósticos exportado.", + "exportFailed": "Error al exportar el paquete de diagnósticos: {message}" + } + }, "banners": { "versionMismatch": { "title": "Actualización de la aplicación detectada", diff --git a/locales/fr.json b/locales/fr.json index 8f53f99c..fb52e554 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -1806,6 +1806,35 @@ "moveFailed": "Failed to move item: {message}" } }, + "doctor": { + "kicker": "Diagnostics système", + "title": "Docteur", + "buttonTitle": "Lancer les diagnostics et les corrections courantes", + "loading": "Vérification de l'environnement...", + "footer": "Exportez un lot de diagnostic si le problème persiste après la réparation.", + "summary": { + "idle": "Lancez une vérification de l'état des paramètres, de l'intégrité du cache et de la cohérence de l'interface.", + "ok": "Aucun problème actif n'a été trouvé dans l'environnement actuel.", + "warning": "{count} problème(s) ont été trouvés. La plupart peuvent être corrigés directement depuis ce panneau.", + "error": "{count} problème(s) nécessitent une attention avant que l'application soit entièrement saine." + }, + "status": { + "ok": "Sain", + "warning": "Nécessite une attention", + "error": "Action requise" + }, + "actions": { + "runAgain": "Relancer", + "exportBundle": "Exporter le lot" + }, + "toast": { + "loadFailed": "Échec du chargement des diagnostics : {message}", + "repairSuccess": "Reconstruction du cache terminée.", + "repairFailed": "Échec de la reconstruction du cache : {message}", + "exportSuccess": "Lot de diagnostics exporté.", + "exportFailed": "Échec de l'export du lot de diagnostics : {message}" + } + }, "banners": { "versionMismatch": { "title": "Mise à jour de l'application détectée", diff --git a/locales/he.json b/locales/he.json index 3cb6592c..9c72da4d 100644 --- a/locales/he.json +++ b/locales/he.json @@ -1806,6 +1806,35 @@ "moveFailed": "Failed to move item: {message}" } }, + "doctor": { + "kicker": "אבחון מערכת", + "title": "דוקטור", + "buttonTitle": "הפעלת אבחון ותיקונים נפוצים", + "loading": "בודק את הסביבה...", + "footer": "ייצא חבילת אבחון אם הבעיה עדיין נמשכת לאחר התיקון.", + "summary": { + "idle": "הרץ בדיקת תקינות עבור הגדרות, שלמות המטמון ועקביות הממשק.", + "ok": "לא נמצאו בעיות פעילות בסביבה הנוכחית.", + "warning": "נמצאה/נמצאו {count} בעיה/בעיות. את רובן אפשר לתקן ישירות מלוח זה.", + "error": "יש לטפל ב-{count} בעיה/בעיות לפני שהאפליקציה תהיה תקינה לחלוטין." + }, + "status": { + "ok": "תקין", + "warning": "דורש תשומת לב", + "error": "נדרשת פעולה" + }, + "actions": { + "runAgain": "הפעל שוב", + "exportBundle": "ייצוא חבילה" + }, + "toast": { + "loadFailed": "טעינת האבחון נכשלה: {message}", + "repairSuccess": "בניית המטמון מחדש הושלמה.", + "repairFailed": "בניית המטמון מחדש נכשלה: {message}", + "exportSuccess": "חבילת האבחון יוצאה.", + "exportFailed": "ייצוא חבילת האבחון נכשל: {message}" + } + }, "banners": { "versionMismatch": { "title": "זוהה עדכון יישום", diff --git a/locales/ja.json b/locales/ja.json index b1164753..0374139c 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -1806,6 +1806,35 @@ "moveFailed": "Failed to move item: {message}" } }, + "doctor": { + "kicker": "システム診断", + "title": "ドクター", + "buttonTitle": "診断と一般的な修復を実行", + "loading": "環境を確認中...", + "footer": "修復後も問題が続く場合は、診断パッケージをエクスポートしてください。", + "summary": { + "idle": "設定、キャッシュ整合性、UI の一貫性をヘルスチェックします。", + "ok": "現在の環境でアクティブな問題は見つかりませんでした。", + "warning": "{count} 件の問題が見つかりました。ほとんどはこのパネルから直接修復できます。", + "error": "アプリが完全に正常になる前に、{count} 件の問題に対処する必要があります。" + }, + "status": { + "ok": "正常", + "warning": "要注意", + "error": "対応が必要" + }, + "actions": { + "runAgain": "再実行", + "exportBundle": "パッケージをエクスポート" + }, + "toast": { + "loadFailed": "診断の読み込みに失敗しました: {message}", + "repairSuccess": "キャッシュの再構築が完了しました。", + "repairFailed": "キャッシュの再構築に失敗しました: {message}", + "exportSuccess": "診断パッケージをエクスポートしました。", + "exportFailed": "診断パッケージのエクスポートに失敗しました: {message}" + } + }, "banners": { "versionMismatch": { "title": "アプリケーション更新が検出されました", diff --git a/locales/ko.json b/locales/ko.json index 77db5867..2f58ff7e 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -1806,6 +1806,35 @@ "moveFailed": "Failed to move item: {message}" } }, + "doctor": { + "kicker": "시스템 진단", + "title": "닥터", + "buttonTitle": "진단 및 일반적인 수정 실행", + "loading": "환경을 확인하는 중...", + "footer": "수리 후에도 문제가 계속되면 진단 번들을 내보내세요.", + "summary": { + "idle": "설정, 캐시 무결성, UI 일관성에 대한 상태 검사를 실행합니다.", + "ok": "현재 환경에서 활성 문제를 찾지 못했습니다.", + "warning": "{count}개의 문제가 발견되었습니다. 대부분은 이 패널에서 바로 해결할 수 있습니다.", + "error": "앱이 완전히 정상 상태가 되기 전에 {count}개의 문제를 처리해야 합니다." + }, + "status": { + "ok": "정상", + "warning": "주의 필요", + "error": "조치 필요" + }, + "actions": { + "runAgain": "다시 실행", + "exportBundle": "번들 내보내기" + }, + "toast": { + "loadFailed": "진단 로드 실패: {message}", + "repairSuccess": "캐시 재구성이 완료되었습니다.", + "repairFailed": "캐시 재구성 실패: {message}", + "exportSuccess": "진단 번들이 내보내졌습니다.", + "exportFailed": "진단 번들 내보내기 실패: {message}" + } + }, "banners": { "versionMismatch": { "title": "애플리케이션 업데이트 감지", diff --git a/locales/ru.json b/locales/ru.json index a90b8b4c..a4ad312a 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1806,6 +1806,35 @@ "moveFailed": "Failed to move item: {message}" } }, + "doctor": { + "kicker": "Системная диагностика", + "title": "Доктор", + "buttonTitle": "Запустить диагностику и обычные исправления", + "loading": "Проверка окружения...", + "footer": "Экспортируйте диагностический пакет, если проблема сохраняется после исправления.", + "summary": { + "idle": "Выполнить проверку настроек, целостности кэша и согласованности интерфейса.", + "ok": "В текущем окружении активных проблем не обнаружено.", + "warning": "Обнаружено {count} проблем(ы). Большинство можно исправить прямо из этой панели.", + "error": "Перед тем как приложение станет полностью исправным, нужно устранить {count} проблем(ы)." + }, + "status": { + "ok": "Исправно", + "warning": "Требует внимания", + "error": "Требуется действие" + }, + "actions": { + "runAgain": "Запустить снова", + "exportBundle": "Экспортировать пакет" + }, + "toast": { + "loadFailed": "Не удалось загрузить диагностику: {message}", + "repairSuccess": "Перестройка кэша завершена.", + "repairFailed": "Не удалось перестроить кэш: {message}", + "exportSuccess": "Диагностический пакет экспортирован.", + "exportFailed": "Не удалось экспортировать диагностический пакет: {message}" + } + }, "banners": { "versionMismatch": { "title": "Обнаружено обновление приложения", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 08187a02..5845550b 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -1806,6 +1806,35 @@ "moveFailed": "Failed to move item: {message}" } }, + "doctor": { + "kicker": "系统诊断", + "title": "医生", + "buttonTitle": "运行诊断并尝试修复常见问题", + "loading": "正在检查当前环境...", + "footer": "如果修复后问题仍然存在,可以导出诊断包进一步排查。", + "summary": { + "idle": "检查设置、缓存健康状况和前后端 UI 版本是否一致。", + "ok": "当前环境未发现活动问题。", + "warning": "发现 {count} 个问题,大多数可以直接在这里处理。", + "error": "发现 {count} 个需要尽快处理的问题。" + }, + "status": { + "ok": "健康", + "warning": "需要关注", + "error": "需要处理" + }, + "actions": { + "runAgain": "重新检查", + "exportBundle": "导出诊断包" + }, + "toast": { + "loadFailed": "加载诊断结果失败:{message}", + "repairSuccess": "缓存重建完成。", + "repairFailed": "缓存重建失败:{message}", + "exportSuccess": "诊断包已导出。", + "exportFailed": "导出诊断包失败:{message}" + } + }, "banners": { "versionMismatch": { "title": "检测到应用更新", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 55835a93..bd38f427 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -1806,6 +1806,35 @@ "moveFailed": "Failed to move item: {message}" } }, + "doctor": { + "kicker": "系統診斷", + "title": "醫生", + "buttonTitle": "執行診斷與常見修復", + "loading": "正在檢查環境...", + "footer": "如果修復後問題仍然存在,請匯出診斷套件。", + "summary": { + "idle": "針對設定、快取完整性與 UI 一致性執行健康檢查。", + "ok": "目前環境中未發現任何活動中的問題。", + "warning": "找到 {count} 個問題。大多可以直接在此面板修復。", + "error": "應先處理 {count} 個問題,應用程式才能完全正常。" + }, + "status": { + "ok": "健康", + "warning": "需要注意", + "error": "需要處理" + }, + "actions": { + "runAgain": "重新執行", + "exportBundle": "匯出套件" + }, + "toast": { + "loadFailed": "載入診斷失敗:{message}", + "repairSuccess": "快取重建完成。", + "repairFailed": "快取重建失敗:{message}", + "exportSuccess": "診斷套件已匯出。", + "exportFailed": "匯出診斷套件失敗:{message}" + } + }, "banners": { "versionMismatch": { "title": "偵測到應用程式更新", diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index 7aedd959..67acf944 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -10,15 +10,19 @@ from __future__ import annotations import asyncio import contextlib +import io import json import logging import os +import platform +import re import subprocess import sys import tempfile import zipfile from dataclasses import dataclass -from typing import Awaitable, Callable, Dict, Mapping, Protocol +from datetime import datetime, timezone +from typing import Any, Awaitable, Callable, Dict, Mapping, Protocol, Sequence from aiohttp import web @@ -32,6 +36,7 @@ from ...services.settings_manager import get_settings_manager from ...services.websocket_manager import ws_manager from ...services.downloader import get_downloader from ...services.errors import ResourceNotFoundError +from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStatus from ...utils.constants import ( CIVITAI_USER_MODEL_TYPES, DEFAULT_NODE_COLOR, @@ -42,12 +47,321 @@ from ...utils.constants import ( from ...utils.civitai_utils import rewrite_preview_url from ...utils.example_images_paths import is_valid_example_images_root from ...utils.lora_metadata import extract_trained_words +from ...utils.session_logging import get_standalone_session_log_snapshot from ...utils.usage_stats import UsageStats from .base_model_handlers import BaseModelHandlerSet logger = logging.getLogger(__name__) +def _get_project_root() -> str: + current_file = os.path.abspath(__file__) + return os.path.dirname( + os.path.dirname(os.path.dirname(os.path.dirname(current_file))) + ) + + +def _get_app_version_string() -> str: + version = "1.0.0" + short_hash = "stable" + try: + import toml + + root_dir = _get_project_root() + pyproject_path = os.path.join(root_dir, "pyproject.toml") + + if os.path.exists(pyproject_path): + with open(pyproject_path, "r", encoding="utf-8") as handle: + data = toml.load(handle) + version = ( + data.get("project", {}).get("version", "1.0.0").replace("v", "") + ) + + git_dir = os.path.join(root_dir, ".git") + if os.path.exists(git_dir): + try: + import git + + repo = git.Repo(root_dir) + short_hash = repo.head.commit.hexsha[:7] + except Exception: + pass + except Exception as exc: # pragma: no cover - defensive logging + logger.debug("Failed to resolve app version for doctor diagnostics: %s", exc) + + return f"{version}-{short_hash}" + + +def _sanitize_sensitive_data(payload: Any) -> Any: + sensitive_markers = ( + "api_key", + "apikey", + "token", + "password", + "secret", + "authorization", + ) + + if isinstance(payload, dict): + sanitized: dict[str, Any] = {} + for key, value in payload.items(): + normalized_key = str(key).lower() + if any(marker in normalized_key for marker in sensitive_markers): + if isinstance(value, str) and value: + sanitized[key] = f"{value[:4]}***{value[-2:]}" if len(value) > 6 else "***" + else: + sanitized[key] = "***" + else: + sanitized[key] = _sanitize_sensitive_data(value) + return sanitized + + if isinstance(payload, list): + return [_sanitize_sensitive_data(item) for item in payload] + + if isinstance(payload, str): + return _sanitize_sensitive_text(payload) + + return payload + + +def _sanitize_sensitive_text(value: str) -> str: + if not value: + return value + + redacted = value + patterns = ( + ( + r'(?i)("authorization"\s*:\s*")Bearer\s+([^"]+)(")', + r'\1Bearer ***\3', + ), + ( + r'(?i)("x[-_]?api[-_]?key"\s*:\s*")([^"]+)(")', + r'\1***\3', + ), + ( + r'(?i)("api[_-]?key"\s*:\s*")([^"]+)(")', + r'\1***\3', + ), + ( + r'(?i)("token"\s*:\s*")([^"]+)(")', + r'\1***\3', + ), + ( + r'(?i)("password"\s*:\s*")([^"]+)(")', + r'\1***\3', + ), + ( + r'(?i)("secret"\s*:\s*")([^"]+)(")', + r'\1***\3', + ), + ( + r"(?i)\b(authorization\s*[:=]\s*bearer\s+)([A-Za-z0-9._\-+/=]+)", + r"\1***", + ), + ( + r"(?i)\b(x[-_]?api[-_]?key\s*[:=]\s*)([^\s,;]+)", + r"\1***", + ), + ( + r"(?i)\b(api[_-]?key\s*[:=]\s*)([^\s,;]+)", + r"\1***", + ), + ( + r"(?i)\b(token\s*[:=]\s*)([^\s,;]+)", + r"\1***", + ), + ( + r"(?i)\b(password\s*[:=]\s*)([^\s,;]+)", + r"\1***", + ), + ( + r"(?i)\b(secret\s*[:=]\s*)([^\s,;]+)", + r"\1***", + ), + ) + + import re + + for pattern, replacement in patterns: + redacted = re.sub(pattern, replacement, redacted) + + return redacted + + +def _read_log_file_tail(path: str, max_bytes: int = 64 * 1024) -> str: + if not path or not os.path.isfile(path): + return "" + + with open(path, "rb") as handle: + handle.seek(0, os.SEEK_END) + file_size = handle.tell() + handle.seek(max(file_size - max_bytes, 0)) + payload = handle.read() + + return payload.decode("utf-8", errors="replace") + + +def _read_text_file_head(path: str, max_bytes: int = 8 * 1024) -> str: + if not path or not os.path.isfile(path): + return "" + + with open(path, "rb") as handle: + payload = handle.read(max_bytes) + + return payload.decode("utf-8", errors="replace") + + +def _extract_startup_marker(text: str, label: str) -> str | None: + if not text: + return None + + pattern = re.compile(rf"{re.escape(label)}\s*:\s*([^\r\n]+)") + match = pattern.search(text) + if not match: + return None + + return match.group(1).strip() + + +def _format_comfyui_log_entries(entries: Sequence[Mapping[str, Any]] | None) -> str: + if not entries: + return "" + + rendered: list[str] = [] + for entry in entries: + timestamp = str(entry.get("t", "")).strip() + message = str(entry.get("m", "")) + if not message: + continue + + if timestamp: + rendered.append(f"{timestamp} - {message}") + else: + rendered.append(message) + + if not rendered: + return "" + + text = "".join(rendered) + if text.endswith("\n"): + return text + return f"{text}\n" + + +def _get_embedded_comfyui_log_path() -> str: + return os.path.abspath( + os.path.join(_get_project_root(), "..", "..", "user", "comfyui.log") + ) + + +def _collect_comfyui_session_logs( + *, + log_entries: Sequence[Mapping[str, Any]] | None = None, + log_file_path: str | None = None, +) -> dict[str, Any]: + if log_entries is None: + try: + import app.logger as comfy_logger + + log_entries = list(comfy_logger.get_logs() or []) + except Exception as exc: # pragma: no cover - environment dependent + logger.debug("Failed to read ComfyUI in-memory logs: %s", exc) + log_entries = [] + + session_log_text = _format_comfyui_log_entries(log_entries) + session_started_at = _extract_startup_marker( + session_log_text, "** ComfyUI startup time" + ) + if not session_started_at and log_entries: + session_started_at = str(log_entries[0].get("t", "")).strip() or None + + resolved_log_path = os.path.abspath(log_file_path or _get_embedded_comfyui_log_path()) + persisted_log_text = "" + notes: list[str] = [] + + if os.path.isfile(resolved_log_path): + head_text = _read_text_file_head(resolved_log_path) + file_started_at = _extract_startup_marker(head_text, "** ComfyUI startup time") + if session_started_at and file_started_at and file_started_at == session_started_at: + persisted_log_text = _read_log_file_tail(resolved_log_path) + elif session_started_at and file_started_at and file_started_at != session_started_at: + notes.append( + "Persistent ComfyUI log file does not match the current process session." + ) + elif not session_started_at and file_started_at: + persisted_log_text = _read_log_file_tail(resolved_log_path) + session_started_at = file_started_at + else: + notes.append( + "Persistent ComfyUI log file is missing a startup marker and was not trusted as the current session log." + ) + else: + notes.append("Persistent ComfyUI log file was not found.") + + source_method = "comfyui_in_memory" + if persisted_log_text: + source_method = "comfyui_in_memory+current_log_file" + elif not session_log_text: + source_method = "unavailable" + + return { + "mode": "comfyui", + "session_started_at": session_started_at, + "session_log_text": session_log_text, + "persistent_log_path": resolved_log_path, + "persistent_log_text": persisted_log_text, + "source_method": source_method, + "notes": notes, + } + + +def _collect_standalone_session_logs( + *, snapshot: Mapping[str, Any] | None = None +) -> dict[str, Any]: + snapshot = snapshot or get_standalone_session_log_snapshot() + + if not snapshot: + return { + "mode": "standalone", + "session_started_at": None, + "session_log_text": "", + "persistent_log_path": None, + "persistent_log_text": "", + "source_method": "unavailable", + "session_id": None, + "notes": ["Standalone session logging was not initialized."], + } + + log_file_path = snapshot.get("log_file_path") + persisted_log_text = _read_log_file_tail(log_file_path) if log_file_path else "" + session_log_text = str(snapshot.get("in_memory_text") or "") + source_method = "standalone_memory" + if persisted_log_text: + source_method = "standalone_session_file" + elif session_log_text: + source_method = "standalone_memory" + else: + source_method = "unavailable" + + return { + "mode": "standalone", + "session_started_at": snapshot.get("started_at"), + "session_log_text": session_log_text, + "persistent_log_path": log_file_path, + "persistent_log_text": persisted_log_text, + "source_method": source_method, + "session_id": snapshot.get("session_id"), + "notes": [], + } + + +def _collect_backend_session_logs() -> dict[str, Any]: + standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" + if standalone_mode: + return _collect_standalone_session_logs() + return _collect_comfyui_session_logs() + + def _is_wsl() -> bool: """Check if running in WSL environment.""" try: @@ -272,6 +586,388 @@ class SupportersHandler: return web.json_response({"success": False, "error": str(exc)}, status=500) +class DoctorHandler: + """Run environment diagnostics and export a support bundle.""" + + def __init__( + self, + *, + settings_service=None, + civitai_client_factory: Callable[[], Awaitable[Any]] = ServiceRegistry.get_civitai_client, + scanner_factories: Sequence[tuple[str, str, Callable[[], Awaitable[Any]]]] | None = None, + app_version_getter: Callable[[], str] = _get_app_version_string, + ) -> None: + self._settings = settings_service or get_settings_manager() + self._civitai_client_factory = civitai_client_factory + self._scanner_factories = tuple( + scanner_factories + or ( + ("lora", "LoRAs", ServiceRegistry.get_lora_scanner), + ("checkpoint", "Checkpoints", ServiceRegistry.get_checkpoint_scanner), + ("embedding", "Embeddings", ServiceRegistry.get_embedding_scanner), + ) + ) + self._app_version_getter = app_version_getter + + async def get_doctor_diagnostics(self, request: web.Request) -> web.Response: + try: + client_version = (request.query.get("clientVersion") or "").strip() + app_version = self._app_version_getter() + diagnostics = [ + await self._check_civitai_api_key(), + await self._check_cache_health(), + self._check_ui_version(client_version, app_version), + ] + + issue_count = sum( + 1 for item in diagnostics if item.get("status") in {"warning", "error"} + ) + error_count = sum(1 for item in diagnostics if item.get("status") == "error") + warning_count = sum( + 1 for item in diagnostics if item.get("status") == "warning" + ) + + overall_status = "ok" + if error_count: + overall_status = "error" + elif warning_count: + overall_status = "warning" + + return web.json_response( + { + "success": True, + "app_version": app_version, + "summary": { + "status": overall_status, + "issue_count": issue_count, + "warning_count": warning_count, + "error_count": error_count, + "checked_at": datetime.now(timezone.utc).isoformat(), + }, + "diagnostics": diagnostics, + } + ) + except Exception as exc: # pragma: no cover - defensive logging + logger.error("Error building doctor diagnostics: %s", exc, exc_info=True) + return web.json_response({"success": False, "error": str(exc)}, status=500) + + async def repair_doctor_cache(self, request: web.Request) -> web.Response: + repaired: list[dict[str, Any]] = [] + failures: list[dict[str, str]] = [] + + for model_type, label, factory in self._scanner_factories: + try: + scanner = await factory() + await scanner.get_cached_data(force_refresh=True, rebuild_cache=True) + repaired.append({"model_type": model_type, "label": label}) + except Exception as exc: # pragma: no cover - defensive logging + logger.error("Doctor cache rebuild failed for %s: %s", model_type, exc, exc_info=True) + failures.append( + { + "model_type": model_type, + "label": label, + "error": str(exc), + } + ) + + status = 200 if not failures else 500 + return web.json_response( + { + "success": not failures, + "repaired": repaired, + "failures": failures, + }, + status=status, + ) + + async def export_doctor_bundle(self, request: web.Request) -> web.Response: + try: + payload = await request.json() + except Exception: + payload = {} + + try: + archive_bytes = self._build_support_bundle(payload) + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + headers = { + "Content-Type": "application/zip", + "Content-Disposition": f'attachment; filename="lora-manager-doctor-{timestamp}.zip"', + } + return web.Response(body=archive_bytes, headers=headers) + except Exception as exc: # pragma: no cover - defensive logging + logger.error("Error exporting doctor bundle: %s", exc, exc_info=True) + return web.json_response({"success": False, "error": str(exc)}, status=500) + + async def _check_civitai_api_key(self) -> dict[str, Any]: + api_key = (self._settings.get("civitai_api_key", "") or "").strip() + if not api_key: + return { + "id": "civitai_api_key", + "title": "Civitai API Key", + "status": "warning", + "summary": "Civitai API key is not configured.", + "details": [ + "Downloads and authenticated Civitai requests may fail until a valid API key is saved." + ], + "actions": [{"id": "open-settings", "label": "Open Settings"}], + } + + obvious_placeholders = {"your_api_key", "changeme", "placeholder", "none"} + if api_key.lower() in obvious_placeholders: + return { + "id": "civitai_api_key", + "title": "Civitai API Key", + "status": "error", + "summary": "Civitai API key looks like a placeholder value.", + "details": ["Replace the placeholder with a real key from your Civitai account settings."], + "actions": [{"id": "open-settings", "label": "Open Settings"}], + } + + try: + client = await self._civitai_client_factory() + success, result = await client._make_request( # noqa: SLF001 - internal diagnostic probe + "GET", + f"{client.base_url}/models", + use_auth=True, + params={"limit": 1}, + ) + if success: + return { + "id": "civitai_api_key", + "title": "Civitai API Key", + "status": "ok", + "summary": "Civitai API key is configured and accepted.", + "details": [], + "actions": [{"id": "open-settings", "label": "Open Settings"}], + } + + error_text = str(result) + lowered = error_text.lower() + if any(token in lowered for token in ("401", "403", "unauthorized", "forbidden", "invalid")): + return { + "id": "civitai_api_key", + "title": "Civitai API Key", + "status": "error", + "summary": "Configured Civitai API key was rejected.", + "details": [error_text], + "actions": [{"id": "open-settings", "label": "Open Settings"}], + } + + return { + "id": "civitai_api_key", + "title": "Civitai API Key", + "status": "warning", + "summary": "Unable to confirm whether the Civitai API key is valid.", + "details": [error_text], + "actions": [{"id": "open-settings", "label": "Open Settings"}], + } + except Exception as exc: # pragma: no cover - network/path dependent + logger.warning("Doctor API key validation failed: %s", exc) + return { + "id": "civitai_api_key", + "title": "Civitai API Key", + "status": "warning", + "summary": "Could not validate the Civitai API key right now.", + "details": [str(exc)], + "actions": [{"id": "open-settings", "label": "Open Settings"}], + } + + async def _check_cache_health(self) -> dict[str, Any]: + details: list[dict[str, Any]] = [] + overall_status = "ok" + summary = "All model caches look healthy." + + for model_type, label, factory in self._scanner_factories: + try: + scanner = await factory() + persisted = None + persistent_cache = getattr(scanner, "_persistent_cache", None) + if persistent_cache and hasattr(persistent_cache, "load_cache"): + loop = asyncio.get_event_loop() + persisted = await loop.run_in_executor( + None, + persistent_cache.load_cache, + getattr(scanner, "model_type", model_type), + ) + + raw_data = list(getattr(persisted, "raw_data", None) or []) + if not raw_data: + cache = await scanner.get_cached_data(force_refresh=False) + raw_data = list(getattr(cache, "raw_data", None) or []) + + report = CacheHealthMonitor().check_health(raw_data, auto_repair=False) + report_status = "ok" + if report.status == CacheHealthStatus.CORRUPTED: + report_status = "error" + elif report.status != CacheHealthStatus.HEALTHY: + report_status = "warning" + + details.append( + { + "model_type": model_type, + "label": label, + "status": report_status, + "message": report.message, + "total_entries": report.total_entries, + "invalid_entries": report.invalid_entries, + "repaired_entries": report.repaired_entries, + "corruption_rate": f"{report.corruption_rate:.1%}", + } + ) + + if report_status == "error": + overall_status = "error" + elif report_status == "warning" and overall_status == "ok": + overall_status = "warning" + except Exception as exc: # pragma: no cover - defensive logging + logger.error("Doctor cache health check failed for %s: %s", model_type, exc, exc_info=True) + details.append( + { + "model_type": model_type, + "label": label, + "status": "error", + "message": str(exc), + } + ) + overall_status = "error" + + if overall_status == "warning": + summary = "One or more model caches contain invalid entries." + elif overall_status == "error": + summary = "One or more model caches are corrupted or unavailable." + + return { + "id": "cache_health", + "title": "Model Cache Health", + "status": overall_status, + "summary": summary, + "details": details, + "actions": [{"id": "repair-cache", "label": "Rebuild Cache"}], + } + + def _check_ui_version(self, client_version: str, app_version: str) -> dict[str, Any]: + if client_version and client_version != app_version: + return { + "id": "ui_version", + "title": "UI Version", + "status": "warning", + "summary": "Browser is running an older UI bundle than the backend expects.", + "details": [ + { + "client_version": client_version, + "server_version": app_version, + } + ], + "actions": [{"id": "reload-page", "label": "Reload UI"}], + } + + return { + "id": "ui_version", + "title": "UI Version", + "status": "ok", + "summary": "Browser UI bundle matches the backend version.", + "details": [ + { + "client_version": client_version or app_version, + "server_version": app_version, + } + ], + "actions": [{"id": "reload-page", "label": "Reload UI"}], + } + + def _collect_backend_session_logs(self) -> dict[str, Any]: + return _collect_backend_session_logs() + + def _build_support_bundle(self, payload: dict[str, Any]) -> bytes: + diagnostics = payload.get("diagnostics") or [] + frontend_logs = payload.get("frontend_logs") or [] + client_context = payload.get("client_context") or {} + + app_version = self._app_version_getter() + settings_snapshot = _sanitize_sensitive_data( + getattr(self._settings, "settings", {}) or {} + ) + startup_messages_getter = getattr(self._settings, "get_startup_messages", None) + startup_messages = ( + list(startup_messages_getter()) if callable(startup_messages_getter) else [] + ) + + environment = { + "app_version": app_version, + "python_version": sys.version, + "platform": platform.platform(), + "cwd": os.getcwd(), + "standalone_mode": os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1", + "settings_file": getattr(self._settings, "settings_file", None), + "exported_at": datetime.now(timezone.utc).isoformat(), + "client_context": client_context, + } + backend_logs = self._collect_backend_session_logs() + backend_session_text = _sanitize_sensitive_text( + str(backend_logs.get("session_log_text") or "") + ) + backend_persisted_text = _sanitize_sensitive_text( + str(backend_logs.get("persistent_log_text") or "") + ) + if not backend_session_text and backend_persisted_text: + backend_session_text = backend_persisted_text + if not backend_session_text: + backend_session_text = "No current backend session logs were available.\n" + + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr( + "doctor-report.json", + json.dumps( + { + "app_version": app_version, + "diagnostics": diagnostics, + "summary": payload.get("summary"), + }, + indent=2, + ensure_ascii=False, + ), + ) + archive.writestr( + "settings-sanitized.json", + json.dumps(settings_snapshot, indent=2, ensure_ascii=False), + ) + archive.writestr( + "startup-messages.json", + json.dumps(startup_messages, indent=2, ensure_ascii=False), + ) + archive.writestr( + "environment.json", + json.dumps(environment, indent=2, ensure_ascii=False), + ) + archive.writestr( + "frontend-console.json", + json.dumps(_sanitize_sensitive_data(frontend_logs), indent=2, ensure_ascii=False), + ) + archive.writestr("backend-logs.txt", backend_session_text) + archive.writestr( + "backend-log-source.json", + json.dumps( + _sanitize_sensitive_data( + { + "mode": backend_logs.get("mode"), + "source_method": backend_logs.get("source_method"), + "session_started_at": backend_logs.get("session_started_at"), + "session_id": backend_logs.get("session_id"), + "persistent_log_path": backend_logs.get("persistent_log_path"), + "notes": backend_logs.get("notes") or [], + } + ), + indent=2, + ensure_ascii=False, + ), + ) + if backend_persisted_text: + archive.writestr("backend-session-file-tail.txt", backend_persisted_text) + + return buffer.getvalue() + + class ExampleWorkflowsHandler: """Handler for example workflow templates.""" @@ -2022,6 +2718,7 @@ class MiscHandlerSet: filesystem: FileSystemHandler, custom_words: CustomWordsHandler, supporters: SupportersHandler, + doctor: DoctorHandler, example_workflows: ExampleWorkflowsHandler, base_model: BaseModelHandlerSet, ) -> None: @@ -2038,6 +2735,7 @@ class MiscHandlerSet: self.filesystem = filesystem self.custom_words = custom_words self.supporters = supporters + self.doctor = doctor self.example_workflows = example_workflows self.base_model = base_model @@ -2048,6 +2746,9 @@ class MiscHandlerSet: "health_check": self.health.health_check, "get_settings": self.settings.get_settings, "update_settings": self.settings.update_settings, + "get_doctor_diagnostics": self.doctor.get_doctor_diagnostics, + "repair_doctor_cache": self.doctor.repair_doctor_cache, + "export_doctor_bundle": self.doctor.export_doctor_bundle, "get_priority_tags": self.settings.get_priority_tags, "get_settings_libraries": self.settings.get_libraries, "activate_library": self.settings.activate_library, diff --git a/py/routes/misc_route_registrar.py b/py/routes/misc_route_registrar.py index 9c7f6245..46edace8 100644 --- a/py/routes/misc_route_registrar.py +++ b/py/routes/misc_route_registrar.py @@ -22,6 +22,9 @@ class RouteDefinition: MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( RouteDefinition("GET", "/api/lm/settings", "get_settings"), RouteDefinition("POST", "/api/lm/settings", "update_settings"), + RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"), + RouteDefinition("POST", "/api/lm/doctor/repair-cache", "repair_doctor_cache"), + RouteDefinition("POST", "/api/lm/doctor/export-bundle", "export_doctor_bundle"), RouteDefinition("GET", "/api/lm/priority-tags", "get_priority_tags"), RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"), RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"), diff --git a/py/routes/misc_routes.py b/py/routes/misc_routes.py index d12a463e..dfe8bcd2 100644 --- a/py/routes/misc_routes.py +++ b/py/routes/misc_routes.py @@ -19,6 +19,7 @@ from ..services.downloader import get_downloader from ..utils.usage_stats import UsageStats from .handlers.misc_handlers import ( CustomWordsHandler, + DoctorHandler, ExampleWorkflowsHandler, FileSystemHandler, HealthCheckHandler, @@ -130,6 +131,7 @@ class MiscRoutes: ) custom_words = CustomWordsHandler() supporters = SupportersHandler() + doctor = DoctorHandler(settings_service=self._settings) example_workflows = ExampleWorkflowsHandler() base_model = BaseModelHandlerSet() @@ -147,6 +149,7 @@ class MiscRoutes: filesystem=filesystem, custom_words=custom_words, supporters=supporters, + doctor=doctor, example_workflows=example_workflows, base_model=base_model, ) diff --git a/py/utils/session_logging.py b/py/utils/session_logging.py new file mode 100644 index 00000000..54903713 --- /dev/null +++ b/py/utils/session_logging.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import logging +import os +import threading +import uuid +from collections import deque +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any + + +LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" +_SESSION_HANDLER_NAME = "lora_manager_standalone_session_memory" +_FILE_HANDLER_NAME = "lora_manager_standalone_session_file" +_session_state: "StandaloneSessionLogState | None" = None +_session_lock = threading.Lock() + + +@dataclass +class StandaloneSessionLogState: + started_at: str + session_id: str + log_file_path: str | None + memory_handler: "StandaloneSessionMemoryHandler" + + +class StandaloneSessionMemoryHandler(logging.Handler): + def __init__(self, capacity: int = 4000) -> None: + super().__init__() + self._entries: deque[str] = deque(maxlen=capacity) + self._lock = threading.Lock() + + def emit(self, record: logging.LogRecord) -> None: + try: + rendered = self.format(record) + except Exception: + rendered = record.getMessage() + + with self._lock: + self._entries.append(rendered) + + def render(self, max_lines: int | None = None) -> str: + with self._lock: + entries = list(self._entries) + + if max_lines is not None and max_lines > 0: + entries = entries[-max_lines:] + + if not entries: + return "" + + return "\n".join(entries) + "\n" + + +def _build_log_file_path(settings_file: str | None, started_at: datetime) -> str | None: + if not settings_file: + return None + + settings_dir = os.path.dirname(os.path.abspath(settings_file)) + log_dir = os.path.join(settings_dir, "logs") + os.makedirs(log_dir, exist_ok=True) + timestamp = started_at.strftime("%Y%m%dT%H%M%SZ") + return os.path.join(log_dir, f"standalone-session-{timestamp}.log") + + +def setup_standalone_session_logging(settings_file: str | None) -> StandaloneSessionLogState: + global _session_state + + with _session_lock: + if _session_state is not None: + return _session_state + + started_dt = datetime.now(timezone.utc) + started_at = started_dt.replace(microsecond=0).isoformat() + session_id = f"{started_dt.strftime('%Y%m%dT%H%M%SZ')}-{uuid.uuid4().hex[:8]}" + formatter = logging.Formatter(LOG_FORMAT) + root_logger = logging.getLogger() + if root_logger.level > logging.INFO: + root_logger.setLevel(logging.INFO) + + memory_handler = StandaloneSessionMemoryHandler() + memory_handler.set_name(_SESSION_HANDLER_NAME) + memory_handler.setFormatter(formatter) + root_logger.addHandler(memory_handler) + + log_file_path = _build_log_file_path(settings_file, started_dt) + if log_file_path: + file_handler = logging.FileHandler(log_file_path, encoding="utf-8") + file_handler.set_name(_FILE_HANDLER_NAME) + file_handler.setFormatter(formatter) + root_logger.addHandler(file_handler) + + _session_state = StandaloneSessionLogState( + started_at=started_at, + session_id=session_id, + log_file_path=log_file_path, + memory_handler=memory_handler, + ) + + logger = logging.getLogger("lora-manager-standalone") + logger.info("LoRA Manager standalone startup time: %s", started_at) + logger.info("LoRA Manager standalone session id: %s", session_id) + if log_file_path: + logger.info("LoRA Manager standalone session log path: %s", log_file_path) + + return _session_state + + +def get_standalone_session_log_snapshot(max_lines: int = 2000) -> dict[str, Any] | None: + state = _session_state + if state is None: + return None + + return { + "started_at": state.started_at, + "session_id": state.session_id, + "log_file_path": state.log_file_path, + "in_memory_text": state.memory_handler.render(max_lines=max_lines), + } + + +def reset_standalone_session_logging_for_tests() -> None: + global _session_state + + with _session_lock: + root_logger = logging.getLogger() + handlers_to_remove = [ + handler + for handler in root_logger.handlers + if handler.get_name() in {_SESSION_HANDLER_NAME, _FILE_HANDLER_NAME} + ] + for handler in handlers_to_remove: + root_logger.removeHandler(handler) + handler.close() + _session_state = None diff --git a/standalone.py b/standalone.py index 7bd1a201..c03645df 100644 --- a/standalone.py +++ b/standalone.py @@ -113,6 +113,8 @@ import asyncio import logging from aiohttp import web +from py.utils.session_logging import setup_standalone_session_logging + # Increase allowable header size to align with in-ComfyUI configuration. HEADER_SIZE_LIMIT = 16384 @@ -125,6 +127,8 @@ logger = logging.getLogger("lora-manager-standalone") # Configure aiohttp access logger to be less verbose logging.getLogger("aiohttp.access").setLevel(logging.WARNING) +setup_standalone_session_logging(ensure_settings_file(logger)) + # Add specific suppression for connection reset errors class ConnectionResetFilter(logging.Filter): diff --git a/static/css/components/modal/doctor-modal.css b/static/css/components/modal/doctor-modal.css new file mode 100644 index 00000000..4eee36a6 --- /dev/null +++ b/static/css/components/modal/doctor-modal.css @@ -0,0 +1,278 @@ +.doctor-trigger { + min-width: 120px; + position: relative; + border-color: color-mix(in srgb, var(--lora-accent) 24%, var(--border-color)); +} + +.doctor-trigger i { + color: var(--lora-accent); +} + +.doctor-status-badge { + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 999px; + background: var(--lora-error); + color: #fff; + font-size: 11px; + line-height: 18px; + font-weight: 700; +} + +.doctor-status-badge.hidden { + display: none; +} + +.doctor-modal { + width: min(960px, 92vw); + max-width: 960px; +} + +.doctor-shell { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding-top: var(--space-2); +} + +.doctor-hero { + display: flex; + justify-content: space-between; + gap: var(--space-3); + align-items: flex-start; + margin-top: var(--space-2); + padding: var(--space-3); + border-radius: var(--border-radius-sm); + border: 1px solid var(--lora-border); + background: rgba(0, 0, 0, 0.03); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); +} + +.doctor-kicker { + display: inline-block; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--lora-accent); + margin-bottom: 6px; +} + +.doctor-hero h2 { + margin: 0 0 8px; +} + +.doctor-hero p { + margin: 0; + color: var(--text-color); + opacity: 0.88; +} + +.doctor-summary-badge { + display: inline-flex; + align-items: center; + gap: 8px; + border-radius: 999px; + padding: 8px 12px; + font-weight: 700; + white-space: nowrap; + border: 1px solid transparent; +} + +.doctor-status-ok { + background: color-mix(in oklch, var(--lora-success) 14%, transparent); + border-color: color-mix(in oklch, var(--lora-success) 28%, transparent); + color: color-mix(in oklch, var(--lora-success) 72%, var(--text-color)); +} + +.doctor-status-warning { + background: color-mix(in oklch, var(--lora-warning) 16%, transparent); + border-color: color-mix(in oklch, var(--lora-warning) 30%, transparent); + color: color-mix(in oklch, var(--lora-warning) 70%, var(--text-color)); +} + +.doctor-status-error { + background: color-mix(in oklch, var(--lora-error) 16%, transparent); + border-color: color-mix(in oklch, var(--lora-error) 30%, transparent); + color: color-mix(in oklch, var(--lora-error) 68%, var(--text-color)); +} + +.doctor-loading-state { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + min-height: 22px; + border-radius: var(--border-radius-sm); + background: var(--lora-surface); + border: 1px solid var(--lora-border); + visibility: hidden; + opacity: 0; + pointer-events: none; + transition: opacity 0.18s ease, visibility 0.18s ease; +} + +.doctor-loading-state.visible { + visibility: visible; + opacity: 1; +} + +.doctor-issues-list { + display: grid; + gap: var(--space-2); +} + +.doctor-issue-card { + border: 1px solid rgba(0, 0, 0, 0.1); + background: rgba(0, 0, 0, 0.03); + border-radius: var(--border-radius-sm); + padding: var(--space-3); + box-shadow: none; +} + +.doctor-issue-card[data-status="warning"] { + border-color: color-mix(in oklch, var(--lora-warning) 32%, var(--lora-border)); +} + +.doctor-issue-card[data-status="error"] { + border-color: color-mix(in oklch, var(--lora-error) 28%, var(--lora-border)); +} + +.doctor-issue-header { + display: flex; + justify-content: space-between; + gap: var(--space-2); + align-items: flex-start; +} + +.doctor-issue-header h3 { + margin: 0 0 6px; + font-size: 1rem; +} + +.doctor-issue-summary { + margin: 0; + color: var(--text-color); + opacity: 0.92; +} + +.doctor-issue-tag { + display: inline-flex; + align-items: center; + gap: 6px; + border-radius: 999px; + padding: 6px 10px; + font-size: 0.8rem; + font-weight: 700; +} + +.doctor-issue-card[data-status="ok"] .doctor-issue-tag { + background: color-mix(in oklch, var(--lora-success) 14%, transparent); + color: color-mix(in oklch, var(--lora-success) 72%, var(--text-color)); +} + +.doctor-issue-card[data-status="warning"] .doctor-issue-tag { + background: color-mix(in oklch, var(--lora-warning) 16%, transparent); + color: color-mix(in oklch, var(--lora-warning) 70%, var(--text-color)); +} + +.doctor-issue-card[data-status="error"] .doctor-issue-tag { + background: color-mix(in oklch, var(--lora-error) 16%, transparent); + color: color-mix(in oklch, var(--lora-error) 68%, var(--text-color)); +} + +.doctor-issue-details { + margin: 14px 0 0; + padding-left: 18px; + color: var(--text-color); +} + +.doctor-issue-details li + li { + margin-top: 8px; +} + +.doctor-issue-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 16px; +} + +.doctor-inline-detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 10px; + margin-top: 14px; +} + +.doctor-inline-detail { + padding: 10px 12px; + border-radius: var(--border-radius-xs); + background: var(--lora-surface); + border: 1px solid var(--lora-border); +} + +.doctor-inline-detail strong { + display: block; + margin-bottom: 4px; + font-size: 0.82rem; +} + +.doctor-footer { + display: flex; + justify-content: space-between; + gap: var(--space-2); + align-items: center; + padding-top: var(--space-1); +} + +.doctor-footer-note { + color: var(--text-color); + opacity: 0.82; +} + +.doctor-footer-actions { + display: flex; + gap: 10px; +} + +[data-theme="dark"] .doctor-hero, +[data-theme="dark"] .doctor-issue-card { + background: rgba(255, 255, 255, 0.03); + border-color: var(--lora-border); + box-shadow: none; +} + +@media (max-width: 760px) { + .doctor-trigger { + min-width: auto; + padding-inline: 10px; + } + + .doctor-trigger span:not(.doctor-status-badge) { + display: none; + } + + .doctor-hero, + .doctor-footer { + flex-direction: column; + align-items: stretch; + } + + .doctor-hero { + padding-right: var(--space-3); + } + + .doctor-summary-badge { + align-self: flex-start; + } + + .doctor-footer-actions { + width: 100%; + } + + .doctor-footer-actions button { + flex: 1; + } +} diff --git a/static/css/style.css b/static/css/style.css index 0f3c18da..b3ab5987 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -12,6 +12,7 @@ @import 'components/modal/delete-modal.css'; @import 'components/modal/update-modal.css'; @import 'components/modal/settings-modal.css'; +@import 'components/modal/doctor-modal.css'; @import 'components/modal/help-modal.css'; @import 'components/modal/relink-civitai-modal.css'; @import 'components/modal/example-access-modal.css'; diff --git a/static/js/core.js b/static/js/core.js index 3a018d68..d2be9e0a 100644 --- a/static/js/core.js +++ b/static/js/core.js @@ -9,6 +9,7 @@ import { moveManager } from './managers/MoveManager.js'; import { bulkManager } from './managers/BulkManager.js'; import { ExampleImagesManager } from './managers/ExampleImagesManager.js'; import { helpManager } from './managers/HelpManager.js'; +import { doctorManager } from './managers/DoctorManager.js'; import { bannerService } from './managers/BannerService.js'; import { initTheme, initBackToTop } from './utils/uiHelpers.js'; import { initializeInfiniteScroll } from './utils/infiniteScroll.js'; @@ -58,6 +59,7 @@ export class AppCore { const exampleImagesManager = new ExampleImagesManager(); window.exampleImagesManager = exampleImagesManager; window.helpManager = helpManager; + window.doctorManager = doctorManager; window.moveManager = moveManager; window.bulkManager = bulkManager; @@ -77,6 +79,7 @@ export class AppCore { exampleImagesManager.initialize(); // Initialize the help manager helpManager.initialize(); + doctorManager.initialize(); const cardInfoDisplay = state.global.settings.card_info_display || 'always'; document.body.classList.toggle('hover-reveal', cardInfoDisplay === 'hover'); diff --git a/static/js/managers/DoctorManager.js b/static/js/managers/DoctorManager.js new file mode 100644 index 00000000..bf295199 --- /dev/null +++ b/static/js/managers/DoctorManager.js @@ -0,0 +1,387 @@ +import { modalManager } from './ModalManager.js'; +import { showToast } from '../utils/uiHelpers.js'; +import { translate } from '../utils/i18nHelpers.js'; +import { escapeHtml } from '../components/shared/utils.js'; + +const MAX_CONSOLE_ENTRIES = 200; + +function stringifyConsoleArg(value) { + if (typeof value === 'string') { + return value; + } + + try { + return JSON.stringify(value); + } catch (_error) { + return String(value); + } +} + +export class DoctorManager { + constructor() { + this.initialized = false; + this.lastDiagnostics = null; + this.consoleEntries = []; + } + + initialize() { + if (this.initialized) { + return; + } + + this.triggerButton = document.getElementById('doctorTriggerBtn'); + this.badge = document.getElementById('doctorStatusBadge'); + this.modal = document.getElementById('doctorModal'); + this.issuesList = document.getElementById('doctorIssuesList'); + this.summaryText = document.getElementById('doctorSummaryText'); + this.summaryBadge = document.getElementById('doctorSummaryBadge'); + this.loadingState = document.getElementById('doctorLoadingState'); + this.refreshButton = document.getElementById('doctorRefreshBtn'); + this.exportButton = document.getElementById('doctorExportBtn'); + + this.installConsoleCapture(); + this.bindEvents(); + this.initialized = true; + } + + bindEvents() { + if (this.triggerButton) { + this.triggerButton.addEventListener('click', async () => { + modalManager.showModal('doctorModal'); + await this.refreshDiagnostics(); + }); + } + + if (this.refreshButton) { + this.refreshButton.addEventListener('click', async () => { + await this.refreshDiagnostics(); + }); + } + + if (this.exportButton) { + this.exportButton.addEventListener('click', async () => { + await this.exportBundle(); + }); + } + } + + installConsoleCapture() { + if (window.__lmDoctorConsolePatched) { + this.consoleEntries = window.__lmDoctorConsoleEntries || []; + return; + } + + const originalConsole = {}; + const levels = ['log', 'info', 'warn', 'error', 'debug']; + window.__lmDoctorConsoleEntries = this.consoleEntries; + + levels.forEach((level) => { + const original = console[level]?.bind(console); + originalConsole[level] = original; + + console[level] = (...args) => { + this.consoleEntries.push({ + level, + timestamp: new Date().toISOString(), + message: args.map(stringifyConsoleArg).join(' '), + }); + + if (this.consoleEntries.length > MAX_CONSOLE_ENTRIES) { + this.consoleEntries.splice(0, this.consoleEntries.length - MAX_CONSOLE_ENTRIES); + } + + if (original) { + original(...args); + } + }; + }); + + window.__lmDoctorConsolePatched = true; + } + + getClientVersion() { + return document.body?.dataset?.appVersion || ''; + } + + setLoading(isLoading) { + if (this.loadingState) { + this.loadingState.classList.toggle('visible', isLoading); + } + if (this.refreshButton) { + this.refreshButton.disabled = isLoading; + } + if (this.exportButton) { + this.exportButton.disabled = isLoading; + } + } + + async refreshDiagnostics({ silent = false } = {}) { + this.setLoading(true); + try { + const clientVersion = encodeURIComponent(this.getClientVersion()); + const response = await fetch(`/api/lm/doctor/diagnostics?clientVersion=${clientVersion}`); + const payload = await response.json(); + + if (!response.ok || payload.success === false) { + throw new Error(payload.error || 'Failed to load doctor diagnostics'); + } + + this.lastDiagnostics = payload; + this.updateTriggerState(payload.summary); + this.renderDiagnostics(payload); + } catch (error) { + console.error('Doctor diagnostics failed:', error); + if (!silent) { + showToast('doctor.toast.loadFailed', { message: error.message }, 'error'); + } + } finally { + this.setLoading(false); + } + } + + updateTriggerState(summary = {}) { + if (!this.badge || !this.triggerButton) { + return; + } + + const issueCount = Number(summary.issue_count || 0); + this.badge.textContent = issueCount > 9 ? '9+' : String(issueCount); + this.badge.classList.toggle('hidden', issueCount === 0); + + this.triggerButton.classList.remove('doctor-status-warning', 'doctor-status-error'); + if (summary.status === 'error') { + this.triggerButton.classList.add('doctor-status-error'); + } else if (summary.status === 'warning') { + this.triggerButton.classList.add('doctor-status-warning'); + } + } + + renderDiagnostics(payload) { + if (!this.modal || !this.issuesList || !this.summaryText || !this.summaryBadge) { + return; + } + + const { summary = {}, diagnostics = [] } = payload; + this.summaryText.textContent = this.getSummaryText(summary); + this.summaryBadge.className = `doctor-summary-badge ${this.getStatusClass(summary.status)}`; + this.summaryBadge.innerHTML = ` + + ${escapeHtml(this.getStatusLabel(summary.status))} + `; + + this.issuesList.innerHTML = diagnostics.map((item) => this.renderIssueCard(item)).join(''); + this.attachIssueActions(); + } + + getSummaryText(summary) { + if (summary.status === 'error') { + return translate( + 'doctor.summary.error', + { count: summary.issue_count || 0 }, + `${summary.issue_count || 0} issue(s) need attention before the app is fully healthy.` + ); + } + + if (summary.status === 'warning') { + return translate( + 'doctor.summary.warning', + { count: summary.issue_count || 0 }, + `${summary.issue_count || 0} issue(s) were found. Most can be fixed directly from this panel.` + ); + } + + return translate( + 'doctor.summary.ok', + {}, + 'No active issues were found in the current environment.' + ); + } + + getStatusClass(status) { + if (status === 'error') { + return 'doctor-status-error'; + } + if (status === 'warning') { + return 'doctor-status-warning'; + } + return 'doctor-status-ok'; + } + + getStatusLabel(status) { + return translate(`doctor.status.${status || 'ok'}`, {}, status || 'ok'); + } + + renderIssueCard(item) { + const status = item.status || 'ok'; + const tagLabel = this.getStatusLabel(status); + const details = Array.isArray(item.details) ? item.details : []; + const listItems = details + .filter((detail) => typeof detail === 'string') + .map((detail) => `
${escapeHtml(item.summary || '')}
+