feat(doctor): add system diagnostics feature

This commit is contained in:
Will Miao
2026-04-11 16:02:13 +08:00
parent 25fa175aa2
commit 1817142a7b
28 changed files with 2231 additions and 6 deletions

View File

@@ -1806,6 +1806,35 @@
"moveFailed": "Failed to move item: {message}" "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": { "banners": {
"versionMismatch": { "versionMismatch": {
"title": "Anwendungs-Update erkannt", "title": "Anwendungs-Update erkannt",

View File

@@ -1806,6 +1806,35 @@
"moveFailed": "Failed to move item: {message}" "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": { "banners": {
"versionMismatch": { "versionMismatch": {
"title": "Application Update Detected", "title": "Application Update Detected",

View File

@@ -1806,6 +1806,35 @@
"moveFailed": "Failed to move item: {message}" "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": { "banners": {
"versionMismatch": { "versionMismatch": {
"title": "Actualización de la aplicación detectada", "title": "Actualización de la aplicación detectada",

View File

@@ -1806,6 +1806,35 @@
"moveFailed": "Failed to move item: {message}" "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": { "banners": {
"versionMismatch": { "versionMismatch": {
"title": "Mise à jour de l'application détectée", "title": "Mise à jour de l'application détectée",

View File

@@ -1806,6 +1806,35 @@
"moveFailed": "Failed to move item: {message}" "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": { "banners": {
"versionMismatch": { "versionMismatch": {
"title": "זוהה עדכון יישום", "title": "זוהה עדכון יישום",

View File

@@ -1806,6 +1806,35 @@
"moveFailed": "Failed to move item: {message}" "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": { "banners": {
"versionMismatch": { "versionMismatch": {
"title": "アプリケーション更新が検出されました", "title": "アプリケーション更新が検出されました",

View File

@@ -1806,6 +1806,35 @@
"moveFailed": "Failed to move item: {message}" "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": { "banners": {
"versionMismatch": { "versionMismatch": {
"title": "애플리케이션 업데이트 감지", "title": "애플리케이션 업데이트 감지",

View File

@@ -1806,6 +1806,35 @@
"moveFailed": "Failed to move item: {message}" "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": { "banners": {
"versionMismatch": { "versionMismatch": {
"title": "Обнаружено обновление приложения", "title": "Обнаружено обновление приложения",

View File

@@ -1806,6 +1806,35 @@
"moveFailed": "Failed to move item: {message}" "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": { "banners": {
"versionMismatch": { "versionMismatch": {
"title": "检测到应用更新", "title": "检测到应用更新",

View File

@@ -1806,6 +1806,35 @@
"moveFailed": "Failed to move item: {message}" "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": { "banners": {
"versionMismatch": { "versionMismatch": {
"title": "偵測到應用程式更新", "title": "偵測到應用程式更新",

View File

@@ -10,15 +10,19 @@ from __future__ import annotations
import asyncio import asyncio
import contextlib import contextlib
import io
import json import json
import logging import logging
import os import os
import platform
import re
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import zipfile import zipfile
from dataclasses import dataclass 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 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.websocket_manager import ws_manager
from ...services.downloader import get_downloader from ...services.downloader import get_downloader
from ...services.errors import ResourceNotFoundError from ...services.errors import ResourceNotFoundError
from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
from ...utils.constants import ( from ...utils.constants import (
CIVITAI_USER_MODEL_TYPES, CIVITAI_USER_MODEL_TYPES,
DEFAULT_NODE_COLOR, DEFAULT_NODE_COLOR,
@@ -42,12 +47,321 @@ from ...utils.constants import (
from ...utils.civitai_utils import rewrite_preview_url from ...utils.civitai_utils import rewrite_preview_url
from ...utils.example_images_paths import is_valid_example_images_root from ...utils.example_images_paths import is_valid_example_images_root
from ...utils.lora_metadata import extract_trained_words 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 ...utils.usage_stats import UsageStats
from .base_model_handlers import BaseModelHandlerSet from .base_model_handlers import BaseModelHandlerSet
logger = logging.getLogger(__name__) 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: def _is_wsl() -> bool:
"""Check if running in WSL environment.""" """Check if running in WSL environment."""
try: try:
@@ -272,6 +586,388 @@ class SupportersHandler:
return web.json_response({"success": False, "error": str(exc)}, status=500) 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: class ExampleWorkflowsHandler:
"""Handler for example workflow templates.""" """Handler for example workflow templates."""
@@ -2022,6 +2718,7 @@ class MiscHandlerSet:
filesystem: FileSystemHandler, filesystem: FileSystemHandler,
custom_words: CustomWordsHandler, custom_words: CustomWordsHandler,
supporters: SupportersHandler, supporters: SupportersHandler,
doctor: DoctorHandler,
example_workflows: ExampleWorkflowsHandler, example_workflows: ExampleWorkflowsHandler,
base_model: BaseModelHandlerSet, base_model: BaseModelHandlerSet,
) -> None: ) -> None:
@@ -2038,6 +2735,7 @@ class MiscHandlerSet:
self.filesystem = filesystem self.filesystem = filesystem
self.custom_words = custom_words self.custom_words = custom_words
self.supporters = supporters self.supporters = supporters
self.doctor = doctor
self.example_workflows = example_workflows self.example_workflows = example_workflows
self.base_model = base_model self.base_model = base_model
@@ -2048,6 +2746,9 @@ class MiscHandlerSet:
"health_check": self.health.health_check, "health_check": self.health.health_check,
"get_settings": self.settings.get_settings, "get_settings": self.settings.get_settings,
"update_settings": self.settings.update_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_priority_tags": self.settings.get_priority_tags,
"get_settings_libraries": self.settings.get_libraries, "get_settings_libraries": self.settings.get_libraries,
"activate_library": self.settings.activate_library, "activate_library": self.settings.activate_library,

View File

@@ -22,6 +22,9 @@ class RouteDefinition:
MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/settings", "get_settings"), RouteDefinition("GET", "/api/lm/settings", "get_settings"),
RouteDefinition("POST", "/api/lm/settings", "update_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/priority-tags", "get_priority_tags"),
RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"), RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"),
RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"), RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"),

View File

@@ -19,6 +19,7 @@ from ..services.downloader import get_downloader
from ..utils.usage_stats import UsageStats from ..utils.usage_stats import UsageStats
from .handlers.misc_handlers import ( from .handlers.misc_handlers import (
CustomWordsHandler, CustomWordsHandler,
DoctorHandler,
ExampleWorkflowsHandler, ExampleWorkflowsHandler,
FileSystemHandler, FileSystemHandler,
HealthCheckHandler, HealthCheckHandler,
@@ -130,6 +131,7 @@ class MiscRoutes:
) )
custom_words = CustomWordsHandler() custom_words = CustomWordsHandler()
supporters = SupportersHandler() supporters = SupportersHandler()
doctor = DoctorHandler(settings_service=self._settings)
example_workflows = ExampleWorkflowsHandler() example_workflows = ExampleWorkflowsHandler()
base_model = BaseModelHandlerSet() base_model = BaseModelHandlerSet()
@@ -147,6 +149,7 @@ class MiscRoutes:
filesystem=filesystem, filesystem=filesystem,
custom_words=custom_words, custom_words=custom_words,
supporters=supporters, supporters=supporters,
doctor=doctor,
example_workflows=example_workflows, example_workflows=example_workflows,
base_model=base_model, base_model=base_model,
) )

136
py/utils/session_logging.py Normal file
View File

@@ -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

View File

@@ -113,6 +113,8 @@ import asyncio
import logging import logging
from aiohttp import web from aiohttp import web
from py.utils.session_logging import setup_standalone_session_logging
# Increase allowable header size to align with in-ComfyUI configuration. # Increase allowable header size to align with in-ComfyUI configuration.
HEADER_SIZE_LIMIT = 16384 HEADER_SIZE_LIMIT = 16384
@@ -125,6 +127,8 @@ logger = logging.getLogger("lora-manager-standalone")
# Configure aiohttp access logger to be less verbose # Configure aiohttp access logger to be less verbose
logging.getLogger("aiohttp.access").setLevel(logging.WARNING) logging.getLogger("aiohttp.access").setLevel(logging.WARNING)
setup_standalone_session_logging(ensure_settings_file(logger))
# Add specific suppression for connection reset errors # Add specific suppression for connection reset errors
class ConnectionResetFilter(logging.Filter): class ConnectionResetFilter(logging.Filter):

View File

@@ -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;
}
}

View File

@@ -12,6 +12,7 @@
@import 'components/modal/delete-modal.css'; @import 'components/modal/delete-modal.css';
@import 'components/modal/update-modal.css'; @import 'components/modal/update-modal.css';
@import 'components/modal/settings-modal.css'; @import 'components/modal/settings-modal.css';
@import 'components/modal/doctor-modal.css';
@import 'components/modal/help-modal.css'; @import 'components/modal/help-modal.css';
@import 'components/modal/relink-civitai-modal.css'; @import 'components/modal/relink-civitai-modal.css';
@import 'components/modal/example-access-modal.css'; @import 'components/modal/example-access-modal.css';

View File

@@ -9,6 +9,7 @@ import { moveManager } from './managers/MoveManager.js';
import { bulkManager } from './managers/BulkManager.js'; import { bulkManager } from './managers/BulkManager.js';
import { ExampleImagesManager } from './managers/ExampleImagesManager.js'; import { ExampleImagesManager } from './managers/ExampleImagesManager.js';
import { helpManager } from './managers/HelpManager.js'; import { helpManager } from './managers/HelpManager.js';
import { doctorManager } from './managers/DoctorManager.js';
import { bannerService } from './managers/BannerService.js'; import { bannerService } from './managers/BannerService.js';
import { initTheme, initBackToTop } from './utils/uiHelpers.js'; import { initTheme, initBackToTop } from './utils/uiHelpers.js';
import { initializeInfiniteScroll } from './utils/infiniteScroll.js'; import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
@@ -58,6 +59,7 @@ export class AppCore {
const exampleImagesManager = new ExampleImagesManager(); const exampleImagesManager = new ExampleImagesManager();
window.exampleImagesManager = exampleImagesManager; window.exampleImagesManager = exampleImagesManager;
window.helpManager = helpManager; window.helpManager = helpManager;
window.doctorManager = doctorManager;
window.moveManager = moveManager; window.moveManager = moveManager;
window.bulkManager = bulkManager; window.bulkManager = bulkManager;
@@ -77,6 +79,7 @@ export class AppCore {
exampleImagesManager.initialize(); exampleImagesManager.initialize();
// Initialize the help manager // Initialize the help manager
helpManager.initialize(); helpManager.initialize();
doctorManager.initialize();
const cardInfoDisplay = state.global.settings.card_info_display || 'always'; const cardInfoDisplay = state.global.settings.card_info_display || 'always';
document.body.classList.toggle('hover-reveal', cardInfoDisplay === 'hover'); document.body.classList.toggle('hover-reveal', cardInfoDisplay === 'hover');

View File

@@ -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 = `
<i class="fas ${summary.status === 'error' ? 'fa-triangle-exclamation' : summary.status === 'warning' ? 'fa-stethoscope' : 'fa-heartbeat'}"></i>
<span>${escapeHtml(this.getStatusLabel(summary.status))}</span>
`;
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) => `<li>${escapeHtml(detail)}</li>`)
.join('');
const inlineDetails = details
.filter((detail) => detail && typeof detail === 'object')
.map((detail) => this.renderInlineDetail(detail))
.join('');
const actions = (item.actions || [])
.map((action) => `
<button class="${action.id === 'repair-cache' || action.id === 'reload-page' ? 'primary-btn' : 'secondary-btn'}" data-doctor-action="${escapeHtml(action.id)}">
${escapeHtml(action.label)}
</button>
`)
.join('');
return `
<section class="doctor-issue-card" data-status="${escapeHtml(status)}" data-issue-id="${escapeHtml(item.id || '')}">
<div class="doctor-issue-header">
<div>
<h3>${escapeHtml(item.title || '')}</h3>
<p class="doctor-issue-summary">${escapeHtml(item.summary || '')}</p>
</div>
<span class="doctor-issue-tag">${escapeHtml(tagLabel)}</span>
</div>
${inlineDetails ? `<div class="doctor-inline-detail-grid">${inlineDetails}</div>` : ''}
${listItems ? `<ul class="doctor-issue-details">${listItems}</ul>` : ''}
${actions ? `<div class="doctor-issue-actions">${actions}</div>` : ''}
</section>
`;
}
renderInlineDetail(detail) {
if (detail.client_version || detail.server_version) {
return `
<div class="doctor-inline-detail">
<strong>${escapeHtml(translate('common.status.version', {}, 'Version'))}</strong>
<div>${escapeHtml(`Client: ${detail.client_version || 'unknown'}`)}</div>
<div>${escapeHtml(`Server: ${detail.server_version || 'unknown'}`)}</div>
</div>
`;
}
const label = detail.label || detail.model_type || detail.client_version || detail.server_version || 'Detail';
const message = detail.message
|| detail.corruption_rate
|| detail.server_version
|| detail.client_version
|| '';
if (detail.model_type) {
return `
<div class="doctor-inline-detail">
<strong>${escapeHtml(detail.label || detail.model_type)}</strong>
<div>${escapeHtml(detail.message || '')}</div>
${detail.corruption_rate ? `<div>${escapeHtml(detail.corruption_rate)} invalid</div>` : ''}
</div>
`;
}
return `
<div class="doctor-inline-detail">
<strong>${escapeHtml(label)}</strong>
<div>${escapeHtml(message)}</div>
</div>
`;
}
attachIssueActions() {
this.issuesList.querySelectorAll('[data-doctor-action]').forEach((button) => {
button.addEventListener('click', async (event) => {
const action = event.currentTarget.dataset.doctorAction;
await this.handleAction(action);
});
});
}
async handleAction(action) {
switch (action) {
case 'open-settings':
modalManager.showModal('settingsModal');
window.setTimeout(() => {
const input = document.getElementById('civitaiApiKey');
if (input) {
input.focus();
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 100);
break;
case 'repair-cache':
await this.repairCache();
break;
case 'reload-page':
window.location.reload();
break;
default:
break;
}
}
async repairCache() {
try {
this.setLoading(true);
const response = await fetch('/api/lm/doctor/repair-cache', { method: 'POST' });
const payload = await response.json();
if (!response.ok || payload.success === false) {
throw new Error(payload.error || translate('doctor.toast.repairFailed', {}, 'Cache rebuild failed.'));
}
showToast('doctor.toast.repairSuccess', {}, 'success');
await this.refreshDiagnostics({ silent: true });
} catch (error) {
console.error('Doctor cache repair failed:', error);
showToast('doctor.toast.repairFailed', { message: error.message }, 'error');
} finally {
this.setLoading(false);
}
}
async exportBundle() {
try {
this.setLoading(true);
const response = await fetch('/api/lm/doctor/export-bundle', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
summary: this.lastDiagnostics?.summary || null,
diagnostics: this.lastDiagnostics?.diagnostics || [],
frontend_logs: this.consoleEntries,
client_context: {
url: window.location.href,
user_agent: navigator.userAgent,
language: navigator.language,
app_version: this.getClientVersion(),
},
}),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload.error || 'Failed to export diagnostics bundle');
}
const blob = await response.blob();
const disposition = response.headers.get('Content-Disposition') || '';
const match = disposition.match(/filename=\"([^\"]+)\"/);
const filename = match?.[1] || 'lora-manager-doctor.zip';
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
showToast('doctor.toast.exportSuccess', {}, 'success');
} catch (error) {
console.error('Doctor export failed:', error);
showToast('doctor.toast.exportFailed', { message: error.message }, 'error');
} finally {
this.setLoading(false);
}
}
}
export const doctorManager = new DoctorManager();

View File

@@ -84,6 +84,18 @@ export class ModalManager {
}); });
} }
const doctorModal = document.getElementById('doctorModal');
if (doctorModal) {
this.registerModal('doctorModal', {
element: doctorModal,
onClose: () => {
this.getModal('doctorModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
// Add moveModal registration // Add moveModal registration
const moveModal = document.getElementById('moveModal'); const moveModal = document.getElementById('moveModal');
if (moveModal) { if (moveModal) {
@@ -451,4 +463,4 @@ export class ModalManager {
} }
// Create and export a singleton instance // Create and export a singleton instance
export const modalManager = new ModalManager(); export const modalManager = new ModalManager();

View File

@@ -62,7 +62,7 @@
</head> </head>
{% set page_id = self.page_id() %} {% set page_id = self.page_id() %}
<body data-page="{{ page_id }}"> <body data-page="{{ page_id }}" data-app-version="{{ version }}">
<!-- Header is always visible, even during initialization --> <!-- Header is always visible, even during initialization -->
{% include 'components/header.html' %} {% include 'components/header.html' %}

View File

@@ -84,6 +84,13 @@
</div> </div>
<div class="controls-right"> <div class="controls-right">
<div class="control-group doctor-control-group">
<button id="doctorTriggerBtn" class="doctor-trigger" title="{{ t('doctor.buttonTitle', default='Run diagnostics and common fixes') }}">
<i class="fas fa-stethoscope"></i>
<span>{{ t('doctor.title', default='Doctor') }}</span>
<span id="doctorStatusBadge" class="doctor-status-badge hidden" aria-hidden="true"></span>
</button>
</div>
<div class="keyboard-nav-hint tooltip"> <div class="keyboard-nav-hint tooltip">
<i class="fas fa-keyboard"></i> <i class="fas fa-keyboard"></i>
<span class="tooltiptext"> <span class="tooltiptext">
@@ -117,4 +124,4 @@
<!-- Breadcrumbs will be populated by JavaScript --> <!-- Breadcrumbs will be populated by JavaScript -->
</nav> </nav>
</div> </div>
</div> </div>

View File

@@ -3,6 +3,7 @@
{% include 'components/modals/confirm_modals.html' %} {% include 'components/modals/confirm_modals.html' %}
{% include 'components/modals/settings_modal.html' %} {% include 'components/modals/settings_modal.html' %}
{% include 'components/modals/doctor_modal.html' %}
{% include 'components/modals/support_modal.html' %} {% include 'components/modals/support_modal.html' %}
{% include 'components/modals/update_modal.html' %} {% include 'components/modals/update_modal.html' %}
{% include 'components/modals/help_modal.html' %} {% include 'components/modals/help_modal.html' %}
@@ -11,4 +12,4 @@
{% include 'components/modals/download_modal.html' %} {% include 'components/modals/download_modal.html' %}
{% include 'components/modals/move_modal.html' %} {% include 'components/modals/move_modal.html' %}
{% include 'components/modals/bulk_add_tags_modal.html' %} {% include 'components/modals/bulk_add_tags_modal.html' %}
{% include 'components/modals/bulk_base_model_modal.html' %} {% include 'components/modals/bulk_base_model_modal.html' %}

View File

@@ -0,0 +1,40 @@
<div id="doctorModal" class="modal">
<div class="modal-content doctor-modal">
<button class="close" onclick="modalManager.closeModal('doctorModal')">&times;</button>
<div class="doctor-shell">
<div class="doctor-hero">
<div class="doctor-hero-copy">
<span class="doctor-kicker">{{ t('doctor.kicker', default='System diagnostics') }}</span>
<h2>{{ t('doctor.title', default='Doctor') }}</h2>
<p id="doctorSummaryText">{{ t('doctor.summary.idle', default='Run a health check for settings, cache integrity, and UI consistency.') }}</p>
</div>
<div id="doctorSummaryBadge" class="doctor-summary-badge doctor-status-ok">
<i class="fas fa-heartbeat"></i>
<span>{{ t('doctor.status.ok', default='Healthy') }}</span>
</div>
</div>
<div id="doctorLoadingState" class="doctor-loading-state">
<i class="fas fa-spinner fa-spin"></i>
<span>{{ t('doctor.loading', default='Checking environment...') }}</span>
</div>
<div id="doctorIssuesList" class="doctor-issues-list"></div>
<div class="doctor-footer">
<div class="doctor-footer-note">{{ t('doctor.footer', default='Export a diagnostics bundle if the issues remain after repair.') }}</div>
<div class="doctor-footer-actions">
<button id="doctorRefreshBtn" class="secondary-btn">
<i class="fas fa-sync-alt"></i>
<span>{{ t('doctor.actions.runAgain', default='Run Again') }}</span>
</button>
<button id="doctorExportBtn" class="primary-btn">
<i class="fas fa-file-export"></i>
<span>{{ t('doctor.actions.exportBundle', default='Export Bundle') }}</span>
</button>
</div>
</div>
</div>
</div>
</div>

View File

@@ -120,6 +120,13 @@
</div> </div>
<div class="controls-right"> <div class="controls-right">
<div class="control-group doctor-control-group">
<button id="doctorTriggerBtn" class="doctor-trigger" title="{{ t('doctor.buttonTitle', default='Run diagnostics and common fixes') }}">
<i class="fas fa-stethoscope"></i>
<span>{{ t('doctor.title', default='Doctor') }}</span>
<span id="doctorStatusBadge" class="doctor-status-badge hidden" aria-hidden="true"></span>
</button>
</div>
<div class="keyboard-nav-hint tooltip"> <div class="keyboard-nav-hint tooltip">
<i class="fas fa-keyboard"></i> <i class="fas fa-keyboard"></i>
<span class="tooltiptext"> <span class="tooltiptext">
@@ -189,4 +196,4 @@
{% block main_script %} {% block main_script %}
<script type="module" src="/loras_static/js/recipes.js?v={{ version }}"></script> <script type="module" src="/loras_static/js/recipes.js?v={{ version }}"></script>
{% endblock %} {% endblock %}

View File

@@ -50,6 +50,12 @@ vi.mock('../../../static/js/managers/HelpManager.js', () => ({
}, },
})); }));
vi.mock('../../../static/js/managers/DoctorManager.js', () => ({
doctorManager: {
initialize: vi.fn(),
},
}));
vi.mock('../../../static/js/managers/BannerService.js', () => ({ vi.mock('../../../static/js/managers/BannerService.js', () => ({
bannerService: { bannerService: {
initialize: vi.fn(), initialize: vi.fn(),
@@ -103,6 +109,7 @@ import { moveManager } from '../../../static/js/managers/MoveManager.js';
import { bulkManager } from '../../../static/js/managers/BulkManager.js'; import { bulkManager } from '../../../static/js/managers/BulkManager.js';
import { ExampleImagesManager } from '../../../static/js/managers/ExampleImagesManager.js'; import { ExampleImagesManager } from '../../../static/js/managers/ExampleImagesManager.js';
import { helpManager } from '../../../static/js/managers/HelpManager.js'; import { helpManager } from '../../../static/js/managers/HelpManager.js';
import { doctorManager } from '../../../static/js/managers/DoctorManager.js';
import { bannerService } from '../../../static/js/managers/BannerService.js'; import { bannerService } from '../../../static/js/managers/BannerService.js';
import { initTheme, initBackToTop } from '../../../static/js/utils/uiHelpers.js'; import { initTheme, initBackToTop } from '../../../static/js/utils/uiHelpers.js';
import { onboardingManager } from '../../../static/js/managers/OnboardingManager.js'; import { onboardingManager } from '../../../static/js/managers/OnboardingManager.js';
@@ -187,6 +194,7 @@ describe('AppCore initialization flow', () => {
delete window.helpManager; delete window.helpManager;
delete window.moveManager; delete window.moveManager;
delete window.bulkManager; delete window.bulkManager;
delete window.doctorManager;
delete window.headerManager; delete window.headerManager;
delete window.i18n; delete window.i18n;
delete window.pageContextMenu; delete window.pageContextMenu;
@@ -214,6 +222,7 @@ describe('AppCore initialization flow', () => {
expect(bannerService.initialize).toHaveBeenCalledTimes(1); expect(bannerService.initialize).toHaveBeenCalledTimes(1);
expect(window.modalManager).toBe(modalManager); expect(window.modalManager).toBe(modalManager);
expect(window.settingsManager).toBe(settingsManager); expect(window.settingsManager).toBe(settingsManager);
expect(window.doctorManager).toBe(doctorManager);
expect(window.moveManager).toBe(moveManager); expect(window.moveManager).toBe(moveManager);
expect(window.bulkManager).toBe(bulkManager); expect(window.bulkManager).toBe(bulkManager);
expect(HeaderManager).toHaveBeenCalledTimes(1); expect(HeaderManager).toHaveBeenCalledTimes(1);
@@ -227,6 +236,7 @@ describe('AppCore initialization flow', () => {
expect(window.exampleImagesManager).toBe(exampleImagesManagerInstance); expect(window.exampleImagesManager).toBe(exampleImagesManagerInstance);
expect(exampleImagesManagerInitialize).toHaveBeenCalledTimes(1); expect(exampleImagesManagerInitialize).toHaveBeenCalledTimes(1);
expect(helpManager.initialize).toHaveBeenCalledTimes(1); expect(helpManager.initialize).toHaveBeenCalledTimes(1);
expect(doctorManager.initialize).toHaveBeenCalledTimes(1);
expect(document.body.classList.contains('hover-reveal')).toBe(true); expect(document.body.classList.contains('hover-reveal')).toBe(true);
expect(initializeEventManagement).toHaveBeenCalledTimes(1); expect(initializeEventManagement).toHaveBeenCalledTimes(1);
expect(onboardingManager.start).not.toHaveBeenCalled(); expect(onboardingManager.start).not.toHaveBeenCalled();

View File

@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mountMarkup, resetDom } from '../utils/domFixtures.js';
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
modalManager: {
showModal: vi.fn(),
},
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: vi.fn(),
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: vi.fn((key, _params, fallback) => fallback || key),
}));
vi.mock('../../../static/js/components/shared/utils.js', () => ({
escapeHtml: vi.fn((value) => String(value)),
}));
import { DoctorManager } from '../../../static/js/managers/DoctorManager.js';
function renderDoctorFixture() {
mountMarkup(`
<button id="doctorTriggerBtn"></button>
<span id="doctorStatusBadge" class="hidden"></span>
<div id="doctorModal"></div>
<div id="doctorIssuesList"></div>
<div id="doctorSummaryText"></div>
<div id="doctorSummaryBadge"></div>
<div id="doctorLoadingState"></div>
<button id="doctorRefreshBtn"></button>
<button id="doctorExportBtn"></button>
`);
document.body.dataset.appVersion = '1.2.3-test';
}
describe('DoctorManager', () => {
beforeEach(() => {
resetDom();
vi.clearAllMocks();
delete window.__lmDoctorConsolePatched;
delete window.__lmDoctorConsoleEntries;
});
it('does not run diagnostics during initialize', () => {
renderDoctorFixture();
const manager = new DoctorManager();
const refreshSpy = vi.spyOn(manager, 'refreshDiagnostics').mockResolvedValue(undefined);
manager.initialize();
expect(refreshSpy).not.toHaveBeenCalled();
});
});

View File

@@ -1,6 +1,9 @@
import io
import json import json
import logging
import os import os
import subprocess import subprocess
import zipfile
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
@@ -9,6 +12,7 @@ from aiohttp import web
from py.routes.handlers.misc_handlers import ( from py.routes.handlers.misc_handlers import (
BackupHandler, BackupHandler,
DoctorHandler,
FileSystemHandler, FileSystemHandler,
LoraCodeHandler, LoraCodeHandler,
ModelLibraryHandler, ModelLibraryHandler,
@@ -16,10 +20,15 @@ from py.routes.handlers.misc_handlers import (
NodeRegistryHandler, NodeRegistryHandler,
ServiceRegistryAdapter, ServiceRegistryAdapter,
SettingsHandler, SettingsHandler,
_collect_comfyui_session_logs,
_is_wsl, _is_wsl,
_wsl_to_windows_path, _wsl_to_windows_path,
_is_docker, _is_docker,
) )
from py.utils.session_logging import (
reset_standalone_session_logging_for_tests,
setup_standalone_session_logging,
)
from py.routes.misc_route_registrar import MISC_ROUTE_DEFINITIONS, MiscRouteRegistrar from py.routes.misc_route_registrar import MISC_ROUTE_DEFINITIONS, MiscRouteRegistrar
from py.routes.misc_routes import MiscRoutes from py.routes.misc_routes import MiscRoutes
@@ -37,6 +46,7 @@ class FakeRequest:
class DummySettings: class DummySettings:
def __init__(self, data=None): def __init__(self, data=None):
self.data = data or {} self.data = data or {}
self.settings = self.data
def get(self, key, default=None): def get(self, key, default=None):
return self.data.get(key, default) return self.data.get(key, default)
@@ -67,6 +77,31 @@ async def dummy_downloader_factory():
return DummyDownloader() return DummyDownloader()
class DummyDoctorScanner:
def __init__(self, *, model_type='lora', raw_data=None, rebuild_error=None):
self.model_type = model_type
self._raw_data = list(raw_data or [])
self._rebuild_error = rebuild_error
self._persistent_cache = SimpleNamespace(
load_cache=lambda _model_type: SimpleNamespace(raw_data=list(self._raw_data))
)
async def get_cached_data(self, force_refresh=False, rebuild_cache=False):
if rebuild_cache and self._rebuild_error:
raise self._rebuild_error
return SimpleNamespace(raw_data=list(self._raw_data))
class DummyCivitaiClient:
def __init__(self, *, success=True, result=None):
self.base_url = 'https://civitai.com/api/v1'
self._success = success
self._result = result if result is not None else {'items': []}
async def _make_request(self, *_args, **_kwargs):
return self._success, self._result
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_settings_excludes_no_sync_keys(): async def test_get_settings_excludes_no_sync_keys():
"""Verify that settings in _NO_SYNC_KEYS are not synced, but others are.""" """Verify that settings in _NO_SYNC_KEYS are not synced, but others are."""
@@ -113,6 +148,257 @@ async def test_update_settings_rejects_missing_example_path(tmp_path):
assert "Path does not exist" in payload["error"] assert "Path does not exist" in payload["error"]
@pytest.mark.asyncio
async def test_doctor_handler_reports_key_cache_and_ui_issues():
settings_service = DummySettings({"civitai_api_key": ""})
invalid_entry = {"file_path": "/tmp/missing.safetensors"}
async def civitai_factory():
return DummyCivitaiClient()
async def scanner_factory():
return DummyDoctorScanner(model_type="lora", raw_data=[invalid_entry])
handler = DoctorHandler(
settings_service=settings_service,
civitai_client_factory=civitai_factory,
scanner_factories=(("lora", "LoRAs", scanner_factory),),
app_version_getter=lambda: "1.2.3-server",
)
response = await handler.get_doctor_diagnostics(
FakeRequest(query={"clientVersion": "1.2.2-client"}, method="GET")
)
payload = json.loads(response.text)
assert payload["success"] is True
assert payload["summary"]["status"] == "error"
diagnostic_map = {item["id"]: item for item in payload["diagnostics"]}
assert diagnostic_map["civitai_api_key"]["status"] == "warning"
assert diagnostic_map["cache_health"]["status"] == "error"
assert diagnostic_map["ui_version"]["status"] == "warning"
@pytest.mark.asyncio
async def test_doctor_handler_can_repair_cache():
scanner = DummyDoctorScanner(model_type="lora", raw_data=[])
async def civitai_factory():
return DummyCivitaiClient()
async def scanner_factory():
return scanner
handler = DoctorHandler(
settings_service=DummySettings({"civitai_api_key": "token"}),
civitai_client_factory=civitai_factory,
scanner_factories=(("lora", "LoRAs", scanner_factory),),
)
response = await handler.repair_doctor_cache(FakeRequest())
payload = json.loads(response.text)
assert response.status == 200
assert payload["success"] is True
assert payload["repaired"] == [{"model_type": "lora", "label": "LoRAs"}]
@pytest.mark.asyncio
async def test_doctor_handler_exports_support_bundle():
async def civitai_factory():
return DummyCivitaiClient()
handler = DoctorHandler(
settings_service=DummySettings({"civitai_api_key": "secret-key"}),
civitai_client_factory=civitai_factory,
scanner_factories=(),
app_version_getter=lambda: "9.9.9-test",
)
response = await handler.export_doctor_bundle(
FakeRequest(
json_data={
"summary": {"status": "warning"},
"diagnostics": [{"id": "cache_health", "status": "warning"}],
"frontend_logs": [{"level": "error", "message": "boom"}],
"client_context": {"app_version": "9.9.8-old"},
}
)
)
assert response.status == 200
with zipfile.ZipFile(io.BytesIO(response.body), "r") as archive:
names = set(archive.namelist())
assert "doctor-report.json" in names
assert "settings-sanitized.json" in names
assert "backend-log-source.json" in names
settings_payload = json.loads(archive.read("settings-sanitized.json").decode("utf-8"))
assert settings_payload["civitai_api_key"].startswith("secr")
@pytest.mark.asyncio
async def test_doctor_handler_redacts_string_secrets_in_bundle():
async def civitai_factory():
return DummyCivitaiClient()
handler = DoctorHandler(
settings_service=DummySettings({"civitai_api_key": "secret-key"}),
civitai_client_factory=civitai_factory,
scanner_factories=(),
app_version_getter=lambda: "9.9.9-test",
)
response = await handler.export_doctor_bundle(
FakeRequest(
json_data={
"frontend_logs": [
{
"level": "error",
"message": "Authorization: Bearer abcdef123456 token=xyz password=hunter2",
}
],
}
)
)
assert response.status == 200
with zipfile.ZipFile(io.BytesIO(response.body), "r") as archive:
frontend_logs = archive.read("frontend-console.json").decode("utf-8")
assert "abcdef123456" not in frontend_logs
assert "hunter2" not in frontend_logs
assert "Bearer ***" in frontend_logs
backend_logs = archive.read("backend-logs.txt").decode("utf-8")
assert "hunter2" not in backend_logs
@pytest.mark.asyncio
async def test_doctor_handler_redacts_json_shaped_string_secrets_in_bundle():
async def civitai_factory():
return DummyCivitaiClient()
handler = DoctorHandler(
settings_service=DummySettings({"civitai_api_key": "secret-key"}),
civitai_client_factory=civitai_factory,
scanner_factories=(),
app_version_getter=lambda: "9.9.9-test",
)
handler._collect_backend_session_logs = lambda: {
"mode": "standalone",
"source_method": "standalone_memory",
"session_started_at": "2026-04-11T10:00:00+00:00",
"session_id": "session-123",
"persistent_log_path": None,
"persistent_log_text": "",
"session_log_text": '{"token":"abcd1234","authorization":"Bearer qwerty","password":"hunter2"}\n',
"notes": [],
}
response = await handler.export_doctor_bundle(
FakeRequest(
json_data={
"frontend_logs": [
{
"level": "error",
"message": '{"token":"abcd1234","authorization":"Bearer qwerty","password":"hunter2"}',
}
],
}
)
)
assert response.status == 200
with zipfile.ZipFile(io.BytesIO(response.body), "r") as archive:
frontend_logs = archive.read("frontend-console.json").decode("utf-8")
backend_logs = archive.read("backend-logs.txt").decode("utf-8")
assert '"token":"abcd1234"' not in frontend_logs
assert '"password":"hunter2"' not in frontend_logs
assert 'Bearer qwerty' not in frontend_logs
assert '\\"token\\":\\"***\\"' in frontend_logs
assert '\\"password\\":\\"***\\"' in frontend_logs
assert 'Bearer ***' in frontend_logs
assert '"token":"abcd1234"' not in backend_logs
assert '"password":"hunter2"' not in backend_logs
assert 'Bearer qwerty' not in backend_logs
@pytest.mark.asyncio
async def test_doctor_handler_exports_backend_session_logs_from_helper():
async def civitai_factory():
return DummyCivitaiClient()
handler = DoctorHandler(
settings_service=DummySettings({"civitai_api_key": "secret-key"}),
civitai_client_factory=civitai_factory,
scanner_factories=(),
app_version_getter=lambda: "9.9.9-test",
)
handler._collect_backend_session_logs = lambda: {
"mode": "standalone",
"source_method": "standalone_session_file",
"session_started_at": "2026-04-11T10:00:00+00:00",
"session_id": "session-123",
"persistent_log_path": "/tmp/standalone.log",
"persistent_log_text": "token=abcd1234\n",
"session_log_text": "Authorization: Bearer supersecret\n",
"notes": [],
}
response = await handler.export_doctor_bundle(FakeRequest(json_data={}))
assert response.status == 200
with zipfile.ZipFile(io.BytesIO(response.body), "r") as archive:
backend_logs = archive.read("backend-logs.txt").decode("utf-8")
backend_source = json.loads(
archive.read("backend-log-source.json").decode("utf-8")
)
assert "supersecret" not in backend_logs
assert backend_source["source_method"] == "standalone_session_file"
assert backend_source["session_id"] == "session-123"
def test_collect_comfyui_session_logs_only_uses_matching_current_session_file(tmp_path):
log_file = tmp_path / "comfyui.log"
log_file.write_text(
"** ComfyUI startup time: 2026-04-11 12:00:00.000\n"
"[2026-04-11 12:00:01.000] file log line\n",
encoding="utf-8",
)
result = _collect_comfyui_session_logs(
log_entries=[
{
"t": "2026-04-11 12:05:00.000",
"m": "** ComfyUI startup time: 2026-04-11 12:05:00.000\n",
},
{"t": "2026-04-11 12:05:01.000", "m": "current session line\n"},
],
log_file_path=str(log_file),
)
assert result["persistent_log_text"] == ""
assert any("does not match" in note for note in result["notes"])
def test_setup_standalone_session_logging_creates_current_session_file(tmp_path):
reset_standalone_session_logging_for_tests()
settings_file = tmp_path / "settings.json"
settings_file.write_text("{}", encoding="utf-8")
state = setup_standalone_session_logging(str(settings_file))
logger = logging.getLogger("lora-manager-standalone-test")
logger.info("standalone current session line")
assert state.log_file_path is not None
assert os.path.isfile(state.log_file_path)
with open(state.log_file_path, "r", encoding="utf-8") as handle:
payload = handle.read()
assert "LoRA Manager standalone startup time:" in payload
class DummyBackupService: class DummyBackupService:
def __init__(self): def __init__(self):
self.restore_calls = [] self.restore_calls = []