mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-04-12 05:42:14 -03:00
feat(doctor): add system diagnostics feature
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "זוהה עדכון יישום",
|
||||||
|
|||||||
@@ -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": "アプリケーション更新が検出されました",
|
||||||
|
|||||||
@@ -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": "애플리케이션 업데이트 감지",
|
||||||
|
|||||||
@@ -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": "Обнаружено обновление приложения",
|
||||||
|
|||||||
@@ -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": "检测到应用更新",
|
||||||
|
|||||||
@@ -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": "偵測到應用程式更新",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
136
py/utils/session_logging.py
Normal 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
|
||||||
@@ -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):
|
||||||
|
|||||||
278
static/css/components/modal/doctor-modal.css
Normal file
278
static/css/components/modal/doctor-modal.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
387
static/js/managers/DoctorManager.js
Normal file
387
static/js/managers/DoctorManager.js
Normal 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();
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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' %}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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' %}
|
||||||
|
|||||||
40
templates/components/modals/doctor_modal.html
Normal file
40
templates/components/modals/doctor_modal.html
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<div id="doctorModal" class="modal">
|
||||||
|
<div class="modal-content doctor-modal">
|
||||||
|
<button class="close" onclick="modalManager.closeModal('doctorModal')">×</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>
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
56
tests/frontend/managers/DoctorManager.test.js
Normal file
56
tests/frontend/managers/DoctorManager.test.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 = []
|
||||||
|
|||||||
Reference in New Issue
Block a user